diff --git a/tests/e2e/test_onboarding_wizard.py b/tests/e2e/test_onboarding_wizard.py new file mode 100644 index 00000000..b184f96b --- /dev/null +++ b/tests/e2e/test_onboarding_wizard.py @@ -0,0 +1,75 @@ +"""Overnight B3 e2e — a fresh admin completes the first-run wizard. + +signup → /a/onboarding shows 0/4 → invite → create event → solve → +publish, with the wizard's progress advancing at each step and ending +in the complete state. +""" + +from __future__ import annotations + +import pytest + +from tests.e2e._helpers import accept_invitation, invite_token, no_js_errors, rid, signup_admin + +pytestmark = pytest.mark.e2e + + +def _progress(page, base, text): + page.goto(f"{base}/a/onboarding") + page.wait_for_selector(f"#onboarding-progress:has-text('{text}')") + + +def test_fresh_admin_completes_wizard(live_server, new_context, page, db_path): + base = live_server + vol_email = f"vol+{rid()}@hope.e2e" + + signup_admin(page, base) + + # Fresh org — nothing done yet. + _progress(page, base, "0 of 4 done") + + # 1) Invite a teammate. + page.goto(f"{base}/a/people") + page.click("button:has-text('Invite person')") + page.fill("#inv_name", "Jamie Park") + page.fill("#inv_email", vol_email) + page.select_option("#inv_role", "volunteer") + page.click("button:has-text('Send invite')") + page.wait_for_selector("#invite-result:has-text('Invitation sent')") + accept_invitation(new_context(), base, invite_token(db_path, vol_email)) + _progress(page, base, "1 of 4 done") + + # 2) Create an event. + page.goto(f"{base}/a/events") + page.click("button:has-text('New event')") + page.wait_for_selector("#ev_type", state="visible") + page.fill("#ev_type", "Sunday 10am Service") + page.fill("#ev_date", "2026-06-07") + page.fill("#ev_start", "10:00") + page.fill("#ev_end", "11:30") + page.fill("input[name=role_name]", "volunteer") + page.fill("input[name=role_count]", "1") + page.click("button:has-text('Create event')") + page.wait_for_selector("#events-list:has-text('Sunday 10am Service')") + _progress(page, base, "2 of 4 done") + + # 3) Generate a schedule, 4) review and publish it — one uninterrupted + # sequence (the solver page only holds the result until you navigate + # away, so the review link must be clicked without leaving). + page.goto(f"{base}/a/solver") + page.fill("#from_date", "2026-05-19") + page.fill("#to_date", "2026-06-30") + page.click("button:has-text('Run solver')") + page.wait_for_selector("#solver-result:has-text('Review solution')") + page.click("a:has-text('Review solution')") + page.wait_for_url("**/a/solution/**") + page.wait_for_selector("#publish-state") + page.click("button:has-text('Publish this solution')") + page.wait_for_selector("#publish-state:has-text('Unpublish')") + + # Wizard is complete. + page.goto(f"{base}/a/onboarding") + page.wait_for_selector("#onboarding-complete") + page.wait_for_selector("#onboarding-progress:has-text('4 of 4 done')") + + no_js_errors(page) diff --git a/tests/web/test_onboarding_wizard.py b/tests/web/test_onboarding_wizard.py new file mode 100644 index 00000000..1f7958c7 --- /dev/null +++ b/tests/web/test_onboarding_wizard.py @@ -0,0 +1,109 @@ +"""Overnight B3 — first-run onboarding wizard.""" + +from __future__ import annotations + +from datetime import datetime, timedelta + +from api.models import Event, Invitation, OnboardingProgress, Solution +from tests.web.conftest import seed_person +from web.deps import SESSION_COOKIE + + +def _admin(client, db, *, org="ob_o", pid="ob_admin", email="admin@ob.test"): + seed_person(db, person_id=pid, org_id=org, email=email, roles=["admin"]) + r = client.post("/auth/login", data={"email": email, "password": "WebPass123!"}) + return r.cookies[SESSION_COOKIE] + + +def _invite(db, org, by, *, iid, token): + db.add( + Invitation( + id=iid, + org_id=org, + email=f"{iid}@ob.test", + name="Invitee", + roles=["volunteer"], + invited_by=by, + token=token, + expires_at=datetime.utcnow() + timedelta(days=7), + ) + ) + db.commit() + + +def test_onboarding_requires_auth(client): + assert client.get("/a/onboarding").status_code == 303 + + +def test_fresh_admin_sees_zero_progress(client, db): + tok = _admin(client, db) + r = client.get("/a/onboarding", cookies={SESSION_COOKIE: tok}) + assert r.status_code == 200 + assert "0 of 4 done" in r.text + row = ( + db.query(OnboardingProgress) + .filter( + OnboardingProgress.person_id == "ob_admin", + OnboardingProgress.org_id == "ob_o", + ) + .first() + ) + assert row is not None and row.wizard_step_completed == 0 + + +def test_partial_progress_and_dashboard_banner(client, db): + tok = _admin(client, db, org="ob_o3", pid="ob_a3", email="a3@ob.test") + _invite(db, "ob_o3", "ob_a3", iid="inv3", token="tk3") + r = client.get("/a/onboarding", cookies={SESSION_COOKIE: tok}) + assert "1 of 4 done" in r.text + d = client.get("/a/dashboard", cookies={SESSION_COOKIE: tok}) + assert 'id="onboarding-banner"' in d.text and "1/4" in d.text + + +def test_full_progress_marks_complete(client, db): + tok = _admin(client, db, org="ob_o2", pid="ob_a2", email="a2@ob.test") + _invite(db, "ob_o2", "ob_a2", iid="inv2", token="tk2") + start = datetime.utcnow() + timedelta(days=3) + db.add( + Event( + id="ev1", + org_id="ob_o2", + type="Svc", + start_time=start, + end_time=start + timedelta(hours=1), + ) + ) + db.add( + Solution( + org_id="ob_o2", + hard_violations=0, + soft_score=1.0, + health_score=90.0, + is_published=True, + published_at=datetime.utcnow(), + ) + ) + db.commit() + r = client.get("/a/onboarding", cookies={SESSION_COOKIE: tok}) + assert "4 of 4 done" in r.text + assert 'id="onboarding-complete"' in r.text + # complete → no dashboard nudge. + d = client.get("/a/dashboard", cookies={SESSION_COOKIE: tok}) + assert 'id="onboarding-banner"' not in d.text + + +def test_skip_persists_and_hides_banner(client, db): + tok = _admin(client, db, org="ob_o4", pid="ob_a4", email="a4@ob.test") + r = client.post("/a/onboarding/skip", cookies={SESSION_COOKIE: tok}) + assert r.status_code == 303 and r.headers["location"] == "/a/dashboard" + row = ( + db.query(OnboardingProgress) + .filter( + OnboardingProgress.person_id == "ob_a4", + OnboardingProgress.org_id == "ob_o4", + ) + .first() + ) + assert row.onboarding_skipped is True + d = client.get("/a/dashboard", cookies={SESSION_COOKIE: tok}) + assert 'id="onboarding-banner"' not in d.text diff --git a/web/routers/pages.py b/web/routers/pages.py index ef40f7bc..1ba15cad 100644 --- a/web/routers/pages.py +++ b/web/routers/pages.py @@ -501,10 +501,133 @@ def admin_dashboard( "person": person, "active_tab": "dashboard", "kpis": _dashboard_kpis(db, person.org_id), + "ob": _onboarding_state(db, person), }, ) +ONBOARDING_TOTAL = 4 + + +def _onboarding_state(db: Session, person: Person) -> dict: + """Derive the 4-step first-run checklist from real org data and + persist the count to onboarding_progress. Steps are independent + (a checklist, not a gated wizard) so progress survives any order.""" + from api.models import Invitation, OnboardingProgress, Solution + + org_id = person.org_id + people_n = db.query(Person).filter(Person.org_id == org_id).count() + invites_n = db.query(Invitation).filter(Invitation.org_id == org_id).count() + events_n = db.query(Event).filter(Event.org_id == org_id).count() + sols_n = db.query(Solution).filter(Solution.org_id == org_id).count() + pub_n = ( + db.query(Solution) + .filter(Solution.org_id == org_id, Solution.is_published.is_(True)) + .count() + ) + + steps = [ + { + "key": "invite", + "title": "Invite a teammate", + "desc": "Add the people you schedule.", + "href": "/a/people", + "cta": "Invite people", + "done": invites_n > 0 or people_n > 1, + }, + { + "key": "event", + "title": "Create an event", + "desc": "Add something that needs volunteers.", + "href": "/a/events", + "cta": "Create event", + "done": events_n > 0, + }, + { + "key": "solve", + "title": "Generate a schedule", + "desc": "Let the solver build a fair roster.", + "href": "/a/solver", + "cta": "Run solver", + "done": sols_n > 0, + }, + { + "key": "publish", + "title": "Publish it", + "desc": "Share the schedule with volunteers.", + "href": "/a/solver", + "cta": "Publish", + "done": pub_n > 0, + }, + ] + done_n = sum(1 for s in steps if s["done"]) + + row = ( + db.query(OnboardingProgress) + .filter( + OnboardingProgress.person_id == person.id, + OnboardingProgress.org_id == org_id, + ) + .first() + ) + if row is None: + row = OnboardingProgress(person_id=person.id, org_id=org_id) + db.add(row) + if row.wizard_step_completed != done_n: + row.wizard_step_completed = done_n + db.commit() + + return { + "steps": steps, + "done": done_n, + "total": ONBOARDING_TOTAL, + "complete": done_n >= ONBOARDING_TOTAL, + "skipped": bool(row.onboarding_skipped), + } + + +@router.get("/a/onboarding", response_class=HTMLResponse) +def admin_onboarding( + request: Request, + person: Person = Depends(get_session_admin), + db: Session = Depends(get_db), +): + from web.app import templates + + return templates.TemplateResponse( + request, + "admin/onboarding.html", + { + "person": person, + "active_tab": "dashboard", + "ob": _onboarding_state(db, person), + }, + ) + + +@router.post("/a/onboarding/skip") +def admin_onboarding_skip( + person: Person = Depends(get_session_admin), + db: Session = Depends(get_db), +): + from api.models import OnboardingProgress + + row = ( + db.query(OnboardingProgress) + .filter( + OnboardingProgress.person_id == person.id, + OnboardingProgress.org_id == person.org_id, + ) + .first() + ) + if row is None: + row = OnboardingProgress(person_id=person.id, org_id=person.org_id) + db.add(row) + row.onboarding_skipped = True + db.commit() + return RedirectResponse(url="/a/dashboard", status_code=303) + + def _org_settings(db: Session, org_id: str) -> dict: """Current org settings for the form (timezone lives in config).""" from api.routers.organizations import get_organization diff --git a/web/templates/admin/dashboard.html b/web/templates/admin/dashboard.html index f1bf4569..6c17ac49 100644 --- a/web/templates/admin/dashboard.html +++ b/web/templates/admin/dashboard.html @@ -9,6 +9,14 @@