import os from typing import Optional, List, Dict import httpx from flask import Flask, g, render_template, request, abort from urllib.parse import urlencode from dotenv import load_dotenv APP_NAME = "1호선 고장코드" def create_app() -> Flask: app = Flask(__name__) app.config.update(TEMPLATES_AUTO_RELOAD=True) # 환경변수 로드 및 Supabase 기본값 설정 load_dotenv() # 기본: Kong 프록시(8000) 또는 사용자가 지정한 URL app.config.setdefault("SUPABASE_URL", os.environ.get("SUPABASE_URL", "http://192.168.0.180:8000")) app.config.setdefault("SUPABASE_ANON_KEY", os.environ.get("SUPABASE_ANON_KEY", "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlzcyI6InN1cGFiYXNlIiwiaWF0IjoxNzU4NTUxNjY2LCJleHAiOjQxMDI0NDQ4MDB9.jMCGL3Q-N2o_l7JQE_HrO7Uoct86CMgLsVxpabisG4I")) # Kong Basic Auth(선택) app.config.setdefault("SUPABASE_BASIC_USER", os.environ.get("SUPABASE_BASIC_USER", "")) app.config.setdefault("SUPABASE_BASIC_PASSWORD", os.environ.get("SUPABASE_BASIC_PASSWORD", "")) # 더 이상 SQLite 초기화/연결을 사용하지 않음 (Supabase만 사용) # PostgREST(REST) 클라이언트 빌더 def build_pg_client() -> httpx.Client: base = app.config["SUPABASE_URL"].rstrip("/") + "/rest/v1" basic_user = app.config.get("SUPABASE_BASIC_USER") or "" basic_pass = app.config.get("SUPABASE_BASIC_PASSWORD") or "" headers = { "apikey": app.config.get("SUPABASE_ANON_KEY", ""), "Accept-Profile": "public", "Content-Profile": "public", } auth = httpx.BasicAuth(basic_user, basic_pass) if basic_user else None if not auth and app.config.get("SUPABASE_ANON_KEY"): headers["Authorization"] = f"Bearer {app.config['SUPABASE_ANON_KEY']}" return httpx.Client(base_url=base, headers=headers, auth=auth, timeout=10) def pg_unique(col: str) -> List[str]: with build_pg_client() as c: r = c.get("/Fault_Code_Table", params={"select": col}) r.raise_for_status() vals = [row.get(col) for row in (r.json() or []) if row.get(col)] seen: Dict[str, bool] = {} out: List[str] = [] for v in vals: if v not in seen: seen[v] = True out.append(v) return out def pg_unique_from(table: str, col: str) -> List[str]: """지정 테이블에서 고유값 리스트를 반환한다.""" with build_pg_client() as c: r = c.get(f"/{table}", params={"select": col}) r.raise_for_status() vals = [row.get(col) for row in (r.json() or []) if row.get(col)] seen: Dict[str, bool] = {} out: List[str] = [] for v in vals: if v not in seen: seen[v] = True out.append(v) return out @app.route("/") def index(): return render_template( "index.html", app_name=APP_NAME, ) # 기존 SQLite 기반 라우트 제거됨 @app.route("/modal/close") def modal_close(): return "" @app.route("/health") def health(): return {"status": "ok"} # 빈 파비콘 응답으로 404 제거 @app.route("/favicon.ico") def favicon(): return ("", 204, {"Content-Type": "image/x-icon"}) # 브라우저/툴 호환을 위해 루트 경로에도 매니페스트 노출 (정적 파일 경로 사용 권장) # 더 이상 /api/* 동기화 엔드포인트 제공하지 않음 # ----------------- Supabase 기반 라우트 ----------------- @app.route("/sb/tabs") def sb_tabs(): try: with build_pg_client() as c: r1 = c.get("/Fault_Code_Table", params={"select": "manufacturer"}) r1.raise_for_status() vals1 = [row.get("manufacturer") for row in (r1.json() or []) if row.get("manufacturer")] vals2: List[str] = [] for path in ("/Signals", "/signals"): try: r2 = c.get(path, params={"select": "manufacturer"}) r2.raise_for_status() vals2 = [row.get("manufacturer") for row in (r2.json() or []) if row.get("manufacturer")] break except httpx.HTTPError: continue vals = vals1 + vals2 seen: Dict[str, bool] = {} manufacturers: List[str] = [] for v in vals: if v not in seen: seen[v] = True manufacturers.append(v) return render_template("partials/sb_tabs.html", manufacturers=manufacturers) except Exception as e: return render_template( "partials/sb_error.html", error_message=str(e), supabase_url=app.config.get("SUPABASE_URL"), ) @app.route("/sb") def sb_home(): try: # ---- MMI 코드 분기 ---- if request.args.get("section", "fault").strip() == "mmicode": PAGE_SIZE = 50 page = int(request.args.get("page", "0")) offset = page * PAGE_SIZE # 셀렉트박스/검색 필드 with build_pg_client() as c: # 제조사 res = c.get("/MMI_Code", params={"select": "manufacturer"}) res.raise_for_status() manufacturers = sorted({row.get("manufacturer") for row in (res.json() or []) if row.get("manufacturer")}) # 차량분류(alias_name) res2 = c.get("/MMI_Code", params={"select": "alias_name"}) res2.raise_for_status() alias_names = sorted({row.get("alias_name") for row in (res2.json() or []) if row.get("alias_name")}) selected_manufacturer = request.args.get("manufacturer", "").strip() selected_alias_name = request.args.get("alias_name", "").strip() q = request.args.get("q", "").strip() group_code = request.args.get("group_code", "").strip().lower() == "on" # MMI 쿼리 params = { "select": "id,code_name,code_description,data_type,car_id,alias_name,manufacturer", "order": "code_name.asc", "limit": str(PAGE_SIZE), "offset": str(offset), } if selected_manufacturer: params["manufacturer"] = f"eq.{selected_manufacturer}" if selected_alias_name: params["alias_name"] = f"eq.{selected_alias_name}" if q: # 여러 컬럼에 대해 ilike filter params["or"] = f"(code_name.ilike.*{q}*,code_description.ilike.*{q}*,alias_name.ilike.*{q}*)" with build_pg_client() as c: res = c.get("/MMI_Code", params=params) res.raise_for_status() rows = res.json() or [] # dedup if group_code on if group_code: seen = set() dedup = [] for r in rows: code = r.get("code_name") if code and code not in seen: seen.add(code) dedup.append(r) rows = dedup query_params = request.args.to_dict() if "page" in query_params: del query_params["page"] query_params_string = urlencode(query_params) # 템플릿 렌더 및 선택 파라미터 전달 return render_template( "partials/sb_mmi_list.html", rows=rows, manufacturers=manufacturers, alias_names=alias_names, selected_manufacturer=selected_manufacturer, selected_alias_name=selected_alias_name, q=q, group_code="on" if group_code else "off", section="mmicode", page=page, page_size=PAGE_SIZE, query_params_string=query_params_string, ) # ---- 기존(고장코드/TCMS) ---- mf_fault = pg_unique("manufacturer") mf_signal = pg_unique_from("Signals", "manufacturer") seen_mf: Dict[str, bool] = {} manufacturers: List[str] = [] for v in (mf_fault + mf_signal): if v and v not in seen_mf: seen_mf[v] = True manufacturers.append(v) devices = pg_unique("device") car_types = pg_unique("car_type") car_ids = pg_unique("car_id") # alias_name 필터는 제작사에 따라 값 제한: 우진/로템/다대 포함 all_alias = pg_unique("alias_name") selected_manufacturer = request.args.get("manufacturer", "").strip() def alias_allowed(name: str) -> bool: if not name: return False low = name.lower() if selected_manufacturer.lower() == "woojin": return ("우진" in name) or ("woojin" in low) if selected_manufacturer.lower() == "rotem": return ("로템" in name) or ("다대" in name) or ("rotem" in low) return True alias_names = [a for a in all_alias if alias_allowed(a)] signal_classifications = pg_unique_from("Signals", "classification") # 선택값 처리 (쿼리스트링에 manufacturer 키가 있으면 빈 문자열이라도 그대로 유지) if "manufacturer" in request.args: selected_manufacturer = (request.args.get("manufacturer") or "").strip() else: selected_manufacturer = manufacturers[0] if manufacturers else "" # 다른 필터의 현재 선택값 유지 selected_device = request.args.get("device", "").strip() selected_car_type = request.args.get("car_type", "").strip() selected_alias_name = request.args.get("alias_name", "").strip() selected_classification = request.args.get("classification", "").strip() q = request.args.get("q", "").strip() section = request.args.get("section", "fault").strip() or "fault" return render_template( "partials/sb_manufacturer.html", manufacturers=manufacturers, devices=devices, car_types=car_types, car_ids=car_ids, alias_names=alias_names, signal_classifications=signal_classifications, selected_manufacturer=selected_manufacturer, selected_device=selected_device, selected_car_type=selected_car_type, selected_alias_name=selected_alias_name, selected_classification=selected_classification, q=q, section=section, ) except Exception as e: return render_template( "partials/sb_error.html", error_message=str(e), supabase_url=app.config.get("SUPABASE_URL"), ) @app.route("/sb/faults/list") def sb_faults_list(): try: PAGE_SIZE = 50 page = int(request.args.get("page", "0")) offset = page * PAGE_SIZE manufacturer = request.args.get("manufacturer", "").strip() device = request.args.get("device", "").strip() car_type = request.args.get("car_type", "").strip() car_id = request.args.get("car_id", "").strip() alias_name = request.args.get("alias_name", "").strip() q = request.args.get("q", "").strip() section = request.args.get("section", "fault").strip() params: Dict[str, str] = { "select": "f_code,f_code_num,f_name,manufacturer,device,car_type,car_id,fault_detail,alias_name", "order": "f_code.asc", "limit": str(PAGE_SIZE), "offset": str(offset), } if manufacturer: params["manufacturer"] = f"eq.{manufacturer}" if device: params["device"] = f"eq.{device}" if car_type: params["car_type"] = f"eq.{car_type}" if car_id: params["car_id"] = f"eq.{car_id}" if alias_name: params["alias_name"] = f"eq.{alias_name}" if q: params["or"] = f"(f_code.ilike.*{q}*,f_name.ilike.*{q}*)" with build_pg_client() as c: r = c.get("/Fault_Code_Table", params=params) r.raise_for_status() rows = r.json() or [] # 그룹핑 옵션: 같은 f_code를 첫 항목만 남김 group_code = request.args.get("group_code", "").strip().lower() if group_code == "on": seen: Dict[str, bool] = {} dedup: List[Dict] = [] for r in rows: code = r.get("f_code") if code and code not in seen: seen[code] = True dedup.append(r) rows = dedup query_params = request.args.to_dict() if "page" in query_params: del query_params["page"] query_params_string = urlencode(query_params) return render_template( "partials/sb_fault_list.html", rows=rows, page=page, page_size=PAGE_SIZE, query_params_string=query_params_string, ) except Exception as e: return render_template( "partials/sb_error.html", error_message=str(e), supabase_url=app.config.get("SUPABASE_URL"), ) @app.route("/sb/faults/") def sb_fault_detail(f_code: str): try: params = { "select": "f_code,f_code_num,f_name,car_type,f_class,grade,device,fault_detail,fault_reaction,fault_detection,fault_clear,fault_action,fault_schematics,car_id,manufacturer", "f_code": f"eq.{f_code}", "limit": "1", } with build_pg_client() as c: r = c.get("/Fault_Code_Table", params=params) r.raise_for_status() rows = r.json() or [] if not rows: abort(404) row = rows[0] return render_template("partials/sb_fault_detail.html", row=row) except Exception as e: return render_template( "partials/sb_error.html", error_message=str(e), supabase_url=app.config.get("SUPABASE_URL"), ) # ----------------- Signals (TCMS) ----------------- @app.route("/sb/signals/list") def sb_signals_list(): try: PAGE_SIZE = 50 page = int(request.args.get("page", "0")) offset = page * PAGE_SIZE manufacturer = request.args.get("manufacturer", "").strip() classification = request.args.get("classification", "").strip() q = request.args.get("q", "").strip() alias_name = request.args.get("alias_name", "").strip() params: Dict[str, str] = { "select": "id,sig_num,signal_abbreviation,signal_description,status_value,manufacturer,classification,alias_name", "order": "sig_num.asc", "limit": str(PAGE_SIZE), "offset": str(offset), } if manufacturer: params["manufacturer"] = f"eq.{manufacturer}" if classification: params["classification"] = f"eq.{classification}" if alias_name: params["alias_name"] = f"eq.{alias_name}" if q: params["or"] = f"(signal_abbreviation.ilike.*{q}*,signal_description.ilike.*{q}*)" with build_pg_client() as c: rows = [] last_error: Exception | None = None for path in ("/Signals", "/signals"): try: r = c.get(path, params=params) r.raise_for_status() rows = r.json() or [] last_error = None break except Exception as e: last_error = e rows = [] continue if last_error and not rows: raise last_error # TCMS도 group_code=on이면 sig_num/dedup(또는 id?) group_code = request.args.get("group_code", "").strip().lower() if group_code == "on": seen: Dict[str, bool] = {} dedup: List[Dict] = [] for r in rows: code = r.get("id") if code and code not in seen: seen[code] = True dedup.append(r) rows = dedup query_params = request.args.to_dict() if "page" in query_params: del query_params["page"] query_params_string = urlencode(query_params) return render_template( "partials/sb_signal_list.html", rows=rows, page=page, page_size=PAGE_SIZE, query_params_string=query_params_string, ) except Exception as e: return render_template( "partials/sb_error.html", error_message=str(e), supabase_url=app.config.get("SUPABASE_URL"), ) @app.route("/sb/signals/") def sb_signal_detail(item_id: str): try: params = { "select": "id,sig_num,signal_abbreviation,signal_description,status_value,manufacturer,classification,alias_name,original_data,created_at,updated_at", "id": f"eq.{item_id}", "limit": "1", } with build_pg_client() as c: rows = [] last_error: Exception | None = None for path in ("/Signals", "/signals"): try: r = c.get(path, params=params) r.raise_for_status() rows = r.json() or [] last_error = None break except Exception as e: last_error = e rows = [] continue if last_error and not rows: raise last_error if not rows: abort(404) row = rows[0] return render_template("partials/sb_signal_detail.html", row=row) except Exception as e: return render_template( "partials/sb_error.html", error_message=str(e), supabase_url=app.config.get("SUPABASE_URL"), ) @app.route("/sb/health") def sb_health(): try: with build_pg_client() as c: r = c.get("/Fault_Code_Table", params={"select": "f_code", "limit": "1"}) r.raise_for_status() return {"sb": "ok", "url": app.config.get("SUPABASE_URL")} except Exception as e: return {"sb": "error", "url": app.config.get("SUPABASE_URL"), "error": str(e)} # 간단한 Signals 테스트 엔드포인트: 상위 5개 레코드를 JSON으로 반환 @app.route("/sb/signals/test") def sb_signals_test(): try: params = { "select": "uuid,sig_num,signal_abbreviation,manufacturer,classification,alias_name", "order": "sig_num.asc", "limit": "5", } with build_pg_client() as c: rows = [] used_path = None last_error = None for path in ("/Signals", "/signals"): try: r = c.get(path, params=params) r.raise_for_status() rows = r.json() or [] used_path = path last_error = None break except Exception as e: last_error = str(e) rows = [] continue if last_error and not rows: return {"ok": False, "url": app.config.get("SUPABASE_URL"), "tried": ["/Signals", "/signals"], "error": last_error}, 500 return {"ok": True, "url": app.config.get("SUPABASE_URL"), "path": used_path, "rows": rows} except Exception as e: return {"ok": False, "url": app.config.get("SUPABASE_URL"), "error": str(e)}, 500 # 상세 디버그: 두 경로 각각의 상태/본문을 그대로 반환 @app.route("/sb/signals/debug") def sb_signals_debug(): try: out = [] with build_pg_client() as c: for path in ("/Signals", "/signals"): try: r = c.get(path, params={"select": "*", "limit": "5"}) ct = r.headers.get("content-type", "") body = None try: body = r.json() except Exception: body = r.text out.append({ "path": path, "status": r.status_code, "content_type": ct, "body": body, }) except httpx.HTTPError as e: out.append({"path": path, "error": str(e)}) return {"ok": True, "url": app.config.get("SUPABASE_URL"), "results": out} except Exception as e: return {"ok": False, "url": app.config.get("SUPABASE_URL"), "error": str(e)}, 500 return app app = create_app() if __name__ == "__main__": port = int(os.environ.get("PORT", "5000")) app.run(host="0.0.0.0", port=port, debug=True)