From 2cc95d8ead19e9896733c570229439709ee21387 Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Thu, 19 Mar 2026 13:02:21 -0500 Subject: [PATCH] feat: add Visa & Invitation Letters system for international attendees (#19) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LetterRequest model with status workflow (submit → review → approve → generate PDF → send), reportlab-based PDF generation service, attendee-facing request form + detail views, staff manage dashboard with review/approve/reject/generate/send actions and inline PDF preview, visa letters report with nationality breakdown and CSV export, seed data with 15 demo requests across all statuses, and 62 tests covering model, form, service, and view layers. Also fixes: reports dashboard revenue decimal formatting, KPI card height alignment. Co-Authored-By: Claude Opus 4.6 (1M context) --- Makefile | 15 +- examples/seed.py | 484 +++++++++++-- .../management/commands/setup_groups.py | 8 +- .../0009_featureflags_visa_letters_enabled.py | 17 + src/django_program/conference/models.py | 5 + src/django_program/manage/reports.py | 53 ++ .../templates/django_program/manage/base.html | 132 ++-- .../django_program/manage/dashboard.html | 1 + .../manage/letter_request_list.html | 150 ++++ .../manage/letter_request_review.html | 184 +++++ .../manage/report_visa_letters.html | 291 ++++++++ .../manage/reports_dashboard.html | 27 +- src/django_program/manage/urls.py | 31 + src/django_program/manage/urls_reports.py | 4 + src/django_program/manage/views.py | 6 + src/django_program/manage/views_letters.py | 336 +++++++++ src/django_program/manage/views_reports.py | 103 +++ src/django_program/registration/forms.py | 40 ++ src/django_program/registration/letter.py | 110 +++ .../migrations/0019_add_letter_request.py | 91 +++ src/django_program/registration/models.py | 2 + .../registration/services/letters.py | 295 ++++++++ .../registration/letter_request_detail.html | 86 +++ .../registration/letter_request_form.html | 108 +++ src/django_program/registration/urls.py | 6 + src/django_program/registration/views.py | 163 ++++- src/django_program/settings.py | 1 + tests/test_registration/test_letters.py | 675 ++++++++++++++++++ 28 files changed, 3274 insertions(+), 150 deletions(-) create mode 100644 src/django_program/conference/migrations/0009_featureflags_visa_letters_enabled.py create mode 100644 src/django_program/manage/templates/django_program/manage/letter_request_list.html create mode 100644 src/django_program/manage/templates/django_program/manage/letter_request_review.html create mode 100644 src/django_program/manage/templates/django_program/manage/report_visa_letters.html create mode 100644 src/django_program/manage/views_letters.py create mode 100644 src/django_program/registration/letter.py create mode 100644 src/django_program/registration/migrations/0019_add_letter_request.py create mode 100644 src/django_program/registration/services/letters.py create mode 100644 src/django_program/registration/templates/django_program/registration/letter_request_detail.html create mode 100644 src/django_program/registration/templates/django_program/registration/letter_request_form.html create mode 100644 tests/test_registration/test_letters.py 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 %} + + + +
+
+ + {% 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 %}
- - {% 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