diff --git a/Makefile b/Makefile index eaf9453..4a69053 100644 --- a/Makefile +++ b/Makefile @@ -44,18 +44,13 @@ install: ## Install package @$(UV) sync @echo "=> Installation complete" -dev: ## Run the example Django dev server (clean slate + migrate + bootstrap + runserver) - @echo "=> Cleaning previous database" +dev: ## Run the example Django dev server (clean slate + migrate + bootstrap + seed + runserver) @rm -f examples/db.sqlite3 - @echo "=> Migrating database" - @$(UV) run python examples/manage.py migrate --run-syncdb - @echo "=> Bootstrapping conference data" - @$(UV) run python examples/manage.py bootstrap_conference --config conference.example.toml --update --seed-demo || true - @echo "=> Setting up permission groups" - @$(UV) run python examples/manage.py setup_groups - @echo "=> Seeding demo data (80 users, 20 speakers, ~100 orders)" + @$(UV) run python examples/manage.py migrate --run-syncdb -v 0 + @$(UV) run python examples/manage.py bootstrap_conference --config conference.example.toml --update || true @$(UV) run python examples/seed.py - @echo "=> Starting dev server at http://localhost:8000/admin/ (login: admin/admin)" + @echo "" + @echo "=> Dev server: http://localhost:8000/admin/ (admin / admin)" @$(UV) run python examples/manage.py runserver upgrade: ## Upgrade all dependencies to the latest stable versions diff --git a/examples/seed.py b/examples/seed.py index 08aa9f3..92570d1 100644 --- a/examples/seed.py +++ b/examples/seed.py @@ -5,6 +5,7 @@ DJANGO_SETTINGS_MODULE=settings uv run python examples/seed.py """ +import contextlib import datetime import hashlib import os @@ -23,11 +24,13 @@ from django.contrib.auth import get_user_model from django.contrib.auth.models import Group +from django.core.management import call_command from django.utils import timezone from django_program.conference.models import Conference, Expense, ExpenseCategory from django_program.pretalx.models import Room, ScheduleSlot, SessionRating, Speaker, Talk, TalkOverride from django_program.programs.models import Activity, ActivitySignup, Survey, SurveyResponse, TravelGrant +from django_program.registration.badge import Badge, BadgeTemplate from django_program.registration.conditions import ( DiscountForCategory, DiscountForProduct, @@ -35,6 +38,7 @@ SpeakerCondition, TimeOrStockLimitCondition, ) +from django_program.registration.letter import LetterRequest from django_program.registration.models import ( AddOn, Attendee, @@ -147,8 +151,6 @@ def __init__(self) -> None: def run(self) -> None: """Create a full conference with realistic registration data.""" - print("Seeding realistic demo data...") - self._create_superuser() conference = self._create_conference() prev_conferences = self._create_previous_conferences() @@ -167,8 +169,6 @@ def run(self) -> None: self._create_overrides(conference, talks) self._create_discount_conditions(conference, ticket_types) self._create_credits(conference, users) - - # Phase 25: Analytics seed data self._create_previous_conference_data(prev_conferences, users, speakers) self._create_sponsor_benefits(sponsors) self._create_activities_and_signups(conference, users, rooms) @@ -178,33 +178,88 @@ def run(self) -> None: self._create_travel_grants(conference, users) self._create_more_carts(conference, users, ticket_types, addons) self._create_bulk_purchases(conference, sponsors, ticket_types, addons, users) + self._create_letter_requests(conference, users) + self._create_badges(conference) + + # Set up permission groups + call_command("setup_groups", verbosity=0) + n_groups = Group.objects.filter(name__startswith="Program:").count() + + self._print_summary(conference, prev_conferences, staff, users, vouchers, ticket_types, addons, n_groups) + + def _print_summary( + self, + conference: Conference, + prev_conferences: list[Conference], + staff: list[object], + users: list[object], + vouchers: list[Voucher], + ticket_types: list[TicketType], + addons: list[AddOn], + n_groups: int, + ) -> None: + """Print a single unified summary of all seeded data.""" + W = 24 # label width n_attendees = Attendee.objects.filter(conference=conference).count() n_orders = Order.objects.filter(conference=conference).count() n_speakers = Speaker.objects.filter(conference=conference).count() - - print(f"\nSeeded {conference.name}:") - print(" Admin: admin / admin") - print(f" Staff users: {len(staff)}") - print(f" Attendee users: {len(users)}") - print(f" Speakers: {n_speakers}") - print(f" Talks: {Talk.objects.filter(conference=conference).count()}") - print(f" Orders: {n_orders}") - print(f" Attendees (registered): {n_attendees}") - print(f" Ticket types: {len(ticket_types)}") - print(f" Add-ons: {len(addons)}") - print(f" Vouchers: {len(vouchers)}") - print(f" Credits: {Credit.objects.filter(conference=conference).count()}") - print(f" Expenses: {Expense.objects.filter(conference=conference).count()}") - print(f" Session ratings: {SessionRating.objects.filter(conference=conference).count()}") - print(f" Activities: {Activity.objects.filter(conference=conference).count()}") - print(f" Travel grants: {TravelGrant.objects.filter(conference=conference).count()}") - print(f" Surveys: {Survey.objects.filter(conference=conference).count()}") - print(f" Bulk purchases: {BulkPurchase.objects.filter(conference=conference).count()}") - for prev_conf in prev_conferences: - prev_att = Attendee.objects.filter(conference=prev_conf).count() - prev_talks = Talk.objects.filter(conference=prev_conf).count() - print(f" Previous conference: {prev_conf.name} ({prev_att} attendees, {prev_talks} talks)") + n_talks = Talk.objects.filter(conference=conference).count() + n_credits = Credit.objects.filter(conference=conference).count() + n_expenses = Expense.objects.filter(conference=conference).count() + n_ratings = SessionRating.objects.filter(conference=conference).count() + n_activities = Activity.objects.filter(conference=conference).count() + n_grants = TravelGrant.objects.filter(conference=conference).count() + n_surveys = Survey.objects.filter(conference=conference).count() + n_bulk = BulkPurchase.objects.filter(conference=conference).count() + n_letters = LetterRequest.objects.filter(conference=conference).count() + n_sponsors = Sponsor.objects.filter(conference=conference).count() + n_badge_templates = BadgeTemplate.objects.filter(conference=conference).count() + n_badges = Badge.objects.filter(attendee__conference=conference).count() + + print(f"\n{'=' * 56}") + print(f" {conference.name} ({conference.slug})") + print(f"{'=' * 56}") + + print(f"\n {'Login':{W}} admin / admin") + + print(f"\n {'--- People ---':{W}}") + print(f" {'Staff users':{W}} {len(staff)}") + print(f" {'Attendee users':{W}} {len(users)}") + print(f" {'Registered attendees':{W}} {n_attendees}") + print(f" {'Speakers':{W}} {n_speakers}") + print(f" {'Permission groups':{W}} {n_groups}") + + print(f"\n {'--- Content ---':{W}}") + print(f" {'Talks':{W}} {n_talks}") + print(f" {'Activities':{W}} {n_activities}") + print(f" {'Session ratings':{W}} {n_ratings}") + print(f" {'Surveys':{W}} {n_surveys}") + + print(f"\n {'--- Registration ---':{W}}") + print(f" {'Ticket types':{W}} {len(ticket_types)}") + print(f" {'Add-ons':{W}} {len(addons)}") + print(f" {'Orders':{W}} {n_orders}") + print(f" {'Vouchers':{W}} {len(vouchers)}") + print(f" {'Credits':{W}} {n_credits}") + print(f" {'Letter requests':{W}} {n_letters}") + print(f" {'Badge templates':{W}} {n_badge_templates}") + print(f" {'Badges generated':{W}} {n_badges}") + + print(f"\n {'--- Finance ---':{W}}") + print(f" {'Sponsors':{W}} {n_sponsors}") + print(f" {'Bulk purchases':{W}} {n_bulk}") + print(f" {'Expenses':{W}} {n_expenses}") + print(f" {'Travel grants':{W}} {n_grants}") + + if prev_conferences: + print(f"\n {'--- History ---':{W}}") + for prev_conf in prev_conferences: + prev_att = Attendee.objects.filter(conference=prev_conf).count() + prev_talks = Talk.objects.filter(conference=prev_conf).count() + print(f" {prev_conf.name:{W}} {prev_att} attendees, {prev_talks} talks") + + print(f"\n{'=' * 56}\n") def _create_superuser(self) -> object: """Create the admin superuser.""" @@ -257,18 +312,26 @@ def _create_conference(self) -> Conference: return conference def _create_ticket_types(self, conference: Conference) -> list[TicketType]: - """Use existing ticket types from bootstrap, or create defaults.""" - existing = list(TicketType.objects.filter(conference=conference).order_by("order")) - if existing: - return existing + """Ensure seed ticket types exist and have bulk_enabled / availability windows set.""" now = timezone.now() - result = [] + # Include bootstrap-created tickets and enable bulk on corporate/regular ones + seed_slugs = {slug for _, slug, *_ in TICKET_TYPES} + bootstrap_tickets = list( + TicketType.objects.filter(conference=conference).exclude(slug__in=seed_slugs).order_by("order") + ) + bulk_eligible = {"corporate", "regular", "individual"} + for bt in bootstrap_tickets: + if str(bt.slug) in bulk_eligible and not bt.bulk_enabled: + bt.bulk_enabled = True + bt.save(update_fields=["bulk_enabled"]) + result = bootstrap_tickets + base_order = len(result) for idx, (name, slug, price, qty, bulk, from_off, until_off) in enumerate(TICKET_TYPES): defaults: dict[str, object] = { "name": name, "price": price, "total_quantity": qty, - "order": idx, + "order": base_order + idx, "is_active": True, "requires_voucher": slug == "speaker", "bulk_enabled": bulk, @@ -299,16 +362,28 @@ def _create_ticket_types(self, conference: Conference) -> list[TicketType]: return result def _create_addons(self, conference: Conference) -> list[AddOn]: - """Use existing add-ons from bootstrap, or create defaults.""" - existing = list(AddOn.objects.filter(conference=conference).order_by("order")) - if existing: - return existing - result = [] + """Ensure seed add-ons exist and have bulk_enabled set.""" + seed_slugs = {slug for _, slug, *_ in ADDONS} + bootstrap_addons = list( + AddOn.objects.filter(conference=conference).exclude(slug__in=seed_slugs).order_by("order") + ) + for ba in bootstrap_addons: + if not ba.bulk_enabled: + ba.bulk_enabled = True + ba.save(update_fields=["bulk_enabled"]) + result = bootstrap_addons + base_order = len(result) for idx, (name, slug, price, bulk) in enumerate(ADDONS): addon, created = AddOn.objects.get_or_create( conference=conference, slug=slug, - defaults={"name": name, "price": price, "order": idx, "is_active": True, "bulk_enabled": bulk}, + defaults={ + "name": name, + "price": price, + "order": base_order + idx, + "is_active": True, + "bulk_enabled": bulk, + }, ) if not created and addon.bulk_enabled != bulk: addon.bulk_enabled = bulk @@ -753,8 +828,6 @@ def _create_orders( attendee.completed_registration = True attendee.save(update_fields=["checked_in_at", "completed_registration"]) - print(f" Orders: {order_num}") - def _create_discount_conditions(self, conference: Conference, ticket_types: list[TicketType]) -> None: """Create a variety of discount conditions.""" now = timezone.now() @@ -897,7 +970,7 @@ def _create_previous_conferences(self) -> list[Conference]: """ confs: list[Conference] = [] - conf_2075, created_2075 = Conference.objects.get_or_create( + conf_2075, _created_2075 = Conference.objects.get_or_create( slug="python-2075", defaults={ "name": "Python 2075", @@ -912,10 +985,8 @@ def _create_previous_conferences(self) -> list[Conference]: }, ) confs.append(conf_2075) - if created_2075: - print(" Created previous conference: Python 2075") - conf_2076, created_2076 = Conference.objects.get_or_create( + conf_2076, _created_2076 = Conference.objects.get_or_create( slug="python-2076", defaults={ "name": "Python 2076", @@ -930,8 +1001,6 @@ def _create_previous_conferences(self) -> list[Conference]: }, ) confs.append(conf_2076) - if created_2076: - print(" Created previous conference: Python 2076") return confs @@ -1057,11 +1126,6 @@ def _create_previous_conference_data( if talk_created and prev_speakers: talk.speakers.add(prev_speakers[t_idx % len(prev_speakers)]) - print( - f" {prev_conference.name}: {attendee_count} attendees, " - f"{speaker_count} speakers, {len(sponsor_names)} sponsors, {talk_count} talks" - ) - def _create_sponsor_benefits(self, sponsors: list[Sponsor]) -> None: """Create sponsor benefits with varying fulfillment status.""" benefit_templates = [ @@ -1074,21 +1138,17 @@ def _create_sponsor_benefits(self, sponsors: list[Sponsor]) -> None: ("Swag bag insert", True), ("Attendee email list", False), ] - count = 0 for sponsor in sponsors: # Higher-tier sponsors get more benefits n_benefits = min(len(benefit_templates), 3 + self.rng.randint(0, 5)) for name, default_complete in benefit_templates[:n_benefits]: # ~70% completion rate is_complete = default_complete if self.rng.random() < 0.7 else not default_complete - _, created = SponsorBenefit.objects.get_or_create( + SponsorBenefit.objects.get_or_create( sponsor=sponsor, name=name, defaults={"is_complete": is_complete}, ) - if created: - count += 1 - print(f" Sponsor benefits: {count}") def _create_activities_and_signups(self, conference: Conference, users: list, rooms: list[Room]) -> None: """Create activities with signups including waitlisted users.""" @@ -1102,7 +1162,6 @@ def _create_activities_and_signups(self, conference: Conference, users: list, ro ("Open Space: Async Python", "open-async", Activity.ActivityType.OPEN_SPACE, None), ("Lightning Talks", "lightning", Activity.ActivityType.LIGHTNING_TALK, None), ] - signup_count = 0 for name, slug, atype, max_p in activity_defs: room = self.rng.choice(rooms) if rooms else None activity, _ = Activity.objects.get_or_create( @@ -1132,15 +1191,11 @@ def _create_activities_and_signups(self, conference: Conference, users: list, ro status = ( ActivitySignup.SignupStatus.CONFIRMED if j < n_confirmed else ActivitySignup.SignupStatus.WAITLISTED ) - _, created = ActivitySignup.objects.get_or_create( + ActivitySignup.objects.get_or_create( activity=activity, user=user, defaults={"status": status}, ) - if created: - signup_count += 1 - - print(f" Activity signups: {signup_count}") def _create_expenses(self, conference: Conference) -> None: """Create expense categories and expenses.""" @@ -1221,7 +1276,6 @@ def _create_expenses(self, conference: Conference) -> None: def _create_session_ratings(self, conference: Conference, talks: list[Talk], users: list) -> None: """Create session ratings from attendees for talks.""" - count = 0 for talk in talks[:20]: # 5-15 ratings per talk n_ratings = self.rng.randint(5, 15) @@ -1230,15 +1284,12 @@ def _create_session_ratings(self, conference: Conference, talks: list[Talk], use for user in shuffled[:n_ratings]: # Bell curve around 3.5-4.0 score = max(1, min(5, int(self.rng.gauss(3.8, 0.9)))) - _, created = SessionRating.objects.get_or_create( + SessionRating.objects.get_or_create( conference=conference, talk=talk, user=user, defaults={"score": score, "comment": "" if self.rng.random() < 0.6 else "Great talk!"}, ) - if created: - count += 1 - print(f" Session ratings: {count}") def _create_surveys(self, conference: Conference, users: list) -> None: """Create NPS and satisfaction surveys with responses.""" @@ -1263,26 +1314,18 @@ def _create_surveys(self, conference: Conference, users: list) -> None: }, ) - nps_count = 0 - sat_count = 0 shuffled = list(users) self.rng.shuffle(shuffled) # ~40 NPS responses (score 0-10) for user in shuffled[:40]: score = max(0, min(10, int(self.rng.gauss(7.5, 2.0)))) - _, created = SurveyResponse.objects.get_or_create(survey=nps, user=user, defaults={"score": score}) - if created: - nps_count += 1 + SurveyResponse.objects.get_or_create(survey=nps, user=user, defaults={"score": score}) # ~35 satisfaction responses (score 1-5) for user in shuffled[:35]: score = max(1, min(5, int(self.rng.gauss(3.8, 0.8)))) - _, created = SurveyResponse.objects.get_or_create(survey=sat, user=user, defaults={"score": score}) - if created: - sat_count += 1 - - print(f" Survey responses: {nps_count} NPS, {sat_count} satisfaction") + SurveyResponse.objects.get_or_create(survey=sat, user=user, defaults={"score": score}) def _create_travel_grants(self, conference: Conference, users: list) -> None: """Create travel grant applications if none exist.""" @@ -1514,7 +1557,290 @@ def _create_bulk_purchases( }, ) - print(f" Bulk purchases: {BulkPurchase.objects.filter(conference=conference).count()}") + def _create_letter_requests(self, conference: Conference, users: list) -> None: + """Create visa invitation letter requests across various workflow statuses.""" + from django_program.registration.services.letters import generate_invitation_letter + + if LetterRequest.objects.filter(conference=conference).exists(): + return + + admin = User.objects.filter(is_superuser=True).first() + now = timezone.now() + + nationalities = [ + "Germany", + "Japan", + "Brazil", + "Nigeria", + "India", + "South Korea", + "France", + "Mexico", + "Kenya", + "Poland", + "Colombia", + "Philippines", + "Italy", + "Australia", + "Egypt", + ] + + passport_prefixes = [ + "C01", + "TK9", + "BR4", + "A00", + "J77", + "KR2", + "FR8", + "MX5", + "KE3", + "PL6", + "CO1", + "PH4", + "IT7", + "AU2", + "EG9", + ] + + embassy_names = [ + "U.S. Embassy Berlin", + "", + "U.S. Consulate São Paulo", + "U.S. Embassy Abuja", + "", + "U.S. Embassy Seoul", + "U.S. Embassy Paris", + "", + "U.S. Embassy Nairobi", + "U.S. Consulate Kraków", + "U.S. Embassy Bogotá", + "", + "U.S. Embassy Rome", + "", + "U.S. Embassy Cairo", + ] + + destination_addresses = [ + "Pittsburgh Convention Center, 1000 Fort Duquesne Blvd, Pittsburgh, PA 15222", + "Omni William Penn Hotel, 530 William Penn Pl, Pittsburgh, PA 15219", + "Pittsburgh Convention Center, 1000 Fort Duquesne Blvd, Pittsburgh, PA 15222", + ] + + # (user_index, desired_status, rejection_reason) + # GENERATED and SENT rows are created as APPROVED first so that + # generate_invitation_letter() can transition them correctly. + request_defs: list[tuple[int, str, str]] = [ + # 3 SUBMITTED + (25, LetterRequest.Status.SUBMITTED, ""), + (26, LetterRequest.Status.SUBMITTED, ""), + (27, LetterRequest.Status.SUBMITTED, ""), + # 2 UNDER_REVIEW + (28, LetterRequest.Status.UNDER_REVIEW, ""), + (29, LetterRequest.Status.UNDER_REVIEW, ""), + # 4 APPROVED + (30, LetterRequest.Status.APPROVED, ""), + (31, LetterRequest.Status.APPROVED, ""), + (32, LetterRequest.Status.APPROVED, ""), + (33, LetterRequest.Status.APPROVED, ""), + # 3 GENERATED (created as APPROVED, then generated) + (34, LetterRequest.Status.GENERATED, ""), + (35, LetterRequest.Status.GENERATED, ""), + (36, LetterRequest.Status.GENERATED, ""), + # 2 SENT (created as APPROVED, then generated, then marked sent) + (37, LetterRequest.Status.SENT, ""), + (38, LetterRequest.Status.SENT, ""), + # 1 REJECTED + ( + 39, + LetterRequest.Status.REJECTED, + "Passport number could not be verified. Please resubmit with a clear scan.", + ), + ] + + reviewed_statuses = { + LetterRequest.Status.APPROVED, + LetterRequest.Status.GENERATED, + LetterRequest.Status.SENT, + LetterRequest.Status.REJECTED, + } + + conf_start = conference.start_date + + needs_pdf = {LetterRequest.Status.GENERATED, LetterRequest.Status.SENT} + + for i, (user_idx, desired_status, rejection_reason) in enumerate(request_defs): + if user_idx >= len(users): + continue + + user = users[user_idx] + nationality = nationalities[i % len(nationalities)] + passport_num = f"{passport_prefixes[i % len(passport_prefixes)]}{self.rng.randint(10000, 99999)}" + travel_from = conf_start - datetime.timedelta(days=self.rng.randint(2, 5)) + travel_until = conf_start + datetime.timedelta(days=self.rng.randint(8, 12)) + dob = datetime.date( + self.rng.randint(1975, 2000), + self.rng.randint(1, 12), + self.rng.randint(1, 28), + ) + + reviewed_by = admin if desired_status in reviewed_statuses else None + reviewed_at = now - datetime.timedelta(days=self.rng.randint(1, 10)) if reviewed_by else None + + # Create rows that need PDFs as APPROVED so generate_invitation_letter() works + create_status = LetterRequest.Status.APPROVED if desired_status in needs_pdf else desired_status + + lr = LetterRequest.objects.create( + conference=conference, + user=user, + passport_name=f"{user.first_name} {user.last_name}", + passport_number=passport_num, + nationality=nationality, + date_of_birth=dob, + travel_from=travel_from, + travel_until=travel_until, + destination_address=destination_addresses[i % len(destination_addresses)], + embassy_name=embassy_names[i % len(embassy_names)], + status=create_status, + rejection_reason=rejection_reason, + reviewed_by=reviewed_by, + reviewed_at=reviewed_at, + ) + + if desired_status in needs_pdf: + with contextlib.suppress(OSError, ValueError): + generate_invitation_letter(lr) + # For SENT rows, transition from GENERATED to SENT after PDF generation + if desired_status == LetterRequest.Status.SENT: + lr.transition_to(LetterRequest.Status.SENT) + lr.sent_at = now - datetime.timedelta(days=self.rng.randint(0, 3)) + lr.save(update_fields=["status", "sent_at", "updated_at"]) + + def _create_badges(self, conference: Conference) -> None: + """Create badge templates for different attendee roles and generate badges. + + Creates a default template plus role-specific variants (speaker, staff, + sponsor, press) with different color schemes and display options, then + generates PDF badges for checked-in attendees. + """ + from django_program.registration.services.badge import BadgeGenerationService + + if BadgeTemplate.objects.filter(conference=conference).exists(): + return + + # Template definitions: (name, slug, is_default, accent, bg, text, show_email, + # show_company, show_qr, banner_pos) + template_defs = [ + ( + "Default Badge", + "default", + True, + "#4338CA", + "#FFFFFF", + "#000000", + False, + False, + True, + BadgeTemplate.BannerPosition.BELOW_HEADER, + ), + ( + "Speaker Badge", + "speaker", + False, + "#DC2626", + "#FEF2F2", + "#1F2937", + True, + True, + True, + BadgeTemplate.BannerPosition.ABOVE_NAME, + ), + ( + "Staff Badge", + "staff", + False, + "#059669", + "#F0FDF4", + "#1F2937", + True, + False, + True, + BadgeTemplate.BannerPosition.BELOW_NAME, + ), + ( + "Sponsor Badge", + "sponsor", + False, + "#D97706", + "#FFFBEB", + "#1F2937", + True, + True, + True, + BadgeTemplate.BannerPosition.BOTTOM, + ), + ( + "Press Badge", + "press", + False, + "#7C3AED", + "#F5F3FF", + "#1F2937", + False, + True, + False, + BadgeTemplate.BannerPosition.BELOW_HEADER, + ), + ] + + templates: dict[str, BadgeTemplate] = {} + for name, slug, is_default, accent, bg, text, email, company, qr, banner in template_defs: + t = BadgeTemplate.objects.create( + conference=conference, + name=name, + slug=slug, + is_default=is_default, + accent_color=accent, + background_color=bg, + text_color=text, + show_name=True, + show_email=email, + show_company=company, + show_ticket_type=True, + show_qr_code=qr, + show_conference_name=True, + ticket_banner_position=banner, + ) + templates[slug] = t + + # Generate badges for checked-in attendees using the default template + service = BadgeGenerationService() + default_template = templates["default"] + speaker_template = templates["speaker"] + + attendees = list( + Attendee.objects.filter(conference=conference) + .select_related("user", "conference", "order") + .order_by("created_at") + ) + + # Identify speakers by user + speaker_user_ids = set( + Speaker.objects.filter(conference=conference, user__isnull=False).values_list("user_id", flat=True) + ) + + for attendee in attendees: + # Skip ~20% to simulate not everyone having a badge yet + if self.rng.random() < 0.2: + continue + + template = speaker_template if attendee.user_id in speaker_user_ids else default_template + + # Mix of PDF and PNG + fmt = Badge.Format.PNG if self.rng.random() < 0.3 else Badge.Format.PDF + + with contextlib.suppress(Exception): + service.generate_or_get_badge(attendee, template=template, badge_format=fmt) if __name__ == "__main__": diff --git a/src/django_program/conference/management/commands/setup_groups.py b/src/django_program/conference/management/commands/setup_groups.py index 34041a0..fa58109 100644 --- a/src/django_program/conference/management/commands/setup_groups.py +++ b/src/django_program/conference/management/commands/setup_groups.py @@ -141,6 +141,8 @@ def handle(self, *args: Any, **options: Any) -> None: *args: Positional arguments (unused). **options: Parsed command-line options. """ + verbosity = options.get("verbosity", 1) + for group_name, perm_specs in _GROUP_PERMISSIONS.items(): group, created = Group.objects.get_or_create(name=group_name) verb = "Created" if created else "Updated" @@ -151,6 +153,8 @@ def handle(self, *args: Any, **options: Any) -> None: matched = [p for p in permissions if (p.content_type.app_label, p.codename) in perm_specs] group.permissions.set(matched) - self.stdout.write(self.style.SUCCESS(f" {verb} group '{group_name}' with {len(matched)} permissions")) + if verbosity > 0: + self.stdout.write(self.style.SUCCESS(f" {verb} group '{group_name}' with {len(matched)} permissions")) - self.stdout.write(self.style.SUCCESS("\nDone.")) + if verbosity > 0: + self.stdout.write(self.style.SUCCESS("\nDone.")) diff --git a/src/django_program/conference/migrations/0009_featureflags_visa_letters_enabled.py b/src/django_program/conference/migrations/0009_featureflags_visa_letters_enabled.py new file mode 100644 index 0000000..b32fabc --- /dev/null +++ b/src/django_program/conference/migrations/0009_featureflags_visa_letters_enabled.py @@ -0,0 +1,17 @@ +# Generated by Django 5.2.11 on 2026-03-19 17:50 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("program_conference", "0008_kpitargets"), + ] + + operations = [ + migrations.AddField( + model_name="featureflags", + name="visa_letters_enabled", + field=models.BooleanField(blank=True, help_text="Override visa invitation letters toggle.", null=True), + ), + ] diff --git a/src/django_program/conference/models.py b/src/django_program/conference/models.py index a3270a8..80f2d71 100644 --- a/src/django_program/conference/models.py +++ b/src/django_program/conference/models.py @@ -131,6 +131,11 @@ class FeatureFlags(models.Model): blank=True, help_text="Override Pretalx sync toggle.", ) + visa_letters_enabled = models.BooleanField( + null=True, + blank=True, + help_text="Override visa invitation letters toggle.", + ) public_ui_enabled = models.BooleanField( null=True, blank=True, diff --git a/src/django_program/manage/reports.py b/src/django_program/manage/reports.py index 84b0534..8038f4a 100644 --- a/src/django_program/manage/reports.py +++ b/src/django_program/manage/reports.py @@ -21,6 +21,7 @@ from django_program.pretalx.models import Room, ScheduleSlot, Speaker, Talk from django_program.programs.models import Activity, ActivitySignup, TravelGrant +from django_program.registration.letter import LetterRequest from django_program.registration.models import ( AddOn, Attendee, @@ -1452,3 +1453,55 @@ def get_content_analytics(conference: Conference) -> dict[str, Any]: "total_schedule_slots": total_schedule_slots, "slot_types": slot_types, } + + +def get_letter_request_summary(conference: Conference) -> dict[str, Any]: + """Return summary statistics for visa invitation letter requests. + + Aggregates letter requests by status, top nationalities, average + processing time, and completion rate for the given conference. + + Args: + conference: The conference to scope the query to. + + Returns: + A dict with total, by_status, by_nationality (top 10), + avg_processing_days, pending_count, and completion_rate. + """ + qs = LetterRequest.objects.filter(conference=conference) + + total = qs.count() + + # Count by status + by_status: dict[str, int] = {} + for row in qs.values("status").annotate(count=Count("id")): + by_status[row["status"]] = row["count"] + + # Top 10 nationalities + by_nationality = list(qs.values("nationality").annotate(count=Count("id")).order_by("-count")[:10]) + + # Average processing days (created_at -> reviewed_at) for reviewed requests + reviewed = qs.filter(reviewed_at__isnull=False) + avg_processing_agg = reviewed.aggregate( + avg_days=Avg(F("reviewed_at") - F("created_at")), + ) + avg_td = avg_processing_agg["avg_days"] + avg_processing_days: float | None = avg_td.total_seconds() / 86400 if avg_td else None + + # Pending = SUBMITTED + UNDER_REVIEW + pending_count = qs.filter( + status__in=[LetterRequest.Status.SUBMITTED, LetterRequest.Status.UNDER_REVIEW], + ).count() + + # Completion rate = percentage that reached SENT + sent_count = by_status.get(LetterRequest.Status.SENT, 0) + completion_rate = (sent_count / total * 100) if total else 0.0 + + return { + "total": total, + "by_status": by_status, + "by_nationality": by_nationality, + "avg_processing_days": avg_processing_days, + "pending_count": pending_count, + "completion_rate": completion_rate, + } diff --git a/src/django_program/manage/templates/django_program/manage/base.html b/src/django_program/manage/templates/django_program/manage/base.html index 31872e6..7b7f237 100644 --- a/src/django_program/manage/templates/django_program/manage/base.html +++ b/src/django_program/manage/templates/django_program/manage/base.html @@ -161,6 +161,22 @@ margin-bottom: 0.4rem; } + .sidebar-sublabel { + font-size: 0.62rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--color-text-muted); + padding: 0.5rem 0.75rem 0.2rem; + opacity: 0.7; + } + + .sidebar-utility-separator { + border: none; + border-top: 1px dashed var(--color-border); + margin: 0.75rem 0.75rem 0; + } + .sidebar-nav { list-style: none; } @@ -982,6 +998,7 @@
{% if conference %}
diff --git a/src/django_program/manage/templates/django_program/manage/letter_request_list.html b/src/django_program/manage/templates/django_program/manage/letter_request_list.html new file mode 100644 index 0000000..c5462d3 --- /dev/null +++ b/src/django_program/manage/templates/django_program/manage/letter_request_list.html @@ -0,0 +1,150 @@ +{% extends "django_program/manage/base.html" %} + +{% block title %}Visa Letter Requests{% endblock %} + +{% block page_title %} +

