641 lines
22 KiB
Python
641 lines
22 KiB
Python
|
|
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)
|