"""Reference lists. Reading is open to anybody with a session: a dropdown is needed by every screen, and a permission on it would mean a form that renders with an empty picker rather than one that refuses. Writing needs `admin.lookup.manage`. Platform lists are visible here and not editable here. A superadmin maintains them through the seeding script, which is idempotent and runs on deploy — a console route for editing shared data that every customer depends on is a button worth not having. """ from __future__ import annotations import uuid from typing import Any, List, Optional from fastapi import APIRouter, Depends, HTTPException, Query, Request, status from pydantic import BaseModel, ConfigDict, Field from sqlalchemy.orm import Session from app.config.database import get_db from app.helper.helpers import get_client_ip from app.middleware.auth_middleware import User, get_current_user, require_access from app.models.system.lookup_model import LookupItem, LookupList from app.services.system import lookup_service from app.services.system.audit_log_service import AuditLogService router = APIRouter() class ListResponse(BaseModel): model_config = ConfigDict(from_attributes=True) id: uuid.UUID code: str name: str description: Optional[str] = None allows_custom_items: bool is_platform: bool = False class ItemResponse(BaseModel): model_config = ConfigDict(from_attributes=True) id: uuid.UUID code: str label: str sort_order: int is_active: bool metadata_json: Optional[dict[str, Any]] = None is_platform: bool = False class ListCreate(BaseModel): code: str = Field(min_length=1, max_length=60) name: str = Field(min_length=1, max_length=150) description: Optional[str] = Field(default=None, max_length=500) class ListUpdate(BaseModel): name: Optional[str] = Field(default=None, max_length=150) description: Optional[str] = Field(default=None, max_length=500) class ItemCreate(BaseModel): code: str = Field(min_length=1, max_length=60) label: str = Field(min_length=1, max_length=200) sort_order: int = 0 metadata_json: Optional[dict[str, Any]] = None class ItemUpdate(BaseModel): label: Optional[str] = Field(default=None, max_length=200) sort_order: Optional[int] = None is_active: Optional[bool] = None metadata_json: Optional[dict[str, Any]] = None def _workspace(user: User) -> uuid.UUID: if user.tenant_id is None: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Account is not associated with a workspace") return user.tenant_id def _list_response(record: LookupList) -> ListResponse: payload = ListResponse.model_validate(record) payload.is_platform = record.tenant_id is None return payload def _item_response(item: LookupItem) -> ItemResponse: payload = ItemResponse.model_validate(item) payload.is_platform = item.tenant_id is None return payload def _own_item(db: Session, item_id: uuid.UUID, tenant_id: uuid.UUID) -> LookupItem: item = db.query(LookupItem).filter(LookupItem.id == item_id).first() if item is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="No such item") lookup_service.assert_owned(item, tenant_id) return item @router.get("", response_model=List[ListResponse]) def all_lists( db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ): """Readable with any session — a picker is needed by every screen, and a permission here means a form that renders empty rather than one that refuses.""" rows = lookup_service.visible_lists(db, _workspace(current_user)) return [_list_response(row) for row in rows] @router.get("/{code}/items", response_model=List[ItemResponse]) def items( code: str, include_inactive: bool = Query(False), db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ): """The platform's items and this workspace's, in one ordered list. `include_inactive` is for the management screen; a picker never asks for it, because a retired item is retired precisely so it stops being offered. """ tenant_id = _workspace(current_user) record = lookup_service.get_list(db, tenant_id, code) rows = lookup_service.items_in(db, record, tenant_id, include_inactive=include_inactive) return [_item_response(row) for row in rows] @router.post("", response_model=ListResponse, status_code=status.HTTP_201_CREATED) def create_list( payload: ListCreate, request: Request, db: Session = Depends(get_db), current_user: User = Depends(get_current_user), _=Depends(require_access("admin.lookup.manage")), ): tenant_id = _workspace(current_user) record = lookup_service.create_list( db, tenant_id=tenant_id, code=payload.code, name=payload.name, description=payload.description, ) AuditLogService.log( db=db, module_name="Reference data", action_type="CREATE", entity_id=str(record.id), entity_name=record.code, description="Reference list '" + record.name + "' created", performed_by_id=str(current_user.id), performed_by_email=current_user.email, ip_address=get_client_ip(request), tenant_id=tenant_id, ) db.commit() db.refresh(record) return _list_response(record) @router.put("/{list_id}", response_model=ListResponse) def update_list( list_id: uuid.UUID, payload: ListUpdate, db: Session = Depends(get_db), current_user: User = Depends(get_current_user), _=Depends(require_access("admin.lookup.manage")), ): tenant_id = _workspace(current_user) record = db.query(LookupList).filter(LookupList.id == list_id).first() if record is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="No such list") lookup_service.assert_owned(record, tenant_id) lookup_service.rename_list(db, record, name=payload.name, description=payload.description) db.commit() db.refresh(record) return _list_response(record) @router.delete("/{list_id}", status_code=status.HTTP_204_NO_CONTENT) def delete_list( list_id: uuid.UUID, request: Request, db: Session = Depends(get_db), current_user: User = Depends(get_current_user), _=Depends(require_access("admin.lookup.manage")), ): tenant_id = _workspace(current_user) record = db.query(LookupList).filter(LookupList.id == list_id).first() if record is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="No such list") lookup_service.assert_owned(record, tenant_id) name = record.name lookup_service.delete_list(db, record) AuditLogService.log( db=db, module_name="Reference data", action_type="DELETE", entity_id=str(list_id), entity_name=name, description="Reference list '" + name + "' deleted", performed_by_id=str(current_user.id), performed_by_email=current_user.email, ip_address=get_client_ip(request), tenant_id=tenant_id, ) db.commit() @router.post("/{code}/items", response_model=ItemResponse, status_code=status.HTTP_201_CREATED) def add_item( code: str, payload: ItemCreate, db: Session = Depends(get_db), current_user: User = Depends(get_current_user), _=Depends(require_access("admin.lookup.manage")), ): """Adding to a **platform** list is allowed and ordinary — the item carries this workspace's id, so "the standard types plus ours" needs no copy of the standard list.""" tenant_id = _workspace(current_user) record = lookup_service.get_list(db, tenant_id, code) item = lookup_service.add_item( db, record=record, tenant_id=tenant_id, code=payload.code, label=payload.label, sort_order=payload.sort_order, metadata=payload.metadata_json, ) db.commit() db.refresh(item) return _item_response(item) @router.put("/items/{item_id}", response_model=ItemResponse) def update_item( item_id: uuid.UUID, payload: ItemUpdate, db: Session = Depends(get_db), current_user: User = Depends(get_current_user), _=Depends(require_access("admin.lookup.manage")), ): item = _own_item(db, item_id, _workspace(current_user)) lookup_service.update_item( db, item, label=payload.label, sort_order=payload.sort_order, is_active=payload.is_active, metadata=payload.metadata_json, ) db.commit() db.refresh(item) return _item_response(item) @router.delete("/items/{item_id}", response_model=ItemResponse) def retire_item( item_id: uuid.UUID, db: Session = Depends(get_db), current_user: User = Depends(get_current_user), _=Depends(require_access("admin.lookup.manage")), ): """Retires rather than deletes, and says so by returning the item. A record from last year still points at it, and deleting would break the report that shows it — months after the change that caused it. """ item = _own_item(db, item_id, _workspace(current_user)) lookup_service.retire_item(db, item) db.commit() db.refresh(item) return _item_response(item)