Visa Letter Requests

+

Review and manage invitation letter requests

+{% endblock %} + +{% block page_actions %} +{% if status_counts.approved %} +
+ {% csrf_token %} + +
+{% endif %} +{% endblock %} + +{% block content %} +{% if total_count %} + +
+
+ +
{{ total_count }}
+
Total Requests
+
+
+
+ +
{{ status_counts.submitted }}
+
Submitted
+
+
+
+ +
{{ status_counts.under_review }}
+
Under Review
+
+
+
+ +
{{ status_counts.approved }}
+
Approved
+
+
+
+ +
{{ status_counts.generated }}
+
Generated
+
+
+
+ +
{{ status_counts.sent }}
+
Sent
+
+
+
+ +
{{ status_counts.rejected }}
+
Rejected
+
+
+
+ +
+
+ + {% if current_status %} + Clear filter + {% endif %} +
+
+{% endif %} + +{% if letter_requests %} + + + + + + + + + + + + + + {% for lr in letter_requests %} + + + + + + + + + + {% endfor %} + +
NameNationalityTravel DatesStatusSubmittedReviewerActions
+ {{ lr.passport_name }} + {% if lr.user.email %}
{{ lr.user.email }}{% endif %} +
{{ lr.nationality }}{{ lr.travel_from|date:"M j" }} – {{ lr.travel_until|date:"M j, Y" }} + {% if lr.status == "submitted" %} + Submitted + {% elif lr.status == "under_review" %} + Under Review + {% elif lr.status == "approved" %} + Approved + {% elif lr.status == "generated" %} + Generated + {% elif lr.status == "sent" %} + Sent + {% elif lr.status == "rejected" %} + Rejected + {% endif %} + {{ lr.created_at|date:"N j, Y" }} + {% if lr.reviewed_by %} + {{ lr.reviewed_by.get_full_name|default:lr.reviewed_by.username }} + {% if lr.reviewed_at %}
{{ lr.reviewed_at|date:"N j" }}{% endif %} + {% else %} + -- + {% endif %} +
+ Review +
+ +{% include "django_program/manage/_pagination.html" %} +{% else %} +
+ {% if current_status %} +

