Files
saas_backend/app/routes/system/lookup.py
T
2026-08-31 20:39:41 -04:00

278 lines
9.2 KiB
Python

"""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)