Skip to content
Draft
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
110 changes: 108 additions & 2 deletions backend/community/management/commands/e2e_seed.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand All @@ -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:
Expand Down Expand Up @@ -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,
Expand All @@ -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,
}


Expand Down
86 changes: 86 additions & 0 deletions frontend/e2e/attendance-analytics.spec.ts
Original file line number Diff line number Diff line change
@@ -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/);
});
67 changes: 67 additions & 0 deletions frontend/e2e/attendance-report.spec.ts
Original file line number Diff line number Diff line change
@@ -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$/);
});
12 changes: 12 additions & 0 deletions frontend/e2e/fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading