import logging from datetime import datetime from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel from sqlalchemy import select, func, desc from sqlalchemy.ext.asyncio import AsyncSession from app.database import get_db from app.dependencies import get_current_admin from app.models.admin_user import AdminUser from app.models.client import Client from app.models.material import Material from app.models.calculation import Calculation from app.models.order import Order from app.models.app_settings import AppSettings from app.services.auth import verify_password, create_access_token, hash_password logger = logging.getLogger("app.routers.admin") router = APIRouter() # ─── Auth ─────────────────────────────────────────────── class LoginRequest(BaseModel): email: str password: str class LoginResponse(BaseModel): token: str email: str name: str class AdminProfile(BaseModel): id: int email: str name: str @router.post("/login", response_model=LoginResponse) async def admin_login(data: LoginRequest, db: AsyncSession = Depends(get_db)): logger.info("Admin login attempt: %s", data.email) result = await db.execute(select(AdminUser).where(AdminUser.email == data.email, AdminUser.is_active == True)) user = result.scalar_one_or_none() if not user or not verify_password(data.password, user.password_hash): logger.warning("Login failed for: %s", data.email) raise HTTPException(401, "Неверный email или пароль") token = create_access_token(user.id, user.email) logger.info("Login successful: %s (id=%d)", user.email, user.id) return LoginResponse(token=token, email=user.email, name=user.name) @router.get("/me", response_model=AdminProfile) async def admin_me(admin: AdminUser = Depends(get_current_admin)): return AdminProfile(id=admin.id, email=admin.email, name=admin.name) class ChangePasswordRequest(BaseModel): current_password: str new_password: str @router.post("/change-password") async def change_password( data: ChangePasswordRequest, admin: AdminUser = Depends(get_current_admin), db: AsyncSession = Depends(get_db), ): if not verify_password(data.current_password, admin.password_hash): raise HTTPException(400, "Неверный текущий пароль") admin.password_hash = hash_password(data.new_password) await db.commit() logger.info("Password changed for admin: %s", admin.email) return {"status": "ok"} # ─── Admin Users Management ───────────────────────────── class AdminUserOut(BaseModel): id: int email: str name: str is_active: bool created_at: str model_config = {"from_attributes": True} class AdminUserCreate(BaseModel): email: str name: str password: str class AdminUserUpdate(BaseModel): name: str | None = None email: str | None = None is_active: bool | None = None class AdminResetPassword(BaseModel): new_password: str @router.get("/users", response_model=list[AdminUserOut]) async def list_admin_users( admin: AdminUser = Depends(get_current_admin), db: AsyncSession = Depends(get_db), ): logger.info("Admin users list requested by %s", admin.email) result = await db.execute(select(AdminUser).order_by(AdminUser.id)) users = result.scalars().all() return [ AdminUserOut( id=u.id, email=u.email, name=u.name, is_active=u.is_active, created_at=u.created_at.isoformat() if u.created_at else "", ) for u in users ] @router.post("/users", response_model=AdminUserOut) async def create_admin_user( data: AdminUserCreate, admin: AdminUser = Depends(get_current_admin), db: AsyncSession = Depends(get_db), ): logger.info("Creating admin user: %s (by %s)", data.email, admin.email) existing = await db.execute(select(AdminUser).where(AdminUser.email == data.email)) if existing.scalar_one_or_none(): raise HTTPException(400, "Пользователь с таким email уже существует") user = AdminUser( email=data.email, name=data.name, password_hash=hash_password(data.password), ) db.add(user) await db.commit() await db.refresh(user) logger.info("Admin user created: id=%d, email=%s", user.id, user.email) return AdminUserOut( id=user.id, email=user.email, name=user.name, is_active=user.is_active, created_at=user.created_at.isoformat() if user.created_at else "", ) @router.put("/users/{user_id}", response_model=AdminUserOut) async def update_admin_user( user_id: int, data: AdminUserUpdate, admin: AdminUser = Depends(get_current_admin), db: AsyncSession = Depends(get_db), ): logger.info("Updating admin user id=%d (by %s)", user_id, admin.email) result = await db.execute(select(AdminUser).where(AdminUser.id == user_id)) user = result.scalar_one_or_none() if not user: raise HTTPException(404, "Пользователь не найден") if data.name is not None: user.name = data.name if data.email is not None: dup = await db.execute(select(AdminUser).where(AdminUser.email == data.email, AdminUser.id != user_id)) if dup.scalar_one_or_none(): raise HTTPException(400, "Email уже занят") user.email = data.email if data.is_active is not None: user.is_active = data.is_active await db.commit() await db.refresh(user) logger.info("Admin user updated: id=%d", user.id) return AdminUserOut( id=user.id, email=user.email, name=user.name, is_active=user.is_active, created_at=user.created_at.isoformat() if user.created_at else "", ) @router.post("/users/{user_id}/reset-password") async def reset_admin_password( user_id: int, data: AdminResetPassword, admin: AdminUser = Depends(get_current_admin), db: AsyncSession = Depends(get_db), ): logger.info("Resetting password for user id=%d (by %s)", user_id, admin.email) result = await db.execute(select(AdminUser).where(AdminUser.id == user_id)) user = result.scalar_one_or_none() if not user: raise HTTPException(404, "Пользователь не найден") user.password_hash = hash_password(data.new_password) await db.commit() logger.info("Password reset for user id=%d, email=%s", user.id, user.email) return {"status": "ok"} @router.delete("/users/{user_id}") async def delete_admin_user( user_id: int, admin: AdminUser = Depends(get_current_admin), db: AsyncSession = Depends(get_db), ): if admin.id == user_id: raise HTTPException(400, "Нельзя удалить самого себя") result = await db.execute(select(AdminUser).where(AdminUser.id == user_id)) user = result.scalar_one_or_none() if not user: raise HTTPException(404, "Пользователь не найден") logger.info("Deleting admin user id=%d, email=%s (by %s)", user.id, user.email, admin.email) await db.delete(user) await db.commit() return {"status": "deleted"} # ─── Dashboard ────────────────────────────────────────── class DashboardStats(BaseModel): total_orders: int pending_orders: int total_revenue: float total_calculations: int materials_count: int clients_count: int orders_today: int @router.get("/dashboard", response_model=DashboardStats) async def dashboard(admin: AdminUser = Depends(get_current_admin), db: AsyncSession = Depends(get_db)): logger.info("Dashboard requested by %s", admin.email) total_orders = (await db.execute(select(func.count(Order.id)))).scalar() or 0 pending_orders = (await db.execute( select(func.count(Order.id)).where(Order.status == "pending") )).scalar() or 0 total_revenue = (await db.execute(select(func.sum(Order.total_rub)))).scalar() or 0.0 total_calcs = (await db.execute(select(func.count(Calculation.id)))).scalar() or 0 materials_count = (await db.execute(select(func.count(Material.id)))).scalar() or 0 clients_count = (await db.execute(select(func.count(Client.id)))).scalar() or 0 today_start = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0) orders_today = (await db.execute( select(func.count(Order.id)).where(Order.created_at >= today_start) )).scalar() or 0 return DashboardStats( total_orders=total_orders, pending_orders=pending_orders, total_revenue=round(total_revenue, 2), total_calculations=total_calcs, materials_count=materials_count, clients_count=clients_count, orders_today=orders_today, ) # ─── Materials CRUD ───────────────────────────────────── class MaterialCreate(BaseModel): name: str category: str density_g_cm3: float price_per_gram: float flow_rate_mm3_s: float max_temp_c: int | None = None min_temp_c: int | None = None strength: str | None = None flexibility: str | None = None chemical_resistance: str | None = None uv_resistance: str | None = None food_safe: bool = False description: str | None = None color_options: list[dict] = [] is_active: bool = True class MaterialUpdate(MaterialCreate): pass class MaterialOut(MaterialCreate): id: int model_config = {"from_attributes": True} @router.get("/materials", response_model=list[MaterialOut]) async def list_materials(admin: AdminUser = Depends(get_current_admin), db: AsyncSession = Depends(get_db)): logger.info("Admin materials list requested") result = await db.execute(select(Material).order_by(Material.id)) materials = result.scalars().all() return materials @router.post("/materials", response_model=MaterialOut) async def create_material( data: MaterialCreate, admin: AdminUser = Depends(get_current_admin), db: AsyncSession = Depends(get_db), ): logger.info("Creating material: %s (by %s)", data.name, admin.email) mat = Material(**data.model_dump()) db.add(mat) await db.commit() await db.refresh(mat) logger.info("Material created: id=%d, name=%s", mat.id, mat.name) return mat @router.put("/materials/{material_id}", response_model=MaterialOut) async def update_material( material_id: int, data: MaterialUpdate, admin: AdminUser = Depends(get_current_admin), db: AsyncSession = Depends(get_db), ): logger.info("Updating material id=%d (by %s)", material_id, admin.email) result = await db.execute(select(Material).where(Material.id == material_id)) mat = result.scalar_one_or_none() if not mat: raise HTTPException(404, "Материал не найден") for key, value in data.model_dump().items(): setattr(mat, key, value) await db.commit() await db.refresh(mat) logger.info("Material updated: id=%d, name=%s", mat.id, mat.name) return mat @router.delete("/materials/{material_id}") async def delete_material( material_id: int, admin: AdminUser = Depends(get_current_admin), db: AsyncSession = Depends(get_db), ): logger.info("Deleting material id=%d (by %s)", material_id, admin.email) result = await db.execute(select(Material).where(Material.id == material_id)) mat = result.scalar_one_or_none() if not mat: raise HTTPException(404, "Материал не найден") await db.delete(mat) await db.commit() logger.info("Material deleted: id=%d", material_id) return {"status": "deleted"} # ─── Orders CRM ───────────────────────────────────────── class OrderOut(BaseModel): id: int order_id: str client_name: str client_phone: str client_email: str | None client_company: str | None delivery_method: str comment: str | None status: str total_rub: float created_at: str updated_at: str # calculation info file_name: str | None = None material_name: str | None = None quantity: int | None = None class OrderStatusUpdate(BaseModel): status: str # pending, confirmed, printing, ready, delivered, cancelled class OrderNote(BaseModel): note: str @router.get("/orders", response_model=list[OrderOut]) async def list_orders( status: str | None = None, admin: AdminUser = Depends(get_current_admin), db: AsyncSession = Depends(get_db), ): logger.info("Admin orders list requested (status_filter=%s)", status) query = select(Order).order_by(desc(Order.created_at)) if status: query = query.where(Order.status == status) result = await db.execute(query) orders = result.scalars().all() out = [] for o in orders: # Get calculation info calc_result = await db.execute(select(Calculation).where(Calculation.id == o.calculation_id)) calc = calc_result.scalar_one_or_none() material_name = None if calc: mat_result = await db.execute(select(Material).where(Material.id == calc.material_id)) mat = mat_result.scalar_one_or_none() material_name = mat.name if mat else None out.append(OrderOut( id=o.id, order_id=o.order_id, client_name=o.client_name, client_phone=o.client_phone, client_email=o.client_email, client_company=o.client_company, delivery_method=o.delivery_method, comment=o.comment, status=o.status, total_rub=o.total_rub, created_at=o.created_at.isoformat() if o.created_at else "", updated_at=o.updated_at.isoformat() if o.updated_at else "", file_name=calc.file_name if calc else None, material_name=material_name, quantity=calc.quantity if calc else None, )) logger.info("Returning %d orders", len(out)) return out @router.get("/orders/{order_id}") async def get_order( order_id: str, admin: AdminUser = Depends(get_current_admin), db: AsyncSession = Depends(get_db), ): logger.info("Admin order detail: %s", order_id) result = await db.execute(select(Order).where(Order.order_id == order_id)) order = result.scalar_one_or_none() if not order: raise HTTPException(404, "Заказ не найден") calc_result = await db.execute(select(Calculation).where(Calculation.id == order.calculation_id)) calc = calc_result.scalar_one_or_none() material_name = None calc_data = None if calc: mat_result = await db.execute(select(Material).where(Material.id == calc.material_id)) mat = mat_result.scalar_one_or_none() material_name = mat.name if mat else None calc_data = { "file_name": calc.file_name, "file_format": calc.file_format, "volume_cm3": calc.volume_cm3, "bounding_box": calc.bounding_box, "is_watertight": calc.is_watertight, "material_id": calc.material_id, "material_name": material_name, "infill_percent": calc.infill_percent, "layer_height_mm": calc.layer_height_mm, "quantity": calc.quantity, "post_processing": calc.post_processing, "weight_grams": calc.weight_grams, "material_cost_rub": calc.material_cost_rub, "time_cost_rub": calc.time_cost_rub, "print_time_hours": calc.print_time_hours, "post_processing_cost_rub": calc.post_processing_cost_rub, "total_rub": calc.total_rub, "estimated_days": calc.estimated_days, } return { "order": { "id": order.id, "order_id": order.order_id, "client_name": order.client_name, "client_phone": order.client_phone, "client_email": order.client_email, "client_company": order.client_company, "delivery_method": order.delivery_method, "comment": order.comment, "status": order.status, "total_rub": order.total_rub, "created_at": order.created_at.isoformat() if order.created_at else "", "updated_at": order.updated_at.isoformat() if order.updated_at else "", }, "calculation": calc_data, } @router.patch("/orders/{order_id}/status") async def update_order_status( order_id: str, data: OrderStatusUpdate, admin: AdminUser = Depends(get_current_admin), db: AsyncSession = Depends(get_db), ): valid_statuses = ["pending", "confirmed", "printing", "ready", "delivered", "cancelled"] if data.status not in valid_statuses: raise HTTPException(400, f"Невалидный статус. Допустимые: {', '.join(valid_statuses)}") logger.info("Updating order %s status to '%s' (by %s)", order_id, data.status, admin.email) result = await db.execute(select(Order).where(Order.order_id == order_id)) order = result.scalar_one_or_none() if not order: raise HTTPException(404, "Заказ не найден") old_status = order.status order.status = data.status await db.commit() logger.info("Order %s status changed: %s -> %s", order_id, old_status, data.status) return {"order_id": order_id, "old_status": old_status, "new_status": data.status} # ─── Settings ─────────────────────────────────────────── class SettingOut(BaseModel): id: int key: str value: str description: str | None class SettingUpdate(BaseModel): value: str DEFAULT_SETTINGS = [ {"key": "time_rate_per_hour", "value": "200", "description": "Стоимость часа печати (руб)"}, {"key": "sanding_cost", "value": "300", "description": "Стоимость шлифовки (руб/шт)"}, {"key": "painting_cost", "value": "500", "description": "Стоимость покраски (руб/шт)"}, {"key": "threading_cost", "value": "200", "description": "Стоимость нарезки резьбы (руб/шт)"}, {"key": "acetone_cost", "value": "400", "description": "Стоимость ацетоновой обработки (руб/шт)"}, {"key": "company_name", "value": "Filam3D", "description": "Название компании"}, {"key": "company_phone", "value": "", "description": "Телефон компании"}, {"key": "company_email", "value": "", "description": "Email компании"}, ] @router.get("/settings", response_model=list[SettingOut]) async def list_settings( admin: AdminUser = Depends(get_current_admin), db: AsyncSession = Depends(get_db), ): logger.info("Admin settings list requested") result = await db.execute(select(AppSettings).order_by(AppSettings.id)) settings_list = result.scalars().all() # Seed defaults if empty if not settings_list: logger.info("No settings found, seeding defaults...") for s in DEFAULT_SETTINGS: db.add(AppSettings(**s)) await db.commit() result = await db.execute(select(AppSettings).order_by(AppSettings.id)) settings_list = result.scalars().all() return settings_list @router.put("/settings/{key}") async def update_setting( key: str, data: SettingUpdate, admin: AdminUser = Depends(get_current_admin), db: AsyncSession = Depends(get_db), ): logger.info("Updating setting '%s' to '%s' (by %s)", key, data.value, admin.email) result = await db.execute(select(AppSettings).where(AppSettings.key == key)) setting = result.scalar_one_or_none() if not setting: raise HTTPException(404, "Настройка не найдена") setting.value = data.value await db.commit() logger.info("Setting updated: %s = %s", key, data.value) return {"key": key, "value": data.value} # ─── Clients CRM ──────────────────────────────────────── class ClientOut(BaseModel): id: int email: str name: str phone: str | None company: str | None is_active: bool created_at: str orders_count: int = 0 total_spent: float = 0 @router.get("/clients", response_model=list[ClientOut]) async def list_clients( admin: AdminUser = Depends(get_current_admin), db: AsyncSession = Depends(get_db), ): logger.info("Admin clients list requested") result = await db.execute(select(Client).order_by(desc(Client.created_at))) clients = result.scalars().all() out = [] for c in clients: orders_result = await db.execute( select(func.count(Order.id), func.coalesce(func.sum(Order.total_rub), 0)) .where(Order.client_id == c.id) ) row = orders_result.one() out.append(ClientOut( id=c.id, email=c.email, name=c.name, phone=c.phone, company=c.company, is_active=c.is_active, created_at=c.created_at.isoformat() if c.created_at else "", orders_count=row[0], total_spent=round(row[1], 2), )) return out @router.get("/clients/{client_id}") async def get_client_detail( client_id: int, admin: AdminUser = Depends(get_current_admin), db: AsyncSession = Depends(get_db), ): result = await db.execute(select(Client).where(Client.id == client_id)) client = result.scalar_one_or_none() if not client: raise HTTPException(404, "Клиент не найден") orders_result = await db.execute( select(Order).where(Order.client_id == client_id).order_by(desc(Order.created_at)) ) orders = orders_result.scalars().all() return { "client": { "id": client.id, "email": client.email, "name": client.name, "phone": client.phone, "company": client.company, "is_active": client.is_active, "created_at": client.created_at.isoformat() if client.created_at else "", }, "orders": [ { "order_id": o.order_id, "status": o.status, "total_rub": o.total_rub, "created_at": o.created_at.isoformat() if o.created_at else "", } for o in orders ], } class ClientToggleActive(BaseModel): is_active: bool @router.patch("/clients/{client_id}/active") async def toggle_client_active( client_id: int, data: ClientToggleActive, admin: AdminUser = Depends(get_current_admin), db: AsyncSession = Depends(get_db), ): result = await db.execute(select(Client).where(Client.id == client_id)) client = result.scalar_one_or_none() if not client: raise HTTPException(404, "Клиент не найден") client.is_active = data.is_active await db.commit() logger.info("Client id=%d is_active=%s (by %s)", client_id, data.is_active, admin.email) return {"status": "ok"}