diff --git a/backend/community/management/commands/e2e_seed.py b/backend/community/management/commands/e2e_seed.py index 80a6e6ec..6e7a01fc 100644 --- a/backend/community/management/commands/e2e_seed.py +++ b/backend/community/management/commands/e2e_seed.py @@ -5,13 +5,17 @@ from django.core.management.base import BaseCommand from django.utils import timezone from ninja_jwt.tokens import RefreshToken -from users.models import NonMemberRsvpToken, User +from users.models import NonMemberRsvpToken, Role, User +from users.permissions import PermissionKey from community.models import ( + AttendanceStatus, Event, EventRSVP, EventStatus, EventType, + FeatureFlag, + FeatureFlagState, PageVisibility, RSVPStatus, ) @@ -20,7 +24,10 @@ def _random_phone() -> str: - return "+1202555" + str(secrets.randbelow(10_000)).zfill(4) + while True: + phone = "+1202555" + str(secrets.randbelow(10_000)).zfill(4) + if not User.objects.filter(phone_number=phone).exists(): + return phone def _random_event(scenario: str, **overrides) -> Event: @@ -149,6 +156,103 @@ def _seed_live_updates() -> dict: } +def _admin_user(phone: str, permissions: list[str]) -> User: + user = _member_user(phone) + role = Role.objects.create(name=f"e2e-role-{secrets.token_hex(4)}", permissions=permissions) + user.roles.add(role) + return user + + +def _seed_attendance_report() -> dict: + FeatureFlagState.objects.update_or_create( + key=FeatureFlag.HOST_ATTENDANCE_REPORT, defaults={"enabled": True} + ) + host_phone = _random_phone() + host = _admin_user(host_phone, [PermissionKey.MANAGE_EVENTS]) + event = _random_event( + "attendance-report", + start_datetime=timezone.now() - timedelta(days=2), + end_datetime=timezone.now() - timedelta(days=2) + timedelta(hours=2), + created_by=host, + ) + + attended = _member_user(_random_phone()) + no_show = _member_user(_random_phone()) + canceled = _non_member_user(_random_phone()) + EventRSVP.objects.create( + event=event, + user=attended, + status=RSVPStatus.ATTENDING, + attendance=AttendanceStatus.ATTENDED, + ) + EventRSVP.objects.create( + event=event, user=no_show, status=RSVPStatus.ATTENDING, attendance=AttendanceStatus.NO_SHOW + ) + EventRSVP.objects.create( + event=event, + user=canceled, + status=RSVPStatus.CANT_GO, + cancelled_at=timezone.now() - timedelta(days=3), + ) + + return { + "event_id": str(event.id), + "event_title": event.title, + "host_phone": host_phone, + "host_password": E2E_PASSWORD, + } + + +def _seed_attendance_analytics() -> dict: + FeatureFlagState.objects.update_or_create( + key=FeatureFlag.ADMIN_ATTENDANCE_ANALYTICS, defaults={"enabled": True} + ) + admin_phone = _random_phone() + _admin_user(admin_phone, [PermissionKey.MANAGE_EVENTS, PermissionKey.MANAGE_USERS]) + + recent_event = _random_event( + "analytics-recent", + event_type=EventType.OFFICIAL, + start_datetime=timezone.now() - timedelta(days=10), + ) + stale_event = _random_event( + "analytics-stale", + event_type=EventType.CLUB, + start_datetime=timezone.now() - timedelta(days=400), + ) + + suffix = secrets.token_hex(4) + compliant_first_name = f"Zoe{suffix}" + at_risk_first_name = f"Yara{suffix}" + + compliant = _member_user(_random_phone()) + compliant.first_name = compliant_first_name + compliant.save(update_fields=["first_name"]) + at_risk = _member_user(_random_phone()) + at_risk.first_name = at_risk_first_name + at_risk.save(update_fields=["first_name"]) + + EventRSVP.objects.create( + event=recent_event, + user=compliant, + status=RSVPStatus.ATTENDING, + attendance=AttendanceStatus.ATTENDED, + ) + EventRSVP.objects.create( + event=stale_event, + user=at_risk, + status=RSVPStatus.ATTENDING, + attendance=AttendanceStatus.ATTENDED, + ) + + return { + "admin_phone": admin_phone, + "admin_password": E2E_PASSWORD, + "compliant_name": f"{compliant_first_name} member".lower(), + "at_risk_name": f"{at_risk_first_name} member".lower(), + } + + SCENARIOS = { "member": _seed_member, "public-new": _seed_public_new, @@ -157,6 +261,8 @@ def _seed_live_updates() -> dict: "comments": _seed_comments, "my-rsvps": _seed_my_rsvps, "live-updates": _seed_live_updates, + "attendance-report": _seed_attendance_report, + "attendance-analytics": _seed_attendance_analytics, } diff --git a/frontend/e2e/attendance-analytics.spec.ts b/frontend/e2e/attendance-analytics.spec.ts new file mode 100644 index 00000000..83a050e2 --- /dev/null +++ b/frontend/e2e/attendance-analytics.spec.ts @@ -0,0 +1,86 @@ +import { expect, type Page, test } from '@playwright/test'; + +import { seed } from './fixtures'; + +async function loginAsMember(page: Page, phone: string, password: string) { + await page.goto('/login'); + await page.getByLabel('phone number').pressSequentially(phone.replace('+1', '')); + await page.getByRole('button', { name: 'continue' }).click(); + await page.getByRole('textbox', { name: 'password' }).fill(password); + await page.getByRole('button', { name: 'sign in' }).click(); + await page.waitForURL((url) => url.pathname !== '/login'); + + if (page.url().includes('/consent')) { + await page + .getByRole('checkbox', { + name: 'i have read and agree to the community guidelines and community agreements', + }) + .check(); + await page.getByRole('checkbox', { name: 'i agree to the sms policy' }).check(); + await page.getByRole('button', { name: 'continue' }).click(); + await page.waitForURL((url) => !url.pathname.includes('/consent')); + } +} + +test('admin switches to the members tab and sees pause candidates ranked first', async ({ + page, +}) => { + const { admin_phone, admin_password, at_risk_name } = seed('attendance-analytics'); + + await loginAsMember(page, admin_phone, admin_password); + await page.goto('/admin/attendance'); + await expect(page.getByRole('heading', { name: 'attendance' })).toBeVisible(); + + const tabs = page.getByRole('tablist', { name: 'attendance view' }); + await expect(tabs).toBeVisible(); + await tabs.getByRole('tab', { name: 'members' }).click(); + + // the at-risk (400-day-stale) member is a pause candidate and shows the badge + await expect(page.getByText(at_risk_name)).toBeVisible(); + await expect(page.getByText('pause candidate').first()).toBeVisible(); + + await page.getByRole('button', { name: 'at risk' }).click(); + await expect(page.getByText(at_risk_name)).toBeVisible(); +}); + +test('admin with manage_users can pause an at-risk member', async ({ page }) => { + const { admin_phone, admin_password, at_risk_name } = seed('attendance-analytics'); + + await loginAsMember(page, admin_phone, admin_password); + await page.goto('/admin/attendance'); + await page + .getByRole('tablist', { name: 'attendance view' }) + .getByRole('tab', { name: 'members' }) + .click(); + await page.getByRole('button', { name: 'at risk' }).click(); + + const card = page.getByRole('listitem').filter({ hasText: at_risk_name }); + await card.getByRole('button', { name: 'pause member' }).click(); + + const confirm = page.getByRole('dialog', { name: 'pause this member?' }); + await confirm.getByRole('button', { name: 'pause' }).click(); + + await expect(page.getByText('paused').first()).toBeVisible(); +}); + +// Bug probe (Issue filed): the events tab links every row to /events/:id/report, +// which is gated by the SEPARATE host_attendance_report flag. With only +// admin_attendance_analytics enabled, clicking a row bounces the admin to +// /calendar via RequireFlag. This test documents the current broken behavior; +// flip the assertion once the cross-flag dead-link is fixed. +test('events-tab row dead-links to /calendar when host flag is off (known bug)', async ({ + page, +}) => { + const { admin_phone, admin_password } = seed('attendance-analytics'); + + await loginAsMember(page, admin_phone, admin_password); + await page.goto('/admin/attendance'); + + const row = page.locator('a[href*="/report"]').first(); + const rowCount = await row.count(); + test.skip(rowCount === 0, 'no marked events seeded on the events tab'); + + await row.click(); + // BUG: expected to land on the report, actually bounced to /calendar. + await expect(page).toHaveURL(/\/calendar/); +}); diff --git a/frontend/e2e/attendance-report.spec.ts b/frontend/e2e/attendance-report.spec.ts new file mode 100644 index 00000000..f8408d37 --- /dev/null +++ b/frontend/e2e/attendance-report.spec.ts @@ -0,0 +1,67 @@ +import { expect, type Page, test } from '@playwright/test'; + +import { seed } from './fixtures'; + +async function loginAsMember(page: Page, phone: string, password: string) { + await page.goto('/login'); + await page.getByLabel('phone number').pressSequentially(phone.replace('+1', '')); + await page.getByRole('button', { name: 'continue' }).click(); + await page.getByRole('textbox', { name: 'password' }).fill(password); + await page.getByRole('button', { name: 'sign in' }).click(); + await page.waitForURL((url) => url.pathname !== '/login'); + + if (page.url().includes('/consent')) { + await page + .getByRole('checkbox', { + name: 'i have read and agree to the community guidelines and community agreements', + }) + .check(); + await page.getByRole('checkbox', { name: 'i agree to the sms policy' }).check(); + await page.getByRole('button', { name: 'continue' }).click(); + await page.waitForURL((url) => !url.pathname.includes('/consent')); + } +} + +test('host opens check-in report from the kebab menu and sees the attendance breakdown', async ({ + page, +}) => { + const { event_id, event_title, host_phone, host_password } = seed('attendance-report'); + + await loginAsMember(page, host_phone, host_password); + await page.goto(`/events/${event_id}`); + await expect(page.getByRole('heading', { name: event_title.toLowerCase() })).toBeVisible(); + + await page.getByRole('button', { name: 'event settings' }).click(); + const menu = page.getByRole('menu'); + await expect(menu.getByRole('menuitem', { name: 'check-in', exact: true })).toBeVisible(); + await menu.getByRole('menuitem', { name: 'check-in report' }).click(); + + await page.waitForURL(`**/events/${event_id}/report`); + await expect(page.getByRole('heading', { name: 'check-in report' })).toBeVisible(); + + // summary pills: 1 attended, 1 no-show, 1 canceled, 0 unmarked + await expect(page.getByText('1 attended')).toBeVisible(); + await expect(page.getByText('1 no-show')).toBeVisible(); + await expect(page.getByText('1 canceled')).toBeVisible(); + + await expect(page.getByRole('heading', { name: 'attended' })).toBeVisible(); + await expect(page.getByRole('heading', { name: 'no-shows' })).toBeVisible(); + await expect(page.getByRole('heading', { name: 'canceled' })).toBeVisible(); +}); + +test('check-in report csv sheet downloads a file with the selected columns', async ({ page }) => { + const { event_id, host_phone, host_password } = seed('attendance-report'); + + await loginAsMember(page, host_phone, host_password); + await page.goto(`/events/${event_id}/report`); + await expect(page.getByRole('heading', { name: 'check-in report' })).toBeVisible(); + + await page.getByRole('button', { name: 'export csv' }).click(); + const dialog = page.getByRole('dialog', { name: 'download csv' }); + await expect(dialog).toBeVisible(); + + const downloadPromise = page.waitForEvent('download'); + await dialog.getByRole('button', { name: 'download' }).click(); + const download = await downloadPromise; + expect(download.suggestedFilename()).toMatch(/\.csv$/); +}); diff --git a/frontend/e2e/fixtures.ts b/frontend/e2e/fixtures.ts index 2400527d..d5fb1ea3 100644 --- a/frontend/e2e/fixtures.ts +++ b/frontend/e2e/fixtures.ts @@ -29,6 +29,18 @@ export interface SeedScenarioMap { user_b_phone: string; user_b_password: string; }; + 'attendance-report': { + event_id: string; + event_title: string; + host_phone: string; + host_password: string; + }; + 'attendance-analytics': { + admin_phone: string; + admin_password: string; + compliant_name: string; + at_risk_name: string; + }; } export type SeedScenario = keyof SeedScenarioMap;