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:
+2
-1
@@ -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)
|
||||
|
||||
@@ -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"]
|
||||
Reference in New Issue
Block a user