No letter requests with status "{{ current_status }}".

+

Show all

+ {% else %} +

No visa letter requests yet.

+ {% endif %} +
+{% endif %} +{% endblock %} diff --git a/src/django_program/manage/templates/django_program/manage/letter_request_review.html b/src/django_program/manage/templates/django_program/manage/letter_request_review.html new file mode 100644 index 0000000..10dd2a3 --- /dev/null +++ b/src/django_program/manage/templates/django_program/manage/letter_request_review.html @@ -0,0 +1,184 @@ +{% extends "django_program/manage/base.html" %} + +{% block title %}Review Letter Request{% endblock %} + +{% block breadcrumb %} + +{% endblock %} + +{% block page_title %} +

Review Letter Request

+{% endblock %} + +{% block content %} +
+
+
Applicant
+
{{ letter_request.user.get_full_name|default:letter_request.user.username }}
+
+
+
Email
+
{{ letter_request.user.email|default:"--" }}
+
+
+
Status
+
+ {% if letter_request.status == "submitted" %} + Submitted + {% elif letter_request.status == "under_review" %} + Under Review + {% elif letter_request.status == "approved" %} + Approved + {% elif letter_request.status == "generated" %} + Generated + {% elif letter_request.status == "sent" %} + Sent + {% elif letter_request.status == "rejected" %} + Rejected + {% endif %} +
+
+
+
Submitted
+
{{ letter_request.created_at|date:"N j, Y, P" }}
+
+ {% if letter_request.reviewed_by %} +
+
Reviewed By
+
+ {{ letter_request.reviewed_by.get_full_name|default:letter_request.reviewed_by.username }} + {% if letter_request.reviewed_at %}
{{ letter_request.reviewed_at|date:"N j, Y, P" }}{% endif %} +
+
+ {% endif %} + {% if letter_request.sent_at %} +
+
Sent At
+
{{ letter_request.sent_at|date:"N j, Y, P" }}
+
+ {% endif %} +
+ +

Passport Information

+
+
+
Passport Name
+
{{ letter_request.passport_name }}
+
+
+
Passport Number
+
{{ letter_request.passport_number }}
+
+
+
Nationality
+
{{ letter_request.nationality }}
+
+ {% if letter_request.date_of_birth %} +
+
Date of Birth
+
{{ letter_request.date_of_birth|date:"M j, Y" }}
+
+ {% endif %} +
+ +

Travel Details

+
+
+
Travel From
+
{{ letter_request.travel_from|date:"M j, Y" }}
+
+
+
Travel Until
+
{{ letter_request.travel_until|date:"M j, Y" }}
+
+
+
Destination Address
+
{{ letter_request.destination_address }}
+
+ {% if letter_request.embassy_name %} +
+
Embassy / Consulate
+
{{ letter_request.embassy_name }}
+
+ {% endif %} +
+ +{% if letter_request.status == "rejected" and letter_request.rejection_reason %} +

Rejection Reason

+
+ {{ letter_request.rejection_reason }} +
+{% endif %} + +{% if can_download %} +

Generated Letter

+
+ +
+{% endif %} + +{% if can_approve or can_reject or can_generate or can_send or can_download %} +

Actions

+
+ + {% if letter_request.status == "submitted" %} +
+ {% csrf_token %} + + +
+ {% endif %} + + {% if can_approve %} +
+ {% csrf_token %} + + +
+ {% endif %} + + {% if can_generate %} +
+ {% csrf_token %} + +
+ {% endif %} + + {% if can_send %} +
+ {% csrf_token %} + +
+ {% endif %} + + {% if can_download %} + Download PDF + {% endif %} + + {% if can_reject %} +
+
+ {% csrf_token %} + +
+ + +
+ +
+
+ {% endif %} + +
+{% endif %} + +
+ Back to Letter Requests +
+{% endblock %} diff --git a/src/django_program/manage/templates/django_program/manage/report_visa_letters.html b/src/django_program/manage/templates/django_program/manage/report_visa_letters.html new file mode 100644 index 0000000..ec70ae9 --- /dev/null +++ b/src/django_program/manage/templates/django_program/manage/report_visa_letters.html @@ -0,0 +1,291 @@ +{% extends "django_program/manage/base.html" %} + +{% block title %}Visa Letters Report{% endblock %} + +{% block breadcrumb %} + +{% endblock %} + +{% block page_title %} +

Visa Invitation Letters

+

Letter request statistics and status breakdown for {{ conference.name }}

+{% endblock %} + +{% block page_actions %} +Export CSV +{% endblock %} + +{% block content %} + +{% include "django_program/manage/charts/_chart_utils.html" %} + +
+
+
{{ letter_summary.total }}
+
Total Requests
+
+
+
{{ letter_summary.pending_count }}
+
Pending Review
+
+
+
{{ letter_summary.completion_rate|floatformat:1 }}%
+
Completion Rate
+
+
+
{% if letter_summary.avg_processing_days is not None %}{{ letter_summary.avg_processing_days|floatformat:1 }}{% else %}--{% endif %}
+
Avg Processing Days
+
+
+ +
+
+
+
Status Breakdown
+
Requests by workflow status
+
+
+ +
+
+
+ +
+
+
Top Nationalities
+
Requests by nationality (top 10)
+
+
+ +
+
+
+
+ +{% if letter_requests %} + + + + + + + + + + + + + + {% for lr in letter_requests %} + + + + + + + + + + {% endfor %} + +
Passport NameNationalityTravel DatesEmbassyStatusSubmittedReviewed By
+ {{ lr.passport_name }} + {% if lr.user.email %}
{{ lr.user.email }}{% endif %} +
{{ lr.nationality }}{{ lr.travel_from|date:"M j" }} – {{ lr.travel_until|date:"M j, Y" }}{{ lr.embassy_name|default:"--" }} + {% if lr.status == "submitted" %} + Submitted + {% elif lr.status == "under_review" %} + Under Review + {% elif lr.status == "approved" %} + Approved + {% elif lr.status == "generated" %} + Generated + {% elif lr.status == "sent" %} + Sent + {% elif lr.status == "rejected" %} + Rejected + {% endif %} + {{ lr.created_at|date:"N j, Y" }} + {% if lr.reviewed_by %} + {{ lr.reviewed_by.get_full_name|default:lr.reviewed_by.username }} + {% if lr.reviewed_at %}
{{ lr.reviewed_at|date:"N j" }}{% endif %} + {% else %} + -- + {% endif %} +
+{% else %} +
+

No visa letter requests for this conference.

+
+{% endif %} + + + +{% endblock %} diff --git a/src/django_program/manage/templates/django_program/manage/reports_dashboard.html b/src/django_program/manage/templates/django_program/manage/reports_dashboard.html index 01fefe6..f3dbd7b 100644 --- a/src/django_program/manage/templates/django_program/manage/reports_dashboard.html +++ b/src/django_program/manage/templates/django_program/manage/reports_dashboard.html @@ -18,17 +18,17 @@

Reports

{% block content %} -
- -
+ +
Total Revenue (30d)
-
${{ recent_sales_total }}
+
${{ recent_sales_total|floatformat:2 }}
- -
+
Attendees
@@ -36,8 +36,8 @@

Reports

{% if attendee_summary.checked_in %}
{{ attendee_summary.checked_in }} checked in
{% endif %}
- -
+
Vouchers
@@ -45,8 +45,8 @@

Reports

{% if voucher_summary.used %}
{{ voucher_summary.used }} redeemed
{% endif %}
- -
+
Speakers
@@ -144,6 +144,13 @@

Available Reports

