Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 90 additions & 0 deletions tests/web/test_solution_review.py
Original file line number Diff line number Diff line change
@@ -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
71 changes: 71 additions & 0 deletions web/routers/pages.py
Original file line number Diff line number Diff line change
Expand Up @@ -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},
)
102 changes: 102 additions & 0 deletions web/templates/admin/solution_review.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
{% extends "base.html" %}
{% block title %}Solution review · SignUpFlow{% endblock %}
{% block body %}
<div class="nav">
<a class="nav-back" href="/a/solver">‹ Solver</a>
<span class="nav-title-inline">Solution</span>
<span class="nav-action"></span>
</div>
<div class="scroll">
{% if review is none %}
<div class="empty">Solution not found in your organization.</div>
{% else %}
<div class="card">
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap">
<div class="page-title" style="padding:0;font-size:22px">
Solution #{{ review.id }}
</div>
{% if review.is_published %}
<span class="status-text confirmed">published</span>
{% endif %}
</div>
<div class="row-sub" style="margin-top:4px">{{ review.created_label }}</div>
<div class="spacer-12"></div>
<div class="kpi-grid">
<div class="kpi accent">
<div class="kpi-value">{{ review.health_score }}</div>
<div class="kpi-label">Health</div>
</div>
<div class="kpi">
<div class="kpi-value">{{ review.assignment_count }}</div>
<div class="kpi-label">Assignments</div>
</div>
<div class="kpi">
<div class="kpi-value">{{ review.hard_violations }}</div>
<div class="kpi-label">Hard violations</div>
</div>
<div class="kpi">
<div class="kpi-value">{{ review.soft_score }}</div>
<div class="kpi-label">Soft score</div>
</div>
</div>
</div>

<div class="spacer-12"></div>
<div x-data="{ seg: 'assignments' }">
<div class="seg cols-2">
<button type="button" :class="seg==='assignments' ? 'active' : ''"
@click="seg='assignments'">Assignments</button>
<button type="button" :class="seg==='stats' ? 'active' : ''"
@click="seg='stats'">Stats</button>
</div>
<div class="spacer-12"></div>

<div x-show="seg==='assignments'">
{% if not review.events %}
<div class="empty">No assignments in this solution.</div>
{% else %}
{% for e in review.events %}
<div class="card">
<div class="row-title">{{ e.event_type }}</div>
<div class="row-sub">{{ e.date_label }}</div>
<div class="spacer-12" style="height:8px"></div>
<div style="display:flex;flex-wrap:wrap;gap:6px">
{% for name in e.assignees %}
<span class="role-chip">{{ name }}</span>
{% endfor %}
{% if not e.assignees %}
<span class="row-sub">Unfilled</span>
{% endif %}
</div>
</div>
{% endfor %}
{% endif %}
</div>

<div x-show="seg==='stats'" x-cloak>
<div class="group">
<div class="row"><div class="row-main"><div class="row-title">Fairness stdev</div></div>
<span class="mono-data">{{ review.stats.fairness_stdev }}</span></div>
<div class="row"><div class="row-main"><div class="row-title">Events / person (max)</div></div>
<span class="mono-data">{{ review.stats.workload_max }}</span></div>
<div class="row"><div class="row-main"><div class="row-title">Events / person (min)</div></div>
<span class="mono-data">{{ review.stats.workload_min }}</span></div>
<div class="row"><div class="row-main"><div class="row-title">Events / person (median)</div></div>
<span class="mono-data">{{ review.stats.workload_median }}</span></div>
<div class="row"><div class="row-main"><div class="row-title">Distinct people</div></div>
<span class="mono-data">{{ review.stats.distinct }}</span></div>
<div class="row"><div class="row-main"><div class="row-title">Total assigned</div></div>
<span class="mono-data">{{ review.stats.total }}</span></div>
</div>
</div>
</div>
{% endif %}
</div>
{% set tabs = [
('/a/dashboard', '▣', 'Dashboard', 'dashboard'),
('/a/people', '◔', 'People', 'people'),
('/a/events', '▤', 'Events', 'events'),
('/a/solver', '◈', 'Solver', 'solver'),
] %}
{% include "_nav.html" %}
{% endblock %}
Loading