import os import re import traceback from datetime import date, datetime from decimal import Decimal from typing import Annotated, Any import pymysql from fastapi import Depends, FastAPI, Header, HTTPException, Query, Security from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import FileResponse from fastapi.security import APIKeyHeader from pymysql.cursors import DictCursor from app.emp_departments import enrich_employee_items, load_emp_departments from app.emp_staffing import enrich_employee_staffing, load_staffing_dict from app.pagination_helpers import DEFAULT_FETCH_ALL_MAX from app.emp_schema import ( EMP_EXCLUDE_SUFFIXES, EMP_FIELD_SUFFIXES, EMP_TABLE_CANONICAL, PHP_REFERENCE, ) MYSQL = { "host": os.environ.get("MYSQL_HOST", "db"), "port": int(os.environ.get("MYSQL_PORT", "3306")), "user": os.environ.get("MYSQL_USER", "root"), "password": os.environ.get("MYSQL_PASSWORD", ""), "database": os.environ.get("MYSQL_DATABASE", "j7508239_tracker"), "charset": "utf8mb4", "cursorclass": DictCursor, } app = FastAPI(title="Mera user reader", version="1.0.0") app.add_middleware( CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"], ) _api_key_header = APIKeyHeader(name="X-Api-Key", auto_error=False) def require_api_key( x_api_key: Annotated[str | None, Security(_api_key_header)] = None, authorization: Annotated[str | None, Header()] = None, ) -> None: """Если задан USER_READER_API_KEY — доступ только с ключом (заголовок или Bearer).""" expected = os.environ.get("USER_READER_API_KEY", "").strip() if not expected: return got = (x_api_key or "").strip() if not got and authorization: auth = authorization.strip() if auth.lower().startswith("bearer "): got = auth[7:].strip() if got != expected: raise HTTPException( status_code=401, detail="Требуется ключ: заголовок X-Api-Key или Authorization: Bearer <ключ>", headers={"WWW-Authenticate": "Bearer"}, ) def get_conn(): return pymysql.connect(**MYSQL) def _json_cell(v: Any) -> Any: if v is None: return None if isinstance(v, datetime): return v.isoformat(sep=" ", timespec="seconds") if isinstance(v, date): return v.isoformat() if isinstance(v, Decimal): return float(v) if isinstance(v, bytes): return v.decode("utf-8", errors="replace") return v def _is_secret_col(name: str) -> bool: n = name.lower() return n.endswith("_pass") or "password" in n or "secret" in n def json_rows(rows: list[dict]) -> list[dict]: out = [] for row in rows: clean = {k: v for k, v in row.items() if not _is_secret_col(k)} out.append({k: _json_cell(v) for k, v in clean.items()}) return out def _db_name(cur) -> str: cur.execute("SELECT DATABASE() AS d") return cur.fetchone()["d"] def _list_tables(cur, db: str) -> list[str]: cur.execute( """ SELECT TABLE_NAME AS n FROM information_schema.TABLES WHERE TABLE_SCHEMA = %s ORDER BY TABLE_NAME """, (db,), ) return [r["n"] for r in cur.fetchall()] def _quote_ident(name: str) -> str: return "`" + name.replace("`", "``") + "`" def resolve_emp_table(cur, db: str) -> str: """Физическое имя таблицы (регистр как в MySQL).""" env = os.environ.get("EMP_TABLE", "").strip().strip("`") if env: cur.execute( """ SELECT COUNT(*) AS c FROM information_schema.TABLES WHERE TABLE_SCHEMA = %s AND TABLE_NAME = %s """, (db, env), ) if cur.fetchone()["c"]: return env raise RuntimeError( f"EMP_TABLE={env!r} не найдена в БД {db!r}. " f"Таблицы: {', '.join(_list_tables(cur, db)[:40])}" ) want = EMP_TABLE_CANONICAL.lower() cur.execute( """ SELECT TABLE_NAME AS n FROM information_schema.TABLES WHERE TABLE_SCHEMA = %s AND LOWER(TABLE_NAME) = %s LIMIT 1 """, (db, want), ) row = cur.fetchone() if row: return row["n"] cur.execute( """ SELECT TABLE_NAME AS n FROM information_schema.TABLES WHERE TABLE_SCHEMA = %s AND LOWER(TABLE_NAME) REGEXP 'merakomis.*emp' AND LOWER(TABLE_NAME) NOT LIKE '%%children%%' AND LOWER(TABLE_NAME) NOT LIKE '%%group%%' ORDER BY CHAR_LENGTH(TABLE_NAME) LIMIT 1 """, (db,), ) row = cur.fetchone() if row: return row["n"] fb = ( os.environ.get("USER_READER_FALLBACK_TABLE", "") or os.environ.get("USER_TABLE", "") ).strip().strip("`") if fb: cur.execute( """ SELECT TABLE_NAME AS n FROM information_schema.TABLES WHERE TABLE_SCHEMA = %s AND TABLE_NAME = %s LIMIT 1 """, (db, fb), ) row = cur.fetchone() if row: return row["n"] raise RuntimeError( f"USER_TABLE / USER_READER_FALLBACK_TABLE={fb!r} не найдена в БД {db!r}." ) all_t = _list_tables(cur, db) hint = ", ".join([t for t in all_t if re.search(r"emp|user|staff|profile", t, re.I)][:25]) raise RuntimeError( f"Нет таблицы {EMP_TABLE_CANONICAL!r} (см. {PHP_REFERENCE}). " f"Импортируйте дамп Merakomis или задайте окружение, например: " f"USER_TABLE=account (аккаунты CMS, module/core/user/account/model.php). " f"Таблица achiprogressuser — это прогресс достижений (module/achi/progress/user/model.php), " f"она может быть пустой. " f"База {db!r}, похожие по имени: {hint or '—'}" ) def _table_columns(cur, db: str, table: str) -> list[str]: cur.execute( """ SELECT COLUMN_NAME AS c FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = %s AND TABLE_NAME = %s ORDER BY ORDINAL_POSITION """, (db, table), ) return [r["c"] for r in cur.fetchall()] def _column_lookup(cols: list[str]) -> dict[str, str]: return {c.lower(): c for c in cols} def table_uses_merakomis_emp_schema(cols: list[str]) -> bool: p = EMP_TABLE_CANONICAL.lower() + "_" return any(c.lower().startswith(p) for c in cols) def build_generic_select( cols: list[str], ) -> tuple[str, str | None, str, list[str]]: """Произвольная таблица пользователей: все колонки кроме секретных.""" selected: list[str] = [] parts: list[str] = [] for c in cols: if _is_secret_col(c): continue selected.append(c) parts.append(_quote_ident(c)) if not parts: raise RuntimeError("Нет ни одной не-секретной колонки для выборки.") removed_db: str | None = None for c in cols: cl = c.lower() if cl == "removed" or cl.endswith("_removed"): removed_db = c break order_db: str | None = None for prefer in ("name", "login", "title", "email", "id"): for c in cols: cl = c.lower() if cl == prefer or cl.endswith("_" + prefer): order_db = c break if order_db: break if not order_db: order_db = cols[0] return ", ".join(parts), removed_db, order_db, selected def build_emp_select( cols: list[str], ) -> tuple[str, str | None, str, list[str], list[str]]: lut = _column_lookup(cols) prefix = EMP_TABLE_CANONICAL parts: list[str] = [] aliases: list[str] = [] skipped: list[str] = [] removed_db: str | None = None order_db: str | None = None for suf in EMP_FIELD_SUFFIXES: if suf in EMP_EXCLUDE_SUFFIXES: continue logical_full = f"{prefix}_{suf}".lower() db_col = lut.get(logical_full) if not db_col: skipped.append(suf) continue parts.append(f"{_quote_ident(db_col)} AS {_quote_ident(suf)}") aliases.append(suf) if suf == "removed": removed_db = db_col if suf == "name": order_db = db_col if not parts: raise RuntimeError( "В таблице нет ни одной колонки из списка Emp в emp_schema.py " f"(префикс {prefix!r}). Колонки БД: {cols[:30]}" ) if not order_db: first_suffix = aliases[0] order_db = lut[f"{prefix}_{first_suffix}".lower()] select_sql = ", ".join(parts) return select_sql, removed_db, order_db, aliases, skipped def _delta_timestamp_column_name(cols: list[str], merakomis: bool) -> str: """Имя колонки БД для сравнения 'изменено после' (unix time или сопоставимое целое).""" c = _delta_column_db_name_optional(cols, merakomis) if c: return c if merakomis: raise HTTPException( status_code=400, detail="Для Merakomis Emp нужна колонка updated (tMerakomisEmp_updated).", ) raise HTTPException( status_code=400, detail="Для delta нужна колонка *_updated, updated, *_date или date.", ) def _delta_column_db_name_optional(cols: list[str], merakomis: bool) -> str | None: lut = _column_lookup(cols) if merakomis: return lut.get(f"{EMP_TABLE_CANONICAL.lower()}_updated") for c in cols: cl = c.lower() if cl.endswith("_updated") or cl == "updated": return c for c in cols: cl = c.lower() if cl.endswith("_date") or cl == "date": return c return None def _delta_json_field_name(cols: list[str], merakomis: bool) -> str | None: """Имя поля в JSON ответа (алиас или физическое имя колонки).""" if merakomis: return "updated" if _delta_column_db_name_optional(cols, True) else None dbn = _delta_column_db_name_optional(cols, False) return dbn def _row_ts_value(v: Any) -> int: if v is None: return 0 if isinstance(v, datetime): return int(v.timestamp()) if isinstance(v, date): return int(datetime(v.year, v.month, v.day).timestamp()) if isinstance(v, Decimal): return int(v) return int(v) @app.get("/") def index(): return FileResponse( os.path.join(os.path.dirname(__file__), "..", "static", "index.html") ) @app.get("/api/health") def health() -> dict[str, Any]: try: with get_conn() as c: with c.cursor() as cur: cur.execute("SELECT 1 AS ok") row = cur.fetchone() db = _db_name(cur) return {"ok": True, "db": bool(row and row.get("ok") == 1), "database": db} except pymysql.Error as e: return {"ok": False, "db": False, "error": str(e)} @app.get("/api/meta") def meta(_auth: Annotated[None, Depends(require_api_key)]) -> dict[str, Any]: try: with get_conn() as c: with c.cursor() as cur: db = _db_name(cur) table = resolve_emp_table(cur, db) cols = _table_columns(cur, db, table) if table_uses_merakomis_emp_schema(cols): _, removed_db, order_db, aliases, skipped = build_emp_select( cols ) mode = "merakomis_emp" else: _, removed_db, order_db, aliases = build_generic_select(cols) skipped = [] mode = "generic_user_table" delta_field = _delta_json_field_name(cols, mode == "merakomis_emp") return { "php_model": PHP_REFERENCE, "logical_table": EMP_TABLE_CANONICAL, "database": db, "physical_table": table, "schema_mode": mode, "delta_field": delta_field, "columns_in_db": len(cols), "selected_fields": aliases, "skipped_unknown_in_db": skipped, "where_removed": removed_db, "order_by": order_db, } except pymysql.Error as e: raise HTTPException(status_code=500, detail=str(e)) from e except Exception as e: raise HTTPException(status_code=500, detail=str(e)) from e @app.get("/api/employees") def employees( _auth: Annotated[None, Depends(require_api_key)], limit: int = Query(100, ge=1, le=500), offset: int = Query(0, ge=0), fetch_all: bool = Query(False, description="Вернуть всех сотрудников без пагинации"), ) -> dict[str, Any]: try: with get_conn() as c: with c.cursor() as cur: db = _db_name(cur) table = resolve_emp_table(cur, db) cols = _table_columns(cur, db, table) if table_uses_merakomis_emp_schema(cols): select_sql, removed_db, order_db, _, skipped = build_emp_select( cols ) else: select_sql, removed_db, order_db, _sel = build_generic_select( cols ) skipped = [] tq = _quote_ident(table) oq = _quote_ident(order_db) # Фильтр "не удалён" только для Merakomis Emp; в произвольных таблицах # колонка *removed* часто NULL или с другой семантикой — иначе получается 0 строк. where_sql = "" if table_uses_merakomis_emp_schema(cols) and removed_db: where_sql = f" WHERE {_quote_ident(removed_db)} = 0 " sql_base = ( f"SELECT {select_sql} FROM {tq} {where_sql}" f" ORDER BY {oq}" ) cnt_sql = f"SELECT COUNT(*) AS n FROM {tq} {where_sql}" cur.execute(cnt_sql) total = int(cur.fetchone()["n"]) if fetch_all: if total > DEFAULT_FETCH_ALL_MAX: raise HTTPException( status_code=400, detail={ "code": "too_many_rows", "message": ( f"Слишком много строк ({total}); " f"максимум {DEFAULT_FETCH_ALL_MAX}" ), "total": total, }, ) cur.execute(sql_base) rows = cur.fetchall() resp_limit, resp_offset = total, 0 else: cur.execute(f"{sql_base} LIMIT %s OFFSET %s", (limit, offset)) rows = cur.fetchall() resp_limit, resp_offset = limit, offset items = json_rows(rows) if table_uses_merakomis_emp_schema(cols): dept_map = load_emp_departments(cur, db) staffing_dict = load_staffing_dict(cur, db) enrich_employee_items(items, dept_map) enrich_employee_staffing(items, staffing_dict) return { "php_model": PHP_REFERENCE, "logical_table": EMP_TABLE_CANONICAL, "physical_table": table, "table": table, "skipped_unknown_in_db": skipped, "total": total, "limit": resp_limit, "offset": resp_offset, "fetch_all": fetch_all, "count": len(items), "items": items, } except pymysql.Error as e: raise HTTPException(status_code=500, detail=str(e)) from e except Exception as e: dbg = os.environ.get("DEBUG", "") msg = str(e) if dbg == "1": msg = f"{msg}\n{traceback.format_exc()}" raise HTTPException(status_code=500, detail=msg) from e @app.get("/api/employees/delta") def employees_delta( _auth: Annotated[None, Depends(require_api_key)], since_updated: int = Query(0, ge=0, description="Unix time: строки строго новее этого значения"), limit: int = Query(500, ge=1, le=500), ) -> dict[str, Any]: """Инкрементальная выборка по колонке времени (Merakomis: updated; иначе *_updated / date).""" try: with get_conn() as c: with c.cursor() as cur: db = _db_name(cur) table = resolve_emp_table(cur, db) cols = _table_columns(cur, db, table) merakomis = table_uses_merakomis_emp_schema(cols) delta_col = _delta_timestamp_column_name(cols, merakomis) if merakomis: select_sql, removed_db, order_db, _, skipped = build_emp_select( cols ) else: select_sql, removed_db, order_db, _sel = build_generic_select( cols ) skipped = [] tq = _quote_ident(table) dq = _quote_ident(delta_col) conds: list[str] = [f"{dq} > %s"] params: list[Any] = [since_updated] if merakomis and removed_db: conds.insert(0, f"{_quote_ident(removed_db)} = 0") where_sql = " WHERE " + " AND ".join(conds) lut = _column_lookup(cols) order_parts = [f"{dq} ASC"] if merakomis: id_col = lut.get(f"{EMP_TABLE_CANONICAL.lower()}_id") else: id_col = None for c in cols: if c.lower() == "id" or c.lower().endswith("_id"): id_col = c break if id_col: order_parts.append(f"{_quote_ident(id_col)} ASC") sql = ( f"SELECT {select_sql} FROM {tq}{where_sql}" f" ORDER BY {', '.join(order_parts)} LIMIT %s" ) cur.execute(sql, (*params, limit)) raw_rows = cur.fetchall() row_key = "updated" if merakomis else delta_col max_ts = since_updated for row in raw_rows: v = row.get(row_key) if v is not None: max_ts = max(max_ts, _row_ts_value(v)) items = json_rows(raw_rows) if merakomis: dept_map = load_emp_departments(cur, db) staffing_dict = load_staffing_dict(cur, db) enrich_employee_items(items, dept_map) enrich_employee_staffing(items, staffing_dict) return { "php_model": PHP_REFERENCE, "logical_table": EMP_TABLE_CANONICAL, "physical_table": table, "table": table, "skipped_unknown_in_db": skipped, "since_updated": since_updated, "max_updated": max_ts, "delta_column": row_key, "schema_mode": "merakomis_emp" if merakomis else "generic_user_table", "limit": limit, "count": len(raw_rows), "items": items, } except HTTPException: raise except pymysql.Error as e: raise HTTPException(status_code=500, detail=str(e)) from e except Exception as e: dbg = os.environ.get("DEBUG", "") msg = str(e) if dbg == "1": msg = f"{msg}\n{traceback.format_exc()}" raise HTTPException(status_code=500, detail=msg) from e @app.get("/labor") def labor_page(): return FileResponse( os.path.join(os.path.dirname(__file__), "..", "static", "labor.html") ) @app.get("/summary") def summary_page(): return FileResponse( os.path.join(os.path.dirname(__file__), "..", "static", "summary.html") ) @app.get("/project-report") def project_report_page(): return FileResponse( os.path.join( os.path.dirname(__file__), "..", "static", "project-report.html" ) ) @app.get("/time-entry") def time_entry_page(): return FileResponse( os.path.join(os.path.dirname(__file__), "..", "static", "time-entry.html") ) @app.get("/project-member") def project_member_page(): return FileResponse( os.path.join(os.path.dirname(__file__), "..", "static", "project-member.html") ) from app.batch_api import router as batch_router from app.labor import router as labor_router from app.labor_calendar import router as labor_calendar_router from app.labor_write import router as labor_write_router from app.project_members_read import router as project_members_read_router from app.project_members_write import router as project_members_write_router app.include_router(labor_router) app.include_router(batch_router) app.include_router(labor_write_router) app.include_router(labor_calendar_router) app.include_router(project_members_read_router) app.include_router(project_members_write_router)