Daily flow of new registrations and cancellations with net change tracking.
+ +
+
Visa & Invitation Letters
+
Letter request status, nationality breakdown, processing times, and completion rates.
+ {% if letter_pending_count %}
{{ letter_pending_count }} pending review
{% endif %} +
+
{% endblock %} diff --git a/src/django_program/manage/urls.py b/src/django_program/manage/urls.py index a39512b..f312e23 100644 --- a/src/django_program/manage/urls.py +++ b/src/django_program/manage/urls.py @@ -86,6 +86,14 @@ VoucherListView, ) from django_program.manage.views_checkin import CheckInDashboardView, CheckInScannerView +from django_program.manage.views_letters import ( + LetterRequestBulkGenerateView, + LetterRequestDownloadView, + LetterRequestGenerateView, + LetterRequestListView, + LetterRequestReviewView, + LetterRequestSendView, +) from django_program.manage.views_terminal import TerminalPOSView app_name = "manage" @@ -279,6 +287,29 @@ path("/checkin/scanner/", CheckInScannerView.as_view(), name="checkin-scanner"), # --- Terminal POS --- path("/terminal/", TerminalPOSView.as_view(), name="terminal-pos"), + # --- Visa/Invitation Letters --- + path("/letters/", LetterRequestListView.as_view(), name="letter-list"), + path("/letters//", LetterRequestReviewView.as_view(), name="letter-review"), + path( + "/letters//generate/", + LetterRequestGenerateView.as_view(), + name="letter-generate", + ), + path( + "/letters//send/", + LetterRequestSendView.as_view(), + name="letter-send", + ), + path( + "/letters//download/", + LetterRequestDownloadView.as_view(), + name="letter-download", + ), + path( + "/letters/bulk-generate/", + LetterRequestBulkGenerateView.as_view(), + name="letter-bulk-generate", + ), # --- Bulk Purchases --- path("/bulk-purchases/", include("django_program.manage.urls_bulk_purchases")), # --- Voucher Bulk Generation --- diff --git a/src/django_program/manage/urls_reports.py b/src/django_program/manage/urls_reports.py index ea254a9..88ff9fb 100644 --- a/src/django_program/manage/urls_reports.py +++ b/src/django_program/manage/urls_reports.py @@ -20,6 +20,8 @@ SalesByDateView, SpeakerRegistrationExportView, SpeakerRegistrationView, + VisaLetterExportView, + VisaLetterReportView, VoucherUsageExportView, VoucherUsageReportView, ) @@ -44,4 +46,6 @@ path("reconciliation/export/", ReconciliationExportView.as_view(), name="report-reconciliation-export"), path("flow/", RegistrationFlowView.as_view(), name="report-registration-flow"), path("flow/export/", RegistrationFlowExportView.as_view(), name="report-registration-flow-export"), + path("visa-letters/", VisaLetterReportView.as_view(), name="report-visa-letters"), + path("visa-letters/export/", VisaLetterExportView.as_view(), name="report-visa-letters-export"), ] diff --git a/src/django_program/manage/views.py b/src/django_program/manage/views.py index bca228e..156e9b8 100644 --- a/src/django_program/manage/views.py +++ b/src/django_program/manage/views.py @@ -75,6 +75,7 @@ SpeakerCondition, TimeOrStockLimitCondition, ) +from django_program.registration.letter import LetterRequest from django_program.registration.models import AddOn, Attendee, Credit, Order, Payment, TicketType, Voucher from django_program.registration.services.badge import BadgeGenerationService from django_program.registration.services.capacity import get_global_sold_count @@ -765,6 +766,11 @@ def get_context_data(self, **kwargs: object) -> dict[str, object]: "vouchers": Voucher.objects.filter(conference=conference).count(), "orders": Order.objects.filter(conference=conference).count(), "paid_orders": Order.objects.filter(conference=conference, status=Order.Status.PAID).count(), + "visa_letters": LetterRequest.objects.filter(conference=conference).count(), + "visa_letters_pending": LetterRequest.objects.filter( + conference=conference, + status__in=[LetterRequest.Status.SUBMITTED, LetterRequest.Status.UNDER_REVIEW], + ).count(), } budget = _build_dashboard_budget_context(conference) diff --git a/src/django_program/manage/views_letters.py b/src/django_program/manage/views_letters.py new file mode 100644 index 0000000..e270a94 --- /dev/null +++ b/src/django_program/manage/views_letters.py @@ -0,0 +1,336 @@ +"""Visa & Invitation Letter management views for the manage app. + +Provides the staff-facing interface for reviewing, approving, generating, +and sending invitation letters for conference attendees who need visa +support documentation. +""" + +import logging + +from django.contrib import messages +from django.db.models import Count, QuerySet +from django.http import HttpRequest, HttpResponse +from django.shortcuts import get_object_or_404, redirect +from django.urls import reverse +from django.utils import timezone +from django.views import View +from django.views.generic import DetailView, ListView + +from django_program.manage.views import ManagePermissionMixin +from django_program.registration.letter import LetterRequest +from django_program.registration.services.letters import generate_invitation_letter, send_invitation_letter + +logger = logging.getLogger(__name__) + + +class LetterRequestListView(ManagePermissionMixin, ListView): + """Staff list of all letter requests for a conference. + + Supports filtering by status via the ``?status=`` query parameter and + provides aggregate status counts for the sidebar/header summary. + """ + + template_name = "django_program/manage/letter_request_list.html" + context_object_name = "letter_requests" + paginate_by = 50 + + def get_queryset(self) -> QuerySet[LetterRequest]: + """Return letter requests for the current conference, optionally filtered by status. + + Returns: + Queryset of letter requests ordered by creation date descending. + """ + qs = ( + LetterRequest.objects.filter(conference=self.conference) + .select_related("user", "attendee", "reviewed_by") + .order_by("-created_at") + ) + status = self.request.GET.get("status", "").strip() + if status and status in LetterRequest.Status.values: + qs = qs.filter(status=status) + return qs + + def get_context_data(self, **kwargs: object) -> dict[str, object]: + """Add status filter state and aggregate counts to the template context. + + Args: + **kwargs: Additional context data. + + Returns: + Template context with status counts, active filter, and navigation state. + """ + context = super().get_context_data(**kwargs) + context["active_nav"] = "letters" + context["current_status"] = self.request.GET.get("status", "") + + counts = LetterRequest.objects.filter(conference=self.conference).values("status").annotate(count=Count("id")) + status_counts: dict[str, int] = {s.value: 0 for s in LetterRequest.Status} + for row in counts: + status_counts[row["status"]] = row["count"] + context["status_counts"] = status_counts + context["total_count"] = sum(status_counts.values()) + return context + + +class LetterRequestReviewView(ManagePermissionMixin, DetailView): + """Review a single letter request with status transition actions. + + GET renders the detail page with available actions. POST handles + status transitions: approve, reject (with reason), or mark as + under review. + """ + + template_name = "django_program/manage/letter_request_review.html" + context_object_name = "letter_request" + + def get_queryset(self) -> QuerySet[LetterRequest]: + """Scope to the current conference with related objects. + + Returns: + Queryset of letter requests for the active conference. + """ + return LetterRequest.objects.filter(conference=self.conference).select_related( + "user", "attendee", "reviewed_by" + ) + + def get_context_data(self, **kwargs: object) -> dict[str, object]: + """Add navigation state and available actions to context. + + Args: + **kwargs: Additional context data. + + Returns: + Template context with letter request detail and action flags. + """ + context = super().get_context_data(**kwargs) + context["active_nav"] = "letters" + lr: LetterRequest = self.object # type: ignore[assignment] + context["can_approve"] = lr.status in ( + LetterRequest.Status.SUBMITTED, + LetterRequest.Status.UNDER_REVIEW, + ) + context["can_reject"] = lr.status in ( + LetterRequest.Status.SUBMITTED, + LetterRequest.Status.UNDER_REVIEW, + ) + context["can_generate"] = lr.status == LetterRequest.Status.APPROVED + context["can_send"] = lr.status == LetterRequest.Status.GENERATED + context["can_download"] = bool(lr.generated_pdf) + return context + + def post(self, request: HttpRequest, **kwargs: str) -> HttpResponse: # noqa: ARG002 + """Handle status transition actions on a letter request. + + Supported actions via the ``action`` POST parameter: + - ``under_review``: Move to under-review status. + - ``approve``: Approve the request and record reviewer. + - ``reject``: Reject the request with a reason and record reviewer. + + Args: + request: The incoming HTTP request. + **kwargs: URL keyword arguments including ``pk``. + + Returns: + A redirect back to the review page. + """ + letter_request = self.get_object() + action = request.POST.get("action", "") + review_url = reverse( + "manage:letter-review", + kwargs={"conference_slug": self.conference.slug, "pk": letter_request.pk}, + ) + + action_map = { + "under_review": LetterRequest.Status.UNDER_REVIEW, + "approve": LetterRequest.Status.APPROVED, + "reject": LetterRequest.Status.REJECTED, + } + target_status = action_map.get(action) + if target_status is None: + messages.error(request, "Invalid action or status transition.") + return redirect(review_url) + + if action == "reject": + reason = request.POST.get("rejection_reason", "").strip() + if not reason: + messages.error(request, "A rejection reason is required.") + return redirect(review_url) + + try: + letter_request.transition_to(target_status) + except ValueError: + messages.error(request, "Invalid action or status transition.") + return redirect(review_url) + + if action in ("approve", "reject"): + letter_request.reviewed_by = request.user + letter_request.reviewed_at = timezone.now() + + update_fields = ["status", "updated_at"] + if action == "reject": + letter_request.rejection_reason = reason + update_fields.extend(["rejection_reason", "reviewed_by", "reviewed_at"]) + elif action == "approve": + update_fields.extend(["reviewed_by", "reviewed_at"]) + + letter_request.save(update_fields=update_fields) + + action_labels = { + "under_review": "Letter request marked as under review.", + "approve": "Letter request approved.", + "reject": "Letter request rejected.", + } + messages.success(request, action_labels[action]) + + return redirect(review_url) + + +class LetterRequestGenerateView(ManagePermissionMixin, View): + """Generate an invitation letter PDF for an approved request. + + POST-only. Calls the letter generation service and redirects back + to the review page. + """ + + def post(self, request: HttpRequest, **kwargs: str) -> HttpResponse: + """Generate the PDF for an approved letter request. + + Args: + request: The incoming HTTP request. + **kwargs: URL keyword arguments including ``pk``. + + Returns: + A redirect to the letter review page. + """ + letter_request = get_object_or_404(LetterRequest, pk=kwargs["pk"], conference=self.conference) + review_url = reverse( + "manage:letter-review", + kwargs={"conference_slug": self.conference.slug, "pk": letter_request.pk}, + ) + + if letter_request.status != LetterRequest.Status.APPROVED: + messages.error(request, "Only approved requests can have PDFs generated.") + return redirect(review_url) + + try: + generate_invitation_letter(letter_request) + messages.success(request, "Invitation letter PDF generated successfully.") + except Exception: + logger.exception("Failed to generate invitation letter for request %s", letter_request.pk) + messages.error(request, "Failed to generate the invitation letter PDF.") + + return redirect(review_url) + + +class LetterRequestBulkGenerateView(ManagePermissionMixin, View): + """Bulk-generate invitation letter PDFs for all approved requests. + + POST-only. Generates PDFs for every letter request in the conference + that has ``APPROVED`` status, then redirects to the list view with a + summary message. + """ + + def post(self, request: HttpRequest, **kwargs: str) -> HttpResponse: # noqa: ARG002 + """Generate PDFs for all approved letter requests in the conference. + + Args: + request: The incoming HTTP request. + **kwargs: URL keyword arguments. + + Returns: + A redirect to the letter request list. + """ + approved = LetterRequest.objects.filter(conference=self.conference, status=LetterRequest.Status.APPROVED) + generated = 0 + failed = 0 + for letter_request in approved: + try: + generate_invitation_letter(letter_request) + generated += 1 + except Exception: + logger.exception("Failed to generate letter for request %s", letter_request.pk) + failed += 1 + + if generated: + messages.success(request, f"Generated {generated} invitation letter(s).") + if failed: + messages.error(request, f"Failed to generate {failed} letter(s). Check logs for details.") + if not generated and not failed: + messages.info(request, "No approved letter requests to generate.") + + return redirect(reverse("manage:letter-list", kwargs={"conference_slug": self.conference.slug})) + + +class LetterRequestSendView(ManagePermissionMixin, View): + """Send a generated invitation letter to the requester. + + POST-only. Calls the letter sending service, updates the sent + timestamp, and redirects back to the review page. + """ + + def post(self, request: HttpRequest, **kwargs: str) -> HttpResponse: + """Send the invitation letter for a generated request. + + Args: + request: The incoming HTTP request. + **kwargs: URL keyword arguments including ``pk``. + + Returns: + A redirect to the letter review page. + """ + letter_request = get_object_or_404(LetterRequest, pk=kwargs["pk"], conference=self.conference) + review_url = reverse( + "manage:letter-review", + kwargs={"conference_slug": self.conference.slug, "pk": letter_request.pk}, + ) + + if letter_request.status != LetterRequest.Status.GENERATED: + messages.error(request, "Only generated letters can be sent.") + return redirect(review_url) + + try: + send_invitation_letter(letter_request) + messages.success(request, "Invitation letter sent successfully.") + except Exception: + logger.exception("Failed to send invitation letter for request %s", letter_request.pk) + messages.error(request, "Failed to send the invitation letter.") + + return redirect(review_url) + + +class LetterRequestDownloadView(ManagePermissionMixin, View): + """Download the generated PDF for a letter request. + + GET-only. Returns the PDF file as an attachment response. Only works + if a generated PDF exists on the letter request. + """ + + def get(self, request: HttpRequest, **kwargs: str) -> HttpResponse: + """Return the generated PDF as a downloadable attachment. + + Args: + request: The incoming HTTP request. + **kwargs: URL keyword arguments including ``pk``. + + Returns: + An HTTP response with the PDF content. + """ + letter_request = get_object_or_404( + LetterRequest.objects.select_related("user"), + pk=kwargs["pk"], + conference=self.conference, + ) + list_url = reverse("manage:letter-list", kwargs={"conference_slug": self.conference.slug}) + + if not letter_request.generated_pdf: + messages.error(request, "No PDF has been generated for this request.") + return redirect(list_url) + + username = letter_request.user.username + filename = f"invitation-letter-{username}.pdf" + with letter_request.generated_pdf.open("rb") as pdf_file: + pdf_data = pdf_file.read() + response = HttpResponse(pdf_data, content_type="application/pdf") + disposition = "inline" if request.GET.get("inline") else "attachment" + response["Content-Disposition"] = f'{disposition}; filename="{filename}"' + return response diff --git a/src/django_program/manage/views_reports.py b/src/django_program/manage/views_reports.py index d8e495f..c999cc0 100644 --- a/src/django_program/manage/views_reports.py +++ b/src/django_program/manage/views_reports.py @@ -32,6 +32,7 @@ get_discount_conditions, get_discount_impact, get_discount_summary, + get_letter_request_summary, get_reconciliation, get_refund_metrics, get_registration_flow, @@ -44,6 +45,7 @@ from django_program.manage.views import _safe_csv_cell from django_program.pretalx.models import Speaker from django_program.programs.models import TravelGrant +from django_program.registration.letter import LetterRequest from django_program.registration.models import Attendee, Order, Payment, TicketType _REPORTS_GROUP_NAME = "Program: Reports" @@ -284,6 +286,11 @@ def get_context_data(self, **kwargs: object) -> dict[str, object]: } ) + # Visa letter request summary + letter_summary = get_letter_request_summary(conference) + context["letter_summary"] = letter_summary + context["letter_pending_count"] = letter_summary["pending_count"] + # Budget vs actuals budget = _build_budget_context(conference) if budget: @@ -1091,3 +1098,99 @@ def get(self, request: HttpRequest, **kwargs: str) -> HttpResponse: # noqa: ARG ) return response + + +class VisaLetterReportView(ReportPermissionMixin, TemplateView): + """Visa invitation letter requests report with status breakdown.""" + + template_name = "django_program/manage/report_visa_letters.html" + + def get_context_data(self, **kwargs: object) -> dict[str, object]: + """Build context with letter request data and chart data. + + Args: + **kwargs: Additional context data. + + Returns: + Template context with letter summary, queryset, and chart JSON. + """ + context: dict[str, object] = super().get_context_data(**kwargs) + summary = get_letter_request_summary(self.conference) + context["letter_summary"] = summary + + context["letter_requests"] = ( + LetterRequest.objects.filter(conference=self.conference) + .select_related("user", "reviewed_by") + .order_by("-created_at") + ) + + context["chart_data"] = json.dumps( + { + "by_nationality": [ + {"nationality": row["nationality"], "count": row["count"]} for row in summary["by_nationality"] + ], + "by_status": { + status: summary["by_status"].get(status, 0) for status, _label in LetterRequest.Status.choices + }, + } + ) + + return context + + +class VisaLetterExportView(ReportPermissionMixin, View): + """CSV export of visa invitation letter requests.""" + + def get(self, request: HttpRequest, **kwargs: str) -> HttpResponse: # noqa: ARG002 + """Return a CSV download of all letter requests. + + Args: + request: The incoming HTTP request. + **kwargs: URL keyword arguments. + + Returns: + An HttpResponse with CSV content. + """ + qs = ( + LetterRequest.objects.filter(conference=self.conference) + .select_related("user", "reviewed_by") + .order_by("-created_at") + ) + + response = HttpResponse(content_type="text/csv") + response["Content-Disposition"] = f'attachment; filename="{self.conference.slug}-visa-letters.csv"' + writer = csv.writer(response) + writer.writerow( + [ + "Passport Name", + "Nationality", + "Status", + "Travel From", + "Travel Until", + "Embassy", + "Submitted", + "Reviewed By", + "Reviewed At", + ] + ) + + for lr in qs: + reviewer = "" + if lr.reviewed_by: + reviewer = lr.reviewed_by.get_full_name() or lr.reviewed_by.username + + writer.writerow( + [ + _safe_csv_cell(str(lr.passport_name)), + _safe_csv_cell(str(lr.nationality)), + lr.get_status_display(), + lr.travel_from.isoformat(), + lr.travel_until.isoformat(), + _safe_csv_cell(str(lr.embassy_name)), + lr.created_at.isoformat(), + _safe_csv_cell(reviewer), + lr.reviewed_at.isoformat() if lr.reviewed_at else "", + ] + ) + + return response diff --git a/src/django_program/registration/forms.py b/src/django_program/registration/forms.py index 029e575..f91c3b2 100644 --- a/src/django_program/registration/forms.py +++ b/src/django_program/registration/forms.py @@ -4,6 +4,8 @@ from django import forms +from django_program.registration.letter import LetterRequest + class CartItemForm(forms.Form): """Form for adding an item to the cart. @@ -54,3 +56,41 @@ class RefundForm(forms.Form): min_value=Decimal("0.01"), ) reason = forms.CharField(widget=forms.Textarea, required=False) + + +class LetterRequestForm(forms.ModelForm): + """Form for attendees to request a visa invitation letter. + + Collects passport details, travel dates, and destination information + needed to produce a formal letter for embassy submission. + """ + + class Meta: + model = LetterRequest + fields = [ + "passport_name", + "passport_number", + "nationality", + "date_of_birth", + "travel_from", + "travel_until", + "destination_address", + "embassy_name", + ] + widgets = { + "date_of_birth": forms.DateInput(attrs={"type": "date"}), + "travel_from": forms.DateInput(attrs={"type": "date"}), + "travel_until": forms.DateInput(attrs={"type": "date"}), + "destination_address": forms.Textarea(attrs={"rows": 3}), + } + + def clean(self) -> dict: + """Validate that travel_from is before travel_until.""" + cleaned = super().clean() + travel_from = cleaned.get("travel_from") + travel_until = cleaned.get("travel_until") + + if travel_from and travel_until and travel_from >= travel_until: + raise forms.ValidationError("Travel start date must be before the end date.") + + return cleaned diff --git a/src/django_program/registration/letter.py b/src/django_program/registration/letter.py new file mode 100644 index 0000000..c9974d4 --- /dev/null +++ b/src/django_program/registration/letter.py @@ -0,0 +1,110 @@ +"""Visa invitation letter request model for conference attendees. + +Tracks the lifecycle of invitation letter requests from submission through +review, PDF generation, and delivery. Used by attendees who need a formal +invitation letter for visa applications. +""" + +from django.conf import settings +from django.db import models + + +class LetterRequest(models.Model): + """A request for a visa invitation letter from a conference attendee. + + Captures passport and travel details needed to produce a formal letter + for embassy submission. Progresses through a review workflow from + ``SUBMITTED`` to ``SENT`` (or ``REJECTED``). + """ + + class Status(models.TextChoices): + """Workflow states for a letter request.""" + + SUBMITTED = "submitted", "Submitted" + UNDER_REVIEW = "under_review", "Under Review" + APPROVED = "approved", "Approved" + GENERATED = "generated", "Generated" + SENT = "sent", "Sent" + REJECTED = "rejected", "Rejected" + + ALLOWED_TRANSITIONS: dict[str, set[str]] = { + Status.SUBMITTED: {Status.UNDER_REVIEW, Status.APPROVED, Status.REJECTED}, + Status.UNDER_REVIEW: {Status.APPROVED, Status.REJECTED}, + Status.APPROVED: {Status.GENERATED}, + Status.GENERATED: {Status.SENT}, + } + + conference = models.ForeignKey( + "program_conference.Conference", + on_delete=models.CASCADE, + related_name="letter_requests", + ) + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="letter_requests", + ) + attendee = models.ForeignKey( + "program_registration.Attendee", + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="letter_requests", + ) + + passport_name = models.CharField(max_length=300) + passport_number = models.CharField(max_length=50) + nationality = models.CharField(max_length=100) + date_of_birth = models.DateField(null=True, blank=True) + travel_from = models.DateField() + travel_until = models.DateField() + destination_address = models.TextField() + embassy_name = models.CharField(max_length=300, blank=True, default="") + + status = models.CharField( + max_length=20, + choices=Status.choices, + default=Status.SUBMITTED, + ) + rejection_reason = models.TextField(blank=True, default="") + generated_pdf = models.FileField(upload_to="letters/", blank=True, null=True) + + reviewed_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="reviewed_letter_requests", + ) + reviewed_at = models.DateTimeField(null=True, blank=True) + sent_at = models.DateTimeField(null=True, blank=True) + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + ordering = ["-created_at"] + unique_together = [("user", "conference")] + + def __str__(self) -> str: + return f"{self.passport_name} — {self.conference} ({self.get_status_display()})" + + def transition_to(self, new_status: str) -> None: + """Transition this request to a new workflow status. + + Validates that the transition is allowed before applying it. + + Args: + new_status: The target status value (one of ``Status`` choices). + + Raises: + ValueError: If the transition from the current status to + ``new_status`` is not permitted. + """ + allowed = self.ALLOWED_TRANSITIONS.get(str(self.status), set()) + if new_status not in allowed: + msg = ( + f"Cannot transition from '{self.get_status_display()}' to '{new_status}'. Allowed: {allowed or 'none'}" + ) + raise ValueError(msg) + self.status = new_status diff --git a/src/django_program/registration/migrations/0019_add_letter_request.py b/src/django_program/registration/migrations/0019_add_letter_request.py new file mode 100644 index 0000000..7529f3b --- /dev/null +++ b/src/django_program/registration/migrations/0019_add_letter_request.py @@ -0,0 +1,91 @@ +# Generated by Django 5.2.11 on 2026-03-19 17:31 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("program_conference", "0008_kpitargets"), + ("program_registration", "0018_alter_payment_method_terminalpayment"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="LetterRequest", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("passport_name", models.CharField(max_length=300)), + ("passport_number", models.CharField(max_length=50)), + ("nationality", models.CharField(max_length=100)), + ("date_of_birth", models.DateField(blank=True, null=True)), + ("travel_from", models.DateField()), + ("travel_until", models.DateField()), + ("destination_address", models.TextField()), + ("embassy_name", models.CharField(blank=True, default="", max_length=300)), + ( + "status", + models.CharField( + choices=[ + ("submitted", "Submitted"), + ("under_review", "Under Review"), + ("approved", "Approved"), + ("generated", "Generated"), + ("sent", "Sent"), + ("rejected", "Rejected"), + ], + default="submitted", + max_length=20, + ), + ), + ("rejection_reason", models.TextField(blank=True, default="")), + ("generated_pdf", models.FileField(blank=True, null=True, upload_to="letters/")), + ("reviewed_at", models.DateTimeField(blank=True, null=True)), + ("sent_at", models.DateTimeField(blank=True, null=True)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "attendee", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="letter_requests", + to="program_registration.attendee", + ), + ), + ( + "conference", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="letter_requests", + to="program_conference.conference", + ), + ), + ( + "reviewed_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="reviewed_letter_requests", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="letter_requests", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "ordering": ["-created_at"], + "unique_together": {("user", "conference")}, + }, + ), + ] diff --git a/src/django_program/registration/models.py b/src/django_program/registration/models.py index 70eba22..a25bd36 100644 --- a/src/django_program/registration/models.py +++ b/src/django_program/registration/models.py @@ -694,6 +694,7 @@ def __str__(self) -> str: SpeakerCondition, TimeOrStockLimitCondition, ) +from django_program.registration.letter import LetterRequest # noqa: E402 from django_program.registration.terminal import TerminalPayment # noqa: E402 __all__ = [ @@ -712,6 +713,7 @@ def __str__(self) -> str: "EventProcessingException", "GroupMemberCondition", "IncludedProductCondition", + "LetterRequest", "Order", "OrderLineItem", "Payment", diff --git a/src/django_program/registration/services/letters.py b/src/django_program/registration/services/letters.py new file mode 100644 index 0000000..dc46bc4 --- /dev/null +++ b/src/django_program/registration/services/letters.py @@ -0,0 +1,295 @@ +"""Visa invitation letter PDF generation and delivery service. + +Uses reportlab to produce formal invitation letters suitable for embassy +submission, embedding conference details and attendee travel information. +""" + +import io +import logging +from typing import TYPE_CHECKING + +from django.core.files.base import ContentFile +from django.utils import timezone + +if TYPE_CHECKING: + from django_program.registration.letter import LetterRequest + +logger = logging.getLogger(__name__) + + +def generate_invitation_letter(letter_request: LetterRequest) -> bytes: + """Generate a formal invitation letter PDF for a visa application. + + Produces a professional letter with conference letterhead, attendee + passport details, and travel dates. Saves the PDF to the + ``generated_pdf`` field and transitions the request to ``GENERATED``. + + Args: + letter_request: The letter request containing attendee and travel info. + + Returns: + The raw PDF bytes. + """ + from reportlab.lib.pagesizes import A4 # noqa: PLC0415 + from reportlab.lib.units import mm # noqa: PLC0415 + from reportlab.pdfgen import canvas # noqa: PLC0415 + + buf = io.BytesIO() + width, height = A4 + c = canvas.Canvas(buf, pagesize=A4) + margin = 25 * mm + usable_width = width - 2 * margin + + y = _draw_letterhead(c, letter_request.conference, margin, height - margin, width) + y = _draw_body(c, letter_request, margin, y, usable_width) + y = _draw_attendee_details(c, letter_request, margin, y) + _draw_closing(c, letter_request.conference, margin, y) + + c.showPage() + c.save() + pdf_bytes = buf.getvalue() + + filename = f"letter-{letter_request.pk}.pdf" + letter_request.generated_pdf.save(filename, ContentFile(pdf_bytes), save=False) + letter_request.transition_to(letter_request.Status.GENERATED) + letter_request.save(update_fields=["status", "generated_pdf", "updated_at"]) + + logger.info("Generated invitation letter PDF for request %s", letter_request.pk) + return pdf_bytes + + +def send_invitation_letter(letter_request: LetterRequest) -> None: + """Mark an invitation letter as sent. + + This is a stub for future email delivery integration. Currently it + only transitions the request status to ``SENT`` and records the + timestamp. + + Args: + letter_request: The letter request to mark as sent. Must be in + ``GENERATED`` status. + """ + letter_request.transition_to(letter_request.Status.SENT) + letter_request.sent_at = timezone.now() + letter_request.save(update_fields=["status", "sent_at", "updated_at"]) + logger.info("Marked invitation letter %s as sent", letter_request.pk) + + +def _draw_letterhead(c: object, conference: object, margin: float, y: float, width: float) -> float: + """Draw conference letterhead at the top of the page. + + Args: + c: The reportlab Canvas instance. + conference: The conference model instance. + margin: Left margin in points. + y: Current y position. + width: Page width in points. + + Returns: + Updated y position after the letterhead. + """ + from reportlab.lib.units import mm # noqa: PLC0415 + + c.setFont("Helvetica-Bold", 16) # type: ignore[attr-defined] + c.drawString(margin, y, str(conference.name)) # type: ignore[attr-defined] + y -= 7 * mm + + if conference.venue or conference.address: + c.setFont("Helvetica", 10) # type: ignore[attr-defined] + venue_line = ", ".join(filter(None, [str(conference.venue), str(conference.address)])) + c.drawString(margin, y, venue_line) # type: ignore[attr-defined] + y -= 5 * mm + + if conference.website_url: + c.setFont("Helvetica", 9) # type: ignore[attr-defined] + c.drawString(margin, y, str(conference.website_url)) # type: ignore[attr-defined] + y -= 5 * mm + + y -= 5 * mm + c.setStrokeColorRGB(0.7, 0.7, 0.7) # type: ignore[attr-defined] + c.line(margin, y, width - margin, y) # type: ignore[attr-defined] + y -= 12 * mm + + c.setFont("Helvetica", 10) # type: ignore[attr-defined] + today_str = timezone.now().strftime("%B %d, %Y") + c.drawString(margin, y, today_str) # type: ignore[attr-defined] + y -= 12 * mm + + return y + + +def _draw_body(c: object, letter_request: LetterRequest, margin: float, y: float, usable_width: float) -> float: + """Draw the letter title, greeting, and body paragraphs. + + Args: + c: The reportlab Canvas instance. + letter_request: The letter request with conference and travel details. + margin: Left margin in points. + y: Current y position. + usable_width: Available text width in points. + + Returns: + Updated y position after the body text. + """ + from reportlab.lib.units import mm # noqa: PLC0415 + + c.setFont("Helvetica-Bold", 14) # type: ignore[attr-defined] + c.drawString(margin, y, "Visa Invitation Letter") # type: ignore[attr-defined] + y -= 10 * mm + + c.setFont("Helvetica", 11) # type: ignore[attr-defined] + y -= 4 * mm + c.drawString(margin, y, "To Whom It May Concern,") # type: ignore[attr-defined] + y -= 10 * mm + + body_lines = _build_body_text(letter_request) + c.setFont("Helvetica", 11) # type: ignore[attr-defined] + line_height = 5 * mm + + for line in body_lines: + wrapped = _wrap_text(c, line, "Helvetica", 11, usable_width) + for segment in wrapped: + c.drawString(margin, y, segment) # type: ignore[attr-defined] + y -= line_height + y -= 2 * mm + + return y + + +def _draw_attendee_details(c: object, letter_request: LetterRequest, margin: float, y: float) -> float: + """Draw the attendee details table section. + + Args: + c: The reportlab Canvas instance. + letter_request: The letter request with passport and travel info. + margin: Left margin in points. + y: Current y position. + + Returns: + Updated y position after the details table. + """ + from reportlab.lib.units import mm # noqa: PLC0415 + + y -= 6 * mm + c.setFont("Helvetica-Bold", 11) # type: ignore[attr-defined] + c.drawString(margin, y, "Attendee Details:") # type: ignore[attr-defined] + y -= 7 * mm + + details = [ + ("Full Name (as on passport)", str(letter_request.passport_name)), + ("Passport Number", str(letter_request.passport_number)), + ("Nationality", str(letter_request.nationality)), + ] + if letter_request.date_of_birth: + details.append(("Date of Birth", letter_request.date_of_birth.strftime("%B %d, %Y"))) + details.extend( + [ + ("Travel From", letter_request.travel_from.strftime("%B %d, %Y")), + ("Travel Until", letter_request.travel_until.strftime("%B %d, %Y")), + ("Destination Address", str(letter_request.destination_address)), + ] + ) + if letter_request.embassy_name: + details.append(("Embassy / Consulate", str(letter_request.embassy_name))) + + for label, value in details: + c.setFont("Helvetica-Bold", 10) # type: ignore[attr-defined] + c.drawString(margin + 5 * mm, y, f"{label}:") # type: ignore[attr-defined] + c.setFont("Helvetica", 10) # type: ignore[attr-defined] + c.drawString(margin + 65 * mm, y, value) # type: ignore[attr-defined] + y -= 6 * mm + + return y + + +def _draw_closing(c: object, conference: object, margin: float, y: float) -> None: + """Draw the closing paragraph, signature line, and organizer title. + + Args: + c: The reportlab Canvas instance. + conference: The conference model instance. + margin: Left margin in points. + y: Current y position. + """ + from reportlab.lib.units import mm # noqa: PLC0415 + + y -= 10 * mm + c.setFont("Helvetica", 11) # type: ignore[attr-defined] + c.drawString(margin, y, "We kindly request that the appropriate visa be granted to the above individual.") # type: ignore[attr-defined] + y -= 8 * mm + c.drawString(margin, y, "Sincerely,") # type: ignore[attr-defined] + y -= 14 * mm + + c.line(margin, y, margin + 60 * mm, y) # type: ignore[attr-defined] + y -= 5 * mm + c.setFont("Helvetica", 10) # type: ignore[attr-defined] + c.drawString(margin, y, f"Conference Organizer, {conference.name}") # type: ignore[attr-defined] + + +def _build_body_text(letter_request: LetterRequest) -> list[str]: + """Build the paragraphs of the invitation letter body. + + Args: + letter_request: The letter request with conference and travel details. + + Returns: + A list of paragraph strings. + """ + conference = letter_request.conference + conf_dates = f"{conference.start_date.strftime('%B %d, %Y')} to {conference.end_date.strftime('%B %d, %Y')}" + venue_info = "" + if conference.venue: + venue_info = f" at {conference.venue}" + if conference.address: + venue_info += f", {conference.address}" + + return [ + ( + f"This letter confirms that {letter_request.passport_name} has been " + f"invited to attend {conference.name}, taking place from " + f"{conf_dates}{venue_info}." + ), + ( + f"The attendee plans to travel from {letter_request.travel_from.strftime('%B %d, %Y')} " + f"to {letter_request.travel_until.strftime('%B %d, %Y')} and will be staying at: " + f"{letter_request.destination_address}." + ), + ( + "We confirm that this individual is a registered participant of our " + "conference and we take full responsibility for verifying their " + "registration status." + ), + ] + + +def _wrap_text(canvas_obj: object, text: str, font: str, size: int, max_width: float) -> list[str]: + """Wrap text to fit within a given width on a reportlab canvas. + + Args: + canvas_obj: The reportlab Canvas instance. + text: The text to wrap. + font: Font name for width calculation. + size: Font size in points. + max_width: Maximum line width in points. + + Returns: + A list of text segments, each fitting within ``max_width``. + """ + words = text.split() + lines: list[str] = [] + current_line = "" + + for word in words: + test_line = f"{current_line} {word}".strip() if current_line else word + tw = canvas_obj.stringWidth(test_line, font, size) # type: ignore[attr-defined] + if tw <= max_width: + current_line = test_line + else: + if current_line: + lines.append(current_line) + current_line = word + + if current_line: + lines.append(current_line) + + return lines or [""] diff --git a/src/django_program/registration/templates/django_program/registration/letter_request_detail.html b/src/django_program/registration/templates/django_program/registration/letter_request_detail.html new file mode 100644 index 0000000..07c78aa --- /dev/null +++ b/src/django_program/registration/templates/django_program/registration/letter_request_detail.html @@ -0,0 +1,86 @@ +{% extends "django_program/base.html" %} + +{% block title %}Visa Letter Request Status{% endblock %} + +{% block content %} + + +{% if letter_request.status == "rejected" and letter_request.rejection_reason %} +
+ Rejection reason: {{ letter_request.rejection_reason }} +
+{% endif %} + +{% if pdf_available %} +
+ Your invitation letter is ready. + Download PDF +
+{% endif %} + +

