klg-asutk-app/backend/app/api/routes/regulator.py

549 lines
24 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.

"""
Панель регулятора ФАВТ — Read-only endpoints.
Данные предоставляются согласно:
- ВК РФ (ст. 8, 36, 37, 67, 68) — сертификация, поддержание лётной годности
- ФАП-246 (приказ Минтранса № 246 от 13.08.2015) — сертификация эксплуатантов
- ФАП-285 (приказ Минтранса № 285 от 25.09.2015) — поддержание лётной годности
- ФГИС РЭВС (приказ Росавиации № 180-П от 09.03.2017) — реестр эксплуатантов и ВС
- ICAO Annex 8 (Airworthiness) — continuing airworthiness, state oversight
- ICAO Annex 6 (Operation of Aircraft) — operator certification
- ICAO Doc 9760 (Airworthiness Manual) — state safety oversight
- EASA Part-M / Part-CAMO — continuing airworthiness management (аналог)
- EASA Part-145 — maintenance organization approvals (аналог)
ПРИНЦИП: Регулятор видит ТОЛЬКО агрегированные / обезличенные данные,
необходимые для функции государственного надзора (oversight).
Коммерческая тайна и персональные данные НЕ раскрываются.
"""
import logging
from datetime import datetime, timezone, timedelta
from typing import Optional
from fastapi import APIRouter, Depends, Query
from sqlalchemy.orm import Session
from sqlalchemy import func, case
from app.api.deps import get_db, get_current_user, require_roles
from app.models import Aircraft, Organization, CertApplication, RiskAlert, Audit
logger = logging.getLogger(__name__)
router = APIRouter(
prefix="/regulator",
tags=["regulator-favt"],
)
# === Access: only favt_inspector and admin ===
FAVT_ROLES = Depends(require_roles("favt_inspector", "admin"))
# -----------------------------------------------------------------------
# 1. СВОДНАЯ СТАТИСТИКА (Overview)
# ВК РФ ст. 8: функция надзора за соблюдением ФАП
# ICAO Doc 9734 (Safety Oversight Manual): CE-7 surveillance obligations
# -----------------------------------------------------------------------
@router.get("/overview", dependencies=[FAVT_ROLES])
def regulator_overview(db: Session = Depends(get_db)):
"""
Сводные показатели подконтрольных организаций.
Не содержит персональных данных — только агрегированные метрики.
"""
now = datetime.now(timezone.utc)
month_ago = now - timedelta(days=30)
# Воздушные суда по статусу лётной годности
aircraft_stats = db.query(
func.count(Aircraft.id).label("total"),
func.count(case((Aircraft.status == "active", 1))).label("airworthy"),
func.count(case((Aircraft.status == "maintenance", 1))).label("in_maintenance"),
func.count(case((Aircraft.status == "grounded", 1))).label("grounded"),
func.count(case((Aircraft.status == "decommissioned", 1))).label("decommissioned"),
).first()
# Организации по типу
org_total = db.query(func.count(Organization.id)).scalar() or 0
# Заявки на сертификацию
cert_stats = db.query(
func.count(CertApplication.id).label("total"),
func.count(case((CertApplication.status == "pending", 1))).label("pending"),
func.count(case((CertApplication.status == "approved", 1))).label("approved"),
func.count(case((CertApplication.status == "rejected", 1))).label("rejected"),
).first()
# Риски
risk_stats = db.query(
func.count(RiskAlert.id).label("total"),
func.count(case((RiskAlert.severity == "critical", 1))).label("critical"),
func.count(case((RiskAlert.severity == "high", 1))).label("high"),
func.count(case((RiskAlert.resolved == False, 1))).label("unresolved"),
).first()
# Аудиты за 30 дней
audit_count = db.query(func.count(Audit.id)).filter(
Audit.created_at >= month_ago
).scalar() or 0
return {
"generated_at": now.isoformat(),
"report_period": "current",
"legal_basis": [
"ВК РФ ст. 8, 35, 36, 37, 37.2 (60-ФЗ)",
"ФЗ-488 от 30.12.2021 — ст. 37.2 ВК РФ «Поддержание ЛГ»",
"ФАП-21 (приказ Минтранса № 184 от 17.06.2019)",
"ФАП-10 / ФАП-246 (серт. требования к эксплуатантам)",
"ФАП-128 (подготовка и выполнение полётов)",
"ФАП-145 (приказ Минтранса № 367 от 18.10.2024) — ТО ГВС",
"ФАП-147 (требования к членам экипажей, спец. по ТО)",
"ФАП-148 (требования к эксплуатантам по ПЛГ)",
"ФАП-149 (электросветотехническое обеспечение)",
"ICAO Annex 6 — Operation of Aircraft",
"ICAO Annex 8 — Airworthiness of Aircraft",
"ICAO Annex 19 — Safety Management",
"ICAO Doc 9734 — Safety Oversight Manual",
"ICAO Doc 9760 — Airworthiness Manual",
"EASA Part-M / Part-CAMO — Continuing Airworthiness",
"EASA Part-145 — Maintenance Organisation",
"Поручение Президента РФ Пр-1379 от 17.07.2019",
"ТЗ АСУ ТК (утв. зам. министра транспорта 24.07.2022)",
],
"aircraft": {
"total": aircraft_stats.total if aircraft_stats else 0,
"airworthy": aircraft_stats.airworthy if aircraft_stats else 0,
"in_maintenance": aircraft_stats.in_maintenance if aircraft_stats else 0,
"grounded": aircraft_stats.grounded if aircraft_stats else 0,
"decommissioned": aircraft_stats.decommissioned if aircraft_stats else 0,
},
"organizations": {
"total": org_total,
},
"certification": {
"total_applications": cert_stats.total if cert_stats else 0,
"pending": cert_stats.pending if cert_stats else 0,
"approved": cert_stats.approved if cert_stats else 0,
"rejected": cert_stats.rejected if cert_stats else 0,
},
"safety": {
"total_risks": risk_stats.total if risk_stats else 0,
"critical": risk_stats.critical if risk_stats else 0,
"high": risk_stats.high if risk_stats else 0,
"unresolved": risk_stats.unresolved if risk_stats else 0,
},
"audits_last_30d": audit_count,
}
# -----------------------------------------------------------------------
# 2. РЕЕСТР ВС (Aircraft Register)
# ВК РФ ст. 33: Государственный реестр гражданских ВС
# ФГИС РЭВС (приказ Росавиации № 180-П)
# ICAO Annex 7 — Aircraft Nationality and Registration Marks
# -----------------------------------------------------------------------
@router.get("/aircraft-register", dependencies=[FAVT_ROLES])
def aircraft_register(
db: Session = Depends(get_db),
status: Optional[str] = Query(None, description="Фильтр: active, grounded, maintenance"),
aircraft_type: Optional[str] = Query(None, description="Тип ВС"),
page: int = Query(1, ge=1),
per_page: int = Query(50, le=200),
):
"""
Реестр ВС — данные, аналогичные ФГИС РЭВС.
Раскрываются: рег. знак, тип, статус годности, эксплуатант (название).
НЕ раскрываются: серийные номера двигателей, стоимость, детали ТО.
"""
q = db.query(Aircraft)
if status:
q = q.filter(Aircraft.status == status)
if aircraft_type:
q = q.filter(Aircraft.aircraft_type.ilike(f"%{aircraft_type}%"))
total = q.count()
items = q.order_by(Aircraft.registration_number).offset(
(page - 1) * per_page
).limit(per_page).all()
return {
"total": total,
"page": page,
"per_page": per_page,
"legal_basis": "ВК РФ ст. 33; ФГИС РЭВС; ICAO Annex 7",
"items": [
{
"registration_number": a.registration_number,
"aircraft_type": a.aircraft_type,
"status": a.status,
"organization": a.organization.name if hasattr(a, 'organization') and a.organization else None,
"cert_expiry": a.cert_expiry.isoformat() if hasattr(a, 'cert_expiry') and a.cert_expiry else None,
}
for a in items
],
}
# -----------------------------------------------------------------------
# 3. СЕРТИФИКАЦИЯ ЭКСПЛУАТАНТОВ (Operator Certification)
# ФАП-246: сертификация эксплуатантов КВП
# ICAO Annex 6 Part I: AOC requirements
# EASA Part-ORO (аналог): organization requirements for air operations
# -----------------------------------------------------------------------
@router.get("/certifications", dependencies=[FAVT_ROLES])
def certification_applications(
db: Session = Depends(get_db),
status: Optional[str] = Query(None),
page: int = Query(1, ge=1),
per_page: int = Query(50, le=200),
):
"""
Заявки на сертификацию / продление сертификата эксплуатанта.
Раскрываются: тип заявки, статус, дата, организация (название).
НЕ раскрываются: внутренние комментарии, персональные данные заявителя.
"""
q = db.query(CertApplication)
if status:
q = q.filter(CertApplication.status == status)
total = q.count()
items = q.order_by(CertApplication.created_at.desc()).offset(
(page - 1) * per_page
).limit(per_page).all()
return {
"total": total,
"page": page,
"per_page": per_page,
"legal_basis": "ФАП-246; ICAO Annex 6; EASA Part-ORO",
"items": [
{
"id": str(c.id),
"type": c.type if hasattr(c, 'type') else "certification",
"status": c.status,
"organization": c.organization.name if hasattr(c, 'organization') and c.organization else None,
"submitted_at": c.created_at.isoformat() if c.created_at else None,
}
for c in items
],
}
# -----------------------------------------------------------------------
# 4. ПОКАЗАТЕЛИ БЕЗОПАСНОСТИ ПОЛЁТОВ (Safety Indicators)
# ВК РФ ст. 24.1: ГПБП — Государственная программа обеспечения БП
# ICAO Annex 19 — Safety Management
# ICAO Doc 9859 (SMM) — Safety Management Manual
# EASA Part-ORO.GEN.200(a)(6): management system / safety reporting
# -----------------------------------------------------------------------
@router.get("/safety-indicators", dependencies=[FAVT_ROLES])
def safety_indicators(
db: Session = Depends(get_db),
days: int = Query(90, ge=7, le=365),
):
"""
Агрегированные показатели безопасности.
Категоризация рисков по severity — без раскрытия деталей эксплуатанта.
Соответствует требованиям ГПБП и Annex 19 Safety Management.
"""
cutoff = datetime.now(timezone.utc) - timedelta(days=days)
# Risk distribution by severity
severity_dist = db.query(
RiskAlert.severity,
func.count(RiskAlert.id),
).filter(RiskAlert.created_at >= cutoff).group_by(RiskAlert.severity).all()
# Risk trend by month
monthly = db.query(
func.date_trunc("month", RiskAlert.created_at).label("month"),
func.count(RiskAlert.id).label("count"),
).filter(RiskAlert.created_at >= cutoff).group_by("month").order_by("month").all()
# Unresolved critical risks count
critical_open = db.query(func.count(RiskAlert.id)).filter(
RiskAlert.severity == "critical",
RiskAlert.resolved == False,
).scalar() or 0
return {
"period_days": days,
"legal_basis": "ВК РФ ст. 24.1 (ГПБП); ICAO Annex 19; ICAO Doc 9859",
"severity_distribution": {s: c for s, c in severity_dist},
"monthly_trend": [
{"month": m.isoformat() if m else None, "count": c}
for m, c in monthly
],
"critical_unresolved": critical_open,
}
# -----------------------------------------------------------------------
# 5. АУДИТЫ И ИНСПЕКЦИИ (Audit & Inspection Results)
# ВК РФ ст. 28: инспектирование ГА
# ICAO Doc 9734 (Safety Oversight Manual): CE-7, CE-8
# EASA Part-ARO.GEN.300: oversight programme
# -----------------------------------------------------------------------
@router.get("/audits", dependencies=[FAVT_ROLES])
def audit_results(
db: Session = Depends(get_db),
days: int = Query(90, ge=7, le=365),
page: int = Query(1, ge=1),
per_page: int = Query(50, le=200),
):
"""
Результаты аудитов и чек-листов.
Раскрываются: дата, тип, результат (pass/fail/open).
НЕ раскрываются: имена инспекторов, детальные замечания.
"""
cutoff = datetime.now(timezone.utc) - timedelta(days=days)
q = db.query(Audit).filter(Audit.created_at >= cutoff)
total = q.count()
items = q.order_by(Audit.created_at.desc()).offset(
(page - 1) * per_page
).limit(per_page).all()
return {
"total": total,
"period_days": days,
"legal_basis": "ВК РФ ст. 28; ICAO Doc 9734 CE-7, CE-8; EASA Part-ARO.GEN.300",
"items": [
{
"id": str(a.id),
"type": a.checklist_type if hasattr(a, 'checklist_type') else "standard",
"status": a.status if hasattr(a, 'status') else "completed",
"aircraft_reg": a.aircraft.registration_number if hasattr(a, 'aircraft') and a.aircraft else None,
"conducted_at": a.created_at.isoformat() if a.created_at else None,
}
for a in items
],
}
# -----------------------------------------------------------------------
# 6. ОТЧЁТ ДЛЯ ФАВТ (Exportable Report)
# Консолидированный отчёт в формате, готовом для загрузки
# -----------------------------------------------------------------------
@router.get("/report", dependencies=[FAVT_ROLES])
def generate_report(
db: Session = Depends(get_db),
user=Depends(get_current_user),
):
"""Консолидированный отчёт для ФАВТ — все разделы в одном JSON."""
from app.api.helpers import audit
overview = regulator_overview(db)
safety = safety_indicators(db)
audit(db, user, "regulator_report", "system",
description="Сформирован отчёт для ФАВТ")
db.commit()
return {
"report_type": "ФАВТ oversight report",
"generated_at": datetime.now(timezone.utc).isoformat(),
"generated_by": user.display_name,
"legal_basis": [
"ВК РФ ст. 8, 24.1, 28, 33, 36, 37, 67, 68",
"ФАП-246, ФАП-285",
"ФГИС РЭВС (приказ Росавиации № 180-П)",
"ICAO Annex 6, 7, 8, 19",
"ICAO Doc 9734, Doc 9760, Doc 9859",
"EASA Part-M, Part-CAMO, Part-145, Part-ARO",
],
"overview": overview,
"safety": safety,
}
# -----------------------------------------------------------------------
# 7. PDF ОТЧЁТ ДЛЯ ФАВТ
# Формат, пригодный для приобщения к делу
# -----------------------------------------------------------------------
@router.get("/report/pdf", dependencies=[FAVT_ROLES])
def generate_pdf_report(
db: Session = Depends(get_db),
user=Depends(get_current_user),
):
"""
Генерация PDF отчёта для ФАВТ.
Структура: титульный лист, сводка, реестр ВС, безопасность.
"""
from io import BytesIO
from fastapi.responses import StreamingResponse
from app.api.helpers import audit
try:
from reportlab.lib.pagesizes import A4
from reportlab.lib.units import mm
from reportlab.pdfgen import canvas
from reportlab.pdfbase import pdfmetrics
from reportlab.pdfbase.ttfonts import TTFont
except ImportError:
return {"error": "reportlab not installed. Install with: pip install reportlab"}
buf = BytesIO()
c = canvas.Canvas(buf, pagesize=A4)
w, h = A4
# Try to register Cyrillic font
try:
pdfmetrics.registerFont(TTFont("DejaVu", "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf"))
font = "DejaVu"
except Exception:
font = "Helvetica"
# Title page
c.setFont(font, 24)
c.drawCentredString(w / 2, h - 80 * mm, "ОТЧЁТ")
c.setFont(font, 14)
c.drawCentredString(w / 2, h - 95 * mm, "для Федерального агентства воздушного транспорта")
c.drawCentredString(w / 2, h - 105 * mm, "(Росавиация)")
c.setFont(font, 10)
c.drawCentredString(w / 2, h - 125 * mm, f"Дата формирования: {datetime.now(timezone.utc).strftime('%d.%m.%Y %H:%M UTC')}")
c.drawCentredString(w / 2, h - 135 * mm, f"Сформировал: {user.display_name}")
c.setFont(font, 8)
c.drawCentredString(w / 2, h - 160 * mm, "Правовые основания: ВК РФ ст. 8, 24.1, 28, 33, 36, 37, 67, 68;")
c.drawCentredString(w / 2, h - 168 * mm, "ФАП-246, ФАП-285; ICAO Annex 6, 7, 8, 19; EASA Part-M, Part-ARO")
c.drawCentredString(w / 2, h - 185 * mm, "АСУ ТК КЛГ — АО «REFLY»")
c.showPage()
# Overview page
overview = regulator_overview(db)
c.setFont(font, 16)
c.drawString(20 * mm, h - 20 * mm, "1. Сводные показатели")
c.setFont(font, 10)
y = h - 40 * mm
sections = [
("Парк ВС", [
f"Всего: {overview['aircraft']['total']}",
f"Годные к полётам: {overview['aircraft']['airworthy']}",
f"На ТО: {overview['aircraft']['in_maintenance']}",
f"Приостановлены: {overview['aircraft']['grounded']}",
f"Списаны: {overview['aircraft']['decommissioned']}",
]),
("Сертификация", [
f"Всего заявок: {overview['certification']['total_applications']}",
f"На рассмотрении: {overview['certification']['pending']}",
f"Одобрено: {overview['certification']['approved']}",
f"Отклонено: {overview['certification']['rejected']}",
]),
("Безопасность полётов", [
f"Всего рисков: {overview['safety']['total_risks']}",
f"Критические: {overview['safety']['critical']}",
f"Высокие: {overview['safety']['high']}",
f"Не устранены: {overview['safety']['unresolved']}",
]),
("Надзор", [
f"Аудитов за 30 дней: {overview['audits_last_30d']}",
f"Организации: {overview['organizations']['total']}",
]),
]
for title, items in sections:
c.setFont(font, 12)
c.drawString(20 * mm, y, title)
y -= 6 * mm
c.setFont(font, 9)
for item in items:
c.drawString(25 * mm, y, f"{item}")
y -= 5 * mm
y -= 4 * mm
if y < 30 * mm:
c.showPage()
y = h - 20 * mm
# Footer
c.setFont(font, 7)
c.drawCentredString(w / 2, 10 * mm, "Документ сформирован автоматически. Персональные данные не раскрываются.")
c.showPage()
c.save()
buf.seek(0)
audit(db, user, "regulator_pdf_report", "system",
description="Сформирован PDF отчёт для ФАВТ")
db.commit()
filename = f"favt_report_{datetime.now(timezone.utc).strftime('%Y%m%d')}.pdf"
return StreamingResponse(
buf,
media_type="application/pdf",
headers={"Content-Disposition": f"attachment; filename={filename}"},
)
# -----------------------------------------------------------------------
# 8. ПЕРСОНАЛ ПЛГ — сводка для регулятора
# ВК РФ ст. 52-54; ФАП-147; ICAO Annex 1
# -----------------------------------------------------------------------
@router.get("/personnel-summary", dependencies=[FAVT_ROLES])
def personnel_summary():
"""
Агрегированные данные о персонале ПЛГ для ФАВТ.
Показываются: количество специалистов, категории, compliance.
НЕ показываются: ФИО, табельные номера, персональные данные.
"""
from app.api.routes.personnel_plg import _specialists, _qualifications
from datetime import datetime, timezone, timedelta
now = datetime.now(timezone.utc)
total = len(_specialists)
by_category = {}
compliant = 0
non_compliant = 0
for sid, spec in _specialists.items():
cat = spec.get("category", "?")
by_category[cat] = by_category.get(cat, 0) + 1
quals = [q for q in _qualifications.values() if q["specialist_id"] == sid]
is_ok = True
for q in quals:
if q.get("next_due"):
due = datetime.fromisoformat(q["next_due"])
if due.replace(tzinfo=timezone.utc) < now:
is_ok = False
if is_ok:
compliant += 1
else:
non_compliant += 1
return {
"legal_basis": "ВК РФ ст. 52-54; ФАП-147; ICAO Annex 1",
"total_specialists": total,
"by_category": by_category,
"compliant": compliant,
"non_compliant": non_compliant,
"compliance_rate": round(compliant / total * 100, 1) if total > 0 else 100.0,
"note": "ПДн не раскрываются — только агрегированные показатели",
}
@router.get("/maintenance-summary", dependencies=[FAVT_ROLES])
def maintenance_summary_for_regulator():
"""
Агрегированные данные о ТО для ФАВТ.
НЕ раскрываются: детали нарядов, ФИО персонала.
Правовые основания: ВК РФ ст. 28; ФАП-145; ICAO Doc 9734 CE-7.
"""
from app.api.routes.work_orders import _work_orders
from app.api.routes.defects import _defects
wos = list(_work_orders.values())
defs = list(_defects.values())
return {
"legal_basis": "ВК РФ ст. 28; ФАП-145; ICAO Doc 9734 CE-7",
"work_orders": {
"total": len(wos),
"in_progress": len([w for w in wos if w["status"] == "in_progress"]),
"closed_last_30d": len([w for w in wos if w["status"] == "closed"]),
"aog": len([w for w in wos if w.get("priority") == "aog"]),
"by_type": {},
},
"defects": {
"total": len(defs),
"open": len([d for d in defs if d["status"] == "open"]),
"deferred_mel": len([d for d in defs if d.get("deferred")]),
"critical": len([d for d in defs if d.get("severity") == "critical"]),
},
"note": "Детали нарядов и ПДн не раскрываются (ФЗ-152)",
}