init
This commit is contained in:
577
backend/app/routers/admin.py
Normal file
577
backend/app/routers/admin.py
Normal file
@@ -0,0 +1,577 @@
|
||||
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.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
|
||||
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
|
||||
|
||||
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,
|
||||
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[str] = []
|
||||
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}
|
||||
Reference in New Issue
Block a user