Passport Information

+
+
+
Full Name
+
{{ letter_request.passport_name }}
+
+
+
Passport Number
+
{{ letter_request.passport_number }}
+
+
+
Nationality
+
{{ letter_request.nationality }}
+
+ {% if letter_request.date_of_birth %} +
+
Date of Birth
+
{{ letter_request.date_of_birth|date:"N j, Y" }}
+
+ {% endif %} +
+ +

Travel Details

+
+
+
Travel From
+
{{ letter_request.travel_from|date:"N j, Y" }}
+
+
+
Travel Until
+
{{ letter_request.travel_until|date:"N j, Y" }}
+
+
+
Destination Address
+
{{ letter_request.destination_address }}
+
+ {% if letter_request.embassy_name %} +
+
Embassy / Consulate
+
{{ letter_request.embassy_name }}
+
+ {% endif %} +
+ +
+ Submitted on {{ letter_request.created_at|date:"N j, Y, g:i A" }} +
+{% endblock %} diff --git a/src/django_program/registration/templates/django_program/registration/letter_request_form.html b/src/django_program/registration/templates/django_program/registration/letter_request_form.html new file mode 100644 index 0000000..55af312 --- /dev/null +++ b/src/django_program/registration/templates/django_program/registration/letter_request_form.html @@ -0,0 +1,108 @@ +{% extends "django_program/base.html" %} + +{% block title %}Request Visa Invitation Letter{% endblock %} + +{% block content %} + + +
+ {% csrf_token %} + + {% if form.non_field_errors %} +
    + {% for error in form.non_field_errors %}
  • {{ error }}
  • {% endfor %} +
