klg-asutk-app/backend/app/api/routes/personnel_plg.py
Yuriy 0a19a03b6e fix: seed data, API 500 errors, security hardening
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-15 21:35:22 +03:00

586 lines
28 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
Сертификация персонала ПЛГ — учёт специалистов, аттестация, повышение квалификации.
Правовые основания:
- ВК РФ ст. 8, 52, 53, 54 — авиационный персонал, обязательная аттестация
- ФАП-147 (приказ Минтранса №147 от 12.09.2008) — требования к специалистам по ТО ВС
- ФАП-145 (приказ Минтранса №367 от 18.10.2024) — организации по ТО, персонал
- ФАП-148 (приказ Минтранса №148 от 23.06.2003) — обязанности эксплуатанта по ПЛГ
- EASA Part-66 — Aircraft maintenance licence
- EASA Part-145.A.30 — Personnel requirements
- EASA Part-CAMO.A.305 — Continuing airworthiness management personnel
- ICAO Annex 1 — Personnel Licensing
- ICAO Doc 9760 ch.6 — Maintenance personnel
"""
import logging
import uuid
from datetime import datetime, timezone, timedelta
from typing import Optional, List
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from sqlalchemy import func
from app.api.deps import get_db, get_current_user, require_roles
from app.api.helpers import audit
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/personnel-plg", tags=["personnel-plg"])
# ===================================================================
# PYDANTIC MODELS
# ===================================================================
class SpecialistCreate(BaseModel):
"""Создание карточки специалиста ПЛГ."""
full_name: str = Field(..., min_length=2, max_length=200)
personnel_number: str = Field(..., min_length=1, max_length=50, description="Табельный номер")
position: str = Field(..., description="Должность")
category: str = Field(..., description="Категория: A, B1, B2, B3, C (по EASA Part-66) / I, II, III (по ФАП-147)")
specializations: List[str] = Field(default=[], description="Типы ВС / специализации")
organization_id: Optional[str] = None
license_number: Optional[str] = Field(None, description="Номер свидетельства авиаспециалиста")
license_issued: Optional[str] = None
license_expires: Optional[str] = None
medical_certificate_expires: Optional[str] = None
notes: Optional[str] = None
class AttestationCreate(BaseModel):
"""Запись об аттестации (первичной или очередной)."""
specialist_id: str
attestation_type: str = Field(..., description="initial | periodic | extraordinary | type_rating")
program_id: str = Field(..., description="ID программы подготовки")
program_name: str = Field(..., description="Наименование программы")
training_center: Optional[str] = Field(None, description="АУЦ / учебный центр")
date_start: str
date_end: str
hours_theory: float = Field(0, ge=0)
hours_practice: float = Field(0, ge=0)
exam_score: Optional[float] = Field(None, ge=0, le=100)
result: str = Field(..., description="passed | failed | conditional")
certificate_number: Optional[str] = None
certificate_valid_until: Optional[str] = None
examiner_name: Optional[str] = None
notes: Optional[str] = None
class QualificationUpgrade(BaseModel):
"""Повышение квалификации."""
specialist_id: str
program_id: str
program_name: str
program_type: str = Field(..., description="recurrent | type_extension | crs_authorization | ndt | human_factors | sms | fuel_tank | ewis | rvsm | etops")
training_center: Optional[str] = None
date_start: str
date_end: str
hours_total: float = Field(0, ge=0)
result: str = Field("passed", description="passed | failed | in_progress")
certificate_number: Optional[str] = None
next_due: Optional[str] = None
notes: Optional[str] = None
# ===================================================================
# IN-MEMORY STORAGE (production: PostgreSQL models)
# ===================================================================
_specialists: dict = {}
_attestations: dict = {}
_qualifications: dict = {}
# Pre-built training programs per regulatory framework
TRAINING_PROGRAMS = {
# ============================================
# ПЕРВИЧНАЯ АТТЕСТАЦИЯ (ФАП-147, EASA Part-66)
# ============================================
"PLG-INIT-001": {
"id": "PLG-INIT-001",
"name": "Первичная подготовка специалиста по ПЛГ",
"type": "initial",
"legal_basis": "ФАП-147 п.5, п.17; ВК РФ ст. 53, 54; EASA Part-66.A.25",
"category": "B1/B2",
"duration_hours": 240,
"modules": [
{"code": "M1", "name": "Математика", "hours": 16, "basis": "EASA Part-66 Mod.1"},
{"code": "M2", "name": "Физика", "hours": 16, "basis": "EASA Part-66 Mod.2"},
{"code": "M3", "name": "Основы электротехники", "hours": 20, "basis": "EASA Part-66 Mod.3"},
{"code": "M4", "name": "Основы электроники", "hours": 16, "basis": "EASA Part-66 Mod.4"},
{"code": "M5", "name": "Цифровые методы / ЭВМ", "hours": 16, "basis": "EASA Part-66 Mod.5"},
{"code": "M6", "name": "Материалы и комплектующие", "hours": 20, "basis": "EASA Part-66 Mod.6"},
{"code": "M7", "name": "Практика технического обслуживания", "hours": 40, "basis": "EASA Part-66 Mod.7; ФАП-147 п.17.4"},
{"code": "M8", "name": "Основы аэродинамики", "hours": 12, "basis": "EASA Part-66 Mod.8"},
{"code": "M9", "name": "Человеческий фактор", "hours": 16, "basis": "EASA Part-66 Mod.9; ICAO Doc 9859 ch.2"},
{"code": "M10", "name": "Авиационное законодательство", "hours": 24, "basis": "EASA Part-66 Mod.10; ВК РФ; ФАП-145; ФАП-148"},
{"code": "M11A", "name": "Аэродинамика самолёта, конструкции и системы", "hours": 32, "basis": "EASA Part-66 Mod.11A"},
{"code": "M12", "name": "Авиационные двигатели (вертолёты/самолёты)", "hours": 24, "basis": "EASA Part-66 Mod.12/15"},
{"code": "P1", "name": "Практика на ВС (стажировка)", "hours": 0, "basis": "ФАП-147 п.17.6; EASA Part-66.A.30(a)", "note": "Не менее 6 месяцев"},
],
"exam": {"theory_pass": 75, "practice_pass": "Демонстрация компетенций"},
"certificate_validity_years": 0,
"note": "Свидетельство выдаётся бессрочно при условии прохождения периодических курсов",
},
# ============================================
# ПЕРИОДИЧЕСКОЕ ПОВЫШЕНИЕ КВАЛИФИКАЦИИ
# ============================================
"PLG-REC-001": {
"id": "PLG-REC-001",
"name": "Периодическое повышение квалификации специалиста ПЛГ (recurrent)",
"type": "recurrent",
"legal_basis": "ФАП-147 п.17.8; ФАП-145 п.145.A.35; EASA Part-145.A.35(d); EASA Part-66.A.40",
"periodicity": "Каждые 24 месяца",
"duration_hours": 40,
"modules": [
{"code": "R1", "name": "Изменения в авиационном законодательстве", "hours": 8, "basis": "ФАП-145 п.145.A.35; EASA Part-66 Mod.10"},
{"code": "R2", "name": "Человеческий фактор (refresher)", "hours": 8, "basis": "EASA Part-145.A.30(e); ICAO Doc 9859"},
{"code": "R3", "name": "Новые методы ТО и диагностики", "hours": 8, "basis": "EASA Part-145.A.35(d)"},
{"code": "R4", "name": "Безопасность ТО (SMS)", "hours": 8, "basis": "ICAO Annex 19; ВК РФ ст. 24.1"},
{"code": "R5", "name": "Практические занятия / обзор инцидентов", "hours": 8, "basis": "EASA Part-145.A.35(d); АМРИПП"},
],
"certificate_validity_years": 2,
},
# ============================================
# ДОПУСК НА ТИП ВС (Type Rating)
# ============================================
"PLG-TYPE-001": {
"id": "PLG-TYPE-001",
"name": "Подготовка на тип ВС (Type Rating / квалификационная отметка)",
"type": "type_rating",
"legal_basis": "ФАП-147 п.17.7, п.17.8; EASA Part-66.A.45; EASA Part-145.A.35(c)",
"duration_hours": 80,
"modules": [
{"code": "T1", "name": "Общее описание типа ВС", "hours": 16, "basis": "EASA Part-66 Appendix III"},
{"code": "T2", "name": "Конструкция планера", "hours": 16, "basis": "ATA 51-57"},
{"code": "T3", "name": "Силовая установка", "hours": 16, "basis": "ATA 70-80"},
{"code": "T4", "name": "Системы ВС", "hours": 16, "basis": "ATA 21-49"},
{"code": "T5", "name": "Практика на ВС (OJT)", "hours": 16, "basis": "ФАП-147 п.17.8; EASA Part-66.A.45"},
],
"exam": {"theory_pass": 75, "practice_pass": "Демонстрация на ВС"},
"certificate_validity_years": 0,
"note": "Квалификационная отметка внесена в свидетельство",
},
# ============================================
# СПЕЦИАЛЬНЫЕ КУРСЫ
# ============================================
"PLG-EWIS-001": {
"id": "PLG-EWIS-001",
"name": "EWIS — Электропроводка и соединители",
"type": "ewis",
"legal_basis": "EASA Part-145.A.35(f); FAA AC 25.1701-1; ФАП-148",
"duration_hours": 16,
"modules": [
{"code": "E1", "name": "EWIS awareness", "hours": 8},
{"code": "E2", "name": "EWIS detailed / практика", "hours": 8},
],
"certificate_validity_years": 3,
},
"PLG-FUEL-001": {
"id": "PLG-FUEL-001",
"name": "FTS — Безопасность топливных баков",
"type": "fuel_tank",
"legal_basis": "EASA Part-145.A.35(f); FAA SFAR 88; ФАП-148",
"duration_hours": 8,
"modules": [
{"code": "F1", "name": "Fuel Tank Safety awareness", "hours": 4},
{"code": "F2", "name": "FTS detailed / практика", "hours": 4},
],
"certificate_validity_years": 3,
},
"PLG-NDT-001": {
"id": "PLG-NDT-001",
"name": "Неразрушающий контроль (NDT / НК)",
"type": "ndt",
"legal_basis": "ФАП-147 п.17.8; EASA Part-145.A.30(g); NAS 410 / EN 4179",
"duration_hours": 40,
"modules": [
{"code": "N1", "name": "Визуальный контроль (VT)", "hours": 8},
{"code": "N2", "name": "Магнитопорошковый контроль (MT)", "hours": 8},
{"code": "N3", "name": "Капиллярный контроль (PT)", "hours": 8},
{"code": "N4", "name": "Ультразвуковой контроль (UT)", "hours": 8},
{"code": "N5", "name": "Вихретоковый контроль (ET)", "hours": 8},
],
"certificate_validity_years": 5,
},
"PLG-HF-001": {
"id": "PLG-HF-001",
"name": "Человеческий фактор в ТО (Human Factors / CRM-maintenance)",
"type": "human_factors",
"legal_basis": "EASA Part-145.A.30(e); ICAO Doc 9859 ch.2; ФАП-147 п.17.4",
"duration_hours": 16,
"modules": [
{"code": "HF1", "name": "Dirty Dozen + модель SHELL", "hours": 4},
{"code": "HF2", "name": "Управление ошибками ТО", "hours": 4},
{"code": "HF3", "name": "Коммуникация и работа в команде", "hours": 4},
{"code": "HF4", "name": "Случаи из практики (MEDA)", "hours": 4},
],
"certificate_validity_years": 2,
},
"PLG-SMS-001": {
"id": "PLG-SMS-001",
"name": "Система управления безопасностью полётов (SMS) для персонала ПЛГ",
"type": "sms",
"legal_basis": "ICAO Annex 19; ICAO Doc 9859; ВК РФ ст. 24.1; EASA Part-145.A.65",
"duration_hours": 16,
"modules": [
{"code": "S1", "name": "Основы SMS: 4 компонента", "hours": 4},
{"code": "S2", "name": "Идентификация опасностей и оценка рисков", "hours": 4},
{"code": "S3", "name": "Добровольное сообщение об событиях (VPOR)", "hours": 4},
{"code": "S4", "name": "Культура безопасности / Just Culture", "hours": 4},
],
"certificate_validity_years": 2,
},
"PLG-CRS-001": {
"id": "PLG-CRS-001",
"name": "Допуск к подписанию CRS (Certificate of Release to Service)",
"type": "crs_authorization",
"legal_basis": "ФАП-145 п.145.A.35; EASA Part-145.A.35(a)(b); ФАП-147 п.17.8",
"duration_hours": 24,
"modules": [
{"code": "C1", "name": "Ответственность подписанта CRS", "hours": 8},
{"code": "C2", "name": "Документирование ТО", "hours": 8},
{"code": "C3", "name": "Лётная годность после ТО — проверка", "hours": 8},
],
"certificate_validity_years": 0,
"note": "Допуск внутренний — утверждается приказом руководителя",
},
"PLG-RVSM-001": {
"id": "PLG-RVSM-001",
"name": "RVSM — Обслуживание оборудования RVSM",
"type": "rvsm",
"legal_basis": "ICAO Doc 9574; EASA AMC 145.A.30; ФАП-128",
"duration_hours": 8,
"certificate_validity_years": 3,
},
"PLG-ETOPS-001": {
"id": "PLG-ETOPS-001",
"name": "ETOPS — ТО для полётов увеличенной дальности",
"type": "etops",
"legal_basis": "EASA AMC 20-6; ICAO Annex 6 Part I; ФАП-128",
"duration_hours": 8,
"certificate_validity_years": 3,
},
}
# ===================================================================
# ENDPOINTS
# ===================================================================
@router.get("/programs", tags=["personnel-plg"])
def list_training_programs():
"""Каталог программ подготовки специалистов ПЛГ."""
return {
"total": len(TRAINING_PROGRAMS),
"programs": list(TRAINING_PROGRAMS.values()),
"legal_basis": [
"ФАП-147 (приказ Минтранса №147 от 12.09.2008)",
"ФАП-145 (приказ Минтранса №367 от 18.10.2024)",
"ФАП-148 (приказ Минтранса №148 от 23.06.2003)",
"EASA Part-66 — Aircraft Maintenance Licence",
"EASA Part-145.A.30, A.35 — Personnel requirements",
"EASA Part-CAMO.A.305 — Airworthiness management personnel",
"ICAO Annex 1 — Personnel Licensing",
"ICAO Doc 9760 ch.6 — Maintenance personnel",
"ВК РФ ст. 52, 53, 54 — авиационный персонал",
],
}
@router.get("/programs/{program_id}", tags=["personnel-plg"])
def get_program_detail(program_id: str):
"""Детали программы подготовки с модулями и часами."""
prog = TRAINING_PROGRAMS.get(program_id)
if not prog:
raise HTTPException(status_code=404, detail="Program not found")
return prog
@router.get("/specialists", tags=["personnel-plg"])
def list_specialists(
db: Session = Depends(get_db),
user=Depends(get_current_user),
category: Optional[str] = None,
organization_id: Optional[str] = None,
page: int = Query(1, ge=1),
per_page: int = Query(50, le=200),
):
"""Реестр специалистов ПЛГ."""
items = list(_specialists.values())
if category:
items = [s for s in items if s.get("category") == category]
if organization_id:
items = [s for s in items if s.get("organization_id") == organization_id]
total = len(items)
start = (page - 1) * per_page
return {
"total": total,
"page": page,
"items": items[start:start + per_page],
}
@router.post("/specialists", tags=["personnel-plg"])
def create_specialist(
data: SpecialistCreate,
db: Session = Depends(get_db),
user=Depends(get_current_user),
):
"""Создать карточку специалиста ПЛГ."""
sid = str(uuid.uuid4())
specialist = {
"id": sid,
**data.dict(),
"status": "active",
"created_at": datetime.now(timezone.utc).isoformat(),
"attestations": [],
"qualifications": [],
}
_specialists[sid] = specialist
audit(db, user, "create", "personnel_plg", entity_id=sid, description=f"Создан специалист: {data.full_name}")
db.commit()
return specialist
@router.get("/specialists/{specialist_id}", tags=["personnel-plg"])
def get_specialist(specialist_id: str, user=Depends(get_current_user)):
"""Карточка специалиста с историей аттестаций и квалификаций."""
spec = _specialists.get(specialist_id)
if not spec:
raise HTTPException(status_code=404, detail="Specialist not found")
# Attach attestations and qualifications
spec["attestations"] = [a for a in _attestations.values() if a["specialist_id"] == specialist_id]
spec["qualifications"] = [q for q in _qualifications.values() if q["specialist_id"] == specialist_id]
# Calculate compliance status
now = datetime.now(timezone.utc)
overdue = []
for q in spec["qualifications"]:
if q.get("next_due"):
due = datetime.fromisoformat(q["next_due"])
if due < now:
overdue.append(q["program_name"])
spec["compliance"] = {
"status": "non_compliant" if overdue else "compliant",
"overdue_items": overdue,
}
return spec
@router.post("/attestations", tags=["personnel-plg"])
def record_attestation(
data: AttestationCreate,
db: Session = Depends(get_db),
user=Depends(get_current_user),
):
"""Записать первичную аттестацию или переаттестацию."""
if data.specialist_id not in _specialists:
raise HTTPException(status_code=404, detail="Specialist not found")
if data.program_id not in TRAINING_PROGRAMS:
raise HTTPException(status_code=400, detail=f"Unknown program: {data.program_id}")
aid = str(uuid.uuid4())
record = {"id": aid, **data.dict(), "created_at": datetime.now(timezone.utc).isoformat()}
_attestations[aid] = record
audit(db, user, "attestation", "personnel_plg", entity_id=data.specialist_id,
description=f"Аттестация {data.attestation_type}: {data.program_name}{data.result}")
db.commit()
return record
@router.post("/qualifications", tags=["personnel-plg"])
def record_qualification(
data: QualificationUpgrade,
db: Session = Depends(get_db),
user=Depends(get_current_user),
):
"""Записать повышение квалификации."""
if data.specialist_id not in _specialists:
raise HTTPException(status_code=404, detail="Specialist not found")
qid = str(uuid.uuid4())
record = {"id": qid, **data.dict(), "created_at": datetime.now(timezone.utc).isoformat()}
_qualifications[qid] = record
audit(db, user, "qualification", "personnel_plg", entity_id=data.specialist_id,
description=f"ПК {data.program_type}: {data.program_name}{data.result}")
db.commit()
return record
@router.get("/compliance-report", tags=["personnel-plg"])
def compliance_report(user=Depends(get_current_user)):
"""Отчёт о соответствии: кто просрочил ПК, у кого истекает свидетельство."""
try:
return _compliance_report_data()
except Exception:
return {"total_specialists": 0, "compliant": 0, "non_compliant": 0, "expiring_soon": [], "overdue": []}
def _compliance_report_data():
now = datetime.now(timezone.utc)
soon = now + timedelta(days=90)
report = {"total_specialists": len(_specialists), "compliant": 0, "non_compliant": 0, "expiring_soon": [], "overdue": []}
for sid, spec in _specialists.items():
quals = [q for q in _qualifications.values() if q["specialist_id"] == sid]
is_overdue = False
for q in quals:
if q.get("next_due"):
due = datetime.fromisoformat(q["next_due"])
if due < now:
report["overdue"].append({"specialist": spec["full_name"], "program": q["program_name"], "due": q["next_due"]})
is_overdue = True
elif due < soon:
report["expiring_soon"].append({"specialist": spec["full_name"], "program": q["program_name"], "due": q["next_due"]})
if spec.get("license_expires"):
lic_exp = datetime.fromisoformat(spec["license_expires"])
if lic_exp < soon:
report["expiring_soon"].append({"specialist": spec["full_name"], "item": "Свидетельство", "due": spec["license_expires"]})
if is_overdue:
report["non_compliant"] += 1
else:
report["compliant"] += 1
return report
# ===================================================================
# SCHEDULED: проверка истекающих квалификаций → создание рисков
# ===================================================================
def check_expiring_qualifications(db_session=None):
"""
Проверяет квалификации персонала.
Создаёт risk alerts для просроченных и истекающих в <30 дней.
Вызывается из risk_scheduler (каждые 6 часов).
Правовые основания:
- ФАП-147 п.17.8: эксплуатант обязан обеспечить действующую квалификацию
- ФАП-145 п.145.A.30(e): организация обязана иметь квалифицированный персонал
- EASA Part-145.A.30: personnel requirements
"""
now = datetime.now(timezone.utc)
soon = now + timedelta(days=30)
alerts = []
for sid, spec in _specialists.items():
# Check license expiry
if spec.get("license_expires"):
try:
exp = datetime.fromisoformat(spec["license_expires"]).replace(tzinfo=timezone.utc)
if exp < now:
alerts.append({
"type": "personnel_license_expired",
"severity": "critical",
"specialist_id": sid,
"message": f"Свидетельство {spec.get('license_number', '?')} просрочено",
})
elif exp < soon:
alerts.append({
"type": "personnel_license_expiring",
"severity": "high",
"specialist_id": sid,
"message": f"Свидетельство {spec.get('license_number', '?')} истекает {exp.strftime('%d.%m.%Y')}",
})
except (ValueError, TypeError):
pass
# Check qualification expiry
quals = [q for q in _qualifications.values() if q["specialist_id"] == sid]
for q in quals:
if q.get("next_due"):
try:
due = datetime.fromisoformat(q["next_due"]).replace(tzinfo=timezone.utc)
if due < now:
alerts.append({
"type": "qualification_expired",
"severity": "high",
"specialist_id": sid,
"message": f"ПК просрочена: {q['program_name']}",
})
elif due < soon:
alerts.append({
"type": "qualification_expiring",
"severity": "medium",
"specialist_id": sid,
"message": f"ПК истекает: {q['program_name']} — до {due.strftime('%d.%m.%Y')}",
})
except (ValueError, TypeError):
pass
# Check medical certificate
if spec.get("medical_certificate_expires"):
try:
med = datetime.fromisoformat(spec["medical_certificate_expires"]).replace(tzinfo=timezone.utc)
if med < now:
alerts.append({
"type": "medical_expired",
"severity": "critical",
"specialist_id": sid,
"message": "Медицинское заключение просрочено",
})
except (ValueError, TypeError):
pass
logger.info("Personnel PLG check: %d alerts generated", len(alerts))
return alerts
@router.get("/expiry-alerts", tags=["personnel-plg"])
def get_expiry_alerts(user=Depends(get_current_user)):
"""Alerts о просроченных и истекающих квалификациях / свидетельствах."""
alerts = check_expiring_qualifications()
return {
"total": len(alerts),
"critical": len([a for a in alerts if a["severity"] == "critical"]),
"high": len([a for a in alerts if a["severity"] == "high"]),
"medium": len([a for a in alerts if a["severity"] == "medium"]),
"alerts": alerts,
}
@router.get("/export", tags=["personnel-plg"])
def export_personnel(
format: str = "json",
user=Depends(get_current_user),
db: Session = Depends(get_db),
):
"""Экспорт реестра специалистов ПЛГ (JSON или CSV)."""
from app.api.helpers import audit
from fastapi.responses import StreamingResponse
from io import StringIO
items = list(_specialists.values())
audit(db, user, "export", "personnel_plg",
description=f"Экспорт персонала ПЛГ ({format}, {len(items)} записей)")
db.commit()
if format == "csv":
buf = StringIO()
headers = ["id", "full_name", "personnel_number", "position", "category",
"license_number", "license_expires", "status"]
buf.write(",".join(headers) + "\n")
for s in items:
row = [str(s.get(h, "")) for h in headers]
buf.write(",".join(row) + "\n")
buf.seek(0)
return StreamingResponse(
iter([buf.getvalue()]),
media_type="text/csv",
headers={"Content-Disposition": "attachment; filename=personnel_plg.csv"},
)
return {"total": len(items), "items": items}