complete admin notifications data explorer
Some checks failed
ci / test (push) Has been cancelled

This commit is contained in:
VPN SaaS Dev
2026-05-19 19:02:16 +09:00
parent 58ff6ff614
commit 99bc9aa6a1
14 changed files with 528 additions and 5 deletions

View File

@@ -20,6 +20,7 @@ from app.models.car import (
AuditLog,
Car,
CarServiceLink,
OCRResult,
ServiceAppointment,
ServiceCenter,
ServiceCenterReview,
@@ -251,7 +252,15 @@ DATA_SOURCES: dict[str, dict[str, Any]] = {
"filters": {"user_id": "actor_user_id", "role": "actor_role"},
"columns": ["id", "actor_user_id", "actor_role", "action", "target_type", "target_id", "ip", "created_at"],
},
"ocr_results": {"model": None, "roles": ADMIN_ROLES, "columns": []},
"ocr_results": {
"model": OCRResult,
"roles": ADMIN_ROLES,
"search": ["filename", "recognized_text", "error"],
"filters": {"user_id": "user_id", "vehicle_id": "vehicle_id", "status": "status", "category": "scope"},
"columns": ["id", "user_id", "vehicle_id", "scope", "filename", "status", "provider", "confidence", "created_at", "error"],
"editable": ["status"],
"delete": {"type": "hard"},
},
"imports_exports": {
"model": AdminExportJob,
"roles": DATA_EXPORT_ROLES,

View File

@@ -1,5 +1,5 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_current_telegram_user
@@ -7,6 +7,7 @@ from app.db.session import get_session
from app.models.car import Car, VehicleAccess
from app.models.user import User
from app.schemas.car import CarCreate, CarRead, CarUpdate
from app.services.admin_notifications import create_admin_notification
from app.services.odometer import add_odometer_history, validate_odometer_change
from app.services.vehicle_identity import normalize_license_plate, validate_vin
@@ -42,6 +43,28 @@ async def create_car(
source_record_id=None,
changed_by=current_user.id,
)
vehicle_count = int(
(await session.execute(select(func.count(Car.id)).where(Car.owner_id == current_user.id))).scalar_one()
or 0
)
if vehicle_count == 1:
await create_admin_notification(
session,
event_type="vehicle_created",
title="Пользователь впервые добавил авто",
body="\n".join(
[
f"User ID: {current_user.id}",
f"Telegram ID: {current_user.telegram_id}",
f"Авто: {car.name}",
f"Пробег: {car.current_odometer or '-'}",
]
),
entity_type="vehicle",
entity_id=car.id,
idempotency_key=f"vehicle_created:{current_user.id}",
metadata={"user_id": current_user.id, "vehicle_id": car.id},
)
await session.commit()
await session.refresh(car)
return car

View File

@@ -3,7 +3,7 @@ from io import BytesIO
import matplotlib.pyplot as plt
from fastapi import APIRouter, Depends, HTTPException, Response, status
from sqlalchemy import select
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_current_telegram_user
@@ -25,6 +25,7 @@ from app.schemas.expense import (
ServiceEntryRead,
ServiceEntryUpdate,
)
from app.services.admin_notifications import create_admin_notification
from app.services.calculations import dataframe_from_query, get_ownership_stats, predict_odometer
from app.services.odometer import (
apply_odometer_from_record,
@@ -53,6 +54,59 @@ async def ensure_entry_owner(
return entry
async def maybe_notify_first_record(
session: AsyncSession,
*,
user: User,
car: Car,
record_type: str,
record_id: int,
) -> None:
fuel_count = int(
(
await session.execute(
select(func.count(FuelEntry.id)).join(Car, FuelEntry.car_id == Car.id).where(Car.owner_id == user.id)
)
).scalar_one()
or 0
)
service_count = int(
(
await session.execute(
select(func.count(ServiceEntry.id)).join(Car, ServiceEntry.car_id == Car.id).where(Car.owner_id == user.id)
)
).scalar_one()
or 0
)
expense_count = int(
(
await session.execute(
select(func.count(ExpenseEntry.id)).join(Car, ExpenseEntry.car_id == Car.id).where(Car.owner_id == user.id)
)
).scalar_one()
or 0
)
if fuel_count + service_count + expense_count != 1:
return
await create_admin_notification(
session,
event_type="first_record_created",
title="Пользователь впервые создал запись",
body="\n".join(
[
f"User ID: {user.id}",
f"Telegram ID: {user.telegram_id}",
f"Авто: {car.name}",
f"Тип записи: {record_type}",
]
),
entity_type="vehicle",
entity_id=car.id,
idempotency_key=f"first_record_created:{user.id}",
metadata={"user_id": user.id, "vehicle_id": car.id, "record_type": record_type, "record_id": record_id},
)
@router.post("/fuel", response_model=FuelEntryRead, status_code=status.HTTP_201_CREATED)
async def create_fuel_entry(
payload: FuelEntryCreate,
@@ -78,6 +132,7 @@ async def create_fuel_entry(
changed_by=current_user.id,
confirm_lower_odometer=payload.confirm_lower_odometer,
)
await maybe_notify_first_record(session, user=current_user, car=car, record_type="fuel", record_id=entry.id)
await session.commit()
await session.refresh(entry)
return entry
@@ -174,6 +229,7 @@ async def create_service_entry(
changed_by=current_user.id,
confirm_lower_odometer=payload.confirm_lower_odometer,
)
await maybe_notify_first_record(session, user=current_user, car=car, record_type="service", record_id=entry.id)
await session.commit()
await session.refresh(entry)
return entry
@@ -266,6 +322,7 @@ async def create_expense_entry(
changed_by=current_user.id,
confirm_lower_odometer=payload.confirm_lower_odometer,
)
await maybe_notify_first_record(session, user=current_user, car=car, record_type="expense", record_id=entry.id)
await session.commit()
await session.refresh(entry)
return entry

View File

@@ -9,9 +9,10 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_current_telegram_user
from app.db.session import get_session
from app.models.car import OCRResult
from app.models.user import User
from app.services.admin_notifications import create_admin_notification
from app.services.ocr_provider import get_ocr_provider
from app.services.ocr_provider import OcrResult, get_ocr_provider
from app.services.rate_limit import check_rate_limit
from app.services.uploads import SAFE_IMAGE_TYPES, SAFE_TEXT_TYPES, validate_upload
@@ -42,6 +43,51 @@ class OCRResultRead(BaseModel):
provider: str = "heuristic"
def ocr_candidates_json(result: OcrResult | None) -> list[dict] | None:
if result is None:
return None
return [
{"type": candidate.type, "value": candidate.value, "confidence": candidate.confidence}
for candidate in result.candidates
]
def ocr_confidence(result: OcrResult | None) -> Decimal | None:
if result is None or not result.candidates:
return None
return Decimal(str(round(max(candidate.confidence for candidate in result.candidates), 4)))
async def save_ocr_result(
session: AsyncSession,
*,
current_user: User,
scope: str,
filename: str | None,
content_type: str | None,
status: str,
result: OcrResult | None = None,
recognized_text: str | None = None,
provider: str | None = None,
error: str | None = None,
) -> OCRResult:
record = OCRResult(
user_id=current_user.id,
scope=scope,
filename=filename,
content_type=content_type,
status=status,
provider=result.provider if result is not None else provider,
confidence=ocr_confidence(result),
recognized_text=result.recognized_text if result is not None else recognized_text,
candidates_json=ocr_candidates_json(result),
error=error,
)
session.add(record)
await session.flush()
return record
async def validate_ocr_upload(
*,
session: AsyncSession,
@@ -59,6 +105,15 @@ async def validate_ocr_upload(
allowed_types=SAFE_IMAGE_TYPES | SAFE_TEXT_TYPES,
)
except HTTPException as exc:
await save_ocr_result(
session,
current_user=current_user,
scope="upload_validation",
filename=filename,
content_type=content_type,
status="blocked",
error=str(exc.detail),
)
await create_admin_notification(
session,
event_type="upload_blocked",
@@ -93,6 +148,15 @@ async def recognize_with_alert(
try:
return await get_ocr_provider().recognize(content, filename)
except Exception as exc: # noqa: BLE001 - OCR must fail gracefully and alert admins
await save_ocr_result(
session,
current_user=current_user,
scope=scope,
filename=filename,
content_type=None,
status="failed",
error=type(exc).__name__,
)
await create_admin_notification(
session,
event_type="ocr_failed",
@@ -134,10 +198,31 @@ async def parse_text_receipt(
scope="parse_text_receipt",
)
if not result or not result.recognized_text:
if result is not None:
await save_ocr_result(
session,
current_user=current_user,
scope="parse_text_receipt",
filename=file.filename,
content_type=file.content_type,
status="preview",
result=result,
)
await session.commit()
return ReceiptSuggestion(
confidence=0,
message="Не удалось уверенно распознать чек. Открылся ручной ввод: проверьте дату, сумму, литры и цену.",
)
await save_ocr_result(
session,
current_user=current_user,
scope="parse_text_receipt",
filename=file.filename,
content_type=file.content_type,
status="preview",
result=result,
)
await session.commit()
return parse_receipt_text(result.recognized_text)
text = " ".join(
[
@@ -145,6 +230,17 @@ async def parse_text_receipt(
content.decode("utf-8", errors="ignore"),
]
)
await save_ocr_result(
session,
current_user=current_user,
scope="parse_text_receipt",
filename=file.filename,
content_type=file.content_type,
status="preview",
recognized_text=text,
provider="text",
)
await session.commit()
return parse_receipt_text(text)
@@ -223,6 +319,16 @@ async def recognize_license_plate(
)
if result is None:
return OCRResultRead(recognized_text="", candidates=[], provider="error")
await save_ocr_result(
session,
current_user=current_user,
scope="license_plate",
filename=file.filename,
content_type=file.content_type,
status="preview",
result=result,
)
await session.commit()
return OCRResultRead(
recognized_text=result.recognized_text,
candidates=[OCRCandidateRead(**item.__dict__) for item in result.candidates if item.type == "license_plate"],
@@ -255,6 +361,16 @@ async def recognize_vin(
)
if result is None:
return OCRResultRead(recognized_text="", candidates=[], provider="error")
await save_ocr_result(
session,
current_user=current_user,
scope="vin",
filename=file.filename,
content_type=file.content_type,
status="preview",
result=result,
)
await session.commit()
return OCRResultRead(
recognized_text=result.recognized_text,
candidates=[OCRCandidateRead(**item.__dict__) for item in result.candidates if item.type == "vin"],
@@ -287,6 +403,16 @@ async def recognize_service_document(
)
if result is None:
return OCRResultRead(recognized_text="", candidates=[], provider="error")
await save_ocr_result(
session,
current_user=current_user,
scope="service_document",
filename=file.filename,
content_type=file.content_type,
status="preview",
result=result,
)
await session.commit()
return OCRResultRead(
recognized_text=result.recognized_text,
candidates=[OCRCandidateRead(**item.__dict__) for item in result.candidates],

View File

@@ -209,6 +209,22 @@ async def update_service_center_application(
)
)
await log_audit(session, actor=current_user, action="service_center.update", target_type="service_center", target_id=center.id)
await create_admin_notification(
session,
event_type="sto_application_updated",
title="СТО обновило заявку",
body="\n".join(
[
f"Название: {center.display_name or center.name}",
f"Город: {center.city or '-'}",
f"Статус: {center.verification_status}",
]
),
entity_type="service_center",
entity_id=center.id,
idempotency_key=f"sto_application_updated:{center.id}:{int(datetime.now(UTC).timestamp() // 60)}",
metadata={"city": center.city, "owner_user_id": current_user.id},
)
await session.commit()
await session.refresh(center)
return center
@@ -847,6 +863,18 @@ async def create_service_center_review(
await log_audit(session, actor=current_user, action="service_review.upsert", target_type="service_center", target_id=service_center_id)
await session.flush()
await refresh_service_rating(session, service_center_id)
if review.rating <= 2:
await create_admin_notification(
session,
event_type="sto_low_review",
title="Низкая оценка СТО",
body=f"СТО ID: {service_center_id}\nОценка: {review.rating}\nОтзыв: {review.text or '-'}",
entity_type="service_center",
entity_id=service_center_id,
severity="warning",
idempotency_key=f"sto_low_review:{review.id}:{review.rating}",
metadata={"review_id": review.id, "rating": review.rating, "user_id": current_user.id},
)
await session.commit()
await session.refresh(review)
return review

View File

@@ -36,6 +36,7 @@ from app.schemas.sto_booking import (
ServiceCenterHolidayRead,
STODashboardRead,
)
from app.services.admin_notifications import create_admin_notification
from app.services.rate_limit import check_rate_limit
from app.services.sto_booking import (
calculate_available_slots,
@@ -238,6 +239,28 @@ async def create_appointment(
body=f"{appointment.service_name}: {appointment.requested_start_at:%Y-%m-%d %H:%M}",
appointment_id=appointment.id,
)
await create_admin_notification(
session,
event_type="appointment_created",
title="Новая запись в СТО",
body="\n".join(
[
f"СТО ID: {appointment.service_center_id}",
f"User ID: {current_user.id}",
f"Авто ID: {appointment.vehicle_id}",
f"Услуга: {appointment.service_name}",
f"Время: {appointment.requested_start_at:%Y-%m-%d %H:%M}",
]
),
entity_type="appointment",
entity_id=appointment.id,
idempotency_key=f"appointment_created:{appointment.id}",
metadata={
"service_center_id": appointment.service_center_id,
"vehicle_id": appointment.vehicle_id,
"owner_id": appointment.owner_id,
},
)
await log_audit(
session,
actor=current_user,
@@ -554,6 +577,17 @@ async def reject_appointment(
title="СТО отклонило запись",
body=payload.comment,
)
await create_admin_notification(
session,
event_type="appointment_cancelled",
title="СТО отклонило запись",
body=payload.comment,
entity_type="appointment",
entity_id=appointment.id,
severity="warning",
idempotency_key=f"appointment_rejected_by_sto:{appointment.id}",
metadata={"service_center_id": appointment.service_center_id, "owner_id": appointment.owner_id},
)
await log_audit(session, actor=current_user, action="appointment.reject", target_type="service_appointment", target_id=appointment_id)
await session.commit()
await session.refresh(appointment)
@@ -579,6 +613,17 @@ async def delete_appointment_by_sto(
body=f"{appointment.service_name}: {appointment.requested_start_at:%Y-%m-%d %H:%M}",
idempotency_key=f"appointment:{appointment.id}:deleted_by_sto",
)
await create_admin_notification(
session,
event_type="appointment_cancelled",
title="СТО удалило запись",
body=f"{appointment.service_name}: {appointment.requested_start_at:%Y-%m-%d %H:%M}",
entity_type="appointment",
entity_id=appointment.id,
severity="warning",
idempotency_key=f"appointment_deleted_by_sto:{appointment.id}",
metadata={"service_center_id": appointment.service_center_id, "owner_id": appointment.owner_id},
)
await log_audit(
session,
actor=current_user,
@@ -677,6 +722,21 @@ async def create_work_order_from_appointment(
body=visit.work_order_number,
idempotency_key=f"work_order:{visit.id}:created",
)
await create_admin_notification(
session,
event_type="work_order_created",
title="Создан заказ-наряд",
body=f"{visit.work_order_number or visit.id}: СТО {visit.service_center_id}, авто {visit.vehicle_id}",
entity_type="work_order",
entity_id=visit.id,
idempotency_key=f"work_order_created:{visit.id}",
metadata={
"appointment_id": appointment.id,
"service_center_id": visit.service_center_id,
"vehicle_id": visit.vehicle_id,
"owner_id": visit.owner_id,
},
)
await log_audit(session, actor=current_user, action="appointment.create_work_order", target_type="service_appointment", target_id=appointment_id, metadata={"service_visit_id": visit.id})
await session.commit()
await session.refresh(visit)

View File

@@ -39,6 +39,7 @@ from app.schemas.service_center import (
WorkOrderStatusHistoryRead,
WorkOrderUpdate,
)
from app.services.admin_notifications import create_admin_notification
from app.services.sto_booking import create_service_notification
from app.services.work_orders import (
add_labor_item,
@@ -461,6 +462,17 @@ async def reject_work_order(
visit.owner_comment = payload.comment
visit.owner_resolved_at = datetime.now(UTC)
await add_status_history(session, visit, to_status="rejected_by_owner", actor=current_user, comment=payload.comment)
await create_admin_notification(
session,
event_type="work_order_rejected_by_owner",
title="Владелец отклонил смету",
body=payload.comment,
entity_type="work_order",
entity_id=visit.id,
severity="warning",
idempotency_key=f"work_order_rejected_by_owner:{visit.id}",
metadata={"service_center_id": visit.service_center_id, "vehicle_id": visit.vehicle_id, "owner_id": current_user.id},
)
await log_audit(session, actor=current_user, action="work_order.reject", target_type="service_visit", target_id=visit.id)
await session.commit()
await session.refresh(visit)
@@ -502,6 +514,21 @@ async def complete_work_order(
actor=current_user,
confirm_lower_odometer=payload.confirm_lower_odometer,
)
await create_admin_notification(
session,
event_type="work_order_completed",
title="Заказ-наряд завершён",
body=f"{visit.work_order_number or visit.id}: {visit.final_total} {visit.currency}",
entity_type="work_order",
entity_id=visit.id,
idempotency_key=f"work_order_completed:{visit.id}",
metadata={
"service_center_id": visit.service_center_id,
"vehicle_id": visit.vehicle_id,
"owner_id": visit.owner_id,
"final_total": str(visit.final_total),
},
)
await log_audit(session, actor=current_user, action="work_order.complete", target_type="service_visit", target_id=visit.id)
await session.commit()
await session.refresh(visit)
@@ -599,6 +626,21 @@ async def create_work_order_correction(
web_app_url=work_order_webapp_url(visit.id),
button_text="Открыть заказ-наряд",
)
await create_admin_notification(
session,
event_type="work_order_correction_requested",
title="Запрошена коррекция заказ-наряда",
body=payload.reason,
entity_type="work_order",
entity_id=visit.id,
severity="warning",
idempotency_key=f"work_order_correction_requested:{visit.id}:{visit.version or 1}:{payload.reason[:80]}",
metadata={
"service_center_id": visit.service_center_id,
"vehicle_id": visit.vehicle_id,
"owner_approval_required": payload.owner_approval_required,
},
)
await log_audit(
session,
actor=current_user,
@@ -626,6 +668,16 @@ async def approve_work_order_correction(
raise HTTPException(status_code=409, detail="Correction is already resolved")
correction.status = "approved"
correction.resolved_at = datetime.now(UTC)
await create_admin_notification(
session,
event_type="work_order_correction_resolved",
title="Коррекция заказ-наряда согласована",
body=payload.comment,
entity_type="work_order",
entity_id=visit.id,
idempotency_key=f"work_order_correction_approved:{correction.id}",
metadata={"correction_id": correction.id, "status": "approved"},
)
await log_audit(
session,
actor=current_user,
@@ -653,6 +705,17 @@ async def reject_work_order_correction(
raise HTTPException(status_code=409, detail="Correction is already resolved")
correction.status = "rejected"
correction.resolved_at = datetime.now(UTC)
await create_admin_notification(
session,
event_type="work_order_correction_resolved",
title="Коррекция заказ-наряда отклонена",
body=payload.comment,
entity_type="work_order",
entity_id=visit.id,
severity="warning",
idempotency_key=f"work_order_correction_rejected:{correction.id}",
metadata={"correction_id": correction.id, "status": "rejected"},
)
await log_audit(
session,
actor=current_user,