This commit is contained in:
xds
2026-03-22 13:26:18 +03:00
parent 28a5d51389
commit f98c57a433
20 changed files with 2949 additions and 0 deletions

View File

@@ -0,0 +1,36 @@
import logging
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.models.admin_user import AdminUser
from app.services.auth import decode_access_token
logger = logging.getLogger("app.dependencies")
security = HTTPBearer()
async def get_current_admin(
credentials: HTTPAuthorizationCredentials = Depends(security),
db: AsyncSession = Depends(get_db),
) -> AdminUser:
"""Dependency that validates JWT and returns the current admin user."""
token = credentials.credentials
payload = decode_access_token(token)
if not payload:
logger.warning("Invalid or expired token")
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Невалидный или просроченный токен")
user_id = int(payload["sub"])
result = await db.execute(select(AdminUser).where(AdminUser.id == user_id, AdminUser.is_active == True))
user = result.scalar_one_or_none()
if not user:
logger.warning("Admin user not found or inactive: id=%d", user_id)
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Пользователь не найден")
logger.debug("Authenticated admin: id=%d, email=%s", user.id, user.email)
return user

View File

@@ -0,0 +1,16 @@
from sqlalchemy import Boolean, Integer, String, func
from sqlalchemy.orm import Mapped, mapped_column
from datetime import datetime
from app.database import Base
class AdminUser(Base):
__tablename__ = "admin_users"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
email: Mapped[str] = mapped_column(String(200), unique=True, nullable=False)
password_hash: Mapped[str] = mapped_column(String(200), nullable=False)
name: Mapped[str] = mapped_column(String(200), nullable=False, default="Admin")
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
created_at: Mapped[datetime] = mapped_column(default=func.now())

View File

@@ -0,0 +1,15 @@
from sqlalchemy import Integer, String, Text, func
from sqlalchemy.orm import Mapped, mapped_column
from datetime import datetime
from app.database import Base
class AppSettings(Base):
__tablename__ = "app_settings"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
key: Mapped[str] = mapped_column(String(100), unique=True, nullable=False)
value: Mapped[str] = mapped_column(Text, nullable=False, default="")
description: Mapped[str | None] = mapped_column(String(300))
updated_at: Mapped[datetime] = mapped_column(default=func.now(), onupdate=func.now())

View 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}

View File

@@ -0,0 +1,41 @@
import logging
from datetime import datetime, timedelta, timezone
import bcrypt
import jwt
from app.config import settings
logger = logging.getLogger("app.services.auth")
def hash_password(password: str) -> str:
return bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode()
def verify_password(plain: str, hashed: str) -> bool:
return bcrypt.checkpw(plain.encode(), hashed.encode())
def create_access_token(user_id: int, email: str) -> str:
expire = datetime.now(timezone.utc) + timedelta(hours=settings.JWT_EXPIRE_HOURS)
payload = {
"sub": str(user_id),
"email": email,
"exp": expire,
}
token = jwt.encode(payload, settings.JWT_SECRET, algorithm=settings.JWT_ALGORITHM)
logger.info("Created JWT for user_id=%d, email=%s, expires=%s", user_id, email, expire)
return token
def decode_access_token(token: str) -> dict | None:
try:
payload = jwt.decode(token, settings.JWT_SECRET, algorithms=[settings.JWT_ALGORITHM])
return payload
except jwt.ExpiredSignatureError:
logger.warning("JWT expired")
return None
except jwt.InvalidTokenError as e:
logger.warning("Invalid JWT: %s", e)
return None