@@ -1,27 +1,826 @@
from datetime import UTC , datetime
import csv
import io
import json
from datetime import UTC , date , datetime , timedelta
from decimal import Decimal
from typing import Any
from fastapi import APIRouter , Depends , HTTPException
from sqlalchemy import select
from fastapi . encoders import jsonable_encoder
from pydantic import BaseModel , ConfigDict , Field
from sqlalchemy import Select , func , or_ , select
from sqlalchemy . ext . asyncio import AsyncSession
from app . api . deps import get_current_telegram_user , log_audit , require_platform_role
from app . db . session import get_session
from app . models . car import (
AdminExportJob ,
AdminNotification ,
AuditLog ,
Car ,
CarServiceLink ,
ServiceAppointment ,
ServiceCenter ,
ServiceCenterReview ,
ServiceCenterVerification ,
ServiceEmployee ,
ServiceNotification ,
ServiceProductItem ,
ServiceVisit ,
ServiceWorkItem ,
)
from app . models . expense import ExpenseEntry , FuelEntry , ServiceEntry
from app . models . user import User
from app . schemas . service_center import AdminModerationDecision , ServiceCenterRead , ServiceVisitRead
from app . services . admin_notifications import (
create_admin_notification ,
dismiss_admin_notification ,
mark_admin_notification_read ,
)
from app . services . notifications import notify_user
router = APIRouter ( prefix = " /admin " , tags = [ " admin " ] )
ADMIN_ROLES = { " admin " , " super_admin " , " moderator " , " support " , " analyst " }
FULL_ADMIN_ROLES = { " admin " , " super_admin " }
MODERATION_ROLES = { " admin " , " super_admin " , " moderator " }
DATA_EXPORT_ROLES = { " admin " , " super_admin " , " analyst " }
class AdminDataQuery ( BaseModel ) :
model_config = ConfigDict ( extra = " forbid " )
source : str
date_from : date | None = None
date_to : date | None = None
status : str | None = None
user_id : int | None = None
telegram_id : int | None = None
vehicle_id : int | None = None
sto_id : int | None = None
city : str | None = None
role : str | None = None
category : str | None = None
amount_min : Decimal | None = None
amount_max : Decimal | None = None
has_errors : bool | None = None
is_pending : bool | None = None
is_completed : bool | None = None
search : str | None = None
sort : str = " created_at_desc "
limit : int = Field ( default = 50 , ge = 1 , le = 500 )
offset : int = Field ( default = 0 , ge = 0 )
include_sensitive : bool = False
reason : str | None = None
class AdminExportRequest ( AdminDataQuery ) :
export_format : str = " json "
class AdminUserNote ( BaseModel ) :
note : str
DATA_SOURCES : dict [ str , dict [ str , Any ] ] = {
" users " : {
" model " : User ,
" roles " : ADMIN_ROLES ,
" search " : [ " username " , " first_name " , " last_name " ] ,
" filters " : { " user_id " : " id " , " telegram_id " : " telegram_id " , " role " : " platform_role " } ,
" sensitive " : { " telegram_id " } ,
" columns " : [ " id " , " telegram_id " , " username " , " first_name " , " last_name " , " platform_role " , " created_at " , " updated_at " ] ,
} ,
" vehicles " : {
" model " : Car ,
" roles " : ADMIN_ROLES ,
" search " : [ " name " , " make " , " model " , " vin " , " plate_number " , " license_plate_display " ] ,
" filters " : { " user_id " : " owner_id " , " vehicle_id " : " id " } ,
" sensitive " : { " vin " , " plate_number " , " license_plate_display " , " vin_normalized " , " license_plate_normalized " } ,
" columns " : [ " id " , " owner_id " , " name " , " make " , " model " , " year " , " vin " , " plate_number " , " license_plate_display " , " current_odometer " , " created_at " ] ,
} ,
" fuel_entries " : {
" model " : FuelEntry ,
" roles " : FULL_ADMIN_ROLES | { " analyst " } ,
" filters " : { " vehicle_id " : " car_id " } ,
" amount " : " total_cost " ,
" date " : " entry_date " ,
" columns " : [ " id " , " car_id " , " entry_date " , " odometer " , " liters " , " total_cost " , " created_at " ] ,
} ,
" service_entries " : {
" model " : ServiceEntry ,
" roles " : FULL_ADMIN_ROLES | { " analyst " , " support " } ,
" search " : [ " title " , " vendor " , " category " ] ,
" filters " : { " vehicle_id " : " car_id " , " category " : " category " } ,
" amount " : " total_cost " ,
" date " : " entry_date " ,
" columns " : [ " id " , " car_id " , " entry_date " , " service_type " , " title " , " total_cost " , " created_at " ] ,
} ,
" expense_entries " : {
" model " : ExpenseEntry ,
" roles " : FULL_ADMIN_ROLES | { " analyst " } ,
" search " : [ " title " , " vendor " ] ,
" filters " : { " vehicle_id " : " car_id " , " category " : " category " } ,
" amount " : " total_cost " ,
" date " : " entry_date " ,
" columns " : [ " id " , " car_id " , " entry_date " , " category " , " title " , " total_cost " , " currency " , " created_at " ] ,
} ,
" sto_profiles " : {
" model " : ServiceCenter ,
" roles " : ADMIN_ROLES ,
" search " : [ " name " , " display_name " , " legal_name " , " city " ] ,
" filters " : { " sto_id " : " id " , " city " : " city " , " status " : " verification_status " , " user_id " : " owner_user_id " } ,
" sensitive " : { " phone " , " contact_phone " , " business_registration_number " } ,
" columns " : [ " id " , " display_name " , " legal_name " , " city " , " phone " , " verification_status " , " owner_user_id " , " created_at " , " verified_at " ] ,
} ,
" sto_applications " : {
" model " : ServiceCenterVerification ,
" roles " : MODERATION_ROLES ,
" filters " : { " sto_id " : " service_center_id " , " status " : " status " , " user_id " : " reviewed_by " } ,
" columns " : [ " id " , " service_center_id " , " status " , " reviewed_by " , " reviewed_at " , " created_at " , " comment " ] ,
} ,
" sto_employees " : {
" model " : ServiceEmployee ,
" roles " : MODERATION_ROLES | { " support " } ,
" filters " : { " sto_id " : " service_center_id " , " user_id " : " user_id " , " role " : " role " , " status " : " status " } ,
" columns " : [ " id " , " service_center_id " , " user_id " , " role " , " status " , " created_at " ] ,
} ,
" vehicle_sto_links " : {
" model " : CarServiceLink ,
" roles " : MODERATION_ROLES | { " support " } ,
" filters " : { " vehicle_id " : " car_id " , " sto_id " : " service_center_id " , " status " : " status " } ,
" columns " : [ " id " , " car_id " , " service_center_id " , " access_level " , " status " , " created_at " ] ,
} ,
" appointments " : {
" model " : ServiceAppointment ,
" roles " : ADMIN_ROLES ,
" filters " : { " vehicle_id " : " vehicle_id " , " sto_id " : " service_center_id " , " user_id " : " owner_user_id " , " status " : " status " } ,
" columns " : [ " id " , " service_center_id " , " vehicle_id " , " owner_user_id " , " service_type " , " service_name " , " status " , " requested_start_at " , " created_at " ] ,
} ,
" work_orders " : {
" model " : ServiceVisit ,
" roles " : ADMIN_ROLES ,
" filters " : { " vehicle_id " : " vehicle_id " , " sto_id " : " service_center_id " , " user_id " : " owner_user_id " , " status " : " status " } ,
" amount " : " final_total " ,
" columns " : [ " id " , " service_center_id " , " vehicle_id " , " owner_user_id " , " status " , " final_total " , " currency " , " created_at " , " completed_at " ] ,
} ,
" work_order_items " : {
" model " : ServiceWorkItem ,
" roles " : FULL_ADMIN_ROLES | { " support " } ,
" filters " : { " category " : " category " } ,
" amount " : " total " ,
" columns " : [ " id " , " service_visit_id " , " work_type " , " title " , " category " , " quantity " , " total " , " created_at " ] ,
} ,
" work_order_products " : {
" model " : ServiceProductItem ,
" roles " : FULL_ADMIN_ROLES | { " support " } ,
" filters " : { " category " : " category " } ,
" amount " : " total " ,
" columns " : [ " id " , " service_visit_id " , " title " , " category " , " product_type " , " quantity " , " total " , " created_at " ] ,
} ,
" reviews " : {
" model " : ServiceCenterReview ,
" roles " : MODERATION_ROLES | { " support " , " analyst " } ,
" filters " : { " sto_id " : " service_center_id " , " user_id " : " user_id " , " status " : " status " } ,
" columns " : [ " id " , " service_center_id " , " user_id " , " rating " , " status " , " created_at " ] ,
} ,
" notifications " : {
" model " : ServiceNotification ,
" roles " : FULL_ADMIN_ROLES | { " support " } ,
" filters " : { " user_id " : " recipient_user_id " , " status " : " status " , " sto_id " : " service_center_id " } ,
" columns " : [ " id " , " recipient_user_id " , " notification_type " , " title " , " status " , " created_at " , " sent_at " ] ,
} ,
" admin_notifications " : {
" model " : AdminNotification ,
" roles " : ADMIN_ROLES ,
" filters " : { " status " : " status " } ,
" columns " : [ " id " , " event_type " , " severity " , " title " , " entity_type " , " entity_id " , " status " , " created_at " ] ,
} ,
" audit_logs " : {
" model " : AuditLog ,
" roles " : FULL_ADMIN_ROLES | { " moderator " , " support " } ,
" search " : [ " action " , " target_type " , " target_id " ] ,
" 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 " : [ ] } ,
" imports_exports " : {
" model " : AdminExportJob ,
" roles " : DATA_EXPORT_ROLES ,
" filters " : { " user_id " : " requested_by_user_id " , " status " : " status " } ,
" columns " : [ " id " , " requested_by_user_id " , " source " , " export_format " , " status " , " row_count " , " created_at " ] ,
} ,
}
def require_admin_or_verifier ( user : User ) - > None :
require_platform_role ( user , { " admin " , " verifier " , " moderato r" } )
require_platform_role ( user , MODERATION_ROLES | { " verifie r" } )
def require_admin_access ( user : User , allowed : set [ str ] | None = None ) - > None :
require_platform_role ( user , allowed or ADMIN_ROLES )
def mask_text ( value : Any , visible : int = 4 ) - > Any :
if value is None :
return None
text = str ( value )
if len ( text ) < = visible :
return " * " * len ( text )
return f " { text [ : 2 ] } { ' * ' * max ( len ( text ) - visible , 3 ) } { text [ - 2 : ] } "
def can_view_sensitive ( user : User , query : AdminDataQuery ) - > bool :
if not query . include_sensitive :
return False
if user . platform_role not in FULL_ADMIN_ROLES :
raise HTTPException ( status_code = 403 , detail = " Sensitive data is restricted " )
if not query . reason or len ( query . reason . strip ( ) ) < 5 :
raise HTTPException ( status_code = 400 , detail = " reason is required for sensitive data " )
return True
def public_columns ( source : str ) - > list [ str ] :
return list ( DATA_SOURCES [ source ] . get ( " columns " ) or [ ] )
def serialize_row ( row : Any , source : str , * , include_sensitive : bool , role : str ) - > dict [ str , Any ] :
config = DATA_SOURCES [ source ]
sensitive = set ( config . get ( " sensitive " ) or set ( ) )
columns = public_columns ( source )
payload : dict [ str , Any ] = { }
for column in columns :
value = getattr ( row , column , None )
if role == " analyst " and column in { " telegram_id " , " username " , " first_name " , " last_name " , " owner_id " , " user_id " } :
value = mask_text ( value )
elif column in sensitive and not include_sensitive :
value = mask_text ( value )
payload [ column ] = jsonable_encoder ( value )
return payload
def source_config ( source : str , user : User ) - > dict [ str , Any ] :
config = DATA_SOURCES . get ( source )
if config is None or config . get ( " model " ) is None :
raise HTTPException ( status_code = 400 , detail = " Unsupported data source " )
require_admin_access ( user , set ( config [ " roles " ] ) )
return config
def apply_data_filters ( stmt : Select , query : AdminDataQuery , config : dict [ str , Any ] ) - > Select :
model = config [ " model " ]
date_column = config . get ( " date " , " created_at " )
if hasattr ( model , date_column ) :
column = getattr ( model , date_column )
if query . date_from :
stmt = stmt . where ( column > = query . date_from )
if query . date_to :
stmt = stmt . where ( column < = query . date_to )
for query_field , model_field in ( config . get ( " filters " ) or { } ) . items ( ) :
value = getattr ( query , query_field )
if value is not None and value != " " and hasattr ( model , model_field ) :
stmt = stmt . where ( getattr ( model , model_field ) == value )
if query . is_pending is True and hasattr ( model , " status " ) :
stmt = stmt . where ( model . status . in_ ( [ " pending " , " requested " , " unread " ] ) )
if query . is_completed is True and hasattr ( model , " status " ) :
stmt = stmt . where ( model . status . in_ ( [ " completed " , " closed " , " confirmed " ] ) )
amount_column = config . get ( " amount " )
if amount_column and hasattr ( model , amount_column ) :
column = getattr ( model , amount_column )
if query . amount_min is not None :
stmt = stmt . where ( column > = query . amount_min )
if query . amount_max is not None :
stmt = stmt . where ( column < = query . amount_max )
if query . has_errors is not None :
if hasattr ( model , " last_error " ) :
stmt = stmt . where ( model . last_error . is_not ( None ) if query . has_errors else model . last_error . is_ ( None ) )
elif hasattr ( model , " telegram_error " ) :
stmt = stmt . where ( model . telegram_error . is_not ( None ) if query . has_errors else model . telegram_error . is_ ( None ) )
if query . search :
search_fields = [ field for field in config . get ( " search " , [ ] ) if hasattr ( model , field ) ]
if search_fields :
pattern = f " % { query . search } % "
stmt = stmt . where ( or_ ( * ( getattr ( model , field ) . ilike ( pattern ) for field in search_fields ) ) )
return stmt
def apply_sort ( stmt : Select , query : AdminDataQuery , config : dict [ str , Any ] ) - > Select :
model = config [ " model " ]
sort_map = {
" created_at_desc " : ( " created_at " , True ) ,
" created_at_asc " : ( " created_at " , False ) ,
" updated_at_desc " : ( " updated_at " , True ) ,
" amount_desc " : ( config . get ( " amount " ) or " created_at " , True ) ,
" status " : ( " status " , False ) ,
" city " : ( " city " , False ) ,
}
field , desc = sort_map . get ( query . sort , ( " created_at " , True ) )
if not hasattr ( model , field ) :
field = " id "
column = getattr ( model , field )
return stmt . order_by ( column . desc ( ) if desc else column . asc ( ) )
async def run_data_query (
session : AsyncSession , current_user : User , query : AdminDataQuery
) - > dict [ str , Any ] :
config = source_config ( query . source , current_user )
include_sensitive = can_view_sensitive ( current_user , query )
stmt = apply_sort ( apply_data_filters ( select ( config [ " model " ] ) , query , config ) , query , config )
result = await session . execute ( stmt . limit ( query . limit ) . offset ( query . offset ) )
rows = [
serialize_row ( item , query . source , include_sensitive = include_sensitive , role = current_user . platform_role )
for item in result . scalars ( )
]
await log_audit (
session ,
actor = current_user ,
action = " admin.data.query " ,
target_type = query . source ,
metadata = {
" filters " : query . model_dump ( mode = " json " , exclude = { " reason " } ) ,
" include_sensitive " : include_sensitive ,
" reason " : query . reason if include_sensitive else None ,
" rows " : len ( rows ) ,
} ,
)
return { " source " : query . source , " limit " : query . limit , " offset " : query . offset , " rows " : rows }
def csv_from_rows ( rows : list [ dict [ str , Any ] ] ) - > str :
output = io . StringIO ( )
if not rows :
return " "
writer = csv . DictWriter ( output , fieldnames = list ( rows [ 0 ] . keys ( ) ) )
writer . writeheader ( )
writer . writerows ( rows )
return output . getvalue ( )
@router.get ( " /dashboard " )
async def admin_dashboard (
session : AsyncSession = Depends ( get_session ) ,
current_user : User = Depends ( get_current_telegram_user ) ,
) - > dict [ str , Any ] :
require_admin_access ( current_user )
today = datetime . now ( UTC ) . date ( )
week_ago = today - timedelta ( days = 6 )
async def count ( stmt : Select ) - > int :
return int ( ( await session . execute ( stmt ) ) . scalar_one ( ) or 0 )
users_total = await count ( select ( func . count ( User . id ) ) )
users_today = await count ( select ( func . count ( User . id ) ) . where ( func . date ( User . created_at ) == today ) )
users_7d = await count ( select ( func . count ( User . id ) ) . where ( func . date ( User . created_at ) > = week_ago ) )
vehicles_total = await count ( select ( func . count ( Car . id ) ) )
pending_sto = await count ( select ( func . count ( ServiceCenter . id ) ) . where ( ServiceCenter . verification_status == " pending " ) )
approved_sto = await count ( select ( func . count ( ServiceCenter . id ) ) . where ( ServiceCenter . verification_status . in_ ( [ " approved " , " verified " ] ) ) )
suspended_sto = await count ( select ( func . count ( ServiceCenter . id ) ) . where ( ServiceCenter . verification_status == " suspended " ) )
appointments_today = await count (
select ( func . count ( ServiceAppointment . id ) ) . where ( func . date ( ServiceAppointment . requested_start_at ) == today )
)
active_work_orders = await count (
select ( func . count ( ServiceVisit . id ) ) . where ( ServiceVisit . status . in_ ( [ " draft " , " awaiting_approval " , " approved " , " in_progress " ] ) )
)
completed_work_orders = await count ( select ( func . count ( ServiceVisit . id ) ) . where ( ServiceVisit . status == " completed " ) )
system_errors = await count ( select ( func . count ( AdminNotification . id ) ) . where ( AdminNotification . severity . in_ ( [ " error " , " critical " ] ) ) )
security_events = await count ( select ( func . count ( AdminNotification . id ) ) . where ( AdminNotification . event_type . in_ ( [ " security_event " , " rate_limit_exceeded " , " upload_blocked " ] ) ) )
latest_alerts = (
await session . execute ( select ( AdminNotification ) . order_by ( AdminNotification . created_at . desc ( ) ) . limit ( 8 ) )
) . scalars ( )
return {
" users_today " : users_today ,
" users_7d " : users_7d ,
" users_total " : users_total ,
" active_users " : users_7d ,
" vehicles_total " : vehicles_total ,
" new_sto_applications " : pending_sto ,
" pending_sto_applications " : pending_sto ,
" approved_sto " : approved_sto ,
" suspended_sto " : suspended_sto ,
" appointments_today " : appointments_today ,
" active_work_orders " : active_work_orders ,
" completed_work_orders " : completed_work_orders ,
" system_errors " : system_errors ,
" security_events " : security_events ,
" latest_alerts " : [ serialize_row ( item , " admin_notifications " , include_sensitive = False , role = current_user . platform_role ) for item in latest_alerts ] ,
}
@router.get ( " /notifications " )
async def admin_notifications (
status : str | None = None ,
limit : int = 50 ,
offset : int = 0 ,
session : AsyncSession = Depends ( get_session ) ,
current_user : User = Depends ( get_current_telegram_user ) ,
) - > dict [ str , Any ] :
require_admin_access ( current_user )
limit = min ( max ( limit , 1 ) , 200 )
stmt = select ( AdminNotification )
if status :
stmt = stmt . where ( AdminNotification . status == status )
rows = (
await session . execute ( stmt . order_by ( AdminNotification . created_at . desc ( ) ) . limit ( limit ) . offset ( max ( offset , 0 ) ) )
) . scalars ( )
return { " rows " : [ jsonable_encoder ( item ) for item in rows ] , " limit " : limit , " offset " : offset }
@router.post ( " /notifications/ {notification_id} /read " )
async def read_admin_notification (
notification_id : int ,
session : AsyncSession = Depends ( get_session ) ,
current_user : User = Depends ( get_current_telegram_user ) ,
) - > dict [ str , Any ] :
require_admin_access ( current_user )
notification = await session . get ( AdminNotification , notification_id )
if notification is None :
raise HTTPException ( status_code = 404 , detail = " Admin notification not found " )
await mark_admin_notification_read ( session , notification )
await log_audit ( session , actor = current_user , action = " admin_notification.read " , target_type = " admin_notification " , target_id = notification_id )
await session . commit ( )
return jsonable_encoder ( notification )
@router.post ( " /notifications/read-all " )
async def read_all_admin_notifications (
session : AsyncSession = Depends ( get_session ) ,
current_user : User = Depends ( get_current_telegram_user ) ,
) - > dict [ str , Any ] :
require_admin_access ( current_user )
rows = ( await session . execute ( select ( AdminNotification ) . where ( AdminNotification . status == " unread " ) ) ) . scalars ( ) . all ( )
for notification in rows :
await mark_admin_notification_read ( session , notification )
await log_audit ( session , actor = current_user , action = " admin_notification.read_all " , target_type = " admin_notification " , metadata = { " count " : len ( rows ) } )
await session . commit ( )
return { " updated " : len ( rows ) }
@router.post ( " /notifications/ {notification_id} /dismiss " )
async def dismiss_notification (
notification_id : int ,
session : AsyncSession = Depends ( get_session ) ,
current_user : User = Depends ( get_current_telegram_user ) ,
) - > dict [ str , Any ] :
require_admin_access ( current_user )
notification = await session . get ( AdminNotification , notification_id )
if notification is None :
raise HTTPException ( status_code = 404 , detail = " Admin notification not found " )
await dismiss_admin_notification ( session , notification )
await log_audit ( session , actor = current_user , action = " admin_notification.dismiss " , target_type = " admin_notification " , target_id = notification_id )
await session . commit ( )
return jsonable_encoder ( notification )
@router.get ( " /data/sources " )
async def admin_data_sources ( current_user : User = Depends ( get_current_telegram_user ) ) - > dict [ str , Any ] :
require_admin_access ( current_user )
return {
" sources " : [
{
" name " : name ,
" available " : bool ( config . get ( " model " ) ) ,
" columns " : config . get ( " columns " ) or [ ] ,
" sensitive " : sorted ( config . get ( " sensitive " ) or [ ] ) ,
" allowed " : current_user . platform_role in set ( config [ " roles " ] ) ,
}
for name , config in DATA_SOURCES . items ( )
] ,
" sorts " : [ " created_at_desc " , " created_at_asc " , " updated_at_desc " , " amount_desc " , " status " , " city " ] ,
" limits " : [ 25 , 50 , 100 , 500 ] ,
}
@router.post ( " /data/query " )
async def admin_data_query (
payload : AdminDataQuery ,
session : AsyncSession = Depends ( get_session ) ,
current_user : User = Depends ( get_current_telegram_user ) ,
) - > dict [ str , Any ] :
data = await run_data_query ( session , current_user , payload )
await session . commit ( )
return data
@router.post ( " /data/export " )
async def admin_data_export (
payload : AdminExportRequest ,
session : AsyncSession = Depends ( get_session ) ,
current_user : User = Depends ( get_current_telegram_user ) ,
) - > dict [ str , Any ] :
require_admin_access ( current_user , DATA_EXPORT_ROLES )
if payload . export_format not in { " json " , " csv " } :
raise HTTPException ( status_code = 400 , detail = " Unsupported export format " )
if payload . source in { " users " , " vehicles " , " sto_profiles " } and not payload . reason :
raise HTTPException ( status_code = 400 , detail = " Export reason is required " )
data = await run_data_query ( session , current_user , payload )
content = json . dumps ( data [ " rows " ] , ensure_ascii = False , default = str , indent = 2 )
if payload . export_format == " csv " :
content = csv_from_rows ( data [ " rows " ] )
job = AdminExportJob (
requested_by_user_id = current_user . id ,
source = payload . source ,
export_format = payload . export_format ,
status = " ready " ,
reason = payload . reason ,
filters_json = payload . model_dump ( mode = " json " , exclude = { " reason " } ) ,
result_text = content ,
row_count = len ( data [ " rows " ] ) ,
expires_at = datetime . now ( UTC ) + timedelta ( days = 7 ) ,
)
session . add ( job )
await log_audit ( session , actor = current_user , action = " admin.data.export " , target_type = payload . source , metadata = { " format " : payload . export_format , " rows " : len ( data [ " rows " ] ) } )
await session . commit ( )
await session . refresh ( job )
return { " id " : job . id , " status " : job . status , " row_count " : job . row_count , " expires_at " : job . expires_at }
@router.get ( " /exports " )
async def admin_exports (
session : AsyncSession = Depends ( get_session ) ,
current_user : User = Depends ( get_current_telegram_user ) ,
) - > dict [ str , Any ] :
require_admin_access ( current_user , DATA_EXPORT_ROLES )
rows = ( await session . execute ( select ( AdminExportJob ) . order_by ( AdminExportJob . created_at . desc ( ) ) . limit ( 50 ) ) ) . scalars ( )
return {
" rows " : [
{
" id " : item . id ,
" requested_by_user_id " : item . requested_by_user_id ,
" source " : item . source ,
" export_format " : item . export_format ,
" status " : item . status ,
" row_count " : item . row_count ,
" reason " : item . reason ,
" expires_at " : item . expires_at ,
" created_at " : item . created_at ,
}
for item in rows
]
}
@router.get ( " /exports/ {export_id} " )
async def admin_export_detail (
export_id : int ,
session : AsyncSession = Depends ( get_session ) ,
current_user : User = Depends ( get_current_telegram_user ) ,
) - > dict [ str , Any ] :
require_admin_access ( current_user , DATA_EXPORT_ROLES )
job = await session . get ( AdminExportJob , export_id )
if job is None :
raise HTTPException ( status_code = 404 , detail = " Export not found " )
await log_audit ( session , actor = current_user , action = " admin.export.view " , target_type = " admin_export " , target_id = export_id )
await session . commit ( )
return jsonable_encoder ( job )
@router.get ( " /users " )
async def admin_users (
search : str | None = None ,
limit : int = 50 ,
offset : int = 0 ,
session : AsyncSession = Depends ( get_session ) ,
current_user : User = Depends ( get_current_telegram_user ) ,
) - > dict [ str , Any ] :
query = AdminDataQuery ( source = " users " , search = search , limit = min ( limit , 100 ) , offset = offset )
data = await run_data_query ( session , current_user , query )
await session . commit ( )
return data
@router.get ( " /users/ {user_id} " )
async def admin_user_detail (
user_id : int ,
session : AsyncSession = Depends ( get_session ) ,
current_user : User = Depends ( get_current_telegram_user ) ,
) - > dict [ str , Any ] :
require_admin_access ( current_user , ADMIN_ROLES - { " analyst " } )
user = await session . get ( User , user_id )
if user is None :
raise HTTPException ( status_code = 404 , detail = " User not found " )
cars_count = int ( ( await session . execute ( select ( func . count ( Car . id ) ) . where ( Car . owner_id == user . id ) ) ) . scalar_one ( ) or 0 )
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
)
appointments_count = int ( ( await session . execute ( select ( func . count ( ServiceAppointment . id ) ) . where ( ServiceAppointment . owner_user_id == user . id ) ) ) . scalar_one ( ) or 0 )
await log_audit ( session , actor = current_user , action = " admin.user.view " , target_type = " user " , target_id = user . id )
await session . commit ( )
return {
* * serialize_row ( user , " users " , include_sensitive = current_user . platform_role in FULL_ADMIN_ROLES , role = current_user . platform_role ) ,
" cars_count " : cars_count ,
" records_count " : fuel_count ,
" appointments_count " : appointments_count ,
}
@router.get ( " /users/ {user_id} /activity " )
async def admin_user_activity (
user_id : int ,
limit : int = 50 ,
session : AsyncSession = Depends ( get_session ) ,
current_user : User = Depends ( get_current_telegram_user ) ,
) - > dict [ str , Any ] :
require_admin_access ( current_user , ADMIN_ROLES - { " analyst " } )
rows = (
await session . execute (
select ( AuditLog )
. where ( AuditLog . actor_user_id == user_id )
. order_by ( AuditLog . created_at . desc ( ) )
. limit ( min ( max ( limit , 1 ) , 100 ) )
)
) . scalars ( )
await log_audit ( session , actor = current_user , action = " admin.user.activity " , target_type = " user " , target_id = user_id )
await session . commit ( )
return { " rows " : [ jsonable_encoder ( item ) for item in rows ] }
@router.post ( " /users/ {user_id} /note " )
async def admin_user_note (
user_id : int ,
payload : AdminUserNote ,
session : AsyncSession = Depends ( get_session ) ,
current_user : User = Depends ( get_current_telegram_user ) ,
) - > dict [ str , str ] :
require_admin_access ( current_user , { " admin " , " super_admin " , " support " } )
await log_audit ( session , actor = current_user , action = " admin.user.note " , target_type = " user " , target_id = user_id , metadata = { " note " : payload . note } )
await session . commit ( )
return { " status " : " saved " }
@router.post ( " /users/ {user_id} /block " )
async def admin_block_user (
user_id : int ,
payload : AdminUserNote | None = None ,
session : AsyncSession = Depends ( get_session ) ,
current_user : User = Depends ( get_current_telegram_user ) ,
) - > dict [ str , Any ] :
require_admin_access ( current_user , FULL_ADMIN_ROLES )
user = await session . get ( User , user_id )
if user is None :
raise HTTPException ( status_code = 404 , detail = " User not found " )
user . platform_role = " blocked "
await log_audit ( session , actor = current_user , action = " admin.user.block " , target_type = " user " , target_id = user_id , metadata = { " reason " : payload . note if payload else None } )
await session . commit ( )
return { " id " : user . id , " platform_role " : user . platform_role }
@router.post ( " /users/ {user_id} /unblock " )
async def admin_unblock_user (
user_id : int ,
payload : AdminUserNote | None = None ,
session : AsyncSession = Depends ( get_session ) ,
current_user : User = Depends ( get_current_telegram_user ) ,
) - > dict [ str , Any ] :
require_admin_access ( current_user , FULL_ADMIN_ROLES )
user = await session . get ( User , user_id )
if user is None :
raise HTTPException ( status_code = 404 , detail = " User not found " )
user . platform_role = " user "
await log_audit ( session , actor = current_user , action = " admin.user.unblock " , target_type = " user " , target_id = user_id , metadata = { " reason " : payload . note if payload else None } )
await session . commit ( )
return { " id " : user . id , " platform_role " : user . platform_role }
@router.get ( " /sto " )
async def admin_sto (
status : str | None = None ,
city : str | None = None ,
limit : int = 50 ,
offset : int = 0 ,
session : AsyncSession = Depends ( get_session ) ,
current_user : User = Depends ( get_current_telegram_user ) ,
) - > dict [ str , Any ] :
query = AdminDataQuery ( source = " sto_profiles " , status = status , city = city , limit = min ( limit , 100 ) , offset = offset )
data = await run_data_query ( session , current_user , query )
await session . commit ( )
return data
@router.get ( " /sto/ {service_center_id} " )
async def admin_sto_detail (
service_center_id : int ,
session : AsyncSession = Depends ( get_session ) ,
current_user : User = Depends ( get_current_telegram_user ) ,
) - > dict [ str , Any ] :
require_admin_access ( current_user , ADMIN_ROLES - { " analyst " } )
center = await session . get ( ServiceCenter , service_center_id )
if center is None :
raise HTTPException ( status_code = 404 , detail = " Service center not found " )
employees_count = int ( ( await session . execute ( select ( func . count ( ServiceEmployee . id ) ) . where ( ServiceEmployee . service_center_id == center . id ) ) ) . scalar_one ( ) or 0 )
appointments_count = int ( ( await session . execute ( select ( func . count ( ServiceAppointment . id ) ) . where ( ServiceAppointment . service_center_id == center . id ) ) ) . scalar_one ( ) or 0 )
work_orders_count = int ( ( await session . execute ( select ( func . count ( ServiceVisit . id ) ) . where ( ServiceVisit . service_center_id == center . id ) ) ) . scalar_one ( ) or 0 )
await log_audit ( session , actor = current_user , action = " admin.sto.view " , target_type = " service_center " , target_id = center . id )
await session . commit ( )
return {
* * serialize_row ( center , " sto_profiles " , include_sensitive = current_user . platform_role in FULL_ADMIN_ROLES , role = current_user . platform_role ) ,
" employees_count " : employees_count ,
" appointments_count " : appointments_count ,
" work_orders_count " : work_orders_count ,
}
@router.get ( " /sto-applications " )
async def admin_sto_applications (
status : str | None = None ,
city : str | None = None ,
limit : int = 50 ,
offset : int = 0 ,
session : AsyncSession = Depends ( get_session ) ,
current_user : User = Depends ( get_current_telegram_user ) ,
) - > dict [ str , Any ] :
require_admin_access ( current_user , MODERATION_ROLES )
stmt = select ( ServiceCenter ) . where ( ServiceCenter . verification_status . in_ ( [ " pending " , " needs_changes " ] if status is None else [ status ] ) )
if city :
stmt = stmt . where ( ServiceCenter . city == city )
rows = ( await session . execute ( stmt . order_by ( ServiceCenter . created_at . asc ( ) ) . limit ( min ( limit , 100 ) ) . offset ( offset ) ) ) . scalars ( )
return { " rows " : [ serialize_row ( item , " sto_profiles " , include_sensitive = False , role = current_user . platform_role ) for item in rows ] }
async def center_id_from_application ( session : AsyncSession , application_id : int ) - > int :
verification = await session . get ( ServiceCenterVerification , application_id )
if verification is None :
center = await session . get ( ServiceCenter , application_id )
if center is None :
raise HTTPException ( status_code = 404 , detail = " STO application not found " )
return center . id
return verification . service_center_id
@router.post ( " /sto-applications/ {application_id} /approve " , response_model = ServiceCenterRead )
async def approve_sto_application (
application_id : int ,
payload : AdminModerationDecision | None = None ,
session : AsyncSession = Depends ( get_session ) ,
current_user : User = Depends ( get_current_telegram_user ) ,
) - > ServiceCenter :
center_id = await center_id_from_application ( session , application_id )
return await verify_service_center ( center_id , payload , session , current_user )
@router.post ( " /sto-applications/ {application_id} /reject " , response_model = ServiceCenterRead )
async def reject_sto_application (
application_id : int ,
payload : AdminModerationDecision | None = None ,
session : AsyncSession = Depends ( get_session ) ,
current_user : User = Depends ( get_current_telegram_user ) ,
) - > ServiceCenter :
center_id = await center_id_from_application ( session , application_id )
return await reject_service_center ( center_id , payload , session , current_user )
@router.post ( " /sto-applications/ {application_id} /request-changes " , response_model = ServiceCenterRead )
async def request_sto_application_changes (
application_id : int ,
payload : AdminModerationDecision ,
session : AsyncSession = Depends ( get_session ) ,
current_user : User = Depends ( get_current_telegram_user ) ,
) - > ServiceCenter :
center_id = await center_id_from_application ( session , application_id )
return await request_service_center_changes ( center_id , payload , session , current_user )
@router.post ( " /sto/ {service_center_id} /suspend " , response_model = ServiceCenterRead )
async def suspend_sto (
service_center_id : int ,
payload : AdminModerationDecision | None = None ,
session : AsyncSession = Depends ( get_session ) ,
current_user : User = Depends ( get_current_telegram_user ) ,
) - > ServiceCenter :
return await suspend_service_center ( service_center_id , payload , session , current_user )
@router.post ( " /sto/ {service_center_id} /unsuspend " , response_model = ServiceCenterRead )
async def unsuspend_sto (
service_center_id : int ,
payload : AdminModerationDecision | None = None ,
session : AsyncSession = Depends ( get_session ) ,
current_user : User = Depends ( get_current_telegram_user ) ,
) - > ServiceCenter :
require_admin_access ( current_user , FULL_ADMIN_ROLES )
center = await session . get ( ServiceCenter , service_center_id )
if center is None :
raise HTTPException ( status_code = 404 , detail = " Service center not found " )
center . verification_status = " approved "
center . suspended_at = None
await create_admin_notification (
session ,
event_type = " sto_approved " ,
title = " С Т О разблокировано" ,
body = center . display_name or center . name ,
entity_type = " service_center " ,
entity_id = center . id ,
idempotency_key = f " sto_unsuspended: { center . id } : { datetime . now ( UTC ) . isoformat ( ) } " ,
)
await log_audit ( session , actor = current_user , action = " service_center.unsuspend " , target_type = " service_center " , target_id = center . id , metadata = { " reason " : payload . reason if payload else None } )
await session . commit ( )
await session . refresh ( center )
return center
@router.get ( " /service-centers/pending " , response_model = list [ ServiceCenterRead ] )
@@ -79,6 +878,15 @@ async def verify_service_center(
target_id = center . id ,
metadata = { " comment " : payload . comment if payload else None } ,
)
await create_admin_notification (
session ,
event_type = " sto_approved " ,
title = " С Т О одобрено" ,
body = center . display_name or center . name ,
entity_type = " service_center " ,
entity_id = center . id ,
idempotency_key = f " sto_approved: { center . id } : { center . verified_at . isoformat ( ) if center . verified_at else ' now ' } " ,
)
await session . commit ( )
await session . refresh ( center )
return center
@@ -110,6 +918,15 @@ async def reject_service_center(
target_id = center . id ,
metadata = { " reason " : payload . reason if payload else None , " comment " : payload . comment if payload else None } ,
)
await create_admin_notification (
session ,
event_type = " sto_application_updated " ,
title = " Заявка С Т О отклонена " ,
body = center . display_name or center . name ,
entity_type = " service_center " ,
entity_id = center . id ,
idempotency_key = f " sto_rejected: { center . id } : { datetime . now ( UTC ) . isoformat ( ) } " ,
)
await session . commit ( )
await session . refresh ( center )
return center
@@ -122,7 +939,7 @@ async def suspend_service_center(
session : AsyncSession = Depends ( get_session ) ,
current_user : User = Depends ( get_current_telegram_user ) ,
) - > ServiceCenter :
require_platform_role ( current_user , { " admin " } )
require_admin_access ( current_user , FULL_ADMIN_ROLES )
center = await session . get ( ServiceCenter , service_center_id )
if center is None :
raise HTTPException ( status_code = 404 , detail = " Service center not found " )
@@ -141,6 +958,16 @@ async def suspend_service_center(
target_id = center . id ,
metadata = { " reason " : payload . reason if payload else None , " comment " : payload . comment if payload else None } ,
)
await create_admin_notification (
session ,
event_type = " sto_suspended " ,
title = " С Т О заблокировано" ,
body = center . display_name or center . name ,
entity_type = " service_center " ,
entity_id = center . id ,
severity = " warning " ,
idempotency_key = f " sto_suspended: { center . id } : { center . suspended_at . isoformat ( ) if center . suspended_at else ' now ' } " ,
)
await session . commit ( )
await session . refresh ( center )
return center
@@ -172,6 +999,15 @@ async def request_service_center_changes(
target_id = center . id ,
metadata = { " reason " : payload . reason , " comment " : payload . comment } ,
)
await create_admin_notification (
session ,
event_type = " sto_application_updated " ,
title = " По заявке С Т О запрошены правки " ,
body = center . display_name or center . name ,
entity_type = " service_center " ,
entity_id = center . id ,
idempotency_key = f " sto_changes: { center . id } : { datetime . now ( UTC ) . isoformat ( ) } " ,
)
await session . commit ( )
await session . refresh ( center )
return center
@@ -179,16 +1015,53 @@ async def request_service_center_changes(
@router.get ( " /audit-log " )
async def audit_log (
actor_id : int | None = None ,
actor_role : str | None = None ,
action : str | None = None ,
entity_type : str | None = None ,
entity_id : str | None = None ,
date_from : date | None = None ,
date_to : date | None = None ,
severity : str | None = None ,
ip : str | None = None ,
user_agent : str | None = None ,
limit : int = 100 ,
offset : int = 0 ,
session : AsyncSession = Depends ( get_session ) ,
current_user : User = Depends ( get_current_telegram_user ) ,
) - > list [ dict ] :
require_platform_role ( current_user , { " admin " , " verifie r" , " moderator " } )
require_admin_access ( current_user , FULL_ADMIN_ROLES | { " moderato r" , " support " } )
limit = min ( max ( limit , 1 ) , 200 )
resul t = await session . execute (
select ( AuditLog ) . order_by ( AuditLog . created_at . desc ( ) ) . limit ( limit ) . offset ( max ( offset , 0 ) )
stm t = select ( AuditLog )
if actor_id is not None :
stmt = stmt . where ( AuditLog . actor_user_id == actor_id )
if actor_role :
stmt = stmt . where ( AuditLog . actor_role == actor_role )
if action :
stmt = stmt . where ( AuditLog . action . ilike ( f " % { action } % " ) )
if entity_type :
stmt = stmt . where ( AuditLog . target_type == entity_type )
if entity_id :
stmt = stmt . where ( AuditLog . target_id == entity_id )
if date_from :
stmt = stmt . where ( func . date ( AuditLog . created_at ) > = date_from )
if date_to :
stmt = stmt . where ( func . date ( AuditLog . created_at ) < = date_to )
if ip :
stmt = stmt . where ( AuditLog . ip == ip )
if user_agent :
stmt = stmt . where ( AuditLog . user_agent . ilike ( f " % { user_agent } % " ) )
if severity :
stmt = stmt . where ( AuditLog . metadata_json [ " severity " ] . as_string ( ) == severity )
result = await session . execute ( stmt . order_by ( AuditLog . created_at . desc ( ) ) . limit ( limit ) . offset ( max ( offset , 0 ) ) )
await log_audit (
session ,
actor = current_user ,
action = " admin.audit_log.view " ,
target_type = " audit_log " ,
metadata = { " limit " : limit , " offset " : offset , " filter_action " : action } ,
)
await session . commit ( )
return [
{
" id " : item . id ,