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:
+76
-22
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user