+ {% endif %} + +

Passport Information

+ +
+ + {{ form.passport_name }} + {% if form.passport_name.errors %} +
    + {% for error in form.passport_name.errors %}
  • {{ error }}
  • {% endfor %} +
+ {% endif %} +
+ +
+ + {{ form.passport_number }} + {% if form.passport_number.errors %} +
    + {% for error in form.passport_number.errors %}
  • {{ error }}
  • {% endfor %} +
+ {% endif %} +
+ +
+ + {{ form.nationality }} + {% if form.nationality.errors %} +
    + {% for error in form.nationality.errors %}
  • {{ error }}
  • {% endfor %} +
+ {% endif %} +
+ +
+ + {{ form.date_of_birth }} + {% if form.date_of_birth.errors %} +
    + {% for error in form.date_of_birth.errors %}
  • {{ error }}
  • {% endfor %} +
+ {% endif %} +
+ +

Travel Details

+ +
+ + {{ form.travel_from }} + {% if form.travel_from.errors %} +
    + {% for error in form.travel_from.errors %}
  • {{ error }}
  • {% endfor %} +
+ {% endif %} +
+ +
+ + {{ form.travel_until }} + {% if form.travel_until.errors %} +
    + {% for error in form.travel_until.errors %}
  • {{ error }}
  • {% endfor %} +
+ {% endif %} +
+ +
+ + {{ form.destination_address }} + {% if form.destination_address.errors %} +
    + {% for error in form.destination_address.errors %}
  • {{ error }}
  • {% endfor %} +
+ {% endif %} +
+ +
+ + {{ form.embassy_name }} + {% if form.embassy_name.errors %} +
    + {% for error in form.embassy_name.errors %}
  • {{ error }}
  • {% endfor %} +
