diff --git a/tests/web/test_solution_review.py b/tests/web/test_solution_review.py new file mode 100644 index 00000000..b381c5bc --- /dev/null +++ b/tests/web/test_solution_review.py @@ -0,0 +1,90 @@ +"""Sprint 11.18 — admin solution review (assignments + stats).""" + +from __future__ import annotations + +from datetime import datetime + +from api.models import Assignment, Event, Solution +from tests.web.conftest import seed_person +from web.deps import SESSION_COOKIE + + +def _admin(client, db, *, org="sr_org", email="sradmin@web.test"): + seed_person(db, person_id="sr_admin", org_id=org, email=email, roles=["admin"]) + r = client.post("/auth/login", data={"email": email, "password": "WebPass123!"}) + return r.cookies[SESSION_COOKIE] + + +def _seed_solution(db, org): + vol = seed_person(db, person_id=f"{org}_v", org_id=org, email=f"{org}v@web.test") + sol = Solution( + org_id=org, + hard_violations=0, + soft_score=1.5, + health_score=98.0, + solve_ms=12.0, + ) + db.add(sol) + db.add( + Event( + id=f"{org}_ev", + org_id=org, + type="Sunday Service", + start_time=datetime(2099, 6, 7, 10, 0), + end_time=datetime(2099, 6, 7, 11, 30), + ) + ) + db.commit() + db.refresh(sol) + db.add( + Assignment( + solution_id=sol.id, + event_id=f"{org}_ev", + person_id=vol.id, + role="usher", + status="confirmed", + ) + ) + db.commit() + return sol + + +def test_solution_review_renders(client, db): + token = _admin(client, db, org="sr_o1", email="sr1@web.test") + sol = _seed_solution(db, "sr_o1") + resp = client.get(f"/a/solution/{sol.id}", cookies={SESSION_COOKIE: token}) + assert resp.status_code == 200 + assert f"Solution #{sol.id}" in resp.text + assert "Health" in resp.text + assert "Sunday Service" in resp.text # assignments segment + assert "Fairness stdev" in resp.text # stats segment + assert "Web User" in resp.text # assignee chip + + +def test_solution_review_404_unknown(client, db): + token = _admin(client, db, org="sr_o2", email="sr2@web.test") + resp = client.get("/a/solution/999999", cookies={SESSION_COOKIE: token}) + assert resp.status_code == 404 + assert "not found" in resp.text.lower() + + +def test_solution_review_404_other_org(client, db): + token = _admin(client, db, org="sr_o3", email="sr3@web.test") + other_sol = _seed_solution(db, "sr_other") + resp = client.get(f"/a/solution/{other_sol.id}", cookies={SESSION_COOKIE: token}) + assert resp.status_code == 404 + + +def test_solution_review_requires_admin(client, db): + seed_person(db, person_id="sr_vol", email="srvol@web.test", roles=["volunteer"]) + login = client.post( + "/auth/login", + data={"email": "srvol@web.test", "password": "WebPass123!"}, + ) + token = login.cookies[SESSION_COOKIE] + resp = client.get("/a/solution/1", cookies={SESSION_COOKIE: token}) + assert resp.status_code == 303 + + +def test_solution_review_requires_auth(client): + assert client.get("/a/solution/1").status_code == 303 diff --git a/web/routers/pages.py b/web/routers/pages.py index 012e3e4e..37161b2b 100644 --- a/web/routers/pages.py +++ b/web/routers/pages.py @@ -401,3 +401,74 @@ def admin_solver( "default_to": (today + timedelta(days=28)).isoformat(), }, ) + + +def _solution_review(db: Session, person: Person, sid: int) -> dict | None: + """Solution header + event-grouped assignments + stats for a solution + in the admin's org. None → 404.""" + from api.models import Solution + from api.routers.solutions import get_solution, get_solution_assignments + from api.routers.solutions import get_solution_stats + + sol = db.query(Solution).filter(Solution.id == sid, Solution.org_id == person.org_id).first() + if sol is None: + return None + detail = get_solution(sid, db) + assignments = get_solution_assignments(sid, db) + stats = get_solution_stats(sid, person, db) + + events = [] + for e in assignments.events: + events.append( + { + "event_type": e.event_type or e.event_id, + "date_label": e.event_start.strftime("%a %d %b %Y · %H:%M").upper() + if e.event_start + else "", + "assignees": [a.person_name or a.person_id for a in e.assignees], + } + ) + return { + "id": detail.id, + "health_score": round(detail.health_score), + "hard_violations": detail.hard_violations, + "soft_score": round(detail.soft_score, 1), + "assignment_count": detail.assignment_count, + "is_published": detail.is_published, + "created_label": detail.created_at.strftime("%a %d %b %Y").upper() + if detail.created_at + else "", + "events": events, + "stats": { + "fairness_stdev": round(stats.fairness.stdev, 2), + "workload_max": stats.workload.max_events_per_person, + "workload_min": stats.workload.min_events_per_person, + "workload_median": stats.workload.median_events_per_person, + "distinct": stats.workload.distinct_persons_assigned, + "total": stats.workload.total_events_assigned, + }, + } + + +@router.get("/a/solution/{solution_id}", response_class=HTMLResponse) +def admin_solution_review( + request: Request, + solution_id: int, + person: Person = Depends(get_session_admin), + db: Session = Depends(get_db), +): + from web.app import templates + + review = _solution_review(db, person, solution_id) + if review is None: + return templates.TemplateResponse( + request, + "admin/solution_review.html", + {"person": person, "active_tab": "solver", "review": None}, + status_code=404, + ) + return templates.TemplateResponse( + request, + "admin/solution_review.html", + {"person": person, "active_tab": "solver", "review": review}, + ) diff --git a/web/templates/admin/solution_review.html b/web/templates/admin/solution_review.html new file mode 100644 index 00000000..445d9d6f --- /dev/null +++ b/web/templates/admin/solution_review.html @@ -0,0 +1,102 @@ +{% extends "base.html" %} +{% block title %}Solution review · SignUpFlow{% endblock %} +{% block body %} + +
+ {% if review is none %} +
Solution not found in your organization.
+ {% else %} +
+
+
+ Solution #{{ review.id }} +
+ {% if review.is_published %} + published + {% endif %} +
+
{{ review.created_label }}
+
+
+
+
{{ review.health_score }}
+
Health
+
+
+
{{ review.assignment_count }}
+
Assignments
+
+
+
{{ review.hard_violations }}
+
Hard violations
+
+
+
{{ review.soft_score }}
+
Soft score
+
+
+
+ +
+
+
+ + +
+
+ +
+ {% if not review.events %} +
No assignments in this solution.
+ {% else %} + {% for e in review.events %} +
+
{{ e.event_type }}
+
{{ e.date_label }}
+
+
+ {% for name in e.assignees %} + {{ name }} + {% endfor %} + {% if not e.assignees %} + Unfilled + {% endif %} +
+
+ {% endfor %} + {% endif %} +
+ +
+
+
Fairness stdev
+ {{ review.stats.fairness_stdev }}
+
Events / person (max)
+ {{ review.stats.workload_max }}
+
Events / person (min)
+ {{ review.stats.workload_min }}
+
Events / person (median)
+ {{ review.stats.workload_median }}
+
Distinct people
+ {{ review.stats.distinct }}
+
Total assigned
+ {{ review.stats.total }}
+
+
+
+ {% endif %} +
+{% set tabs = [ + ('/a/dashboard', '▣', 'Dashboard', 'dashboard'), + ('/a/people', '◔', 'People', 'people'), + ('/a/events', '▤', 'Events', 'events'), + ('/a/solver', '◈', 'Solver', 'solver'), +] %} +{% include "_nav.html" %} +{% endblock %}