Phase 4 of Migration
This commit is contained in:
0
backend/audit/__init__.py
Normal file
0
backend/audit/__init__.py
Normal file
74
backend/audit/router.py
Normal file
74
backend/audit/router.py
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Query
|
||||||
|
from sqlalchemy import select, and_
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from database.postgres import get_pg_session
|
||||||
|
from shared.orm import AuditLog
|
||||||
|
from auth.dependencies import require_admin_or_above
|
||||||
|
from auth.models import TokenPayload
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/audit-log", tags=["audit-log"])
|
||||||
|
|
||||||
|
_MAX_LIMIT = 200
|
||||||
|
_DEFAULT_LIMIT = 50
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
async def list_audit_log(
|
||||||
|
actor_id: Optional[str] = Query(None),
|
||||||
|
entity_type: Optional[str] = Query(None),
|
||||||
|
entity_id: Optional[str] = Query(None),
|
||||||
|
action: Optional[str] = Query(None),
|
||||||
|
from_date: Optional[datetime] = Query(None),
|
||||||
|
to_date: Optional[datetime] = Query(None),
|
||||||
|
limit: int = Query(_DEFAULT_LIMIT, ge=1, le=_MAX_LIMIT),
|
||||||
|
offset: int = Query(0, ge=0),
|
||||||
|
_user: TokenPayload = Depends(require_admin_or_above),
|
||||||
|
db: AsyncSession = Depends(get_pg_session),
|
||||||
|
):
|
||||||
|
filters = []
|
||||||
|
if actor_id:
|
||||||
|
filters.append(AuditLog.actor_id == actor_id)
|
||||||
|
if entity_type:
|
||||||
|
filters.append(AuditLog.entity_type == entity_type)
|
||||||
|
if entity_id:
|
||||||
|
filters.append(AuditLog.entity_id == entity_id)
|
||||||
|
if action:
|
||||||
|
filters.append(AuditLog.action == action)
|
||||||
|
if from_date:
|
||||||
|
filters.append(AuditLog.occurred_at >= from_date)
|
||||||
|
if to_date:
|
||||||
|
filters.append(AuditLog.occurred_at <= to_date)
|
||||||
|
|
||||||
|
stmt = (
|
||||||
|
select(AuditLog)
|
||||||
|
.where(and_(*filters) if filters else True)
|
||||||
|
.order_by(AuditLog.occurred_at.desc())
|
||||||
|
.offset(offset)
|
||||||
|
.limit(limit)
|
||||||
|
)
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
rows = result.scalars().all()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"entries": [
|
||||||
|
{
|
||||||
|
"id": r.id,
|
||||||
|
"occurred_at": r.occurred_at.isoformat(),
|
||||||
|
"actor_id": r.actor_id,
|
||||||
|
"actor_name": r.actor_name,
|
||||||
|
"action": r.action,
|
||||||
|
"entity_type": r.entity_type,
|
||||||
|
"entity_id": r.entity_id,
|
||||||
|
"entity_label": r.entity_label,
|
||||||
|
"changes": r.changes,
|
||||||
|
"meta": r.meta,
|
||||||
|
}
|
||||||
|
for r in rows
|
||||||
|
],
|
||||||
|
"limit": limit,
|
||||||
|
"offset": offset,
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
from fastapi import APIRouter, Depends
|
from fastapi import APIRouter, Depends, Request
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
@@ -6,6 +6,7 @@ from database.postgres import get_pg_session
|
|||||||
from staff.orm import Staff
|
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.audit import log_action
|
||||||
from shared.exceptions import AuthenticationError
|
from shared.exceptions import AuthenticationError
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
||||||
@@ -21,7 +22,11 @@ _ROLE_MAP = {
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/login", response_model=TokenResponse)
|
@router.post("/login", response_model=TokenResponse)
|
||||||
async def login(body: LoginRequest, db: AsyncSession = Depends(get_pg_session)):
|
async def login(
|
||||||
|
body: LoginRequest,
|
||||||
|
request: Request,
|
||||||
|
db: AsyncSession = Depends(get_pg_session),
|
||||||
|
):
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(Staff).where(Staff.email == body.email).limit(1)
|
select(Staff).where(Staff.email == body.email).limit(1)
|
||||||
)
|
)
|
||||||
@@ -49,6 +54,18 @@ async def login(body: LoginRequest, db: AsyncSession = Depends(get_pg_session)):
|
|||||||
if role in ("editor", "user"):
|
if role in ("editor", "user"):
|
||||||
permissions = staff.permissions
|
permissions = staff.permissions
|
||||||
|
|
||||||
|
await log_action(
|
||||||
|
db,
|
||||||
|
actor_id=staff.id,
|
||||||
|
actor_name=staff.name,
|
||||||
|
action="LOGIN",
|
||||||
|
entity_type="staff",
|
||||||
|
entity_id=staff.id,
|
||||||
|
entity_label=staff.email,
|
||||||
|
meta={"ip": request.client.host if request.client else None},
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
return TokenResponse(
|
return TokenResponse(
|
||||||
access_token=token,
|
access_token=token,
|
||||||
role=role,
|
role=role,
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ from crm.quotations_router import router as crm_quotations_router
|
|||||||
from public.router import router as public_router
|
from public.router import router as public_router
|
||||||
from notes.router import router as notes_router
|
from notes.router import router as notes_router
|
||||||
from tickets.router import router as tickets_router
|
from tickets.router import router as tickets_router
|
||||||
|
from audit.router import router as audit_router
|
||||||
from crm.nextcloud import close_client as close_nextcloud_client, keepalive_ping as nextcloud_keepalive
|
from crm.nextcloud import close_client as close_nextcloud_client, keepalive_ping as nextcloud_keepalive
|
||||||
from crm.mail_accounts import get_mail_accounts
|
from crm.mail_accounts import get_mail_accounts
|
||||||
from mqtt.client import mqtt_manager
|
from mqtt.client import mqtt_manager
|
||||||
@@ -74,6 +75,7 @@ app.include_router(crm_quotations_router)
|
|||||||
app.include_router(public_router)
|
app.include_router(public_router)
|
||||||
app.include_router(notes_router)
|
app.include_router(notes_router)
|
||||||
app.include_router(tickets_router)
|
app.include_router(tickets_router)
|
||||||
|
app.include_router(audit_router)
|
||||||
|
|
||||||
|
|
||||||
async def nextcloud_keepalive_loop():
|
async def nextcloud_keepalive_loop():
|
||||||
|
|||||||
83
backend/shared/audit.py
Normal file
83
backend/shared/audit.py
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
"""
|
||||||
|
Audit log utility — all services call log_action() to record staff events.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
from shared.audit import log_action
|
||||||
|
|
||||||
|
await log_action(
|
||||||
|
db, actor_id, actor_name,
|
||||||
|
action="CREATE", entity_type="customer", entity_id=cust_id,
|
||||||
|
entity_label="Church of St. George",
|
||||||
|
)
|
||||||
|
|
||||||
|
await log_action(
|
||||||
|
db, actor_id, actor_name,
|
||||||
|
action="UPDATE", entity_type="order", entity_id=order_id,
|
||||||
|
entity_label="ORD-0042",
|
||||||
|
changes={"status": {"old": "negotiating", "new": "confirmed"}},
|
||||||
|
)
|
||||||
|
|
||||||
|
Never raises — a logging failure must never break the primary operation.
|
||||||
|
The call is fire-and-forget safe: wrap in try/except internally.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from shared.orm import AuditLog
|
||||||
|
|
||||||
|
|
||||||
|
async def log_action(
|
||||||
|
db: AsyncSession,
|
||||||
|
actor_id: str,
|
||||||
|
actor_name: str,
|
||||||
|
action: str,
|
||||||
|
entity_type: str,
|
||||||
|
entity_id: str,
|
||||||
|
entity_label: str | None = None,
|
||||||
|
changes: dict[str, Any] | None = None,
|
||||||
|
meta: dict[str, Any] | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Insert one row into audit_log. Never raises — failures are silently swallowed
|
||||||
|
so a logging error never disrupts the primary request.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
entry = AuditLog(
|
||||||
|
occurred_at=datetime.now(timezone.utc),
|
||||||
|
actor_id=actor_id,
|
||||||
|
actor_name=actor_name,
|
||||||
|
action=action,
|
||||||
|
entity_type=entity_type,
|
||||||
|
entity_id=entity_id,
|
||||||
|
entity_label=entity_label,
|
||||||
|
changes=changes,
|
||||||
|
meta=meta,
|
||||||
|
)
|
||||||
|
db.add(entry)
|
||||||
|
# Flush without committing — caller's transaction commits it atomically.
|
||||||
|
# If the caller hasn't started a transaction, flush still works; the
|
||||||
|
# session will auto-commit on the next explicit commit call.
|
||||||
|
await db.flush()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def diff(old: dict, new: dict) -> dict[str, dict]:
|
||||||
|
"""
|
||||||
|
Build a changes dict from two flat dicts.
|
||||||
|
Only includes keys whose values actually changed.
|
||||||
|
Skip internal/unloggable keys (hashed_password, updated_at).
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
changes = diff(old_record, new_record)
|
||||||
|
await log_action(..., changes=changes or None)
|
||||||
|
"""
|
||||||
|
_SKIP = {"hashed_password", "updated_at", "firestore_id"}
|
||||||
|
return {
|
||||||
|
k: {"old": old.get(k), "new": new.get(k)}
|
||||||
|
for k in new
|
||||||
|
if k not in _SKIP and old.get(k) != new.get(k)
|
||||||
|
}
|
||||||
@@ -69,6 +69,8 @@ async def create_staff(
|
|||||||
db,
|
db,
|
||||||
data=body.model_dump(),
|
data=body.model_dump(),
|
||||||
current_user_role=current_user.role,
|
current_user_role=current_user.role,
|
||||||
|
actor_id=current_user.sub,
|
||||||
|
actor_name=current_user.name,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -85,6 +87,8 @@ async def update_staff(
|
|||||||
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,
|
||||||
current_user_id=current_user.sub,
|
current_user_id=current_user.sub,
|
||||||
|
actor_id=current_user.sub,
|
||||||
|
actor_name=current_user.name,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -100,6 +104,8 @@ async def update_staff_password(
|
|||||||
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,
|
||||||
|
actor_id=current_user.sub,
|
||||||
|
actor_name=current_user.name,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -114,4 +120,6 @@ async def delete_staff(
|
|||||||
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,
|
||||||
|
actor_id=current_user.sub,
|
||||||
|
actor_name=current_user.name,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
from staff.orm import Staff
|
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.audit import log_action, diff
|
||||||
from shared.exceptions import NotFoundError, AuthorizationError
|
from shared.exceptions import NotFoundError, AuthorizationError
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
@@ -17,7 +18,7 @@ def _now() -> datetime:
|
|||||||
return datetime.now(timezone.utc)
|
return datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
def _to_response(staff: Staff) -> dict:
|
def _to_dict(staff: Staff) -> dict:
|
||||||
return {
|
return {
|
||||||
"id": staff.id,
|
"id": staff.id,
|
||||||
"email": staff.email,
|
"email": staff.email,
|
||||||
@@ -28,6 +29,10 @@ def _to_response(staff: Staff) -> dict:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _to_response(staff: Staff) -> dict:
|
||||||
|
return _to_dict(staff)
|
||||||
|
|
||||||
|
|
||||||
async def list_staff(db: AsyncSession, search: str = None, role_filter: str = None) -> dict:
|
async def list_staff(db: AsyncSession, search: str = None, role_filter: str = None) -> dict:
|
||||||
stmt = select(Staff)
|
stmt = select(Staff)
|
||||||
if role_filter:
|
if role_filter:
|
||||||
@@ -58,7 +63,13 @@ async def get_staff_me(db: AsyncSession, user_sub: str) -> dict:
|
|||||||
return await get_staff(db, user_sub)
|
return await get_staff(db, user_sub)
|
||||||
|
|
||||||
|
|
||||||
async def create_staff(db: AsyncSession, data: dict, current_user_role: str) -> dict:
|
async def create_staff(
|
||||||
|
db: AsyncSession,
|
||||||
|
data: dict,
|
||||||
|
current_user_role: str,
|
||||||
|
actor_id: str,
|
||||||
|
actor_name: 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}")
|
||||||
@@ -86,10 +97,23 @@ async def create_staff(db: AsyncSession, data: dict, current_user_role: str) ->
|
|||||||
hashed_password=hash_password(data["password"]),
|
hashed_password=hash_password(data["password"]),
|
||||||
is_active=True,
|
is_active=True,
|
||||||
permissions=permissions,
|
permissions=permissions,
|
||||||
|
ui_prefs={},
|
||||||
created_at=now,
|
created_at=now,
|
||||||
updated_at=now,
|
updated_at=now,
|
||||||
)
|
)
|
||||||
db.add(staff)
|
db.add(staff)
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
await log_action(
|
||||||
|
db,
|
||||||
|
actor_id=actor_id,
|
||||||
|
actor_name=actor_name,
|
||||||
|
action="CREATE",
|
||||||
|
entity_type="staff",
|
||||||
|
entity_id=uid,
|
||||||
|
entity_label=data["email"],
|
||||||
|
meta={"role": role},
|
||||||
|
)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(staff)
|
await db.refresh(staff)
|
||||||
return _to_response(staff)
|
return _to_response(staff)
|
||||||
@@ -101,6 +125,8 @@ async def update_staff(
|
|||||||
data: dict,
|
data: dict,
|
||||||
current_user_role: str,
|
current_user_role: str,
|
||||||
current_user_id: str,
|
current_user_id: str,
|
||||||
|
actor_id: str,
|
||||||
|
actor_name: str,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
result = await db.execute(select(Staff).where(Staff.id == staff_id).limit(1))
|
result = await db.execute(select(Staff).where(Staff.id == staff_id).limit(1))
|
||||||
staff = result.scalar_one_or_none()
|
staff = result.scalar_one_or_none()
|
||||||
@@ -112,6 +138,8 @@ async def update_staff(
|
|||||||
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")
|
||||||
|
|
||||||
|
old = _to_dict(staff)
|
||||||
|
|
||||||
if data.get("email") is not None:
|
if data.get("email") is not None:
|
||||||
dup = await db.execute(
|
dup = await db.execute(
|
||||||
select(Staff).where(Staff.email == data["email"], Staff.id != staff_id).limit(1)
|
select(Staff).where(Staff.email == data["email"], Staff.id != staff_id).limit(1)
|
||||||
@@ -132,6 +160,20 @@ async def update_staff(
|
|||||||
staff.permissions = data["permissions"]
|
staff.permissions = data["permissions"]
|
||||||
|
|
||||||
staff.updated_at = _now()
|
staff.updated_at = _now()
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
changes = diff(old, _to_dict(staff))
|
||||||
|
action = "PERMISSION_CHANGE" if "permissions" in data and len(changes) == 1 else "UPDATE"
|
||||||
|
await log_action(
|
||||||
|
db,
|
||||||
|
actor_id=actor_id,
|
||||||
|
actor_name=actor_name,
|
||||||
|
action=action,
|
||||||
|
entity_type="staff",
|
||||||
|
entity_id=staff_id,
|
||||||
|
entity_label=staff.email,
|
||||||
|
changes=changes or None,
|
||||||
|
)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(staff)
|
await db.refresh(staff)
|
||||||
return _to_response(staff)
|
return _to_response(staff)
|
||||||
@@ -142,6 +184,8 @@ async def update_staff_password(
|
|||||||
staff_id: str,
|
staff_id: str,
|
||||||
new_password: str,
|
new_password: str,
|
||||||
current_user_role: str,
|
current_user_role: str,
|
||||||
|
actor_id: str,
|
||||||
|
actor_name: str,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
result = await db.execute(select(Staff).where(Staff.id == staff_id).limit(1))
|
result = await db.execute(select(Staff).where(Staff.id == staff_id).limit(1))
|
||||||
staff = result.scalar_one_or_none()
|
staff = result.scalar_one_or_none()
|
||||||
@@ -152,12 +196,23 @@ async def update_staff_password(
|
|||||||
|
|
||||||
staff.hashed_password = hash_password(new_password)
|
staff.hashed_password = hash_password(new_password)
|
||||||
staff.updated_at = _now()
|
staff.updated_at = _now()
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
await log_action(
|
||||||
|
db,
|
||||||
|
actor_id=actor_id,
|
||||||
|
actor_name=actor_name,
|
||||||
|
action="UPDATE",
|
||||||
|
entity_type="staff",
|
||||||
|
entity_id=staff_id,
|
||||||
|
entity_label=staff.email,
|
||||||
|
meta={"detail": "password changed"},
|
||||||
|
)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
return {"message": "Password updated successfully"}
|
return {"message": "Password updated successfully"}
|
||||||
|
|
||||||
|
|
||||||
async def get_preferences(db: AsyncSession, staff_id: str) -> dict:
|
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))
|
result = await db.execute(select(Staff).where(Staff.id == staff_id).limit(1))
|
||||||
staff = result.scalar_one_or_none()
|
staff = result.scalar_one_or_none()
|
||||||
if staff is None:
|
if staff is None:
|
||||||
@@ -166,7 +221,6 @@ async def get_preferences(db: AsyncSession, staff_id: str) -> dict:
|
|||||||
|
|
||||||
|
|
||||||
async def update_preferences(db: AsyncSession, 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 page-level preferences into the staff member's ui_prefs column."""
|
|
||||||
result = await db.execute(select(Staff).where(Staff.id == staff_id).limit(1))
|
result = await db.execute(select(Staff).where(Staff.id == staff_id).limit(1))
|
||||||
staff = result.scalar_one_or_none()
|
staff = result.scalar_one_or_none()
|
||||||
if staff is None:
|
if staff is None:
|
||||||
@@ -185,6 +239,8 @@ async def delete_staff(
|
|||||||
staff_id: str,
|
staff_id: str,
|
||||||
current_user_role: str,
|
current_user_role: str,
|
||||||
current_user_id: str,
|
current_user_id: str,
|
||||||
|
actor_id: str,
|
||||||
|
actor_name: str,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
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")
|
||||||
@@ -197,6 +253,16 @@ async def delete_staff(
|
|||||||
if current_user_role == "admin" and staff.role == "sysadmin":
|
if current_user_role == "admin" and staff.role == "sysadmin":
|
||||||
raise AuthorizationError("Admin cannot delete sysadmin accounts")
|
raise AuthorizationError("Admin cannot delete sysadmin accounts")
|
||||||
|
|
||||||
|
label = staff.email
|
||||||
|
await log_action(
|
||||||
|
db,
|
||||||
|
actor_id=actor_id,
|
||||||
|
actor_name=actor_name,
|
||||||
|
action="DELETE",
|
||||||
|
entity_type="staff",
|
||||||
|
entity_id=staff_id,
|
||||||
|
entity_label=label,
|
||||||
|
)
|
||||||
await db.delete(staff)
|
await db.delete(staff)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
return {"message": "Staff member deleted"}
|
return {"message": "Staff member deleted"}
|
||||||
|
|||||||
@@ -469,7 +469,7 @@ backend/
|
|||||||
| 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) | **COMPLETE** — all 5 scripts ran successfully on VPS 2026-04-17 |
|
| 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 |
|
| 3 | Staff auth cutover | **COMPLETE** — Postgres auth live 2026-04-17 |
|
||||||
| 4 | Audit log system | NOT STARTED |
|
| 4 | Audit log system | **COMPLETE** — shared/audit.py live, wired into auth + staff 2026-04-17 |
|
||||||
| 5 | MQTT live data cutover | NOT STARTED |
|
| 5 | MQTT live data cutover | NOT STARTED |
|
||||||
|
|
||||||
Update this table as each phase completes.
|
Update this table as each phase completes.
|
||||||
|
|||||||
Reference in New Issue
Block a user