Phase 3 of Migration

This commit is contained in:
2026-04-17 15:39:29 +03:00
parent c7d5206d0c
commit 83361fad77
9 changed files with 481 additions and 198 deletions

View File

@@ -16,8 +16,9 @@ class Staff(Base):
email = Column(String(256), unique=True, nullable=False)
name = Column(String(255), nullable=False)
role = Column(String(64), nullable=False, default="staff")
permissions = Column(JSONB, nullable=False, default=dict)
permissions = Column(JSONB, nullable=True)
hashed_password = Column(String(256), nullable=False)
is_active = Column(Boolean, nullable=False, default=True)
ui_prefs = Column(JSONB, nullable=False, default=dict)
created_at = Column(DateTime(timezone=True), nullable=False, default=_now)
updated_at = Column(DateTime(timezone=True), nullable=False, default=_now, onupdate=_now)

View File

@@ -1,5 +1,7 @@
from fastapi import APIRouter, Depends, Query
from typing import Any
from sqlalchemy.ext.asyncio import AsyncSession
from database.postgres import get_pg_session
from auth.dependencies import get_current_user, require_staff_management
from auth.models import TokenPayload
from staff import service
@@ -13,14 +15,19 @@ router = APIRouter(prefix="/api/staff", tags=["staff"])
@router.get("/me", response_model=StaffResponse)
async def get_current_staff(current_user: TokenPayload = Depends(get_current_user)):
return await service.get_staff_me(current_user.sub)
async def get_current_staff(
current_user: TokenPayload = Depends(get_current_user),
db: AsyncSession = Depends(get_pg_session),
):
return await service.get_staff_me(db, current_user.sub)
@router.get("/me/preferences", response_model=dict)
async def get_preferences(current_user: TokenPayload = Depends(get_current_user)):
"""Return all UI preferences for the current staff member."""
return await service.get_preferences(current_user.sub)
async def get_preferences(
current_user: TokenPayload = Depends(get_current_user),
db: AsyncSession = Depends(get_pg_session),
):
return await service.get_preferences(db, current_user.sub)
@router.patch("/me/preferences/{page_key}", response_model=dict)
@@ -28,9 +35,9 @@ async def update_preferences(
page_key: str,
body: PreferencesUpdate,
current_user: TokenPayload = Depends(get_current_user),
db: AsyncSession = Depends(get_pg_session),
):
"""Merge preference keys for a specific page into the staff member's stored prefs."""
return await service.update_preferences(current_user.sub, page_key, body.prefs)
return await service.update_preferences(db, current_user.sub, page_key, body.prefs)
@router.get("", response_model=StaffListResponse)
@@ -38,24 +45,28 @@ async def list_staff(
search: str = Query(None),
role: str = Query(None),
current_user: TokenPayload = Depends(require_staff_management),
db: AsyncSession = Depends(get_pg_session),
):
return await service.list_staff(search=search, role_filter=role)
return await service.list_staff(db, search=search, role_filter=role)
@router.get("/{staff_id}", response_model=StaffResponse)
async def get_staff(
staff_id: str,
current_user: TokenPayload = Depends(require_staff_management),
db: AsyncSession = Depends(get_pg_session),
):
return await service.get_staff(staff_id)
return await service.get_staff(db, staff_id)
@router.post("", response_model=StaffResponse)
async def create_staff(
body: StaffCreate,
current_user: TokenPayload = Depends(require_staff_management),
db: AsyncSession = Depends(get_pg_session),
):
return await service.create_staff(
db,
data=body.model_dump(),
current_user_role=current_user.role,
)
@@ -66,8 +77,10 @@ async def update_staff(
staff_id: str,
body: StaffUpdate,
current_user: TokenPayload = Depends(require_staff_management),
db: AsyncSession = Depends(get_pg_session),
):
return await service.update_staff(
db,
staff_id=staff_id,
data=body.model_dump(exclude_unset=True),
current_user_role=current_user.role,
@@ -80,8 +93,10 @@ async def update_staff_password(
staff_id: str,
body: StaffPasswordUpdate,
current_user: TokenPayload = Depends(require_staff_management),
db: AsyncSession = Depends(get_pg_session),
):
return await service.update_staff_password(
db,
staff_id=staff_id,
new_password=body.new_password,
current_user_role=current_user.role,
@@ -92,8 +107,10 @@ async def update_staff_password(
async def delete_staff(
staff_id: str,
current_user: TokenPayload = Depends(require_staff_management),
db: AsyncSession = Depends(get_pg_session),
):
return await service.delete_staff(
db,
staff_id=staff_id,
current_user_role=current_user.role,
current_user_id=current_user.sub,

View File

@@ -1,4 +1,9 @@
from shared.firebase import get_db
from datetime import datetime, timezone
from sqlalchemy import select, func, or_
from sqlalchemy.ext.asyncio import AsyncSession
from staff.orm import Staff
from auth.utils import hash_password
from auth.models import default_permissions_for_role
from shared.exceptions import NotFoundError, AuthorizationError
@@ -8,198 +13,190 @@ import uuid
VALID_ROLES = ("sysadmin", "admin", "editor", "user")
def _staff_doc_to_response(doc_id: str, data: dict) -> dict:
def _now() -> datetime:
return datetime.now(timezone.utc)
def _to_response(staff: Staff) -> dict:
return {
"id": doc_id,
"email": data.get("email", ""),
"name": data.get("name", ""),
"role": data.get("role", ""),
"is_active": data.get("is_active", True),
"permissions": data.get("permissions"),
"id": staff.id,
"email": staff.email,
"name": staff.name,
"role": staff.role,
"is_active": staff.is_active,
"permissions": staff.permissions,
}
async def list_staff(search: str = None, role_filter: str = None) -> dict:
db = get_db()
ref = db.collection("admin_users")
docs = ref.get()
staff = []
for doc in docs:
data = doc.to_dict()
if search:
s = search.lower()
if s not in (data.get("name", "").lower()) and s not in (data.get("email", "").lower()):
continue
if role_filter:
if data.get("role") != role_filter:
continue
staff.append(_staff_doc_to_response(doc.id, data))
return {"staff": staff, "total": len(staff)}
async def list_staff(db: AsyncSession, search: str = None, role_filter: str = None) -> dict:
stmt = select(Staff)
if role_filter:
stmt = stmt.where(Staff.role == role_filter)
if search:
s = f"%{search.lower()}%"
stmt = stmt.where(
or_(
func.lower(Staff.name).like(s),
func.lower(Staff.email).like(s),
)
)
stmt = stmt.order_by(Staff.name)
result = await db.execute(stmt)
rows = result.scalars().all()
return {"staff": [_to_response(r) for r in rows], "total": len(rows)}
async def get_staff(staff_id: str) -> dict:
db = get_db()
doc = db.collection("admin_users").document(staff_id).get()
if not doc.exists:
async def get_staff(db: AsyncSession, staff_id: str) -> dict:
result = await db.execute(select(Staff).where(Staff.id == staff_id).limit(1))
staff = result.scalar_one_or_none()
if staff is None:
raise NotFoundError("Staff member not found")
return _staff_doc_to_response(doc.id, doc.to_dict())
return _to_response(staff)
async def get_staff_me(user_sub: str) -> dict:
db = get_db()
doc = db.collection("admin_users").document(user_sub).get()
if not doc.exists:
raise NotFoundError("Staff member not found")
return _staff_doc_to_response(doc.id, doc.to_dict())
async def get_staff_me(db: AsyncSession, user_sub: str) -> dict:
return await get_staff(db, user_sub)
async def create_staff(data: dict, current_user_role: str) -> dict:
async def create_staff(db: AsyncSession, data: dict, current_user_role: str) -> dict:
role = data.get("role", "user")
if role not in VALID_ROLES:
raise AuthorizationError(f"Invalid role: {role}")
# Admin cannot create sysadmin
if current_user_role == "admin" and role == "sysadmin":
raise AuthorizationError("Admin cannot create sysadmin accounts")
db = get_db()
# Check for duplicate email
existing = db.collection("admin_users").where("email", "==", data["email"]).limit(1).get()
if existing:
existing = await db.execute(
select(Staff).where(Staff.email == data["email"]).limit(1)
)
if existing.scalar_one_or_none() is not None:
raise AuthorizationError("A staff member with this email already exists")
uid = str(uuid.uuid4())
hashed = hash_password(data["password"])
# Set default permissions for editor/user if not provided
permissions = data.get("permissions")
if permissions is None and role in ("editor", "user"):
permissions = default_permissions_for_role(role)
doc_data = {
"uid": uid,
"email": data["email"],
"hashed_password": hashed,
"name": data["name"],
"role": role,
"is_active": True,
"permissions": permissions,
}
doc_ref = db.collection("admin_users").document(uid)
doc_ref.set(doc_data)
return _staff_doc_to_response(uid, doc_data)
uid = str(uuid.uuid4())
now = _now()
staff = Staff(
id=uid,
firestore_id=None,
email=data["email"],
name=data["name"],
role=role,
hashed_password=hash_password(data["password"]),
is_active=True,
permissions=permissions,
created_at=now,
updated_at=now,
)
db.add(staff)
await db.commit()
await db.refresh(staff)
return _to_response(staff)
async def update_staff(staff_id: str, data: dict, current_user_role: str, current_user_id: str) -> dict:
db = get_db()
doc_ref = db.collection("admin_users").document(staff_id)
doc = doc_ref.get()
if not doc.exists:
async def update_staff(
db: AsyncSession,
staff_id: str,
data: dict,
current_user_role: str,
current_user_id: str,
) -> dict:
result = await db.execute(select(Staff).where(Staff.id == staff_id).limit(1))
staff = result.scalar_one_or_none()
if staff is None:
raise NotFoundError("Staff member not found")
existing = doc.to_dict()
# Admin cannot edit sysadmin accounts
if current_user_role == "admin" and existing.get("role") == "sysadmin":
if current_user_role == "admin" and staff.role == "sysadmin":
raise AuthorizationError("Admin cannot modify sysadmin accounts")
# Admin cannot promote to sysadmin
if current_user_role == "admin" and data.get("role") == "sysadmin":
raise AuthorizationError("Admin cannot promote to sysadmin")
update_data = {}
if data.get("email") is not None:
# Check for duplicate email
others = db.collection("admin_users").where("email", "==", data["email"]).limit(1).get()
for other in others:
if other.id != staff_id:
raise AuthorizationError("A staff member with this email already exists")
update_data["email"] = data["email"]
dup = await db.execute(
select(Staff).where(Staff.email == data["email"], Staff.id != staff_id).limit(1)
)
if dup.scalar_one_or_none() is not None:
raise AuthorizationError("A staff member with this email already exists")
staff.email = data["email"]
if data.get("name") is not None:
update_data["name"] = data["name"]
staff.name = data["name"]
if data.get("role") is not None:
if data["role"] not in VALID_ROLES:
raise AuthorizationError(f"Invalid role: {data['role']}")
update_data["role"] = data["role"]
staff.role = data["role"]
if data.get("is_active") is not None:
update_data["is_active"] = data["is_active"]
staff.is_active = data["is_active"]
if "permissions" in data:
update_data["permissions"] = data["permissions"]
staff.permissions = data["permissions"]
if update_data:
doc_ref.update(update_data)
updated = {**existing, **update_data}
return _staff_doc_to_response(staff_id, updated)
staff.updated_at = _now()
await db.commit()
await db.refresh(staff)
return _to_response(staff)
async def update_staff_password(staff_id: str, new_password: str, current_user_role: str) -> dict:
db = get_db()
doc_ref = db.collection("admin_users").document(staff_id)
doc = doc_ref.get()
if not doc.exists:
async def update_staff_password(
db: AsyncSession,
staff_id: str,
new_password: str,
current_user_role: str,
) -> dict:
result = await db.execute(select(Staff).where(Staff.id == staff_id).limit(1))
staff = result.scalar_one_or_none()
if staff is None:
raise NotFoundError("Staff member not found")
existing = doc.to_dict()
# Admin cannot change sysadmin password
if current_user_role == "admin" and existing.get("role") == "sysadmin":
if current_user_role == "admin" and staff.role == "sysadmin":
raise AuthorizationError("Admin cannot modify sysadmin accounts")
hashed = hash_password(new_password)
doc_ref.update({"hashed_password": hashed})
staff.hashed_password = hash_password(new_password)
staff.updated_at = _now()
await db.commit()
return {"message": "Password updated successfully"}
async def get_preferences(staff_id: str) -> dict:
"""Return the ui_prefs map for a staff member, defaulting to {} if not set."""
db = get_db()
doc = db.collection("admin_users").document(staff_id).get()
if not doc.exists:
async def get_preferences(db: AsyncSession, staff_id: str) -> dict:
"""Return ui_prefs JSONB for a staff member."""
result = await db.execute(select(Staff).where(Staff.id == staff_id).limit(1))
staff = result.scalar_one_or_none()
if staff is None:
raise NotFoundError("Staff member not found")
return doc.to_dict().get("ui_prefs", {})
return staff.ui_prefs or {}
async def update_preferences(staff_id: str, page_key: str, prefs: dict) -> dict:
"""Merge a page-level preferences dict into ui_prefs.<page_key> on the staff document.
Only the supplied keys are overwritten — other keys in the same page block survive.
"""
db = get_db()
doc_ref = db.collection("admin_users").document(staff_id)
doc = doc_ref.get()
if not doc.exists:
async def update_preferences(db: AsyncSession, staff_id: str, page_key: str, prefs: dict) -> dict:
"""Merge page-level preferences into the staff member's ui_prefs column."""
result = await db.execute(select(Staff).where(Staff.id == staff_id).limit(1))
staff = result.scalar_one_or_none()
if staff is None:
raise NotFoundError("Staff member not found")
existing_prefs = doc.to_dict().get("ui_prefs", {})
page_prefs = {**existing_prefs.get(page_key, {}), **prefs}
existing_prefs[page_key] = page_prefs
doc_ref.update({"ui_prefs": existing_prefs})
return existing_prefs
current = dict(staff.ui_prefs or {})
current[page_key] = {**current.get(page_key, {}), **prefs}
staff.ui_prefs = current
staff.updated_at = _now()
await db.commit()
return current
async def delete_staff(staff_id: str, current_user_role: str, current_user_id: str) -> dict:
db = get_db()
doc_ref = db.collection("admin_users").document(staff_id)
doc = doc_ref.get()
if not doc.exists:
raise NotFoundError("Staff member not found")
existing = doc.to_dict()
# Cannot delete self
async def delete_staff(
db: AsyncSession,
staff_id: str,
current_user_role: str,
current_user_id: str,
) -> dict:
if staff_id == current_user_id:
raise AuthorizationError("Cannot delete your own account")
# Admin cannot delete sysadmin
if current_user_role == "admin" and existing.get("role") == "sysadmin":
result = await db.execute(select(Staff).where(Staff.id == staff_id).limit(1))
staff = result.scalar_one_or_none()
if staff is None:
raise NotFoundError("Staff member not found")
if current_user_role == "admin" and staff.role == "sysadmin":
raise AuthorizationError("Admin cannot delete sysadmin accounts")
doc_ref.delete()
await db.delete(staff)
await db.commit()
return {"message": "Staff member deleted"}