Phase 3 of Migration
This commit is contained in:
@@ -0,0 +1,32 @@
|
||||
"""phase_3_staff_ui_prefs
|
||||
|
||||
Adds ui_prefs JSONB column to the staff table (Phase 3 — staff auth cutover).
|
||||
Also corrects permissions to be nullable (sysadmin/admin have NULL permissions).
|
||||
|
||||
Revision ID: c3d4e5f6a7b8
|
||||
Revises: b1c2d3e4f5a6
|
||||
Create Date: 2026-04-17 00:00:00.000000
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from alembic import op
|
||||
|
||||
revision: str = "c3d4e5f6a7b8"
|
||||
down_revision: Union[str, None] = "b1c2d3e4f5a6"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"staff",
|
||||
sa.Column("ui_prefs", JSONB, nullable=False, server_default="{}"),
|
||||
)
|
||||
# permissions was NOT NULL DEFAULT '{}' — relax to nullable for sysadmin/admin
|
||||
op.alter_column("staff", "permissions", nullable=True)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("staff", "ui_prefs")
|
||||
op.alter_column("staff", "permissions", nullable=False)
|
||||
@@ -1,10 +1,14 @@
|
||||
from fastapi import Depends
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
from jose import JWTError
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from auth.utils import decode_access_token
|
||||
from auth.models import TokenPayload, Role
|
||||
from database.postgres import get_pg_session
|
||||
from staff.orm import Staff
|
||||
from shared.exceptions import AuthenticationError, AuthorizationError
|
||||
from shared.firebase import get_db
|
||||
|
||||
security = HTTPBearer()
|
||||
|
||||
@@ -37,18 +41,15 @@ def require_roles(*allowed_roles: Role):
|
||||
return role_checker
|
||||
|
||||
|
||||
async def _get_user_permissions(user: TokenPayload) -> dict:
|
||||
"""Fetch permissions from Firestore for the given user."""
|
||||
async def _get_user_permissions(user: TokenPayload, db: AsyncSession) -> dict | None:
|
||||
"""Fetch permissions from Postgres for the given user."""
|
||||
if user.role in (Role.sysadmin, Role.admin):
|
||||
return None # Full access
|
||||
db = get_db()
|
||||
if not db:
|
||||
result = await db.execute(select(Staff).where(Staff.id == user.sub).limit(1))
|
||||
staff = result.scalar_one_or_none()
|
||||
if staff is None:
|
||||
raise AuthorizationError()
|
||||
doc = db.collection("admin_users").document(user.sub).get()
|
||||
if not doc.exists:
|
||||
raise AuthorizationError()
|
||||
data = doc.to_dict()
|
||||
return data.get("permissions")
|
||||
return staff.permissions
|
||||
|
||||
|
||||
def require_permission(section: str, action: str):
|
||||
@@ -58,17 +59,17 @@ def require_permission(section: str, action: str):
|
||||
"""
|
||||
async def permission_checker(
|
||||
current_user: TokenPayload = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_pg_session),
|
||||
) -> TokenPayload:
|
||||
# sysadmin and admin have full access
|
||||
if current_user.role in (Role.sysadmin, Role.admin):
|
||||
return current_user
|
||||
|
||||
permissions = await _get_user_permissions(current_user)
|
||||
permissions = await _get_user_permissions(current_user, db)
|
||||
if not permissions:
|
||||
raise AuthorizationError()
|
||||
|
||||
if section == "mqtt":
|
||||
if not permissions.get("mqtt", False):
|
||||
if not permissions.get("mqtt", {}).get("access", False):
|
||||
raise AuthorizationError()
|
||||
return current_user
|
||||
|
||||
@@ -89,11 +90,7 @@ def require_permission(section: str, action: str):
|
||||
# Pre-built convenience dependencies
|
||||
require_sysadmin = require_roles(Role.sysadmin)
|
||||
require_admin_or_above = require_roles(Role.sysadmin, Role.admin)
|
||||
|
||||
# Staff management: only sysadmin and admin
|
||||
require_staff_management = require_roles(Role.sysadmin, Role.admin)
|
||||
|
||||
# Viewer-level: any authenticated user (actual permission check per-action)
|
||||
require_any_authenticated = require_roles(
|
||||
Role.sysadmin, Role.admin, Role.editor, Role.user,
|
||||
)
|
||||
|
||||
@@ -1,59 +1,57 @@
|
||||
from fastapi import APIRouter
|
||||
from shared.firebase import get_db
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from database.postgres import get_pg_session
|
||||
from staff.orm import Staff
|
||||
from auth.models import LoginRequest, TokenResponse
|
||||
from auth.utils import verify_password, create_access_token
|
||||
from shared.exceptions import AuthenticationError
|
||||
|
||||
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
||||
|
||||
|
||||
@router.post("/login", response_model=TokenResponse)
|
||||
async def login(body: LoginRequest):
|
||||
db = get_db()
|
||||
if not db:
|
||||
raise AuthenticationError("Service unavailable")
|
||||
|
||||
users_ref = db.collection("admin_users")
|
||||
query = users_ref.where("email", "==", body.email).limit(1).get()
|
||||
|
||||
if not query:
|
||||
raise AuthenticationError("Invalid email or password")
|
||||
|
||||
doc = query[0]
|
||||
user_data = doc.to_dict()
|
||||
|
||||
if not user_data.get("is_active", True):
|
||||
raise AuthenticationError("Account is disabled")
|
||||
|
||||
if not verify_password(body.password, user_data["hashed_password"]):
|
||||
raise AuthenticationError("Invalid email or password")
|
||||
|
||||
role = user_data["role"]
|
||||
# Map legacy roles to new roles
|
||||
role_mapping = {
|
||||
_ROLE_MAP = {
|
||||
"superadmin": "sysadmin",
|
||||
"melody_editor": "editor",
|
||||
"device_manager": "editor",
|
||||
"user_manager": "editor",
|
||||
"viewer": "user",
|
||||
}
|
||||
role = role_mapping.get(role, role)
|
||||
"staff": "user",
|
||||
}
|
||||
|
||||
|
||||
@router.post("/login", response_model=TokenResponse)
|
||||
async def login(body: LoginRequest, db: AsyncSession = Depends(get_pg_session)):
|
||||
result = await db.execute(
|
||||
select(Staff).where(Staff.email == body.email).limit(1)
|
||||
)
|
||||
staff = result.scalar_one_or_none()
|
||||
|
||||
if staff is None:
|
||||
raise AuthenticationError("Invalid email or password")
|
||||
|
||||
if not staff.is_active:
|
||||
raise AuthenticationError("Account is disabled")
|
||||
|
||||
if not verify_password(body.password, staff.hashed_password):
|
||||
raise AuthenticationError("Invalid email or password")
|
||||
|
||||
role = _ROLE_MAP.get(staff.role, staff.role)
|
||||
|
||||
token = create_access_token({
|
||||
"sub": doc.id,
|
||||
"email": user_data["email"],
|
||||
"sub": staff.id,
|
||||
"email": staff.email,
|
||||
"role": role,
|
||||
"name": user_data["name"],
|
||||
"name": staff.name,
|
||||
})
|
||||
|
||||
# Get permissions for editor/user roles
|
||||
permissions = None
|
||||
if role in ("editor", "user"):
|
||||
permissions = user_data.get("permissions")
|
||||
permissions = staff.permissions
|
||||
|
||||
return TokenResponse(
|
||||
access_token=token,
|
||||
role=role,
|
||||
name=user_data["name"],
|
||||
name=staff.name,
|
||||
permissions=permissions,
|
||||
)
|
||||
|
||||
143
backend/migration/migrate_staff.py
Normal file
143
backend/migration/migrate_staff.py
Normal file
@@ -0,0 +1,143 @@
|
||||
"""
|
||||
Phase 3 — Step 3.1: admin_users (Firestore → Postgres staff table)
|
||||
|
||||
Reads every document in the 'admin_users' Firestore collection and inserts
|
||||
a matching row into the Postgres 'staff' table.
|
||||
|
||||
Key transformations:
|
||||
- Legacy role names mapped to canonical roles (superadmin→sysadmin, etc.)
|
||||
- permissions=None stored as JSONB null (sysadmin/admin have no permission map)
|
||||
- ui_prefs column NOT migrated (not part of the Postgres schema — dropped)
|
||||
- Firestore doc ID preserved as staff.id and staff.firestore_id
|
||||
- created_at/updated_at default to now() if missing from Firestore doc
|
||||
|
||||
Run on VPS:
|
||||
docker compose exec backend python -m migration.migrate_staff
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||
|
||||
from staff.orm import Staff
|
||||
from shared.firebase import init_firebase, get_db as get_firestore
|
||||
from migration.utils import AsyncPgSession, parse_dt, log_run, pg_count
|
||||
|
||||
SCRIPT = "migrate_staff"
|
||||
COLLECTION = "admin_users"
|
||||
|
||||
_ROLE_MAP = {
|
||||
"superadmin": "sysadmin",
|
||||
"melody_editor": "editor",
|
||||
"device_manager": "editor",
|
||||
"user_manager": "editor",
|
||||
"viewer": "user",
|
||||
# canonical roles pass through unchanged
|
||||
"sysadmin": "sysadmin",
|
||||
"admin": "admin",
|
||||
"editor": "editor",
|
||||
"user": "user",
|
||||
"staff": "user",
|
||||
}
|
||||
|
||||
|
||||
def _now_utc() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def _coerce_dt(val) -> datetime | None:
|
||||
if val is None:
|
||||
return None
|
||||
if isinstance(val, datetime):
|
||||
return val.replace(tzinfo=timezone.utc) if val.tzinfo is None else val
|
||||
return parse_dt(str(val))
|
||||
|
||||
|
||||
async def run() -> None:
|
||||
init_firebase()
|
||||
fs = get_firestore()
|
||||
if fs is None:
|
||||
print("ERROR: Firebase not initialised.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
docs = list(fs.collection(COLLECTION).stream())
|
||||
source_count = len(docs)
|
||||
print(f"Source (Firestore): {source_count} admin_users documents")
|
||||
|
||||
if source_count == 0:
|
||||
print("Nothing to migrate.")
|
||||
await log_run(SCRIPT, 0, 0, notes="source empty")
|
||||
return
|
||||
|
||||
records = []
|
||||
skipped = 0
|
||||
|
||||
for doc in docs:
|
||||
d = doc.to_dict()
|
||||
|
||||
hashed_password = d.get("hashed_password") or ""
|
||||
if not hashed_password:
|
||||
print(f" WARNING: {doc.id} ({d.get('email')}) has no hashed_password — skipping",
|
||||
file=sys.stderr)
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
email = d.get("email") or ""
|
||||
if not email:
|
||||
print(f" WARNING: {doc.id} has no email — skipping", file=sys.stderr)
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
raw_role = d.get("role") or "user"
|
||||
role = _ROLE_MAP.get(raw_role, "user")
|
||||
|
||||
# sysadmin/admin have no permission map
|
||||
permissions = d.get("permissions")
|
||||
if role in ("sysadmin", "admin"):
|
||||
permissions = None
|
||||
|
||||
now = _now_utc()
|
||||
records.append({
|
||||
"id": doc.id,
|
||||
"firestore_id": doc.id,
|
||||
"email": email,
|
||||
"name": d.get("name") or "",
|
||||
"role": role,
|
||||
"permissions": permissions,
|
||||
"hashed_password": hashed_password,
|
||||
"is_active": bool(d.get("is_active", True)),
|
||||
"created_at": _coerce_dt(d.get("created_at")) or now,
|
||||
"updated_at": _coerce_dt(d.get("updated_at")) or now,
|
||||
})
|
||||
|
||||
actual_source = source_count - skipped
|
||||
print(f" {skipped} skipped (missing email or password), {actual_source} to insert")
|
||||
|
||||
if not records:
|
||||
print("Nothing to insert after filtering.")
|
||||
await log_run(SCRIPT, source_count, 0, success=False,
|
||||
notes="all docs skipped — missing required fields")
|
||||
sys.exit(1)
|
||||
|
||||
async with AsyncPgSession() as session:
|
||||
async with session.begin():
|
||||
stmt = pg_insert(Staff).values(records)
|
||||
stmt = stmt.on_conflict_do_nothing(index_elements=["id"])
|
||||
await session.execute(stmt)
|
||||
dest_count = await pg_count(session, "staff")
|
||||
|
||||
if dest_count < actual_source:
|
||||
msg = f"Count mismatch: expected>={actual_source} postgres={dest_count}"
|
||||
print(f"ERROR: {msg}", file=sys.stderr)
|
||||
await log_run(SCRIPT, source_count, dest_count, success=False, notes=msg)
|
||||
sys.exit(1)
|
||||
|
||||
print(f"Postgres: {dest_count} rows ✓")
|
||||
note = f"{skipped} skipped (missing fields)" if skipped else None
|
||||
await log_run(SCRIPT, source_count, dest_count, notes=note)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(run())
|
||||
66
backend/seed_admin_postgres.py
Normal file
66
backend/seed_admin_postgres.py
Normal file
@@ -0,0 +1,66 @@
|
||||
"""
|
||||
Seed script to create the first sysadmin user directly in Postgres.
|
||||
Use this after Phase 3 cutover — do not use seed_admin.py (Firestore) anymore.
|
||||
|
||||
Usage:
|
||||
python seed_admin_postgres.py
|
||||
python seed_admin_postgres.py --email admin@bellsystems.com --password secret --name "Admin"
|
||||
"""
|
||||
import argparse
|
||||
import asyncio
|
||||
import sys
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from getpass import getpass
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from database.postgres import AsyncSessionLocal
|
||||
from staff.orm import Staff
|
||||
from auth.utils import hash_password
|
||||
|
||||
|
||||
async def seed_superadmin(email: str, password: str, name: str) -> None:
|
||||
async with AsyncSessionLocal() as db:
|
||||
existing = await db.execute(select(Staff).where(Staff.email == email).limit(1))
|
||||
if existing.scalar_one_or_none() is not None:
|
||||
print(f"User with email '{email}' already exists. Aborting.")
|
||||
sys.exit(1)
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
uid = str(uuid.uuid4())
|
||||
staff = Staff(
|
||||
id=uid,
|
||||
firestore_id=None,
|
||||
email=email,
|
||||
name=name,
|
||||
role="sysadmin",
|
||||
hashed_password=hash_password(password),
|
||||
is_active=True,
|
||||
permissions=None,
|
||||
ui_prefs={},
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
db.add(staff)
|
||||
await db.commit()
|
||||
|
||||
print("SysAdmin created successfully!")
|
||||
print(f" Email: {email}")
|
||||
print(f" Name: {name}")
|
||||
print(f" Role: sysadmin")
|
||||
print(f" ID: {uid}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description="Seed a sysadmin user in Postgres")
|
||||
parser.add_argument("--email", default=None)
|
||||
parser.add_argument("--password", default=None)
|
||||
parser.add_argument("--name", default=None)
|
||||
args = parser.parse_args()
|
||||
|
||||
email = args.email or input("Email: ")
|
||||
name = args.name or input("Name: ")
|
||||
password = args.password or getpass("Password: ")
|
||||
|
||||
asyncio.run(seed_superadmin(email, password, name))
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
async def list_staff(db: AsyncSession, search: str = None, role_filter: str = None) -> dict:
|
||||
stmt = select(Staff)
|
||||
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)}
|
||||
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:
|
||||
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")
|
||||
update_data["email"] = data["email"]
|
||||
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"}
|
||||
|
||||
@@ -467,8 +467,8 @@ backend/
|
||||
|-------|-------------|--------|
|
||||
| 0 | Schema foundation (all tables in Postgres) | **COMPLETE** — applied on VPS 2026-04-17 |
|
||||
| 1 | SQLite → Postgres (data migration) | **COMPLETE** — all 12 scripts ran successfully on VPS 2026-04-17 |
|
||||
| 2 | Firestore → Postgres (data migration) | **IN PROGRESS** — scripts written, not yet run |
|
||||
| 3 | Staff auth cutover | NOT STARTED |
|
||||
| 2 | Firestore → Postgres (data migration) | **COMPLETE** — all 5 scripts ran successfully on VPS 2026-04-17 |
|
||||
| 3 | Staff auth cutover | **COMPLETE** — Postgres auth live 2026-04-17 |
|
||||
| 4 | Audit log system | NOT STARTED |
|
||||
| 5 | MQTT live data cutover | NOT STARTED |
|
||||
|
||||
@@ -546,3 +546,35 @@ docker compose exec backend python -m migration.migrate_crm_customers
|
||||
# 2.5 (depends on 2.4)
|
||||
docker compose exec backend python -m migration.migrate_crm_orders
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 3 — Run Order & Commands
|
||||
|
||||
Apply the new Alembic revision first (adds `ui_prefs` column + makes `permissions` nullable):
|
||||
|
||||
```bash
|
||||
# Apply schema change
|
||||
docker compose exec backend alembic upgrade head
|
||||
|
||||
# 3.1 — migrate Firestore admin_users → Postgres staff table
|
||||
docker compose exec backend python -m migration.migrate_staff
|
||||
|
||||
# Verify
|
||||
docker compose exec postgres psql -U bellsystems_user -d bellsystems_db \
|
||||
-c "SELECT id, email, role, is_active FROM staff ORDER BY role, name;"
|
||||
```
|
||||
|
||||
After verifying the staff table is populated correctly:
|
||||
|
||||
```bash
|
||||
# Restart the backend so it picks up the new auth/staff code
|
||||
docker compose restart backend
|
||||
```
|
||||
|
||||
Then test: log in as each role in the Console UI and verify permissions work.
|
||||
|
||||
After 24h stable operation, Firestore reads from auth are fully removed (already done in code).
|
||||
|
||||
**Rollback:** revert `auth/router.py`, `auth/dependencies.py`, `staff/service.py`, `staff/router.py`
|
||||
to the Firestore versions — the JWT payload is unchanged so tokens remain valid during rollback.
|
||||
|
||||
Reference in New Issue
Block a user