add admin data mutations and load check
Some checks failed
ci / test (pull_request) Has been cancelled

This commit is contained in:
VPN SaaS Dev
2026-05-18 18:37:19 +09:00
parent 59bc6ebd4f
commit 8982299e71
9 changed files with 650 additions and 44 deletions

View File

@@ -1,4 +1,5 @@
import csv
import enum
import io
import json
from datetime import UTC, date, datetime, timedelta
@@ -83,6 +84,20 @@ class AdminUserNote(BaseModel):
note: str
class AdminDataMutation(BaseModel):
model_config = ConfigDict(extra="forbid")
values: dict[str, Any] = Field(default_factory=dict)
reason: str
class AdminDataDelete(BaseModel):
model_config = ConfigDict(extra="forbid")
reason: str
hard: bool = False
DATA_SOURCES: dict[str, dict[str, Any]] = {
"users": {
"model": User,
@@ -91,6 +106,8 @@ DATA_SOURCES: dict[str, dict[str, Any]] = {
"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"],
"editable": ["username", "first_name", "last_name", "platform_role", "locale", "currency"],
"delete": {"type": "soft", "field": "platform_role", "value": "blocked"},
},
"vehicles": {
"model": Car,
@@ -99,6 +116,8 @@ DATA_SOURCES: dict[str, dict[str, Any]] = {
"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"],
"editable": ["name", "make", "model", "year", "vin", "plate_number", "license_plate_display", "current_odometer", "notes"],
"delete": {"type": "hard", "requires_super_admin": True},
},
"fuel_entries": {
"model": FuelEntry,
@@ -107,6 +126,8 @@ DATA_SOURCES: dict[str, dict[str, Any]] = {
"amount": "total_cost",
"date": "entry_date",
"columns": ["id", "car_id", "entry_date", "odometer", "liters", "total_cost", "created_at"],
"editable": ["entry_date", "odometer", "liters", "price_per_liter", "total_cost", "station", "fuel_brand", "is_full_tank", "notes"],
"delete": {"type": "hard"},
},
"service_entries": {
"model": ServiceEntry,
@@ -116,6 +137,8 @@ DATA_SOURCES: dict[str, dict[str, Any]] = {
"amount": "total_cost",
"date": "entry_date",
"columns": ["id", "car_id", "entry_date", "service_type", "title", "total_cost", "created_at"],
"editable": ["entry_date", "odometer", "service_type", "title", "category", "vendor", "total_cost", "next_due_date", "next_due_odometer", "notes"],
"delete": {"type": "hard"},
},
"expense_entries": {
"model": ExpenseEntry,
@@ -125,6 +148,8 @@ DATA_SOURCES: dict[str, dict[str, Any]] = {
"amount": "total_cost",
"date": "entry_date",
"columns": ["id", "car_id", "entry_date", "category", "title", "total_cost", "currency", "created_at"],
"editable": ["entry_date", "category", "title", "vendor", "total_cost", "currency", "odometer", "period_start", "period_end", "period_months", "is_recurring", "notes"],
"delete": {"type": "hard"},
},
"sto_profiles": {
"model": ServiceCenter,
@@ -133,30 +158,40 @@ DATA_SOURCES: dict[str, dict[str, Any]] = {
"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"],
"editable": ["display_name", "legal_name", "country", "city", "address", "phone", "contact_phone", "description", "working_hours", "contact_person", "verification_status"],
"delete": {"type": "soft", "field": "verification_status", "value": "suspended", "timestamp_field": "suspended_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"],
"editable": ["status", "comment", "reviewed_by", "reviewed_at"],
"delete": {"type": "soft", "field": "status", "value": "rejected"},
},
"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"],
"editable": ["role", "permissions", "status"],
"delete": {"type": "soft", "field": "status", "value": "revoked", "timestamp_field": "invite_revoked_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"],
"editable": ["access_level", "status", "external_vehicle_ref"],
"delete": {"type": "soft", "field": "status", "value": "revoked", "timestamp_field": "revoked_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"],
"editable": ["service_type", "service_name", "requested_start_at", "requested_end_at", "confirmed_start_at", "confirmed_end_at", "estimated_duration_minutes", "status", "customer_comment", "service_center_comment"],
"delete": {"type": "soft", "field": "status", "value": "cancelled"},
},
"work_orders": {
"model": ServiceVisit,
@@ -164,6 +199,8 @@ DATA_SOURCES: dict[str, dict[str, Any]] = {
"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"],
"editable": ["visit_date", "odometer", "status", "customer_complaint", "diagnosis", "recommendations", "internal_notes", "labor_total", "product_total", "discount_total", "final_total", "currency"],
"delete": {"type": "soft", "field": "status", "value": "archived"},
},
"work_order_items": {
"model": ServiceWorkItem,
@@ -171,6 +208,8 @@ DATA_SOURCES: dict[str, dict[str, Any]] = {
"filters": {"category": "category"},
"amount": "total",
"columns": ["id", "service_visit_id", "work_type", "title", "category", "quantity", "total", "created_at"],
"editable": ["work_type", "title", "category", "description", "quantity", "unit", "unit_price", "discount", "total", "parts", "oil_brand", "oil_viscosity", "oil_volume"],
"delete": {"type": "hard"},
},
"work_order_products": {
"model": ServiceProductItem,
@@ -178,24 +217,32 @@ DATA_SOURCES: dict[str, dict[str, Any]] = {
"filters": {"category": "category"},
"amount": "total",
"columns": ["id", "service_visit_id", "title", "category", "product_type", "quantity", "total", "created_at"],
"editable": ["title", "category", "product_type", "brand", "sku", "quantity", "unit", "unit_price", "discount", "total"],
"delete": {"type": "hard"},
},
"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"],
"editable": ["rating", "text", "status", "service_response", "service_responded_at"],
"delete": {"type": "soft", "field": "status", "value": "hidden"},
},
"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"],
"editable": ["title", "body", "status", "retry_count", "last_error"],
"delete": {"type": "soft", "field": "status", "value": "dismissed"},
},
"admin_notifications": {
"model": AdminNotification,
"roles": ADMIN_ROLES,
"filters": {"status": "status"},
"columns": ["id", "event_type", "severity", "title", "entity_type", "entity_id", "status", "created_at"],
"editable": ["severity", "title", "body", "status", "telegram_status", "telegram_error"],
"delete": {"type": "soft", "field": "status", "value": "dismissed", "timestamp_field": "dismissed_at"},
},
"audit_logs": {
"model": AuditLog,
@@ -210,6 +257,8 @@ DATA_SOURCES: dict[str, dict[str, Any]] = {
"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"],
"editable": ["status", "reason", "expires_at"],
"delete": {"type": "soft", "field": "status", "value": "expired"},
},
}
@@ -268,6 +317,79 @@ def source_config(source: str, user: User) -> dict[str, Any]:
return config
def mutation_source_config(source: str, user: User, *, action: str) -> dict[str, Any]:
config = source_config(source, user)
if user.platform_role not in FULL_ADMIN_ROLES:
raise HTTPException(status_code=403, detail="Only admin roles can modify data")
if action == "update" and not config.get("editable"):
raise HTTPException(status_code=400, detail="Data source is read-only")
if action == "delete" and not config.get("delete"):
raise HTTPException(status_code=400, detail="Data source cannot be deleted from admin")
delete_config = config.get("delete") or {}
if action == "delete" and delete_config.get("requires_super_admin") and user.platform_role != "super_admin":
raise HTTPException(status_code=403, detail="Super admin role is required")
return config
def require_mutation_reason(reason: str) -> str:
clean = reason.strip()
if len(clean) < 5:
raise HTTPException(status_code=400, detail="reason is required")
return clean
def model_column_python_type(model: Any, field: str) -> type | None:
column = getattr(model, "__mapper__", None).columns.get(field) if getattr(model, "__mapper__", None) else None
if column is None:
return None
try:
return column.type.python_type
except NotImplementedError:
return None
def coerce_admin_value(model: Any, field: str, value: Any, current_value: Any) -> Any:
if value == "":
return None
if value is None:
return None
target_type = type(current_value) if current_value is not None else model_column_python_type(model, field)
if target_type is None:
return value
if isinstance(current_value, enum.Enum):
return current_value.__class__(value)
if isinstance(target_type, type) and issubclass(target_type, enum.Enum):
return target_type(value)
if target_type is Decimal:
return Decimal(str(value))
if target_type is datetime:
if isinstance(value, datetime):
return value
return datetime.fromisoformat(str(value).replace("Z", "+00:00"))
if target_type is date:
if isinstance(value, date) and not isinstance(value, datetime):
return value
return date.fromisoformat(str(value)[:10])
if target_type is bool:
if isinstance(value, bool):
return value
return str(value).strip().lower() in {"1", "true", "yes", "on", "да"}
if target_type in {int, float, str}:
return target_type(value)
return value
async def get_mutation_item(session: AsyncSession, config: dict[str, Any], item_id: int) -> Any:
item = await session.get(config["model"], item_id)
if item is None:
raise HTTPException(status_code=404, detail="Record not found")
return item
def mutation_snapshot(item: Any, fields: list[str]) -> dict[str, Any]:
return {field: jsonable_encoder(getattr(item, field, None)) for field in fields}
def apply_data_filters(stmt: Select, query: AdminDataQuery, config: dict[str, Any]) -> Select:
model = config["model"]
date_column = config.get("date", "created_at")
@@ -513,6 +635,9 @@ async def admin_data_sources(current_user: User = Depends(get_current_telegram_u
"columns": config.get("columns") or [],
"sensitive": sorted(config.get("sensitive") or []),
"allowed": current_user.platform_role in set(config["roles"]),
"editable": sorted(config.get("editable") or []),
"deletable": bool(config.get("delete")),
"delete_mode": (config.get("delete") or {}).get("type"),
}
for name, config in DATA_SOURCES.items()
],
@@ -532,6 +657,96 @@ async def admin_data_query(
return data
@router.patch("/data/{source}/{item_id}")
async def admin_data_update(
source: str,
item_id: int,
payload: AdminDataMutation,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> dict[str, Any]:
reason = require_mutation_reason(payload.reason)
config = mutation_source_config(source, current_user, action="update")
editable = set(config.get("editable") or [])
values = {key: value for key, value in payload.values.items() if key in editable}
if not payload.values:
raise HTTPException(status_code=400, detail="values are required")
forbidden = sorted(set(payload.values) - editable)
if forbidden:
raise HTTPException(status_code=400, detail=f"Forbidden fields: {', '.join(forbidden)}")
item = await get_mutation_item(session, config, item_id)
old_values = mutation_snapshot(item, sorted(values))
for field, value in values.items():
setattr(item, field, coerce_admin_value(config["model"], field, value, getattr(item, field, None)))
await session.flush()
await session.refresh(item)
new_values = mutation_snapshot(item, sorted(values))
await log_audit(
session,
actor=current_user,
action="admin.data.update",
target_type=source,
target_id=item_id,
metadata={"reason": reason, "old": old_values, "new": new_values},
)
await session.commit()
await session.refresh(item)
return {
"source": source,
"id": item_id,
"row": serialize_row(item, source, include_sensitive=True, role=current_user.platform_role),
}
@router.delete("/data/{source}/{item_id}")
async def admin_data_delete(
source: str,
item_id: int,
payload: AdminDataDelete,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> dict[str, Any]:
reason = require_mutation_reason(payload.reason)
config = mutation_source_config(source, current_user, action="delete")
delete_config = config["delete"]
item = await get_mutation_item(session, config, item_id)
columns = public_columns(source)
old_values = mutation_snapshot(item, columns)
delete_type = delete_config.get("type")
if payload.hard and delete_type != "hard":
raise HTTPException(status_code=400, detail="Hard delete is not supported for this source")
if delete_type == "hard":
await session.delete(item)
result_payload: dict[str, Any] = {"source": source, "id": item_id, "deleted": True, "mode": "hard"}
else:
field = delete_config["field"]
if not hasattr(item, field):
raise HTTPException(status_code=400, detail="Soft delete field is not available")
setattr(item, field, delete_config.get("value"))
timestamp_field = delete_config.get("timestamp_field")
if timestamp_field and hasattr(item, timestamp_field):
setattr(item, timestamp_field, datetime.now(UTC))
await session.flush()
await session.refresh(item)
result_payload = {
"source": source,
"id": item_id,
"deleted": True,
"mode": "soft",
"row": serialize_row(item, source, include_sensitive=True, role=current_user.platform_role),
}
await log_audit(
session,
actor=current_user,
action="admin.data.delete",
target_type=source,
target_id=item_id,
metadata={"reason": reason, "mode": result_payload["mode"], "old": old_values},
)
await session.commit()
return result_payload
@router.post("/data/export")
async def admin_data_export(
payload: AdminExportRequest,