248 lines
7.7 KiB
Python
248 lines
7.7 KiB
Python
|
|
"""Write API составов проектов."""
|
|||
|
|
|
|||
|
|
from __future__ import annotations
|
|||
|
|
|
|||
|
|
import os
|
|||
|
|
import traceback
|
|||
|
|
from typing import Annotated, Any
|
|||
|
|
|
|||
|
|
import pymysql
|
|||
|
|
from fastapi import APIRouter, Depends, HTTPException
|
|||
|
|
from pydantic import BaseModel, Field
|
|||
|
|
|
|||
|
|
from app.labor import _column_lookup, _prefixed_col, _resolve_table
|
|||
|
|
from app.labor_identity import ensure_emp_exists, parse_acting_emp_id
|
|||
|
|
from app.labor_permissions import can_write_project_member
|
|||
|
|
from app.main import _db_name, _quote_ident, _table_columns, get_conn, require_api_key
|
|||
|
|
from app.member_roles import is_valid_member_role
|
|||
|
|
from app.merakomis_schema import TEAM_MEMBER_TABLE
|
|||
|
|
from app.project_members_helpers import (
|
|||
|
|
emp_is_active_for_team,
|
|||
|
|
fetch_member_item,
|
|||
|
|
fetch_project_row,
|
|||
|
|
fetch_team_member_row,
|
|||
|
|
project_section_ids,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
router = APIRouter()
|
|||
|
|
|
|||
|
|
|
|||
|
|
class ProjectMemberWrite(BaseModel):
|
|||
|
|
project_id: int = Field(..., ge=1)
|
|||
|
|
emp_id: int = Field(..., ge=1)
|
|||
|
|
section_id: int = Field(..., ge=1)
|
|||
|
|
role: int = Field(..., ge=1)
|
|||
|
|
active: bool = True
|
|||
|
|
text: str = ""
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _http_500(e: Exception) -> HTTPException:
|
|||
|
|
dbg = os.environ.get("DEBUG", "")
|
|||
|
|
msg = str(e)
|
|||
|
|
if dbg == "1":
|
|||
|
|
msg = f"{msg}\n{traceback.format_exc()}"
|
|||
|
|
return HTTPException(status_code=500, detail=msg)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _member_columns(cur, db: str) -> dict[str, str]:
|
|||
|
|
table = _resolve_table(cur, db, TEAM_MEMBER_TABLE)
|
|||
|
|
cols = _table_columns(cur, db, table)
|
|||
|
|
lut = _column_lookup(cols)
|
|||
|
|
mapping: dict[str, str] = {"table": table}
|
|||
|
|
for f in ("id", "emp", "team", "section", "role", "active", "text", "portal"):
|
|||
|
|
c = _prefixed_col(lut, TEAM_MEMBER_TABLE, f)
|
|||
|
|
if c:
|
|||
|
|
mapping[f] = c
|
|||
|
|
return mapping
|
|||
|
|
|
|||
|
|
|
|||
|
|
def validate_member_write(
|
|||
|
|
cur,
|
|||
|
|
db: str,
|
|||
|
|
body: ProjectMemberWrite,
|
|||
|
|
) -> tuple[dict[str, Any], list[int]]:
|
|||
|
|
"""Валидация тела PUT. Возвращает project row и allowed section ids."""
|
|||
|
|
if not is_valid_member_role(body.role):
|
|||
|
|
raise HTTPException(
|
|||
|
|
status_code=400,
|
|||
|
|
detail={"code": "invalid_role", "message": "Укажите роль в проекте"},
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
project = fetch_project_row(cur, db, body.project_id)
|
|||
|
|
if not project:
|
|||
|
|
raise HTTPException(
|
|||
|
|
status_code=404,
|
|||
|
|
detail={"code": "project_not_found", "message": "Проект не найден"},
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
try:
|
|||
|
|
ensure_emp_exists(cur, db, body.emp_id, kind="target")
|
|||
|
|
except HTTPException as e:
|
|||
|
|
if e.status_code == 404:
|
|||
|
|
raise HTTPException(
|
|||
|
|
status_code=404,
|
|||
|
|
detail={"code": "emp_not_found", "message": f"Сотрудник {body.emp_id} не найден"},
|
|||
|
|
) from e
|
|||
|
|
raise
|
|||
|
|
|
|||
|
|
if not emp_is_active_for_team(cur, db, body.emp_id):
|
|||
|
|
raise HTTPException(
|
|||
|
|
status_code=400,
|
|||
|
|
detail={
|
|||
|
|
"code": "invalid_employee",
|
|||
|
|
"message": "Нельзя добавить архивного или удалённого сотрудника",
|
|||
|
|
},
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
allowed = project_section_ids(cur, db, body.project_id)
|
|||
|
|
if not allowed:
|
|||
|
|
raise HTTPException(
|
|||
|
|
status_code=400,
|
|||
|
|
detail={
|
|||
|
|
"code": "no_project_sections",
|
|||
|
|
"message": "У проекта не настроены разделы",
|
|||
|
|
},
|
|||
|
|
)
|
|||
|
|
if body.section_id not in allowed:
|
|||
|
|
raise HTTPException(
|
|||
|
|
status_code=400,
|
|||
|
|
detail={
|
|||
|
|
"code": "invalid_section",
|
|||
|
|
"message": "Раздел не входит в состав проекта",
|
|||
|
|
},
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
team_id = int(project.get("team") or 0)
|
|||
|
|
if not team_id:
|
|||
|
|
raise HTTPException(
|
|||
|
|
status_code=400,
|
|||
|
|
detail={"code": "no_team", "message": "У проекта не задана команда"},
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
return project, allowed
|
|||
|
|
|
|||
|
|
|
|||
|
|
def upsert_team_member(
|
|||
|
|
cur,
|
|||
|
|
db: str,
|
|||
|
|
*,
|
|||
|
|
team_id: int,
|
|||
|
|
emp_id: int,
|
|||
|
|
section_id: int,
|
|||
|
|
role: int,
|
|||
|
|
active: bool,
|
|||
|
|
text: str,
|
|||
|
|
) -> int:
|
|||
|
|
"""INSERT или UPDATE; возвращает member id."""
|
|||
|
|
cols = _member_columns(cur, db)
|
|||
|
|
table = cols["table"]
|
|||
|
|
tq = _quote_ident(table)
|
|||
|
|
|
|||
|
|
existing = fetch_team_member_row(cur, db, team_id, emp_id)
|
|||
|
|
active_val = 1 if active else 0
|
|||
|
|
|
|||
|
|
if existing:
|
|||
|
|
member_id = int(existing["id"])
|
|||
|
|
sets = []
|
|||
|
|
params: list[Any] = []
|
|||
|
|
for field, value in (
|
|||
|
|
("section", section_id),
|
|||
|
|
("role", role),
|
|||
|
|
("active", active_val),
|
|||
|
|
("text", text),
|
|||
|
|
):
|
|||
|
|
if field in cols:
|
|||
|
|
sets.append(f"{_quote_ident(cols[field])} = %s")
|
|||
|
|
params.append(value)
|
|||
|
|
if sets:
|
|||
|
|
params.append(member_id)
|
|||
|
|
cur.execute(
|
|||
|
|
f"UPDATE {tq} SET {', '.join(sets)} WHERE {_quote_ident(cols['id'])} = %s",
|
|||
|
|
params,
|
|||
|
|
)
|
|||
|
|
return member_id
|
|||
|
|
|
|||
|
|
insert_cols: list[str] = []
|
|||
|
|
insert_vals: list[Any] = []
|
|||
|
|
for field, value in (
|
|||
|
|
("emp", emp_id),
|
|||
|
|
("team", team_id),
|
|||
|
|
("section", section_id),
|
|||
|
|
("role", role),
|
|||
|
|
("active", active_val),
|
|||
|
|
("text", text),
|
|||
|
|
("portal", 0),
|
|||
|
|
):
|
|||
|
|
if field in cols:
|
|||
|
|
insert_cols.append(_quote_ident(cols[field]))
|
|||
|
|
insert_vals.append(value)
|
|||
|
|
|
|||
|
|
placeholders = ", ".join(["%s"] * len(insert_vals))
|
|||
|
|
cur.execute(
|
|||
|
|
f"INSERT INTO {tq} ({', '.join(insert_cols)}) VALUES ({placeholders})",
|
|||
|
|
insert_vals,
|
|||
|
|
)
|
|||
|
|
return int(cur.lastrowid)
|
|||
|
|
|
|||
|
|
|
|||
|
|
@router.put("/api/project-members")
|
|||
|
|
def put_project_member(
|
|||
|
|
body: ProjectMemberWrite,
|
|||
|
|
_auth: Annotated[None, Depends(require_api_key)],
|
|||
|
|
acting_emp_id: Annotated[int, Depends(parse_acting_emp_id)],
|
|||
|
|
) -> dict[str, Any]:
|
|||
|
|
try:
|
|||
|
|
with get_conn() as c:
|
|||
|
|
with c.cursor() as cur:
|
|||
|
|
db = _db_name(cur)
|
|||
|
|
ensure_emp_exists(cur, db, acting_emp_id, kind="acting")
|
|||
|
|
|
|||
|
|
project, _allowed = validate_member_write(cur, db, body)
|
|||
|
|
team_id = int(project["team"])
|
|||
|
|
|
|||
|
|
existing = fetch_team_member_row(cur, db, team_id, body.emp_id)
|
|||
|
|
member_id = int(existing["id"]) if existing else None
|
|||
|
|
|
|||
|
|
if not can_write_project_member(
|
|||
|
|
cur,
|
|||
|
|
db,
|
|||
|
|
acting_emp_id,
|
|||
|
|
body.project_id,
|
|||
|
|
member_id=member_id,
|
|||
|
|
target_emp_id=body.emp_id,
|
|||
|
|
):
|
|||
|
|
raise HTTPException(
|
|||
|
|
status_code=403,
|
|||
|
|
detail={
|
|||
|
|
"code": "forbidden",
|
|||
|
|
"message": "Нет прав на изменение состава проекта",
|
|||
|
|
},
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
upsert_team_member(
|
|||
|
|
cur,
|
|||
|
|
db,
|
|||
|
|
team_id=team_id,
|
|||
|
|
emp_id=body.emp_id,
|
|||
|
|
section_id=body.section_id,
|
|||
|
|
role=body.role,
|
|||
|
|
active=body.active,
|
|||
|
|
text=body.text or "",
|
|||
|
|
)
|
|||
|
|
c.commit()
|
|||
|
|
|
|||
|
|
item = fetch_member_item(cur, db, body.project_id, body.emp_id)
|
|||
|
|
if not item:
|
|||
|
|
raise HTTPException(
|
|||
|
|
status_code=500,
|
|||
|
|
detail={"code": "db_error", "message": "Запись не найдена после сохранения"},
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
return {"ok": True, **item}
|
|||
|
|
except HTTPException:
|
|||
|
|
raise
|
|||
|
|
except pymysql.Error as e:
|
|||
|
|
raise HTTPException(status_code=500, detail=str(e)) from e
|
|||
|
|
except Exception as e:
|
|||
|
|
raise _http_500(e) from e
|