diff --git a/tests/web/test_solver.py b/tests/web/test_solver.py new file mode 100644 index 00000000..0e48ab8f --- /dev/null +++ b/tests/web/test_solver.py @@ -0,0 +1,111 @@ +"""Sprint 11.17 — admin run solver.""" + +from __future__ import annotations + +from datetime import datetime + +from api.models import Event, Solution +from tests.web.conftest import seed_person +from web.deps import SESSION_COOKIE + + +def _admin(client, db, *, org="s_org", email="sadmin@web.test"): + seed_person(db, person_id="s_admin", org_id=org, email=email, roles=["admin"]) + r = client.post("/auth/login", data={"email": email, "password": "WebPass123!"}) + return r.cookies[SESSION_COOKIE] + + +def test_solver_form_renders(client, db): + token = _admin(client, db) + resp = client.get("/a/solver", cookies={SESSION_COOKIE: token}) + assert resp.status_code == 200 + assert "Run Solver" in resp.text + assert 'name="from_date"' in resp.text + assert 'hx-post="/a/solver/run"' in resp.text + assert "Strict" in resp.text and "Relaxed" in resp.text + + +def test_solver_run_creates_solution(client, db): + token = _admin(client, db, org="s_org2", email="sadmin2@web.test") + # Solver needs at least one event in range (it 400s on an empty + # schedule — correct behaviour). + seed_person(db, person_id="s_vol2", org_id="s_org2", email="svol2@web.test") + db.add( + Event( + id="s_ev", + org_id="s_org2", + type="Sunday Service", + start_time=datetime(2099, 6, 7, 10, 0), + end_time=datetime(2099, 6, 7, 11, 30), + ) + ) + db.commit() + resp = client.post( + "/a/solver/run", + data={ + "from_date": "2099-06-01", + "to_date": "2099-06-30", + "mode": "strict", + }, + cookies={SESSION_COOKIE: token}, + ) + assert resp.status_code == 200 + assert 'id="solver-result"' in resp.text + assert "Solution #" in resp.text + assert "Review solution" in resp.text + sol = ( + db.query(Solution).filter(Solution.org_id == "s_org2").order_by(Solution.id.desc()).first() + ) + assert sol is not None + + +def test_solver_run_invalid_range_rejected(client, db): + token = _admin(client, db, org="s_org3", email="sadmin3@web.test") + resp = client.post( + "/a/solver/run", + data={ + "from_date": "not-a-date", + "to_date": "2099-06-30", + "mode": "strict", + }, + cookies={SESSION_COOKIE: token}, + ) + assert resp.status_code == 400 + assert "form-error" in resp.text + + +def test_solver_requires_admin(client, db): + seed_person(db, person_id="s_vol", email="svol@web.test", roles=["volunteer"]) + login = client.post( + "/auth/login", + data={"email": "svol@web.test", "password": "WebPass123!"}, + ) + token = login.cookies[SESSION_COOKIE] + assert client.get("/a/solver", cookies={SESSION_COOKIE: token}).status_code == 303 + assert ( + client.post( + "/a/solver/run", + data={ + "from_date": "2099-06-01", + "to_date": "2099-06-30", + "mode": "strict", + }, + cookies={SESSION_COOKIE: token}, + ).status_code + == 303 + ) + + +def test_solver_requires_auth(client): + assert client.get("/a/solver").status_code == 303 + assert ( + client.post( + "/a/solver/run", + data={ + "from_date": "2099-06-01", + "to_date": "2099-06-30", + "mode": "strict", + }, + ).status_code + == 303 + ) diff --git a/web/routers/pages.py b/web/routers/pages.py index 5035e312..012e3e4e 100644 --- a/web/routers/pages.py +++ b/web/routers/pages.py @@ -377,3 +377,27 @@ def admin_events( "events": _events(db, person.org_id), }, ) + + +@router.get("/a/solver", response_class=HTMLResponse) +def admin_solver( + request: Request, + person: Person = Depends(get_session_admin), + db: Session = Depends(get_db), +): + from datetime import timedelta + + from api.timeutils import utcnow + from web.app import templates + + today = utcnow().date() + return templates.TemplateResponse( + request, + "admin/solver.html", + { + "person": person, + "active_tab": "solver", + "default_from": today.isoformat(), + "default_to": (today + timedelta(days=28)).isoformat(), + }, + ) diff --git a/web/routers/partials.py b/web/routers/partials.py index bcb79b4d..6ce7506a 100644 --- a/web/routers/partials.py +++ b/web/routers/partials.py @@ -38,8 +38,10 @@ from api.routers.calendar import reset_calendar_token from api.routers.events import create_event, delete_event from api.routers.invitations import create_invitation +from api.routers.solver import solve_schedule from api.schemas.event import EventCreate from api.schemas.invitation import InvitationCreate +from api.schemas.solver import SolveRequest from web.routers.pages import ( RRULE_PRESETS, _events, @@ -391,3 +393,57 @@ def event_delete( except HTTPException: pass # already gone — return a fresh, correct list return _events_list(request, person, db) + + +# ── Admin: run solver ──────────────────────────────────────────────── + + +@router.post("/a/solver/run", response_class=HTMLResponse) +def solver_run( + request: Request, + from_date: str = Form(...), + to_date: str = Form(...), + mode: str = Form("strict"), + change_min: str | None = Form(None), + person: Person = Depends(get_session_admin), + db: Session = Depends(get_db), +): + """Run the scheduler for the admin's org and render a result summary + with a link to review the new solution (11.18).""" + from web.app import templates + + def _err(msg: str, code: int = 400): + return templates.TemplateResponse( + request, "partials/solver_result.html", {"error": msg}, status_code=code + ) + + mode = mode if mode in ("strict", "relaxed") else "strict" + try: + req = SolveRequest( + org_id=person.org_id, + from_date=from_date, + to_date=to_date, + mode=mode, + change_min=(change_min == "true"), + ) + except ValueError: + return _err("Enter a valid date range.") + try: + resp = solve_schedule(req, person, db) + except HTTPException as exc: + return _err(str(exc.detail), exc.status_code or 400) + + m = resp.metrics + return templates.TemplateResponse( + request, + "partials/solver_result.html", + { + "r": { + "solution_id": resp.solution_id, + "assignment_count": resp.assignment_count, + "health_score": round(m.health_score), + "hard_violations": m.hard_violations, + "solve_ms": round(m.solve_ms), + } + }, + ) diff --git a/web/templates/admin/dashboard.html b/web/templates/admin/dashboard.html index 3df0cf10..641433aa 100644 --- a/web/templates/admin/dashboard.html +++ b/web/templates/admin/dashboard.html @@ -60,6 +60,7 @@ ('/a/dashboard', '▣', 'Dashboard', 'dashboard'), ('/a/people', '◔', 'People', 'people'), ('/a/events', '▤', 'Events', 'events'), + ('/a/solver', '◈', 'Solver', 'solver'), ] %} {% include "_nav.html" %} {% endblock %} diff --git a/web/templates/admin/events.html b/web/templates/admin/events.html index f42d3b56..2db6194e 100644 --- a/web/templates/admin/events.html +++ b/web/templates/admin/events.html @@ -15,6 +15,7 @@ ('/a/dashboard', '▣', 'Dashboard', 'dashboard'), ('/a/people', '◔', 'People', 'people'), ('/a/events', '▤', 'Events', 'events'), + ('/a/solver', '◈', 'Solver', 'solver'), ] %} {% include "_nav.html" %} {% endblock %} diff --git a/web/templates/admin/people.html b/web/templates/admin/people.html index 7b1eb052..e9cb9817 100644 --- a/web/templates/admin/people.html +++ b/web/templates/admin/people.html @@ -63,6 +63,7 @@ ('/a/dashboard', '▣', 'Dashboard', 'dashboard'), ('/a/people', '◔', 'People', 'people'), ('/a/events', '▤', 'Events', 'events'), + ('/a/solver', '◈', 'Solver', 'solver'), ] %} {% include "_nav.html" %} {% endblock %} diff --git a/web/templates/admin/solver.html b/web/templates/admin/solver.html new file mode 100644 index 00000000..a27a666a --- /dev/null +++ b/web/templates/admin/solver.html @@ -0,0 +1,64 @@ +{% extends "base.html" %} +{% block title %}Run solver · SignUpFlow{% endblock %} +{% block body %} + +
Run Solver
+
+
+
Date range
+
+ + +
+
+ + +
+ +
Mode
+ +
+ + +
+ +
Options
+
+ +
+ +
+ +
+ +
+
+
+{% set tabs = [ + ('/a/dashboard', '▣', 'Dashboard', 'dashboard'), + ('/a/people', '◔', 'People', 'people'), + ('/a/events', '▤', 'Events', 'events'), + ('/a/solver', '◈', 'Solver', 'solver'), +] %} +{% include "_nav.html" %} +{% endblock %} diff --git a/web/templates/partials/solver_result.html b/web/templates/partials/solver_result.html new file mode 100644 index 00000000..14a92f79 --- /dev/null +++ b/web/templates/partials/solver_result.html @@ -0,0 +1,33 @@ +{# Solver outcome. Swapped into #solver-result. #} +
+ {% if error %} + + {% else %} +
+
Solution #{{ r.solution_id }}
+
+
+
+
{{ r.assignment_count }}
+
Assignments
+
+
+
{{ r.health_score }}
+
Health score
+
+
+
{{ r.hard_violations }}
+
Hard violations
+
+
+
{{ r.solve_ms }}ms
+
Solve time
+
+
+
+ + Review solution + +
+ {% endif %} +