+ {% endif %} +
+ +
+ +
+
+{% endblock %} diff --git a/src/django_program/registration/urls.py b/src/django_program/registration/urls.py index c41318b..d4ca9be 100644 --- a/src/django_program/registration/urls.py +++ b/src/django_program/registration/urls.py @@ -17,6 +17,9 @@ from django_program.registration.views import ( CartView, CheckoutView, + LetterRequestCreateView, + LetterRequestDetailView, + LetterRequestDownloadView, OrderConfirmationView, OrderDetailView, TicketSelectView, @@ -47,6 +50,9 @@ path("checkout/", CheckoutView.as_view(), name="checkout"), path("orders//", OrderDetailView.as_view(), name="order-detail"), path("orders//confirmation/", OrderConfirmationView.as_view(), name="order-confirmation"), + path("visa-letter/", LetterRequestCreateView.as_view(), name="letter-request"), + path("visa-letter/status/", LetterRequestDetailView.as_view(), name="letter-request-detail"), + path("visa-letter/download/", LetterRequestDownloadView.as_view(), name="letter-request-download"), path("webhooks/stripe/", stripe_webhook, name="stripe-webhook"), # Check-in API (staff-only, JSON endpoints for scanner UI) path("checkin/scan/", ScanView.as_view(), name="checkin-scan"), diff --git a/src/django_program/registration/views.py b/src/django_program/registration/views.py index 2195855..5044e46 100644 --- a/src/django_program/registration/views.py +++ b/src/django_program/registration/views.py @@ -14,9 +14,9 @@ from django.contrib import messages from django.contrib.auth.mixins import LoginRequiredMixin from django.core.exceptions import ValidationError -from django.db import models, transaction +from django.db import IntegrityError, models, transaction from django.db.models.functions import Coalesce -from django.http import Http404 +from django.http import Http404, HttpResponse from django.shortcuts import get_object_or_404, redirect, render from django.urls import reverse from django.utils import timezone @@ -25,11 +25,12 @@ from django_program.features import FeatureRequiredMixin from django_program.pretalx.views import ConferenceMixin -from django_program.registration.forms import CartItemForm, CheckoutForm, VoucherApplyForm +from django_program.registration.forms import CartItemForm, CheckoutForm, LetterRequestForm, VoucherApplyForm from django_program.registration.models import ( AddOn, Cart, CartItem, + LetterRequest, Order, OrderLineItem, TicketType, @@ -38,7 +39,7 @@ if TYPE_CHECKING: from django.db.models import QuerySet - from django.http import HttpRequest, HttpResponse + from django.http import HttpRequest logger = logging.getLogger(__name__) @@ -748,3 +749,157 @@ def get_context_data(self, **kwargs: object) -> dict[str, object]: context["line_items"] = self.object.line_items.all() context["payments"] = self.object.payments.all() return context + + +class LetterRequestCreateView(LoginRequiredMixin, ConferenceMixin, FeatureRequiredMixin, View): + """Attendee-facing view to request a visa invitation letter. + + If the user already has a request for this conference, redirects to + the detail view instead of allowing a duplicate submission. + """ + + required_feature = ("registration", "visa_letters") + template_name = "django_program/registration/letter_request_form.html" + + def get(self, request: HttpRequest, **kwargs: str) -> HttpResponse: # noqa: ARG002 + """Render the letter request form, pre-filling the name if available. + + Args: + request: The incoming HTTP request. + **kwargs: URL keyword arguments (unused). + + Returns: + The rendered form page, or a redirect to the detail view if a + request already exists. + """ + existing = LetterRequest.objects.filter( + user=request.user, + conference=self.conference, + ).first() + if existing: + return redirect(reverse("registration:letter-request-detail", args=[self.conference.slug])) + + full_name = f"{request.user.first_name} {request.user.last_name}".strip() + form = LetterRequestForm(initial={"passport_name": full_name} if full_name else None) + return render( + request, + self.template_name, + { + "conference": self.conference, + "form": form, + }, + ) + + def post(self, request: HttpRequest, **kwargs: str) -> HttpResponse: # noqa: ARG002 + """Validate and create a letter request. + + Args: + request: The incoming HTTP request. + **kwargs: URL keyword arguments (unused). + + Returns: + A redirect to the detail view on success, or the form with + errors on validation failure. + """ + existing = LetterRequest.objects.filter( + user=request.user, + conference=self.conference, + ).first() + if existing: + return redirect(reverse("registration:letter-request-detail", args=[self.conference.slug])) + + form = LetterRequestForm(request.POST) + if not form.is_valid(): + return render( + request, + self.template_name, + { + "conference": self.conference, + "form": form, + }, + ) + + letter_request = form.save(commit=False) + letter_request.user = request.user + letter_request.conference = self.conference + letter_request.status = LetterRequest.Status.SUBMITTED + try: + letter_request.save() + except IntegrityError: + return redirect(reverse("registration:letter-request-detail", args=[self.conference.slug])) + + messages.success(request, "Your visa invitation letter request has been submitted.") + return redirect(reverse("registration:letter-request-detail", args=[self.conference.slug])) + + +class LetterRequestDetailView(LoginRequiredMixin, ConferenceMixin, FeatureRequiredMixin, DetailView): + """Detail view for an attendee's own visa letter request. + + Shows current status, submitted details, and a download link for the + generated PDF when available. + """ + + required_feature = ("registration", "visa_letters") + template_name = "django_program/registration/letter_request_detail.html" + context_object_name = "letter_request" + + def get_object(self, queryset: QuerySet[LetterRequest] | None = None) -> LetterRequest: # noqa: ARG002 + """Look up the letter request for the current user and conference. + + Returns: + The user's LetterRequest for this conference. + + Raises: + Http404: If no letter request exists for this user/conference. + """ + return get_object_or_404( + LetterRequest, + user=self.request.user, + conference=self.conference, + ) + + def get_context_data(self, **kwargs: object) -> dict[str, object]: + """Add PDF availability flag to the template context.""" + context = super().get_context_data(**kwargs) + lr = self.object + context["pdf_available"] = ( + lr.status in (LetterRequest.Status.GENERATED, LetterRequest.Status.SENT) and lr.generated_pdf + ) + return context + + +class LetterRequestDownloadView(LoginRequiredMixin, ConferenceMixin, FeatureRequiredMixin, View): + """Download the generated invitation letter PDF. + + Only the requesting user can download their own letter. Serves the + PDF through an authenticated, owner-checked view instead of exposing + a direct media URL. + """ + + required_feature = ("registration", "visa_letters") + + def get(self, request: HttpRequest, **kwargs: str) -> HttpResponse: # noqa: ARG002 + """Return the generated PDF as a downloadable attachment. + + Args: + request: The incoming HTTP request. + **kwargs: URL keyword arguments (unused). + + Returns: + An HTTP response with the PDF content. + + Raises: + Http404: If no letter request exists or no PDF has been generated. + """ + letter_request = get_object_or_404( + LetterRequest, + conference=self.conference, + user=request.user, + ) + if not letter_request.generated_pdf: + raise Http404 + with letter_request.generated_pdf.open("rb") as f: + pdf_data = f.read() + response = HttpResponse(pdf_data, content_type="application/pdf") + response["Content-Disposition"] = 'attachment; filename="invitation-letter.pdf"' + return response diff --git a/src/django_program/settings.py b/src/django_program/settings.py index 4e1c7a6..6ac1331 100644 --- a/src/django_program/settings.py +++ b/src/django_program/settings.py @@ -71,6 +71,7 @@ class FeaturesConfig: travel_grants_enabled: bool = True programs_enabled: bool = True pretalx_sync_enabled: bool = True + visa_letters_enabled: bool = True public_ui_enabled: bool = True manage_ui_enabled: bool = True diff --git a/tests/test_registration/test_letters.py b/tests/test_registration/test_letters.py new file mode 100644 index 0000000..96e5027 --- /dev/null +++ b/tests/test_registration/test_letters.py @@ -0,0 +1,675 @@ +"""Tests for visa invitation letter requests — models, forms, services, and views.""" + +from datetime import date +from unittest.mock import patch + +import pytest +from django.contrib.auth.models import User +from django.core.files.base import ContentFile +from django.db import IntegrityError +from django.test import Client +from django.urls import reverse + +from django_program.conference.models import Conference +from django_program.registration.forms import LetterRequestForm +from django_program.registration.letter import LetterRequest + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture +def conference(db): + return Conference.objects.create( + name="TestCon 2027", + slug="testcon", + start_date=date(2027, 5, 1), + end_date=date(2027, 5, 3), + timezone="UTC", + is_active=True, + venue="Convention Center", + address="123 Main St, Portland, OR", + ) + + +@pytest.fixture +def user(db): + return User.objects.create_user( + username="attendee", + password="password", + email="attendee@test.com", + first_name="Jane", + last_name="Doe", + ) + + +@pytest.fixture +def other_user(db): + return User.objects.create_user(username="other", password="password", email="other@test.com") + + +@pytest.fixture +def staff_user(db): + return User.objects.create_superuser(username="admin", password="password", email="admin@test.com") + + +@pytest.fixture +def letter_data(): + return { + "passport_name": "Jane Doe", + "passport_number": "AB1234567", + "nationality": "United States", + "date_of_birth": "1990-06-15", + "travel_from": "2027-04-28", + "travel_until": "2027-05-05", + "destination_address": "456 Hotel Ave, Portland, OR 97201", + "embassy_name": "US Embassy Berlin", + } + + +@pytest.fixture +def letter_request(conference, user): + return LetterRequest.objects.create( + conference=conference, + user=user, + passport_name="Jane Doe", + passport_number="AB1234567", + nationality="United States", + date_of_birth=date(1990, 6, 15), + travel_from=date(2027, 4, 28), + travel_until=date(2027, 5, 5), + destination_address="456 Hotel Ave, Portland, OR 97201", + embassy_name="US Embassy Berlin", + status=LetterRequest.Status.SUBMITTED, + ) + + +@pytest.fixture +def client_logged_in(user): + c = Client() + c.force_login(user) + return c + + +@pytest.fixture +def client_other_user(other_user): + c = Client() + c.force_login(other_user) + return c + + +@pytest.fixture +def client_staff(staff_user): + c = Client() + c.force_login(staff_user) + return c + + +@pytest.fixture +def anon_client(): + return Client() + + +# --------------------------------------------------------------------------- +# Model tests +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +@pytest.mark.django_db +class TestLetterRequestModel: + """Tests for the LetterRequest model.""" + + def test_create_with_all_required_fields(self, conference, user): + lr = LetterRequest.objects.create( + conference=conference, + user=user, + passport_name="John Smith", + passport_number="XY9876543", + nationality="Germany", + travel_from=date(2027, 4, 28), + travel_until=date(2027, 5, 5), + destination_address="456 Hotel Ave", + ) + assert lr.pk is not None + assert lr.status == LetterRequest.Status.SUBMITTED + assert lr.created_at is not None + + def test_str_representation(self, letter_request, conference): + result = str(letter_request) + assert "Jane Doe" in result + assert str(conference) in result + assert "Submitted" in result + + def test_status_choices_exist(self): + values = LetterRequest.Status.values + assert "submitted" in values + assert "under_review" in values + assert "approved" in values + assert "generated" in values + assert "sent" in values + assert "rejected" in values + + def test_transition_submitted_to_under_review(self, letter_request): + letter_request.transition_to(LetterRequest.Status.UNDER_REVIEW) + assert letter_request.status == LetterRequest.Status.UNDER_REVIEW + + def test_transition_submitted_to_rejected(self, letter_request): + letter_request.transition_to(LetterRequest.Status.REJECTED) + assert letter_request.status == LetterRequest.Status.REJECTED + + def test_transition_under_review_to_approved(self, letter_request): + letter_request.status = LetterRequest.Status.UNDER_REVIEW + letter_request.transition_to(LetterRequest.Status.APPROVED) + assert letter_request.status == LetterRequest.Status.APPROVED + + def test_transition_under_review_to_rejected(self, letter_request): + letter_request.status = LetterRequest.Status.UNDER_REVIEW + letter_request.transition_to(LetterRequest.Status.REJECTED) + assert letter_request.status == LetterRequest.Status.REJECTED + + def test_transition_approved_to_generated(self, letter_request): + letter_request.status = LetterRequest.Status.APPROVED + letter_request.transition_to(LetterRequest.Status.GENERATED) + assert letter_request.status == LetterRequest.Status.GENERATED + + def test_transition_generated_to_sent(self, letter_request): + letter_request.status = LetterRequest.Status.GENERATED + letter_request.transition_to(LetterRequest.Status.SENT) + assert letter_request.status == LetterRequest.Status.SENT + + def test_transition_submitted_to_approved(self, letter_request): + letter_request.transition_to(LetterRequest.Status.APPROVED) + assert letter_request.status == LetterRequest.Status.APPROVED + + def test_transition_invalid_submitted_to_generated_raises(self, letter_request): + with pytest.raises(ValueError, match="Cannot transition"): + letter_request.transition_to(LetterRequest.Status.GENERATED) + + def test_transition_invalid_approved_to_sent_raises(self, letter_request): + letter_request.status = LetterRequest.Status.APPROVED + with pytest.raises(ValueError, match="Cannot transition"): + letter_request.transition_to(LetterRequest.Status.SENT) + + def test_transition_from_sent_raises(self, letter_request): + letter_request.status = LetterRequest.Status.SENT + with pytest.raises(ValueError, match="Cannot transition"): + letter_request.transition_to(LetterRequest.Status.SUBMITTED) + + def test_transition_from_rejected_raises(self, letter_request): + letter_request.status = LetterRequest.Status.REJECTED + with pytest.raises(ValueError, match="Cannot transition"): + letter_request.transition_to(LetterRequest.Status.SUBMITTED) + + def test_unique_together_user_conference(self, conference, user, letter_request): + with pytest.raises(IntegrityError): + LetterRequest.objects.create( + conference=conference, + user=user, + passport_name="Duplicate Request", + passport_number="ZZ0000000", + nationality="France", + travel_from=date(2027, 4, 28), + travel_until=date(2027, 5, 5), + destination_address="Some address", + ) + + def test_default_ordering(self, conference, user, other_user): + lr1 = LetterRequest.objects.create( + conference=conference, + user=user, + passport_name="First", + passport_number="A1", + nationality="US", + travel_from=date(2027, 4, 28), + travel_until=date(2027, 5, 5), + destination_address="Addr 1", + ) + lr2 = LetterRequest.objects.create( + conference=conference, + user=other_user, + passport_name="Second", + passport_number="A2", + nationality="US", + travel_from=date(2027, 4, 28), + travel_until=date(2027, 5, 5), + destination_address="Addr 2", + ) + results = list(LetterRequest.objects.filter(conference=conference)) + assert results[0] == lr2 + assert results[1] == lr1 + + +# --------------------------------------------------------------------------- +# Form tests +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +@pytest.mark.django_db +class TestLetterRequestForm: + """Tests for the LetterRequestForm.""" + + def test_valid_data(self, letter_data): + form = LetterRequestForm(data=letter_data) + assert form.is_valid(), form.errors + + def test_travel_from_must_be_before_travel_until(self, letter_data): + letter_data["travel_from"] = "2027-05-10" + letter_data["travel_until"] = "2027-05-05" + form = LetterRequestForm(data=letter_data) + assert not form.is_valid() + assert "__all__" in form.errors + + def test_travel_same_dates_invalid(self, letter_data): + letter_data["travel_from"] = "2027-05-01" + letter_data["travel_until"] = "2027-05-01" + form = LetterRequestForm(data=letter_data) + assert not form.is_valid() + + def test_missing_required_passport_name(self, letter_data): + del letter_data["passport_name"] + form = LetterRequestForm(data=letter_data) + assert not form.is_valid() + assert "passport_name" in form.errors + + def test_missing_required_passport_number(self, letter_data): + del letter_data["passport_number"] + form = LetterRequestForm(data=letter_data) + assert not form.is_valid() + assert "passport_number" in form.errors + + def test_missing_required_nationality(self, letter_data): + del letter_data["nationality"] + form = LetterRequestForm(data=letter_data) + assert not form.is_valid() + assert "nationality" in form.errors + + def test_missing_required_travel_dates(self, letter_data): + del letter_data["travel_from"] + del letter_data["travel_until"] + form = LetterRequestForm(data=letter_data) + assert not form.is_valid() + assert "travel_from" in form.errors + assert "travel_until" in form.errors + + def test_missing_required_destination_address(self, letter_data): + del letter_data["destination_address"] + form = LetterRequestForm(data=letter_data) + assert not form.is_valid() + assert "destination_address" in form.errors + + def test_optional_fields_can_be_omitted(self, letter_data): + del letter_data["date_of_birth"] + del letter_data["embassy_name"] + form = LetterRequestForm(data=letter_data) + assert form.is_valid(), form.errors + + +# --------------------------------------------------------------------------- +# Service tests +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +@pytest.mark.django_db +class TestGenerateInvitationLetter: + """Tests for the generate_invitation_letter service.""" + + def test_produces_pdf_bytes(self, letter_request): + from django_program.registration.services.letters import generate_invitation_letter + + letter_request.status = LetterRequest.Status.APPROVED + letter_request.save(update_fields=["status"]) + + pdf_bytes = generate_invitation_letter(letter_request) + + assert isinstance(pdf_bytes, bytes) + assert pdf_bytes[:5] == b"%PDF-" + + def test_updates_status_to_generated(self, letter_request): + from django_program.registration.services.letters import generate_invitation_letter + + letter_request.status = LetterRequest.Status.APPROVED + letter_request.save(update_fields=["status"]) + + generate_invitation_letter(letter_request) + + letter_request.refresh_from_db() + assert letter_request.status == LetterRequest.Status.GENERATED + + def test_saves_pdf_to_generated_pdf_field(self, letter_request): + from django_program.registration.services.letters import generate_invitation_letter + + letter_request.status = LetterRequest.Status.APPROVED + letter_request.save(update_fields=["status"]) + + generate_invitation_letter(letter_request) + + letter_request.refresh_from_db() + assert letter_request.generated_pdf + assert letter_request.generated_pdf.name.endswith(".pdf") + + def test_pdf_without_optional_fields(self, conference, other_user): + from django_program.registration.services.letters import generate_invitation_letter + + lr = LetterRequest.objects.create( + conference=conference, + user=other_user, + passport_name="No Optional", + passport_number="XX000", + nationality="Canada", + travel_from=date(2027, 4, 28), + travel_until=date(2027, 5, 5), + destination_address="Some hotel", + status=LetterRequest.Status.APPROVED, + ) + pdf_bytes = generate_invitation_letter(lr) + assert pdf_bytes[:5] == b"%PDF-" + + +@pytest.mark.unit +@pytest.mark.django_db +class TestSendInvitationLetter: + """Tests for the send_invitation_letter service.""" + + def test_updates_status_to_sent(self, letter_request): + from django_program.registration.services.letters import send_invitation_letter + + letter_request.status = LetterRequest.Status.GENERATED + letter_request.save(update_fields=["status"]) + + send_invitation_letter(letter_request) + + letter_request.refresh_from_db() + assert letter_request.status == LetterRequest.Status.SENT + + def test_sets_sent_at_timestamp(self, letter_request): + from django_program.registration.services.letters import send_invitation_letter + + letter_request.status = LetterRequest.Status.GENERATED + letter_request.save(update_fields=["status"]) + + assert letter_request.sent_at is None + send_invitation_letter(letter_request) + + letter_request.refresh_from_db() + assert letter_request.sent_at is not None + + def test_raises_on_invalid_transition(self, letter_request): + from django_program.registration.services.letters import send_invitation_letter + + with pytest.raises(ValueError, match="Cannot transition"): + send_invitation_letter(letter_request) + + +# --------------------------------------------------------------------------- +# Helper: patch the feature check so visa_letters doesn't raise ValueError +# --------------------------------------------------------------------------- + + +def _mock_feature_enabled(feature, conference=None): + """Always return True for feature checks during view tests.""" + return True + + +# --------------------------------------------------------------------------- +# Registration view tests (attendee-facing) +# --------------------------------------------------------------------------- + + +@pytest.mark.integration +@pytest.mark.django_db +class TestLetterRequestCreateView: + """Tests for the LetterRequestCreateView.""" + + def _url(self, conference): + return reverse("registration:letter-request", kwargs={"conference_slug": conference.slug}) + + @patch("django_program.features.is_feature_enabled", side_effect=_mock_feature_enabled) + def test_get_renders_form(self, mock_feature, client_logged_in, conference): + resp = client_logged_in.get(self._url(conference)) + assert resp.status_code == 200 + assert "form" in resp.context + + @patch("django_program.features.is_feature_enabled", side_effect=_mock_feature_enabled) + def test_get_prefills_passport_name(self, mock_feature, client_logged_in, conference): + resp = client_logged_in.get(self._url(conference)) + form = resp.context["form"] + assert form.initial.get("passport_name") == "Jane Doe" + + @patch("django_program.features.is_feature_enabled", side_effect=_mock_feature_enabled) + def test_post_creates_letter_request(self, mock_feature, client_logged_in, conference, user, letter_data): + resp = client_logged_in.post(self._url(conference), letter_data) + assert resp.status_code == 302 + assert LetterRequest.objects.filter(user=user, conference=conference).exists() + lr = LetterRequest.objects.get(user=user, conference=conference) + assert lr.passport_name == "Jane Doe" + assert lr.status == LetterRequest.Status.SUBMITTED + + @patch("django_program.features.is_feature_enabled", side_effect=_mock_feature_enabled) + def test_post_invalid_data_re_renders_form(self, mock_feature, client_logged_in, conference): + resp = client_logged_in.post(self._url(conference), {"passport_name": ""}) + assert resp.status_code == 200 + assert "form" in resp.context + assert resp.context["form"].errors + + @patch("django_program.features.is_feature_enabled", side_effect=_mock_feature_enabled) + def test_redirects_if_already_exists(self, mock_feature, client_logged_in, conference, letter_request): + resp = client_logged_in.get(self._url(conference)) + assert resp.status_code == 302 + detail_url = reverse("registration:letter-request-detail", args=[conference.slug]) + assert resp.url == detail_url + + @patch("django_program.features.is_feature_enabled", side_effect=_mock_feature_enabled) + def test_post_redirects_if_already_exists( + self, mock_feature, client_logged_in, conference, letter_request, letter_data + ): + resp = client_logged_in.post(self._url(conference), letter_data) + assert resp.status_code == 302 + + @patch("django_program.features.is_feature_enabled", side_effect=_mock_feature_enabled) + def test_anonymous_redirects_to_login(self, mock_feature, anon_client, conference): + resp = anon_client.get(self._url(conference)) + assert resp.status_code == 302 + assert "login" in resp.url + + +@pytest.mark.integration +@pytest.mark.django_db +class TestLetterRequestDetailView: + """Tests for the LetterRequestDetailView.""" + + def _url(self, conference): + return reverse("registration:letter-request-detail", kwargs={"conference_slug": conference.slug}) + + @patch("django_program.features.is_feature_enabled", side_effect=_mock_feature_enabled) + def test_shows_request_for_owner(self, mock_feature, client_logged_in, conference, letter_request): + resp = client_logged_in.get(self._url(conference)) + assert resp.status_code == 200 + assert resp.context["letter_request"] == letter_request + + @patch("django_program.features.is_feature_enabled", side_effect=_mock_feature_enabled) + def test_returns_404_for_non_owner(self, mock_feature, client_other_user, conference, letter_request): + resp = client_other_user.get(self._url(conference)) + assert resp.status_code == 404 + + @patch("django_program.features.is_feature_enabled", side_effect=_mock_feature_enabled) + def test_returns_404_when_no_request_exists(self, mock_feature, client_logged_in, conference): + resp = client_logged_in.get(self._url(conference)) + assert resp.status_code == 404 + + @patch("django_program.features.is_feature_enabled", side_effect=_mock_feature_enabled) + def test_pdf_available_when_generated(self, mock_feature, client_logged_in, conference, letter_request): + letter_request.status = LetterRequest.Status.GENERATED + letter_request.generated_pdf.save("test.pdf", ContentFile(b"%PDF-fake"), save=True) + resp = client_logged_in.get(self._url(conference)) + assert resp.status_code == 200 + assert resp.context["pdf_available"] + + @patch("django_program.features.is_feature_enabled", side_effect=_mock_feature_enabled) + def test_pdf_not_available_when_submitted(self, mock_feature, client_logged_in, conference, letter_request): + resp = client_logged_in.get(self._url(conference)) + assert resp.status_code == 200 + assert not resp.context["pdf_available"] + + +# --------------------------------------------------------------------------- +# Manage view tests (staff-facing) +# --------------------------------------------------------------------------- + + +@pytest.mark.integration +@pytest.mark.django_db +class TestLetterRequestListView: + """Tests for the manage LetterRequestListView.""" + + def _url(self, conference): + return reverse("manage:letter-list", kwargs={"conference_slug": conference.slug}) + + def test_staff_can_view_list(self, client_staff, conference, letter_request): + resp = client_staff.get(self._url(conference)) + assert resp.status_code == 200 + assert letter_request in resp.context["letter_requests"] + + def test_non_staff_gets_403(self, client_logged_in, conference): + resp = client_logged_in.get(self._url(conference)) + assert resp.status_code == 403 + + def test_anonymous_redirects(self, anon_client, conference): + resp = anon_client.get(self._url(conference)) + assert resp.status_code == 302 + assert "login" in resp.url + + def test_filter_by_status(self, client_staff, conference, letter_request): + resp = client_staff.get(self._url(conference), {"status": "submitted"}) + assert resp.status_code == 200 + assert letter_request in resp.context["letter_requests"] + + def test_filter_excludes_non_matching(self, client_staff, conference, letter_request): + resp = client_staff.get(self._url(conference), {"status": "approved"}) + assert resp.status_code == 200 + assert letter_request not in resp.context["letter_requests"] + + def test_status_counts_in_context(self, client_staff, conference, letter_request): + resp = client_staff.get(self._url(conference)) + assert "status_counts" in resp.context + assert resp.context["status_counts"]["submitted"] == 1 + assert resp.context["total_count"] == 1 + + +@pytest.mark.integration +@pytest.mark.django_db +class TestLetterRequestReviewView: + """Tests for the manage LetterRequestReviewView.""" + + def _url(self, conference, lr): + return reverse("manage:letter-review", kwargs={"conference_slug": conference.slug, "pk": lr.pk}) + + def test_get_renders_review_page(self, client_staff, conference, letter_request): + resp = client_staff.get(self._url(conference, letter_request)) + assert resp.status_code == 200 + assert resp.context["letter_request"] == letter_request + + def test_approve_action(self, client_staff, conference, letter_request): + resp = client_staff.post(self._url(conference, letter_request), {"action": "approve"}) + assert resp.status_code == 302 + letter_request.refresh_from_db() + assert letter_request.status == LetterRequest.Status.APPROVED + assert letter_request.reviewed_by is not None + assert letter_request.reviewed_at is not None + + def test_reject_action_with_reason(self, client_staff, conference, letter_request): + resp = client_staff.post( + self._url(conference, letter_request), + {"action": "reject", "rejection_reason": "Incomplete passport details"}, + ) + assert resp.status_code == 302 + letter_request.refresh_from_db() + assert letter_request.status == LetterRequest.Status.REJECTED + assert letter_request.rejection_reason == "Incomplete passport details" + assert letter_request.reviewed_by is not None + + def test_reject_without_reason_shows_error(self, client_staff, conference, letter_request): + resp = client_staff.post( + self._url(conference, letter_request), + {"action": "reject", "rejection_reason": ""}, + ) + assert resp.status_code == 302 + letter_request.refresh_from_db() + assert letter_request.status == LetterRequest.Status.SUBMITTED + + def test_under_review_action(self, client_staff, conference, letter_request): + resp = client_staff.post(self._url(conference, letter_request), {"action": "under_review"}) + assert resp.status_code == 302 + letter_request.refresh_from_db() + assert letter_request.status == LetterRequest.Status.UNDER_REVIEW + + def test_invalid_action_shows_error(self, client_staff, conference, letter_request): + letter_request.status = LetterRequest.Status.GENERATED + letter_request.save(update_fields=["status"]) + resp = client_staff.post(self._url(conference, letter_request), {"action": "approve"}) + assert resp.status_code == 302 + letter_request.refresh_from_db() + assert letter_request.status == LetterRequest.Status.GENERATED + + +@pytest.mark.integration +@pytest.mark.django_db +class TestLetterRequestGenerateView: + """Tests for the manage LetterRequestGenerateView.""" + + def _url(self, conference, lr): + return reverse("manage:letter-generate", kwargs={"conference_slug": conference.slug, "pk": lr.pk}) + + def test_generates_pdf_for_approved_request(self, client_staff, conference, letter_request): + letter_request.status = LetterRequest.Status.APPROVED + letter_request.save(update_fields=["status"]) + + resp = client_staff.post(self._url(conference, letter_request)) + assert resp.status_code == 302 + + letter_request.refresh_from_db() + assert letter_request.status == LetterRequest.Status.GENERATED + assert letter_request.generated_pdf + + def test_rejects_non_approved_request(self, client_staff, conference, letter_request): + resp = client_staff.post(self._url(conference, letter_request)) + assert resp.status_code == 302 + letter_request.refresh_from_db() + assert letter_request.status == LetterRequest.Status.SUBMITTED + + def test_non_staff_gets_403(self, client_logged_in, conference, letter_request): + letter_request.status = LetterRequest.Status.APPROVED + letter_request.save(update_fields=["status"]) + resp = client_logged_in.post(self._url(conference, letter_request)) + assert resp.status_code == 403 + + +@pytest.mark.integration +@pytest.mark.django_db +class TestLetterRequestDownloadView: + """Tests for the manage LetterRequestDownloadView.""" + + def _url(self, conference, lr): + return reverse("manage:letter-download", kwargs={"conference_slug": conference.slug, "pk": lr.pk}) + + def test_serves_pdf_when_available(self, client_staff, conference, letter_request): + pdf_content = b"%PDF-1.4 fake pdf content" + letter_request.generated_pdf.save("test.pdf", ContentFile(pdf_content), save=True) + + resp = client_staff.get(self._url(conference, letter_request)) + assert resp.status_code == 200 + assert resp["Content-Type"] == "application/pdf" + assert "attachment" in resp["Content-Disposition"] + assert b"%PDF-" in resp.content + + def test_redirects_when_no_pdf(self, client_staff, conference, letter_request): + resp = client_staff.get(self._url(conference, letter_request)) + assert resp.status_code == 302 + + def test_non_staff_gets_403(self, client_logged_in, conference, letter_request): + letter_request.generated_pdf.save("test.pdf", ContentFile(b"%PDF-fake"), save=True) + resp = client_logged_in.get(self._url(conference, letter_request)) + assert resp.status_code == 403