Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 5 additions & 10 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
484 changes: 405 additions & 79 deletions examples/seed.py

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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."))
Original file line number Diff line number Diff line change
@@ -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),
),
]
5 changes: 5 additions & 0 deletions src/django_program/conference/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
53 changes: 53 additions & 0 deletions src/django_program/manage/reports.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
}
132 changes: 87 additions & 45 deletions src/django_program/manage/templates/django_program/manage/base.html
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -982,6 +998,7 @@
<div class="layout">
{% if conference %}
<nav class="sidebar" aria-label="Conference navigation">
{# ── Conference ── #}
<div class="sidebar-section">
<div class="sidebar-section-title">{{ conference.name }}</div>
<ul class="sidebar-nav">
Expand All @@ -997,8 +1014,10 @@
</li>
</ul>
</div>

{# ── Program ── #}
<div class="sidebar-section">
<div class="sidebar-section-title">Content</div>
<div class="sidebar-section-title">Program</div>
<ul class="sidebar-nav">
<li>
<a href="{% url 'manage:section-list' conference.slug %}" class="{% if active_nav == 'sections' %}active{% endif %}">
Expand Down Expand Up @@ -1047,27 +1066,23 @@
</a>
</li>
<li>
<div class="sidebar-nav-expandable">
<a href="{% url 'manage:override-list' conference.slug %}" class="{% if active_nav == 'overrides' %}active{% endif %}">
<span class="sidebar-nav-icon"><svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M11 1.5L4.5 8l2 2L13 3.5"/><path d="M2.5 12.5h11M2.5 14.5h7"/></svg></span> Overrides
</a>
<button type="button" class="sidebar-nav-expand-btn{% if active_nav == 'overrides' %} open{% endif %}" id="overrides-expand-btn" aria-expanded="{% if active_nav == 'overrides' %}true{% else %}false{% endif %}" aria-controls="override-subnav" onclick="this.classList.toggle('open');var s=document.getElementById('override-subnav');s.classList.toggle('open');this.setAttribute('aria-expanded',s.classList.contains('open'))">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M6 4l4 4-4 4"/></svg>
</button>
</div>
<ul class="sidebar-subnav{% if active_nav == 'overrides' %} open{% endif %}" id="override-subnav">
<li><a href="{% url 'manage:override-list' conference.slug %}" class="{% if active_override_tab == 'talks' %}active{% endif %}">Talks</a></li>
<li><a href="{% url 'manage:speaker-override-list' conference.slug %}" class="{% if active_override_tab == 'speakers' %}active{% endif %}">Speakers</a></li>
<li><a href="{% url 'manage:room-override-list' conference.slug %}" class="{% if active_override_tab == 'rooms' %}active{% endif %}">Rooms</a></li>
<li><a href="{% url 'manage:sponsor-override-list' conference.slug %}" class="{% if active_override_tab == 'sponsors' %}active{% endif %}">Sponsors</a></li>
<li><a href="{% url 'manage:type-default-list' conference.slug %}" class="{% if active_override_tab == 'type-defaults' %}active{% endif %}">Type Defaults</a></li>
</ul>
<a href="{% url 'manage:activity-list' conference.slug %}" class="{% if active_nav == 'activities' %}active{% endif %}">
<span class="sidebar-nav-icon"><svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M9.5 1.5L4 8.5h4l-1 6 5.5-7H8.5l1-6z"/></svg></span> Activities
</a>
</li>
<li>
<a href="{% url 'manage:travel-grant-list' conference.slug %}" class="{% if active_nav == 'travel-grants' %}active{% endif %}">
<span class="sidebar-nav-icon"><svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M14.5 1.5l-13 5 5 2.5m8-7.5l-4.5 12-3.5-4.5m8-7.5l-8 7.5"/></svg></span> Travel Grants
</a>
</li>
</ul>
</div>

{# ── Registration ── #}
<div class="sidebar-section">
<div class="sidebar-section-title">Registration</div>
<ul class="sidebar-nav">
<li class="sidebar-sublabel">People</li>
<li>
<a href="{% url 'manage:attendee-list' conference.slug %}" class="{% if active_nav == 'attendees' %}active{% endif %}">
<span class="sidebar-nav-icon"><svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="6" cy="4.5" r="2.5"/><path d="M1 13c0-2.8 2.2-5 5-5s5 2.2 5 5"/><circle cx="12" cy="5.5" r="1.5"/><path d="M15 13c0-1.7-1.3-3-3-3-.8 0-1.5.3-2 .8"/></svg></span> Attendees
Expand All @@ -1078,6 +1093,17 @@
<span class="sidebar-nav-icon"><svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M4 1.5h8v13l-2-1.5-2 1.5-2-1.5-2 1.5v-13z"/><path d="M6.5 5.5h3M6.5 8h2"/></svg></span> Orders
</a>
</li>
<li>
<a href="{% url 'manage:letter-list' conference.slug %}" class="{% if active_nav == 'letters' %}active{% endif %}">
<span class="sidebar-nav-icon"><svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M3 2.5h10v11H3z"/><path d="M5.5 5.5h5M5.5 7.5h5M5.5 9.5h3"/><path d="M10 1v3M6 1v3"/></svg></span> Visa Letters
</a>
</li>
<li>
<a href="{% url 'manage:badge-list' conference.slug %}" class="{% if active_nav == 'badges' %}active{% endif %}">
<span class="sidebar-nav-icon"><svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="2" y="3" width="12" height="10" rx="1.5"/><circle cx="7" cy="7" r="1.5"/><path d="M9.5 10V6.5h1"/><path d="M12 10V8.5"/></svg></span> Badges
</a>
</li>
<li class="sidebar-sublabel">Commerce</li>
<li>
<a href="{% url 'manage:ticket-type-list' conference.slug %}" class="{% if active_nav == 'ticket-types' %}active{% endif %}">
<span class="sidebar-nav-icon"><svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M2 4.5h12V7a1.5 1.5 0 010 3v2.5H2V10a1.5 1.5 0 010-3V4.5z"/></svg></span> Ticket Types
Expand All @@ -1099,15 +1125,34 @@
</a>
</li>
<li>
<a href="{% url 'manage:badge-list' conference.slug %}" class="{% if active_nav == 'badges' %}active{% endif %}">
<span class="sidebar-nav-icon"><svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="2" y="3" width="12" height="10" rx="1.5"/><circle cx="7" cy="7" r="1.5"/><path d="M9.5 10V6.5h1"/><path d="M12 10V8.5"/></svg></span> Badges
<a href="{% url 'manage:bulk-purchase-list' conference.slug %}" class="{% if active_nav == 'bulk-purchases' %}active{% endif %}">
<span class="sidebar-nav-icon"><svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M2 4h12M2 8h12M2 12h12"/><circle cx="5" cy="4" r="1"/><circle cx="5" cy="8" r="1"/><circle cx="5" cy="12" r="1"/></svg></span> Bulk Purchases
</a>
</li>
</ul>
</div>

{# ── Sponsors ── #}
<div class="sidebar-section">
<div class="sidebar-section-title">Sponsors</div>
<ul class="sidebar-nav">
<li>
<a href="{% url 'manage:bulk-purchase-list' conference.slug %}" class="{% if active_nav == 'bulk-purchases' %}active{% endif %}">
<span class="sidebar-nav-icon"><svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M2 4h12M2 8h12M2 12h12"/><circle cx="5" cy="4" r="1"/><circle cx="5" cy="8" r="1"/><circle cx="5" cy="12" r="1"/></svg></span> Bulk Deals
<a href="{% url 'manage:sponsor-level-list' conference.slug %}" class="{% if active_nav == 'sponsor-levels' %}active{% endif %}">
<span class="sidebar-nav-icon"><svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M5 2.5h6v4.5a3 3 0 01-6 0V2.5z"/><path d="M5 4.5H3.5V6A1.5 1.5 0 005 7.5"/><path d="M11 4.5h1.5V6A1.5 1.5 0 0111 7.5"/><path d="M8 10v2.5M5.5 12.5h5"/></svg></span> Levels
</a>
</li>
<li>
<a href="{% url 'manage:sponsor-manage-list' conference.slug %}" class="{% if active_nav == 'sponsors' %}active{% endif %}">
<span class="sidebar-nav-icon"><svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M8 2L2.5 8 8 14l5.5-6L8 2z"/></svg></span> Sponsors
</a>
</li>
</ul>
</div>

{# ── On-site ── #}
<div class="sidebar-section">
<div class="sidebar-section-title">On-site</div>
<ul class="sidebar-nav">
<li>
<a href="{% url 'manage:checkin-dashboard' conference.slug %}" class="{% if active_nav == 'checkin' %}active{% endif %}">
<span class="sidebar-nav-icon"><svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M4 8l3 3 5-6"/><rect x="1.5" y="1.5" width="13" height="13" rx="2"/></svg></span> Check-in
Expand All @@ -1120,8 +1165,10 @@
</li>
</ul>
</div>

{# ── Finance & Insights ── #}
<div class="sidebar-section">
<div class="sidebar-section-title">Finance & Analytics</div>
<div class="sidebar-section-title">Finance & Insights</div>
<ul class="sidebar-nav">
<li>
<a href="{% url 'manage:financial-dashboard' conference.slug %}" class="{% if active_nav == 'financial' %}active{% endif %}">
Expand All @@ -1145,33 +1192,28 @@
</li>
</ul>
</div>

{# ── Sync & Overrides (utility) ── #}
<hr class="sidebar-utility-separator">
<div class="sidebar-section">
<div class="sidebar-section-title">Sponsors</div>
<ul class="sidebar-nav">
<li>
<a href="{% url 'manage:sponsor-level-list' conference.slug %}" class="{% if active_nav == 'sponsor-levels' %}active{% endif %}">
<span class="sidebar-nav-icon"><svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M5 2.5h6v4.5a3 3 0 01-6 0V2.5z"/><path d="M5 4.5H3.5V6A1.5 1.5 0 005 7.5"/><path d="M11 4.5h1.5V6A1.5 1.5 0 0111 7.5"/><path d="M8 10v2.5M5.5 12.5h5"/></svg></span> Levels
</a>
</li>
<li>
<a href="{% url 'manage:sponsor-manage-list' conference.slug %}" class="{% if active_nav == 'sponsors' %}active{% endif %}">
<span class="sidebar-nav-icon"><svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M8 2L2.5 8 8 14l5.5-6L8 2z"/></svg></span> Sponsors
</a>
</li>
</ul>
</div>
<div class="sidebar-section">
<div class="sidebar-section-title">Programs</div>
<div class="sidebar-section-title">Sync & Overrides</div>
<ul class="sidebar-nav">
<li>
<a href="{% url 'manage:activity-list' conference.slug %}" class="{% if active_nav == 'activities' %}active{% endif %}">
<span class="sidebar-nav-icon"><svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M9.5 1.5L4 8.5h4l-1 6 5.5-7H8.5l1-6z"/></svg></span> Activities
</a>
</li>
<li>
<a href="{% url 'manage:travel-grant-list' conference.slug %}" class="{% if active_nav == 'travel-grants' %}active{% endif %}">
<span class="sidebar-nav-icon"><svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M14.5 1.5l-13 5 5 2.5m8-7.5l-4.5 12-3.5-4.5m8-7.5l-8 7.5"/></svg></span> Travel Grants
</a>
<div class="sidebar-nav-expandable">
<a href="{% url 'manage:override-list' conference.slug %}" class="{% if active_nav == 'overrides' %}active{% endif %}">
<span class="sidebar-nav-icon"><svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M11 1.5L4.5 8l2 2L13 3.5"/><path d="M2.5 12.5h11M2.5 14.5h7"/></svg></span> Overrides
</a>
<button type="button" class="sidebar-nav-expand-btn{% if active_nav == 'overrides' %} open{% endif %}" id="overrides-expand-btn" aria-expanded="{% if active_nav == 'overrides' %}true{% else %}false{% endif %}" aria-controls="override-subnav" onclick="this.classList.toggle('open');var s=document.getElementById('override-subnav');s.classList.toggle('open');this.setAttribute('aria-expanded',s.classList.contains('open'))">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M6 4l4 4-4 4"/></svg>
</button>
</div>
<ul class="sidebar-subnav{% if active_nav == 'overrides' %} open{% endif %}" id="override-subnav">
<li><a href="{% url 'manage:override-list' conference.slug %}" class="{% if active_override_tab == 'talks' %}active{% endif %}">Talks</a></li>
<li><a href="{% url 'manage:speaker-override-list' conference.slug %}" class="{% if active_override_tab == 'speakers' %}active{% endif %}">Speakers</a></li>
<li><a href="{% url 'manage:room-override-list' conference.slug %}" class="{% if active_override_tab == 'rooms' %}active{% endif %}">Rooms</a></li>
<li><a href="{% url 'manage:sponsor-override-list' conference.slug %}" class="{% if active_override_tab == 'sponsors' %}active{% endif %}">Sponsors</a></li>
<li><a href="{% url 'manage:type-default-list' conference.slug %}" class="{% if active_override_tab == 'type-defaults' %}active{% endif %}">Type Defaults</a></li>
</ul>
</li>
</ul>
</div>
Expand Down
Loading
Loading