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 import Depends
|
||||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||||
from jose import JWTError
|
from jose import JWTError
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from auth.utils import decode_access_token
|
from auth.utils import decode_access_token
|
||||||
from auth.models import TokenPayload, Role
|
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.exceptions import AuthenticationError, AuthorizationError
|
||||||
from shared.firebase import get_db
|
|
||||||
|
|
||||||
security = HTTPBearer()
|
security = HTTPBearer()
|
||||||
|
|
||||||
@@ -37,18 +41,15 @@ def require_roles(*allowed_roles: Role):
|
|||||||
return role_checker
|
return role_checker
|
||||||
|
|
||||||
|
|
||||||
async def _get_user_permissions(user: TokenPayload) -> dict:
|
async def _get_user_permissions(user: TokenPayload, db: AsyncSession) -> dict | None:
|
||||||
"""Fetch permissions from Firestore for the given user."""
|
"""Fetch permissions from Postgres for the given user."""
|
||||||
if user.role in (Role.sysadmin, Role.admin):
|
if user.role in (Role.sysadmin, Role.admin):
|
||||||
return None # Full access
|
return None # Full access
|
||||||
db = get_db()
|
result = await db.execute(select(Staff).where(Staff.id == user.sub).limit(1))
|
||||||
if not db:
|
staff = result.scalar_one_or_none()
|
||||||
|
if staff is None:
|
||||||
raise AuthorizationError()
|
raise AuthorizationError()
|
||||||
doc = db.collection("admin_users").document(user.sub).get()
|
return staff.permissions
|
||||||
if not doc.exists:
|
|
||||||
raise AuthorizationError()
|
|
||||||
data = doc.to_dict()
|
|
||||||
return data.get("permissions")
|
|
||||||
|
|
||||||
|
|
||||||
def require_permission(section: str, action: str):
|
def require_permission(section: str, action: str):
|
||||||
@@ -58,17 +59,17 @@ def require_permission(section: str, action: str):
|
|||||||
"""
|
"""
|
||||||
async def permission_checker(
|
async def permission_checker(
|
||||||
current_user: TokenPayload = Depends(get_current_user),
|
current_user: TokenPayload = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_pg_session),
|
||||||
) -> TokenPayload:
|
) -> TokenPayload:
|
||||||
# sysadmin and admin have full access
|
|
||||||
if current_user.role in (Role.sysadmin, Role.admin):
|
if current_user.role in (Role.sysadmin, Role.admin):
|
||||||
return current_user
|
return current_user
|
||||||
|
|
||||||
permissions = await _get_user_permissions(current_user)
|
permissions = await _get_user_permissions(current_user, db)
|
||||||
if not permissions:
|
if not permissions:
|
||||||
raise AuthorizationError()
|
raise AuthorizationError()
|
||||||
|
|
||||||
if section == "mqtt":
|
if section == "mqtt":
|
||||||
if not permissions.get("mqtt", False):
|
if not permissions.get("mqtt", {}).get("access", False):
|
||||||
raise AuthorizationError()
|
raise AuthorizationError()
|
||||||
return current_user
|
return current_user
|
||||||
|
|
||||||
@@ -89,11 +90,7 @@ def require_permission(section: str, action: str):
|
|||||||
# Pre-built convenience dependencies
|
# Pre-built convenience dependencies
|
||||||
require_sysadmin = require_roles(Role.sysadmin)
|
require_sysadmin = require_roles(Role.sysadmin)
|
||||||
require_admin_or_above = require_roles(Role.sysadmin, Role.admin)
|
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)
|
require_staff_management = require_roles(Role.sysadmin, Role.admin)
|
||||||
|
|
||||||
# Viewer-level: any authenticated user (actual permission check per-action)
|
|
||||||
require_any_authenticated = require_roles(
|
require_any_authenticated = require_roles(
|
||||||
Role.sysadmin, Role.admin, Role.editor, Role.user,
|
Role.sysadmin, Role.admin, Role.editor, Role.user,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,59 +1,57 @@
|
|||||||
from fastapi import APIRouter
|
from fastapi import APIRouter, Depends
|
||||||
from shared.firebase import get_db
|
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.models import LoginRequest, TokenResponse
|
||||||
from auth.utils import verify_password, create_access_token
|
from auth.utils import verify_password, create_access_token
|
||||||
from shared.exceptions import AuthenticationError
|
from shared.exceptions import AuthenticationError
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
||||||
|
|
||||||
|
_ROLE_MAP = {
|
||||||
|
"superadmin": "sysadmin",
|
||||||
|
"melody_editor": "editor",
|
||||||
|
"device_manager": "editor",
|
||||||
|
"user_manager": "editor",
|
||||||
|
"viewer": "user",
|
||||||
|
"staff": "user",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/login", response_model=TokenResponse)
|
@router.post("/login", response_model=TokenResponse)
|
||||||
async def login(body: LoginRequest):
|
async def login(body: LoginRequest, db: AsyncSession = Depends(get_pg_session)):
|
||||||
db = get_db()
|
result = await db.execute(
|
||||||
if not db:
|
select(Staff).where(Staff.email == body.email).limit(1)
|
||||||
raise AuthenticationError("Service unavailable")
|
)
|
||||||
|
staff = result.scalar_one_or_none()
|
||||||
|
|
||||||
users_ref = db.collection("admin_users")
|
if staff is None:
|
||||||
query = users_ref.where("email", "==", body.email).limit(1).get()
|
|
||||||
|
|
||||||
if not query:
|
|
||||||
raise AuthenticationError("Invalid email or password")
|
raise AuthenticationError("Invalid email or password")
|
||||||
|
|
||||||
doc = query[0]
|
if not staff.is_active:
|
||||||
user_data = doc.to_dict()
|
|
||||||
|
|
||||||
if not user_data.get("is_active", True):
|
|
||||||
raise AuthenticationError("Account is disabled")
|
raise AuthenticationError("Account is disabled")
|
||||||
|
|
||||||
if not verify_password(body.password, user_data["hashed_password"]):
|
if not verify_password(body.password, staff.hashed_password):
|
||||||
raise AuthenticationError("Invalid email or password")
|
raise AuthenticationError("Invalid email or password")
|
||||||
|
|
||||||
role = user_data["role"]
|
role = _ROLE_MAP.get(staff.role, staff.role)
|
||||||
# Map legacy roles to new roles
|
|
||||||
role_mapping = {
|
|
||||||
"superadmin": "sysadmin",
|
|
||||||
"melody_editor": "editor",
|
|
||||||
"device_manager": "editor",
|
|
||||||
"user_manager": "editor",
|
|
||||||
"viewer": "user",
|
|
||||||
}
|
|
||||||
role = role_mapping.get(role, role)
|
|
||||||
|
|
||||||
token = create_access_token({
|
token = create_access_token({
|
||||||
"sub": doc.id,
|
"sub": staff.id,
|
||||||
"email": user_data["email"],
|
"email": staff.email,
|
||||||
"role": role,
|
"role": role,
|
||||||
"name": user_data["name"],
|
"name": staff.name,
|
||||||
})
|
})
|
||||||
|
|
||||||
# Get permissions for editor/user roles
|
|
||||||
permissions = None
|
permissions = None
|
||||||
if role in ("editor", "user"):
|
if role in ("editor", "user"):
|
||||||
permissions = user_data.get("permissions")
|
permissions = staff.permissions
|
||||||
|
|
||||||
return TokenResponse(
|
return TokenResponse(
|
||||||
access_token=token,
|
access_token=token,
|
||||||
role=role,
|
role=role,
|
||||||
name=user_data["name"],
|
name=staff.name,
|
||||||
permissions=permissions,
|
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)
|
email = Column(String(256), unique=True, nullable=False)
|
||||||
name = Column(String(255), nullable=False)
|
name = Column(String(255), nullable=False)
|
||||||
role = Column(String(64), nullable=False, default="staff")
|
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)
|
hashed_password = Column(String(256), nullable=False)
|
||||||
is_active = Column(Boolean, nullable=False, default=True)
|
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)
|
created_at = Column(DateTime(timezone=True), nullable=False, default=_now)
|
||||||
updated_at = Column(DateTime(timezone=True), nullable=False, default=_now, onupdate=_now)
|
updated_at = Column(DateTime(timezone=True), nullable=False, default=_now, onupdate=_now)
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
from fastapi import APIRouter, Depends, Query
|
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.dependencies import get_current_user, require_staff_management
|
||||||
from auth.models import TokenPayload
|
from auth.models import TokenPayload
|
||||||
from staff import service
|
from staff import service
|
||||||
@@ -13,14 +15,19 @@ router = APIRouter(prefix="/api/staff", tags=["staff"])
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/me", response_model=StaffResponse)
|
@router.get("/me", response_model=StaffResponse)
|
||||||
async def get_current_staff(current_user: TokenPayload = Depends(get_current_user)):
|
async def get_current_staff(
|
||||||
return await service.get_staff_me(current_user.sub)
|
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)
|
@router.get("/me/preferences", response_model=dict)
|
||||||
async def get_preferences(current_user: TokenPayload = Depends(get_current_user)):
|
async def get_preferences(
|
||||||
"""Return all UI preferences for the current staff member."""
|
current_user: TokenPayload = Depends(get_current_user),
|
||||||
return await service.get_preferences(current_user.sub)
|
db: AsyncSession = Depends(get_pg_session),
|
||||||
|
):
|
||||||
|
return await service.get_preferences(db, current_user.sub)
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/me/preferences/{page_key}", response_model=dict)
|
@router.patch("/me/preferences/{page_key}", response_model=dict)
|
||||||
@@ -28,9 +35,9 @@ async def update_preferences(
|
|||||||
page_key: str,
|
page_key: str,
|
||||||
body: PreferencesUpdate,
|
body: PreferencesUpdate,
|
||||||
current_user: TokenPayload = Depends(get_current_user),
|
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(db, current_user.sub, page_key, body.prefs)
|
||||||
return await service.update_preferences(current_user.sub, page_key, body.prefs)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=StaffListResponse)
|
@router.get("", response_model=StaffListResponse)
|
||||||
@@ -38,24 +45,28 @@ async def list_staff(
|
|||||||
search: str = Query(None),
|
search: str = Query(None),
|
||||||
role: str = Query(None),
|
role: str = Query(None),
|
||||||
current_user: TokenPayload = Depends(require_staff_management),
|
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)
|
@router.get("/{staff_id}", response_model=StaffResponse)
|
||||||
async def get_staff(
|
async def get_staff(
|
||||||
staff_id: str,
|
staff_id: str,
|
||||||
current_user: TokenPayload = Depends(require_staff_management),
|
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)
|
@router.post("", response_model=StaffResponse)
|
||||||
async def create_staff(
|
async def create_staff(
|
||||||
body: StaffCreate,
|
body: StaffCreate,
|
||||||
current_user: TokenPayload = Depends(require_staff_management),
|
current_user: TokenPayload = Depends(require_staff_management),
|
||||||
|
db: AsyncSession = Depends(get_pg_session),
|
||||||
):
|
):
|
||||||
return await service.create_staff(
|
return await service.create_staff(
|
||||||
|
db,
|
||||||
data=body.model_dump(),
|
data=body.model_dump(),
|
||||||
current_user_role=current_user.role,
|
current_user_role=current_user.role,
|
||||||
)
|
)
|
||||||
@@ -66,8 +77,10 @@ async def update_staff(
|
|||||||
staff_id: str,
|
staff_id: str,
|
||||||
body: StaffUpdate,
|
body: StaffUpdate,
|
||||||
current_user: TokenPayload = Depends(require_staff_management),
|
current_user: TokenPayload = Depends(require_staff_management),
|
||||||
|
db: AsyncSession = Depends(get_pg_session),
|
||||||
):
|
):
|
||||||
return await service.update_staff(
|
return await service.update_staff(
|
||||||
|
db,
|
||||||
staff_id=staff_id,
|
staff_id=staff_id,
|
||||||
data=body.model_dump(exclude_unset=True),
|
data=body.model_dump(exclude_unset=True),
|
||||||
current_user_role=current_user.role,
|
current_user_role=current_user.role,
|
||||||
@@ -80,8 +93,10 @@ async def update_staff_password(
|
|||||||
staff_id: str,
|
staff_id: str,
|
||||||
body: StaffPasswordUpdate,
|
body: StaffPasswordUpdate,
|
||||||
current_user: TokenPayload = Depends(require_staff_management),
|
current_user: TokenPayload = Depends(require_staff_management),
|
||||||
|
db: AsyncSession = Depends(get_pg_session),
|
||||||
):
|
):
|
||||||
return await service.update_staff_password(
|
return await service.update_staff_password(
|
||||||
|
db,
|
||||||
staff_id=staff_id,
|
staff_id=staff_id,
|
||||||
new_password=body.new_password,
|
new_password=body.new_password,
|
||||||
current_user_role=current_user.role,
|
current_user_role=current_user.role,
|
||||||
@@ -92,8 +107,10 @@ async def update_staff_password(
|
|||||||
async def delete_staff(
|
async def delete_staff(
|
||||||
staff_id: str,
|
staff_id: str,
|
||||||
current_user: TokenPayload = Depends(require_staff_management),
|
current_user: TokenPayload = Depends(require_staff_management),
|
||||||
|
db: AsyncSession = Depends(get_pg_session),
|
||||||
):
|
):
|
||||||
return await service.delete_staff(
|
return await service.delete_staff(
|
||||||
|
db,
|
||||||
staff_id=staff_id,
|
staff_id=staff_id,
|
||||||
current_user_role=current_user.role,
|
current_user_role=current_user.role,
|
||||||
current_user_id=current_user.sub,
|
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.utils import hash_password
|
||||||
from auth.models import default_permissions_for_role
|
from auth.models import default_permissions_for_role
|
||||||
from shared.exceptions import NotFoundError, AuthorizationError
|
from shared.exceptions import NotFoundError, AuthorizationError
|
||||||
@@ -8,198 +13,190 @@ import uuid
|
|||||||
VALID_ROLES = ("sysadmin", "admin", "editor", "user")
|
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 {
|
return {
|
||||||
"id": doc_id,
|
"id": staff.id,
|
||||||
"email": data.get("email", ""),
|
"email": staff.email,
|
||||||
"name": data.get("name", ""),
|
"name": staff.name,
|
||||||
"role": data.get("role", ""),
|
"role": staff.role,
|
||||||
"is_active": data.get("is_active", True),
|
"is_active": staff.is_active,
|
||||||
"permissions": data.get("permissions"),
|
"permissions": staff.permissions,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
async def list_staff(search: str = None, role_filter: str = None) -> dict:
|
async def list_staff(db: AsyncSession, search: str = None, role_filter: str = None) -> dict:
|
||||||
db = get_db()
|
stmt = select(Staff)
|
||||||
ref = db.collection("admin_users")
|
if role_filter:
|
||||||
docs = ref.get()
|
stmt = stmt.where(Staff.role == role_filter)
|
||||||
|
if search:
|
||||||
staff = []
|
s = f"%{search.lower()}%"
|
||||||
for doc in docs:
|
stmt = stmt.where(
|
||||||
data = doc.to_dict()
|
or_(
|
||||||
if search:
|
func.lower(Staff.name).like(s),
|
||||||
s = search.lower()
|
func.lower(Staff.email).like(s),
|
||||||
if s not in (data.get("name", "").lower()) and s not in (data.get("email", "").lower()):
|
)
|
||||||
continue
|
)
|
||||||
if role_filter:
|
stmt = stmt.order_by(Staff.name)
|
||||||
if data.get("role") != role_filter:
|
result = await db.execute(stmt)
|
||||||
continue
|
rows = result.scalars().all()
|
||||||
staff.append(_staff_doc_to_response(doc.id, data))
|
return {"staff": [_to_response(r) for r in rows], "total": len(rows)}
|
||||||
|
|
||||||
return {"staff": staff, "total": len(staff)}
|
|
||||||
|
|
||||||
|
|
||||||
async def get_staff(staff_id: str) -> dict:
|
async def get_staff(db: AsyncSession, staff_id: str) -> dict:
|
||||||
db = get_db()
|
result = await db.execute(select(Staff).where(Staff.id == staff_id).limit(1))
|
||||||
doc = db.collection("admin_users").document(staff_id).get()
|
staff = result.scalar_one_or_none()
|
||||||
if not doc.exists:
|
if staff is None:
|
||||||
raise NotFoundError("Staff member not found")
|
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:
|
async def get_staff_me(db: AsyncSession, user_sub: str) -> dict:
|
||||||
db = get_db()
|
return await get_staff(db, user_sub)
|
||||||
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 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")
|
role = data.get("role", "user")
|
||||||
if role not in VALID_ROLES:
|
if role not in VALID_ROLES:
|
||||||
raise AuthorizationError(f"Invalid role: {role}")
|
raise AuthorizationError(f"Invalid role: {role}")
|
||||||
|
|
||||||
# Admin cannot create sysadmin
|
|
||||||
if current_user_role == "admin" and role == "sysadmin":
|
if current_user_role == "admin" and role == "sysadmin":
|
||||||
raise AuthorizationError("Admin cannot create sysadmin accounts")
|
raise AuthorizationError("Admin cannot create sysadmin accounts")
|
||||||
|
|
||||||
db = get_db()
|
existing = await db.execute(
|
||||||
|
select(Staff).where(Staff.email == data["email"]).limit(1)
|
||||||
# Check for duplicate email
|
)
|
||||||
existing = db.collection("admin_users").where("email", "==", data["email"]).limit(1).get()
|
if existing.scalar_one_or_none() is not None:
|
||||||
if existing:
|
|
||||||
raise AuthorizationError("A staff member with this email already exists")
|
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")
|
permissions = data.get("permissions")
|
||||||
if permissions is None and role in ("editor", "user"):
|
if permissions is None and role in ("editor", "user"):
|
||||||
permissions = default_permissions_for_role(role)
|
permissions = default_permissions_for_role(role)
|
||||||
|
|
||||||
doc_data = {
|
uid = str(uuid.uuid4())
|
||||||
"uid": uid,
|
now = _now()
|
||||||
"email": data["email"],
|
staff = Staff(
|
||||||
"hashed_password": hashed,
|
id=uid,
|
||||||
"name": data["name"],
|
firestore_id=None,
|
||||||
"role": role,
|
email=data["email"],
|
||||||
"is_active": True,
|
name=data["name"],
|
||||||
"permissions": permissions,
|
role=role,
|
||||||
}
|
hashed_password=hash_password(data["password"]),
|
||||||
|
is_active=True,
|
||||||
doc_ref = db.collection("admin_users").document(uid)
|
permissions=permissions,
|
||||||
doc_ref.set(doc_data)
|
created_at=now,
|
||||||
|
updated_at=now,
|
||||||
return _staff_doc_to_response(uid, doc_data)
|
)
|
||||||
|
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:
|
async def update_staff(
|
||||||
db = get_db()
|
db: AsyncSession,
|
||||||
doc_ref = db.collection("admin_users").document(staff_id)
|
staff_id: str,
|
||||||
doc = doc_ref.get()
|
data: dict,
|
||||||
if not doc.exists:
|
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")
|
raise NotFoundError("Staff member not found")
|
||||||
|
|
||||||
existing = doc.to_dict()
|
if current_user_role == "admin" and staff.role == "sysadmin":
|
||||||
|
|
||||||
# Admin cannot edit sysadmin accounts
|
|
||||||
if current_user_role == "admin" and existing.get("role") == "sysadmin":
|
|
||||||
raise AuthorizationError("Admin cannot modify sysadmin accounts")
|
raise AuthorizationError("Admin cannot modify sysadmin accounts")
|
||||||
|
|
||||||
# Admin cannot promote to sysadmin
|
|
||||||
if current_user_role == "admin" and data.get("role") == "sysadmin":
|
if current_user_role == "admin" and data.get("role") == "sysadmin":
|
||||||
raise AuthorizationError("Admin cannot promote to sysadmin")
|
raise AuthorizationError("Admin cannot promote to sysadmin")
|
||||||
|
|
||||||
update_data = {}
|
|
||||||
if data.get("email") is not None:
|
if data.get("email") is not None:
|
||||||
# Check for duplicate email
|
dup = await db.execute(
|
||||||
others = db.collection("admin_users").where("email", "==", data["email"]).limit(1).get()
|
select(Staff).where(Staff.email == data["email"], Staff.id != staff_id).limit(1)
|
||||||
for other in others:
|
)
|
||||||
if other.id != staff_id:
|
if dup.scalar_one_or_none() is not None:
|
||||||
raise AuthorizationError("A staff member with this email already exists")
|
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:
|
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.get("role") is not None:
|
||||||
if data["role"] not in VALID_ROLES:
|
if data["role"] not in VALID_ROLES:
|
||||||
raise AuthorizationError(f"Invalid role: {data['role']}")
|
raise AuthorizationError(f"Invalid role: {data['role']}")
|
||||||
update_data["role"] = data["role"]
|
staff.role = data["role"]
|
||||||
if data.get("is_active") is not None:
|
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:
|
if "permissions" in data:
|
||||||
update_data["permissions"] = data["permissions"]
|
staff.permissions = data["permissions"]
|
||||||
|
|
||||||
if update_data:
|
staff.updated_at = _now()
|
||||||
doc_ref.update(update_data)
|
await db.commit()
|
||||||
|
await db.refresh(staff)
|
||||||
updated = {**existing, **update_data}
|
return _to_response(staff)
|
||||||
return _staff_doc_to_response(staff_id, updated)
|
|
||||||
|
|
||||||
|
|
||||||
async def update_staff_password(staff_id: str, new_password: str, current_user_role: str) -> dict:
|
async def update_staff_password(
|
||||||
db = get_db()
|
db: AsyncSession,
|
||||||
doc_ref = db.collection("admin_users").document(staff_id)
|
staff_id: str,
|
||||||
doc = doc_ref.get()
|
new_password: str,
|
||||||
if not doc.exists:
|
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")
|
raise NotFoundError("Staff member not found")
|
||||||
|
if current_user_role == "admin" and staff.role == "sysadmin":
|
||||||
existing = doc.to_dict()
|
|
||||||
|
|
||||||
# Admin cannot change sysadmin password
|
|
||||||
if current_user_role == "admin" and existing.get("role") == "sysadmin":
|
|
||||||
raise AuthorizationError("Admin cannot modify sysadmin accounts")
|
raise AuthorizationError("Admin cannot modify sysadmin accounts")
|
||||||
|
|
||||||
hashed = hash_password(new_password)
|
staff.hashed_password = hash_password(new_password)
|
||||||
doc_ref.update({"hashed_password": hashed})
|
staff.updated_at = _now()
|
||||||
|
await db.commit()
|
||||||
return {"message": "Password updated successfully"}
|
return {"message": "Password updated successfully"}
|
||||||
|
|
||||||
|
|
||||||
async def get_preferences(staff_id: str) -> dict:
|
async def get_preferences(db: AsyncSession, staff_id: str) -> dict:
|
||||||
"""Return the ui_prefs map for a staff member, defaulting to {} if not set."""
|
"""Return ui_prefs JSONB for a staff member."""
|
||||||
db = get_db()
|
result = await db.execute(select(Staff).where(Staff.id == staff_id).limit(1))
|
||||||
doc = db.collection("admin_users").document(staff_id).get()
|
staff = result.scalar_one_or_none()
|
||||||
if not doc.exists:
|
if staff is None:
|
||||||
raise NotFoundError("Staff member not found")
|
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:
|
async def update_preferences(db: AsyncSession, staff_id: str, page_key: str, prefs: dict) -> dict:
|
||||||
"""Merge a page-level preferences dict into ui_prefs.<page_key> on the staff document.
|
"""Merge page-level preferences into the staff member's ui_prefs column."""
|
||||||
Only the supplied keys are overwritten — other keys in the same page block survive.
|
result = await db.execute(select(Staff).where(Staff.id == staff_id).limit(1))
|
||||||
"""
|
staff = result.scalar_one_or_none()
|
||||||
db = get_db()
|
if staff is None:
|
||||||
doc_ref = db.collection("admin_users").document(staff_id)
|
|
||||||
doc = doc_ref.get()
|
|
||||||
if not doc.exists:
|
|
||||||
raise NotFoundError("Staff member not found")
|
raise NotFoundError("Staff member not found")
|
||||||
|
|
||||||
existing_prefs = doc.to_dict().get("ui_prefs", {})
|
current = dict(staff.ui_prefs or {})
|
||||||
page_prefs = {**existing_prefs.get(page_key, {}), **prefs}
|
current[page_key] = {**current.get(page_key, {}), **prefs}
|
||||||
existing_prefs[page_key] = page_prefs
|
staff.ui_prefs = current
|
||||||
|
staff.updated_at = _now()
|
||||||
doc_ref.update({"ui_prefs": existing_prefs})
|
await db.commit()
|
||||||
return existing_prefs
|
return current
|
||||||
|
|
||||||
|
|
||||||
async def delete_staff(staff_id: str, current_user_role: str, current_user_id: str) -> dict:
|
async def delete_staff(
|
||||||
db = get_db()
|
db: AsyncSession,
|
||||||
doc_ref = db.collection("admin_users").document(staff_id)
|
staff_id: str,
|
||||||
doc = doc_ref.get()
|
current_user_role: str,
|
||||||
if not doc.exists:
|
current_user_id: str,
|
||||||
raise NotFoundError("Staff member not found")
|
) -> dict:
|
||||||
|
|
||||||
existing = doc.to_dict()
|
|
||||||
|
|
||||||
# Cannot delete self
|
|
||||||
if staff_id == current_user_id:
|
if staff_id == current_user_id:
|
||||||
raise AuthorizationError("Cannot delete your own account")
|
raise AuthorizationError("Cannot delete your own account")
|
||||||
|
|
||||||
# Admin cannot delete sysadmin
|
result = await db.execute(select(Staff).where(Staff.id == staff_id).limit(1))
|
||||||
if current_user_role == "admin" and existing.get("role") == "sysadmin":
|
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")
|
raise AuthorizationError("Admin cannot delete sysadmin accounts")
|
||||||
|
|
||||||
doc_ref.delete()
|
await db.delete(staff)
|
||||||
|
await db.commit()
|
||||||
return {"message": "Staff member deleted"}
|
return {"message": "Staff member deleted"}
|
||||||
|
|||||||
@@ -467,8 +467,8 @@ backend/
|
|||||||
|-------|-------------|--------|
|
|-------|-------------|--------|
|
||||||
| 0 | Schema foundation (all tables in Postgres) | **COMPLETE** — applied on VPS 2026-04-17 |
|
| 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 |
|
| 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 |
|
| 2 | Firestore → Postgres (data migration) | **COMPLETE** — all 5 scripts ran successfully on VPS 2026-04-17 |
|
||||||
| 3 | Staff auth cutover | NOT STARTED |
|
| 3 | Staff auth cutover | **COMPLETE** — Postgres auth live 2026-04-17 |
|
||||||
| 4 | Audit log system | NOT STARTED |
|
| 4 | Audit log system | NOT STARTED |
|
||||||
| 5 | MQTT live data cutover | 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)
|
# 2.5 (depends on 2.4)
|
||||||
docker compose exec backend python -m migration.migrate_crm_orders
|
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