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
+2 -1
View File
@@ -31,7 +31,8 @@ async def client():
async with async_session_factory() as session:
await seed_users(session)
await session.commit()
# json_path=None → built-in fallback units (apartment_mapping.json is not committed)
# json_path=None → built-in fallback units (same data as the committed
# apartment_mapping.json, kept deterministic for tests)
await seed_units(session, json_path=None)
await session.commit()
await seed_categories(session)
+213
View File
@@ -0,0 +1,213 @@
"""Tests for the Sprint A category + property-hierarchy changes.
Anchors:
* Alert-only categories (Gas Leak) are hidden from the picker endpoints by
default but still present in the DB with urgent SLA urgency, and retrievable
via ``include_hidden=true`` (emergency quick path).
* New seed categories (Aluminum/Glass, Carpentry, Mould & Damp) and the
Lost Property → Missing Item rename are idempotent.
* ``GET /api/tickets/units`` gains a ``building`` filter and the grouped
variant ``GET /api/tickets/units/grouped`` returns ``{property: {building: [units]}}``.
* ``GET /api/tickets`` supports additive ``building``/``unit_id`` filters.
"""
from __future__ import annotations
import pytest
pytestmark = pytest.mark.asyncio
def _find(categories: list[dict], name: str) -> dict | None:
return next((c for c in categories if c["name"] == name), None)
# ── Category picker visibility ────────────────────────────────────────
async def test_gas_leak_hidden_from_category_tree_by_default(client):
"""Gas Leak must not appear in the default category picker."""
resp = await client.get("/api/tickets/categories")
assert resp.status_code == 200
data = resp.json()
# Any nesting level: walk top-level only; Gas Leak is top-level emergency
assert _find(data, "Gas Leak") is None
names = [c["name"] for c in data]
assert "Fire" in names and "Flood" in names # other emergencies still visible
async def test_gas_leak_hidden_from_emergency_type_filter(client):
"""The `?type=emergency` picker also excludes Gas Leak by default."""
resp = await client.get("/api/tickets/categories", params={"type": "emergency"})
assert resp.status_code == 200
data = resp.json()
assert _find(data, "Gas Leak") is None
assert _find(data, "Fire") is not None
async def test_include_hidden_returns_gas_leak(client):
"""include_hidden=true exposes alert-only categories (emergency quick path)."""
resp = await client.get("/api/tickets/categories", params={"include_hidden": "true"})
assert resp.status_code == 200
gas = _find(resp.json(), "Gas Leak")
assert gas is not None
assert gas["show_in_form"] is False
assert gas["sla_urgency"] == "urgent"
async def test_flat_list_hides_gas_leak_and_its_children(client):
"""Flat picker excludes Gas Leak and orphaned children of hidden parents."""
resp = await client.get("/api/tickets/categories/flat")
assert resp.status_code == 200
names = [c["name"] for c in resp.json()]
assert "Gas Leak" not in names
assert "Gas smell" not in names # child of alert-only parent
assert "Suspected leak" not in names
assert "Fire" in names
async def test_flat_list_include_hidden_keeps_gas_leak(client):
resp = await client.get("/api/tickets/categories/flat", params={"include_hidden": "true"})
assert resp.status_code == 200
names = [c["name"] for c in resp.json()]
assert "Gas Leak" in names
assert "Gas smell" in names
# ── Seed taxonomy ─────────────────────────────────────────────────────
async def test_seed_adds_new_categories(client):
"""Aluminum/Glass, Carpentry, Mould & Damp land as maintenance categories."""
resp = await client.get("/api/tickets/categories", params={"type": "maintenance"})
assert resp.status_code == 200
data = resp.json()
for name in ("Aluminum/Glass", "Carpentry", "Mould & Damp"):
cat = _find(data, name)
assert cat is not None, f"expected {name} in maintenance categories"
assert cat["children"], f"{name} should have sub-categories"
mould = _find(data, "Mould & Damp")
assert mould["sla_urgency"] == "medium"
async def test_seed_renames_lost_property_to_missing_item(client):
"""Seed must create 'Missing Item' (not 'Lost Property')."""
resp = await client.get("/api/tickets/categories", params={"type": "cs"})
assert resp.status_code == 200
data = resp.json()
assert _find(data, "Missing Item") is not None
assert _find(data, "Lost Property") is None
missing = _find(data, "Missing Item")
sub_names = [s["name"] for s in missing["children"]]
assert "Guest left items behind" in sub_names
assert "Item search request" in sub_names
async def test_seed_is_idempotent(client):
"""Running the seed twice does not duplicate categories."""
from app.core.database import async_session_factory
from app.services.seed import seed_categories
async with async_session_factory() as session:
created = await seed_categories(session)
await session.commit()
assert created == [] # nothing new to insert/sync on second pass
async def test_seed_syncs_show_in_form_on_existing_rows(client):
"""Existing databases pick up Gas Leak's alert-only flag on next startup.
Simulates a pre-existing DB (Gas Leak seeded with show_in_form=True before
the flag existed) — re-running the seed must flip it back to False.
"""
from sqlalchemy import select
from app.core.database import async_session_factory
from app.models.category import Category
from app.services.seed import seed_categories
async with async_session_factory() as session:
result = await session.execute(
select(Category).where(Category.type == "emergency", Category.name == "Gas Leak", Category.parent_id.is_(None))
)
gas = result.scalar_one()
gas.show_in_form = True # simulate legacy DB before the flag existed
await session.commit()
created = await seed_categories(session)
await session.commit()
await session.refresh(gas)
assert gas.show_in_form is False
assert gas.sla_urgency == "urgent" # SLA/alert mapping unchanged
# Sync counts as a change, but no duplicates are created
assert len(created) >= 1
# ── Unit hierarchy ────────────────────────────────────────────────────
async def test_units_building_filter(client):
"""GET /api/tickets/units?building= filters to one building."""
resp = await client.get("/api/tickets/units", params={"building": "Pavilion East"})
assert resp.status_code == 200
units = resp.json()
assert len(units) > 0
assert all(u["building"] == "Pavilion East" for u in units)
assert all(u["property"] == "East" for u in units)
async def test_units_grouped_shape(client):
"""Grouped variant returns {property: {building: [units]}}."""
resp = await client.get("/api/tickets/units/grouped")
assert resp.status_code == 200
grouped = resp.json()
assert "East" in grouped and "West" in grouped
east = grouped["East"]
assert "Pavilion East" in east
assert len(east["Pavilion East"]) == 60
unit = east["Pavilion East"][0]
assert {"id", "property", "apartment_code", "building", "floor"} <= set(unit.keys())
# Distinct apartment codes
codes = [u["apartment_code"] for u in east["Pavilion East"]]
assert len(set(codes)) == len(codes)
async def test_units_grouped_property_filter(client):
resp = await client.get("/api/tickets/units/grouped", params={"property": "West"})
assert resp.status_code == 200
grouped = resp.json()
assert set(grouped.keys()) == {"West"}
# ── Ticket list filters ───────────────────────────────────────────────
async def test_tickets_filter_by_building_and_unit(client):
"""Additive building/unit_id filters compose with the list endpoint."""
# Grab two units from different buildings/properties
resp = await client.get("/api/tickets/units", params={"building": "Pavilion East"})
east_unit = resp.json()[0]
resp = await client.get("/api/tickets/units", params={"building": "Pavilion West"})
west_unit = resp.json()[0]
# Two tickets, one per building
payloads = [
{"description": "east ticket", "unit_id": east_unit["id"], "priority": "medium"},
{"description": "west ticket", "unit_id": west_unit["id"], "priority": "high"},
]
token = await _login(client)
for p in payloads:
r = await client.post("/api/tickets", json=p, headers={"Authorization": f"Bearer {token}"})
assert r.status_code == 201, r.text
# building filter
r = await client.get("/api/tickets", params={"building": "Pavilion East"})
data = r.json()
assert data["total"] == 1
assert data["items"][0]["description"] == "east ticket"
# unit_id filter
r = await client.get("/api/tickets", params={"unit_id": west_unit["id"]})
data = r.json()
assert data["total"] == 1
assert data["items"][0]["description"] == "west ticket"
async def _login(client) -> str:
resp = await client.post(
"/api/auth/login",
json={"email": "wahab@denya.com", "password": "denya123"},
)
assert resp.status_code == 200, resp.text
return resp.json()["access_token"]