679 lines
23 KiB
Python
679 lines
23 KiB
Python
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": "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"}
|