Files
filam3d/backend/app/routers/admin.py
2026-03-23 12:48:44 +03:00

679 lines
23 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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": "ai_use_proxy", "value": "true", "description": "Использовать AI-прокси (true/false)"},
{"key": "ai_proxy_url", "value": "", "description": "URL AI-прокси"},
{"key": "ai_proxy_salt", "value": "", "description": "Секретная соль для AI-прокси"},
{"key": "ai_direct_api_key", "value": "", "description": "Google API Key для прямого подключения"},
{"key": "company_name", "value": "Bambu Russia", "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"}