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
111 changes: 111 additions & 0 deletions tests/web/test_solver.py
Original file line number Diff line number Diff line change
@@ -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
)
24 changes: 24 additions & 0 deletions web/routers/pages.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
},
)
56 changes: 56 additions & 0 deletions web/routers/partials.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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),
}
},
)
1 change: 1 addition & 0 deletions web/templates/admin/dashboard.html
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@
('/a/dashboard', '▣', 'Dashboard', 'dashboard'),
('/a/people', '◔', 'People', 'people'),
('/a/events', '▤', 'Events', 'events'),
('/a/solver', '◈', 'Solver', 'solver'),
] %}
{% include "_nav.html" %}
{% endblock %}
1 change: 1 addition & 0 deletions web/templates/admin/events.html
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
('/a/dashboard', '▣', 'Dashboard', 'dashboard'),
('/a/people', '◔', 'People', 'people'),
('/a/events', '▤', 'Events', 'events'),
('/a/solver', '◈', 'Solver', 'solver'),
] %}
{% include "_nav.html" %}
{% endblock %}
1 change: 1 addition & 0 deletions web/templates/admin/people.html
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@
('/a/dashboard', '▣', 'Dashboard', 'dashboard'),
('/a/people', '◔', 'People', 'people'),
('/a/events', '▤', 'Events', 'events'),
('/a/solver', '◈', 'Solver', 'solver'),
] %}
{% include "_nav.html" %}
{% endblock %}
64 changes: 64 additions & 0 deletions web/templates/admin/solver.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
{% extends "base.html" %}
{% block title %}Run solver · SignUpFlow{% endblock %}
{% block body %}
<div class="nav">
<span class="nav-back"></span>
<form method="post" action="/auth/logout" style="margin:0">
<button class="nav-action" type="submit">Sign out</button>
</form>
</div>
<div class="page-title">Run Solver</div>
<div class="scroll" x-data="{ mode: 'strict' }">
<form hx-post="/a/solver/run"
hx-target="#solver-result" hx-swap="outerHTML"
hx-indicator="#solver-busy">
<div class="mono-label" style="margin-top:8px">Date range</div>
<div class="field">
<label class="field-label" for="from_date">From</label>
<input id="from_date" name="from_date" type="date" required
value="{{ default_from }}">
</div>
<div class="field">
<label class="field-label" for="to_date">To</label>
<input id="to_date" name="to_date" type="date" required
value="{{ default_to }}">
</div>

<div class="mono-label">Mode</div>
<input type="hidden" name="mode" :value="mode">
<div class="seg cols-2">
<button type="button" :class="mode==='strict' ? 'active' : ''"
@click="mode='strict'">Strict</button>
<button type="button" :class="mode==='relaxed' ? 'active' : ''"
@click="mode='relaxed'">Relaxed</button>
</div>

<div class="mono-label">Options</div>
<div class="card">
<label style="display:flex;align-items:center;gap:10px;cursor:pointer">
<input type="checkbox" name="change_min" value="true">
<span>
<span class="row-title" style="font-size:14px">Minimize moves from published</span>
<span class="row-sub">Bias toward keeping current published assignments.</span>
</span>
</label>
</div>

<div class="spacer-18"></div>
<button class="btn btn-go" type="submit">
Run solver →
<span id="solver-busy" class="htmx-indicator">…</span>
</button>
</form>

<div class="spacer-18"></div>
<div id="solver-result"></div>
</div>
{% set tabs = [
('/a/dashboard', '▣', 'Dashboard', 'dashboard'),
('/a/people', '◔', 'People', 'people'),
('/a/events', '▤', 'Events', 'events'),
('/a/solver', '◈', 'Solver', 'solver'),
] %}
{% include "_nav.html" %}
{% endblock %}
33 changes: 33 additions & 0 deletions web/templates/partials/solver_result.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
{# Solver outcome. Swapped into #solver-result. #}
<div id="solver-result">
{% if error %}
<div class="form-error" role="alert">{{ error }}</div>
{% else %}
<div class="card">
<div class="field-label">Solution #{{ r.solution_id }}</div>
<div class="spacer-12" style="height:6px"></div>
<div class="kpi-grid">
<div class="kpi">
<div class="kpi-value">{{ r.assignment_count }}</div>
<div class="kpi-label">Assignments</div>
</div>
<div class="kpi accent">
<div class="kpi-value">{{ r.health_score }}</div>
<div class="kpi-label">Health score</div>
</div>
<div class="kpi">
<div class="kpi-value">{{ r.hard_violations }}</div>
<div class="kpi-label">Hard violations</div>
</div>
<div class="kpi">
<div class="kpi-value">{{ r.solve_ms }}<span class="unit">ms</span></div>
<div class="kpi-label">Solve time</div>
</div>
</div>
<div class="spacer-12"></div>
<a class="btn btn-primary" href="/a/solution/{{ r.solution_id }}">
Review solution
</a>
</div>
{% endif %}
</div>
Loading