feat: Sprint A Wahab review batch — categories, property hierarchy, priority grouping

Items 1-5, 9, 11 from denya-wahab-feedback-s2:
- Category.show_in_form (alert-only flag): Gas Leak hidden from issue picker
  but kept urgent for SLA/alert and reporting; emergency quick path on the
  new-issue form creates urgent tickets via include_hidden categories.
- Seed: Aluminum/Glass, Carpentry, Mould & Damp (Medium default) maintenance
  categories; Lost Property renamed Missing Item (+ sub).
- One alembic migration: add show_in_form (backfill True, Gas Leak False) +
  data rename Lost Property -> Missing Item.
- Property -> Building -> Apartment cascade with searchable apartment combobox
  on the new-issue form and ticket list filters; /api/tickets/units gains
  building filter + /units/grouped variant; /api/tickets gains additive
  building/unit_id filters. apartment_mapping.json committed (deterministic).
- Group-by-priority toggle on /tickets (four sections + unknown bucket,
  age-sortable, composes with filters, URL deep links), FM dashboard active
  tickets, and CS dashboard priority card click-through.
- Tests: 13 new (category visibility, seed idempotency/sync, unit grouping,
  ticket building/unit filters).
This commit is contained in:
root
2026-08-02 09:30:29 +00:00
parent 2e995ee758
commit 2407f294f4
16 changed files with 1579 additions and 97 deletions
+76 -22
View File
@@ -45,33 +45,61 @@ UPLOADS_DIR.mkdir(parents=True, exist_ok=True)
async def list_units(
db: Annotated[AsyncSession, Depends(get_db)],
property_filter: str | None = Query(None, alias="property"),
building: str | None = Query(None),
) -> list[Unit]:
"""List all units, optionally filtered by property (East/West)."""
"""List all units, optionally filtered by property (East/West) and building."""
query = select(Unit).order_by(Unit.apartment_code)
if property_filter:
query = query.where(Unit.property == property_filter)
if building:
query = query.where(Unit.building == building)
result = await db.execute(query)
return list(result.scalars().all())
@router.get("/units/grouped")
async def list_units_grouped(
db: Annotated[AsyncSession, Depends(get_db)],
property_filter: str | None = Query(None, alias="property"),
) -> dict[str, dict[str, list[dict]]]:
"""Return units grouped as ``{property: {building: [units]}}``.
Additive convenience variant of ``GET /api/tickets/units`` for the
Property → Building → Apartment cascade. Units without a building are
grouped under an empty-string key.
"""
query = select(Unit).order_by(Unit.property, Unit.building, Unit.apartment_code)
if property_filter:
query = query.where(Unit.property == property_filter)
result = await db.execute(query)
grouped: dict[str, dict[str, list[dict]]] = {}
for unit in result.scalars().all():
prop = unit.property or ""
building = unit.building or ""
grouped.setdefault(prop, {}).setdefault(building, []).append(
UnitOut.model_validate(unit).model_dump()
)
return grouped
# ── Categories ──────────────────────────────────────────────────────
async def _build_category_tree(db: AsyncSession, parent_id: int | None = None) -> list[CategoryTreeOut]:
async def _build_category_tree(db: AsyncSession, parent_id: int | None = None, include_hidden: bool = False) -> list[CategoryTreeOut]:
"""Build a nested category tree."""
result = await db.execute(
select(Category)
.where(Category.parent_id == parent_id)
.order_by(Category.name)
)
query = select(Category).where(Category.parent_id == parent_id).order_by(Category.name)
if not include_hidden:
query = query.where(Category.show_in_form.is_(True))
result = await db.execute(query)
categories = result.scalars().all()
tree = []
for cat in categories:
children = await _build_category_tree(db, cat.id)
children = await _build_category_tree(db, cat.id, include_hidden=include_hidden)
tree.append(CategoryTreeOut(
id=cat.id,
type=cat.type,
name=cat.name,
parent_id=cat.parent_id,
sla_urgency=cat.sla_urgency,
show_in_form=cat.show_in_form,
children=children,
))
return tree
@@ -81,23 +109,26 @@ async def _build_category_tree(db: AsyncSession, parent_id: int | None = None) -
async def list_categories(
db: Annotated[AsyncSession, Depends(get_db)],
type_filter: str | None = Query(None, alias="type"),
include_hidden: bool = Query(False, description="Include alert-only categories (e.g. Gas Leak)"),
) -> list[CategoryTreeOut]:
"""Return the full category tree, optionally filtered by type (maintenance, cs, emergency)."""
"""Return the full category tree, optionally filtered by type (maintenance, cs, emergency).
Alert-only categories (``show_in_form=False``, e.g. Gas Leak) are excluded
unless ``include_hidden=true`` is passed (used by the emergency quick path).
"""
if type_filter:
# Return flat list of top-level categories of the given type
result = await db.execute(
select(Category)
.where(Category.parent_id.is_(None), Category.type == type_filter)
.order_by(Category.name)
)
query = select(Category).where(Category.parent_id.is_(None), Category.type == type_filter).order_by(Category.name)
if not include_hidden:
query = query.where(Category.show_in_form.is_(True))
result = await db.execute(query)
parents = result.scalars().all()
tree = []
for parent in parents:
children_result = await db.execute(
select(Category)
.where(Category.parent_id == parent.id)
.order_by(Category.name)
)
children_query = select(Category).where(Category.parent_id == parent.id).order_by(Category.name)
if not include_hidden:
children_query = children_query.where(Category.show_in_form.is_(True))
children_result = await db.execute(children_query)
children = children_result.scalars().all()
tree.append(CategoryTreeOut(
id=parent.id,
@@ -105,24 +136,43 @@ async def list_categories(
name=parent.name,
parent_id=parent.parent_id,
sla_urgency=parent.sla_urgency,
show_in_form=parent.show_in_form,
children=[CategoryOut.model_validate(c) for c in children],
))
return tree
return await _build_category_tree(db)
return await _build_category_tree(db, include_hidden=include_hidden)
@router.get("/categories/flat", response_model=list[CategoryOut])
async def list_categories_flat(
db: Annotated[AsyncSession, Depends(get_db)],
type_filter: str | None = Query(None, alias="type"),
include_hidden: bool = Query(False, description="Include alert-only categories (e.g. Gas Leak)"),
) -> list[CategoryOut]:
"""Return a flat list of all categories (no nesting), optionally filtered by type."""
"""Return a flat list of all categories (no nesting), optionally filtered by type.
Alert-only categories are excluded by default; children of alert-only parents
are excluded too so no orphaned picker entries leak through.
"""
query = select(Category).order_by(Category.type, Category.name)
if type_filter:
query = query.where(Category.type == type_filter)
result = await db.execute(query)
categories = result.scalars().all()
return [CategoryOut.model_validate(c) for c in categories]
if include_hidden:
return [CategoryOut.model_validate(c) for c in categories]
# Exclude alert-only categories and any children whose parent is alert-only.
hidden_ids = {
c.id
for c in categories
if not c.show_in_form and c.parent_id is None
}
visible = [
c
for c in categories
if c.show_in_form and (c.parent_id is None or c.parent_id not in hidden_ids)
]
return [CategoryOut.model_validate(c) for c in visible]
# ── Ticket CRUD ──────────────────────────────────────────────────────
@@ -149,6 +199,8 @@ async def list_tickets(
status: str | None = Query(None),
priority: str | None = Query(None),
property: str | None = Query(None),
building: str | None = Query(None),
unit_id: int | None = Query(None),
category_id: int | None = Query(None),
assigned_to: int | None = Query(None),
date_from: datetime | None = Query(None),
@@ -160,6 +212,8 @@ async def list_tickets(
status_filter=status,
priority_filter=priority,
property_filter=property,
building_filter=building,
unit_id=unit_id,
category_id=category_id,
assigned_to=assigned_to,
date_from=date_from,