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
462 changes: 392 additions & 70 deletions examples/seed.py

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ classifiers = [
dependencies = [
"django>=5.2",
"django-fernet-encrypted-fields>=0.3.1",
"httpx>=0.28.1",
"pillow>=12.1.1",
"pretalx-client>=0.1.0",
"qrcode[pil]>=8.2",
Expand Down
57 changes: 57 additions & 0 deletions src/django_program/conference/migrations/0010_add_qbo_fields.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# Generated by Django 5.2.11 on 2026-03-19 18:36

import encrypted_fields.fields
from django.db import migrations, models


class Migration(migrations.Migration):
dependencies = [
("program_conference", "0009_featureflags_visa_letters_enabled"),
]

operations = [
migrations.AddField(
model_name="conference",
name="qbo_access_token",
field=encrypted_fields.fields.EncryptedCharField(
blank=True, default=None, help_text="QBO OAuth2 access token.", max_length=2000, null=True
),
),
migrations.AddField(
model_name="conference",
name="qbo_client_id",
field=models.CharField(
blank=True, default="", help_text="QBO OAuth2 client ID for token refresh.", max_length=200
),
),
migrations.AddField(
model_name="conference",
name="qbo_client_secret",
field=encrypted_fields.fields.EncryptedCharField(
blank=True,
default=None,
help_text="QBO OAuth2 client secret for token refresh.",
max_length=500,
null=True,
),
),
migrations.AddField(
model_name="conference",
name="qbo_realm_id",
field=models.CharField(
blank=True, default="", help_text="QuickBooks Online Company/Realm ID.", max_length=200
),
),
migrations.AddField(
model_name="conference",
name="qbo_refresh_token",
field=encrypted_fields.fields.EncryptedCharField(
blank=True, default=None, help_text="QBO OAuth2 refresh token.", max_length=2000, null=True
),
),
migrations.AddField(
model_name="conference",
name="qbo_token_expires_at",
field=models.DateTimeField(blank=True, help_text="When the QBO access token expires.", null=True),
),
]
39 changes: 39 additions & 0 deletions src/django_program/conference/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,45 @@ class Conference(models.Model):
stripe_publishable_key = EncryptedCharField(max_length=200, blank=True, null=True, default=None)
stripe_webhook_secret = EncryptedCharField(max_length=200, blank=True, null=True, default=None)

qbo_realm_id = models.CharField(
max_length=200,
blank=True,
default="",
help_text="QuickBooks Online Company/Realm ID.",
)
qbo_access_token = EncryptedCharField(
max_length=2000,
blank=True,
null=True,
default=None,
help_text="QBO OAuth2 access token.",
)
qbo_refresh_token = EncryptedCharField(
max_length=2000,
blank=True,
null=True,
default=None,
help_text="QBO OAuth2 refresh token.",
)
qbo_token_expires_at = models.DateTimeField(
null=True,
blank=True,
help_text="When the QBO access token expires.",
)
qbo_client_id = models.CharField(
max_length=200,
blank=True,
default="",
help_text="QBO OAuth2 client ID for token refresh.",
)
qbo_client_secret = EncryptedCharField(
max_length=500,
blank=True,
null=True,
default=None,
help_text="QBO OAuth2 client secret for token refresh.",
)

total_capacity = models.PositiveIntegerField(
default=0,
help_text="Maximum total tickets across all types. 0 means unlimited.",
Expand Down
17 changes: 16 additions & 1 deletion src/django_program/manage/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
from django import forms
from django.core.validators import RegexValidator

from django_program.conference.models import Conference, Expense, ExpenseCategory, Section
from django_program.conference.models import Conference, Expense, ExpenseCategory, KPITargets, Section
from django_program.pretalx.models import Room, ScheduleSlot, Talk
from django_program.programs.models import Activity, TravelGrant, TravelGrantMessage
from django_program.registration.badge import BadgeTemplate
Expand Down Expand Up @@ -101,6 +101,21 @@ class Meta:
}


class KPITargetsForm(forms.ModelForm):
"""Form for editing per-conference KPI target thresholds."""

class Meta:
model = KPITargets
fields = [
"target_conversion_rate",
"target_refund_rate",
"target_checkin_rate",
"target_fulfillment_rate",
"target_revenue_per_attendee",
"target_room_utilization",
]


class SectionForm(forms.ModelForm):
"""Form for editing a conference section."""

Expand Down
169 changes: 153 additions & 16 deletions src/django_program/manage/templates/django_program/manage/base.html
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,40 @@
color: var(--color-text-muted);
padding: 0 0.75rem;
margin-bottom: 0.4rem;
display: flex;
align-items: center;
justify-content: space-between;
cursor: pointer;
user-select: none;
border-radius: var(--radius-sm);
transition: color 0.15s ease;
}
.sidebar-section-title:hover {
color: var(--color-text-secondary);
}
.sidebar-section-title .sidebar-collapse-icon {
width: 12px;
height: 12px;
flex-shrink: 0;
opacity: 0.5;
transition: transform 0.2s ease, opacity 0.15s ease;
}
.sidebar-section-title:hover .sidebar-collapse-icon {
opacity: 0.8;
}
.sidebar-section.collapsed .sidebar-collapse-icon {
transform: rotate(-90deg);
}
.sidebar-section.collapsed > .sidebar-nav {
max-height: 0;
overflow: hidden;
margin: 0;
opacity: 0;
}
.sidebar-section > .sidebar-nav {
max-height: 2000px;
opacity: 1;
transition: max-height 0.25s ease, opacity 0.2s ease;
}

.sidebar-sublabel {
Expand All @@ -169,6 +203,39 @@
color: var(--color-text-muted);
padding: 0.5rem 0.75rem 0.2rem;
opacity: 0.7;
display: flex;
align-items: center;
justify-content: space-between;
cursor: pointer;
user-select: none;
border-radius: var(--radius-sm);
transition: opacity 0.15s ease;
}
.sidebar-sublabel:hover {
opacity: 1;
}
.sidebar-sublabel .sidebar-collapse-icon {
width: 10px;
height: 10px;
flex-shrink: 0;
opacity: 0.5;
transition: transform 0.2s ease, opacity 0.15s ease;
}
.sidebar-sublabel:hover .sidebar-collapse-icon {
opacity: 0.8;
}
.sidebar-subgroup {
max-height: 500px;
opacity: 1;
overflow: hidden;
transition: max-height 0.25s ease, opacity 0.2s ease;
}
.sidebar-subgroup.collapsed {
max-height: 0;
opacity: 0;
}
.sidebar-sublabel.collapsed-label .sidebar-collapse-icon {
transform: rotate(-90deg);
}

.sidebar-utility-separator {
Expand Down Expand Up @@ -999,8 +1066,8 @@
{% if conference %}
<nav class="sidebar" aria-label="Conference navigation">
{# ── Conference ── #}
<div class="sidebar-section">
<div class="sidebar-section-title">{{ conference.name }}</div>
<div class="sidebar-section" data-section="conference">
<div class="sidebar-section-title" role="button" tabindex="0" aria-expanded="true">{{ conference.name }} <svg class="sidebar-collapse-icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M4 6l4 4 4-4"/></svg></div>
<ul class="sidebar-nav">
<li>
<a href="{% url 'manage:dashboard' conference.slug %}" class="{% if active_nav == 'dashboard' %}active{% endif %}">
Expand All @@ -1016,8 +1083,8 @@
</div>

{# ── Program ── #}
<div class="sidebar-section">
<div class="sidebar-section-title">Program</div>
<div class="sidebar-section" data-section="program">
<div class="sidebar-section-title" role="button" tabindex="0" aria-expanded="true">Program <svg class="sidebar-collapse-icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M4 6l4 4 4-4"/></svg></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 @@ -1079,10 +1146,13 @@
</div>

{# ── Registration ── #}
<div class="sidebar-section">
<div class="sidebar-section-title">Registration</div>
<div class="sidebar-section" data-section="registration">
<div class="sidebar-section-title" role="button" tabindex="0" aria-expanded="true">Registration <svg class="sidebar-collapse-icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M4 6l4 4 4-4"/></svg></div>
<ul class="sidebar-nav">
<li class="sidebar-sublabel" data-subsection="reg-people">People <svg class="sidebar-collapse-icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M4 6l4 4 4-4"/></svg></li>
</ul>
<div class="sidebar-subgroup" data-subsection-target="reg-people">
<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 @@ -1103,7 +1173,13 @@
<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>
</ul>
</div>
<ul class="sidebar-nav">
<li class="sidebar-sublabel" data-subsection="reg-commerce">Commerce <svg class="sidebar-collapse-icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M4 6l4 4 4-4"/></svg></li>
</ul>
<div class="sidebar-subgroup" data-subsection-target="reg-commerce">
<ul class="sidebar-nav">
<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 @@ -1130,11 +1206,12 @@
</a>
</li>
</ul>
</div>
</div>

{# ── Sponsors ── #}
<div class="sidebar-section">
<div class="sidebar-section-title">Sponsors</div>
<div class="sidebar-section" data-section="sponsors">
<div class="sidebar-section-title" role="button" tabindex="0" aria-expanded="true">Sponsors <svg class="sidebar-collapse-icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M4 6l4 4 4-4"/></svg></div>
<ul class="sidebar-nav">
<li>
<a href="{% url 'manage:sponsor-level-list' conference.slug %}" class="{% if active_nav == 'sponsor-levels' %}active{% endif %}">
Expand All @@ -1150,8 +1227,8 @@
</div>

{# ── On-site ── #}
<div class="sidebar-section">
<div class="sidebar-section-title">On-site</div>
<div class="sidebar-section" data-section="onsite">
<div class="sidebar-section-title" role="button" tabindex="0" aria-expanded="true">On-site <svg class="sidebar-collapse-icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M4 6l4 4 4-4"/></svg></div>
<ul class="sidebar-nav">
<li>
<a href="{% url 'manage:checkin-dashboard' conference.slug %}" class="{% if active_nav == 'checkin' %}active{% endif %}">
Expand All @@ -1167,14 +1244,19 @@
</div>

{# ── Finance & Insights ── #}
<div class="sidebar-section">
<div class="sidebar-section-title">Finance & Insights</div>
<div class="sidebar-section" data-section="finance">
<div class="sidebar-section-title" role="button" tabindex="0" aria-expanded="true">Finance & Insights <svg class="sidebar-collapse-icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M4 6l4 4 4-4"/></svg></div>
<ul class="sidebar-nav">
<li>
<a href="{% url 'manage:financial-dashboard' conference.slug %}" class="{% if active_nav == 'financial' %}active{% endif %}">
<span class="sidebar-nav-icon"><svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M2 14h12M4 10h2v4H4zM7 7h2v7H7zM10 4h2v10h-2z"/></svg></span> Financial
</a>
</li>
<li>
<a href="{% url 'manage:purchase-order-list' conference.slug %}" class="{% if active_nav == 'purchase-orders' %}active{% endif %}">
<span class="sidebar-nav-icon"><svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M3 1.5h10v13H3z"/><path d="M5.5 4.5h5M5.5 7h5M5.5 9.5h3"/><path d="M7 12.5h2"/></svg></span> Purchase Orders
</a>
</li>
<li>
<a href="{% url 'manage:expense-list' conference.slug %}" class="{% if active_nav == 'expenses' %}active{% endif %}">
<span class="sidebar-nav-icon"><svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M4 2h8v12H4z"/><path d="M6 5h4M6 7.5h4M6 10h2.5"/><path d="M2 4v10h10"/></svg></span> Expenses
Expand All @@ -1195,8 +1277,8 @@

{# ── Sync & Overrides (utility) ── #}
<hr class="sidebar-utility-separator">
<div class="sidebar-section">
<div class="sidebar-section-title">Sync & Overrides</div>
<div class="sidebar-section" data-section="sync-overrides">
<div class="sidebar-section-title" role="button" tabindex="0" aria-expanded="true">Sync & Overrides <svg class="sidebar-collapse-icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M4 6l4 4 4-4"/></svg></div>
<ul class="sidebar-nav">
<li>
<div class="sidebar-nav-expandable">
Expand Down Expand Up @@ -1245,5 +1327,60 @@
</div>

{% block extra_js %}{% endblock %}
<script>
(function() {
var KEY = 'manage-sidebar-collapsed';
var stored = {};
try { stored = JSON.parse(localStorage.getItem(KEY) || '{}'); } catch(e) {}

// Section-level collapse
document.querySelectorAll('.sidebar-section[data-section]').forEach(function(section) {
var id = section.dataset.section;
var title = section.querySelector('.sidebar-section-title');

var hasActive = section.querySelector('.sidebar-nav li a.active, .sidebar-subnav li a.active');
if (stored[id] && !hasActive) {
section.classList.add('collapsed');
title.setAttribute('aria-expanded', 'false');
}

function toggleSection() {
section.classList.toggle('collapsed');
var isCollapsed = section.classList.contains('collapsed');
stored[id] = isCollapsed;
title.setAttribute('aria-expanded', String(!isCollapsed));
try { localStorage.setItem(KEY, JSON.stringify(stored)); } catch(e) {}
}

title.addEventListener('click', toggleSection);
title.addEventListener('keydown', function(e) {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
toggleSection();
}
});
});

// Sub-section collapse (People, Commerce, etc.)
document.querySelectorAll('.sidebar-sublabel[data-subsection]').forEach(function(label) {
var id = label.dataset.subsection;
var target = document.querySelector('.sidebar-subgroup[data-subsection-target="' + id + '"]');
if (!target) return;

var hasActive = target.querySelector('a.active');
if (stored[id] && !hasActive) {
target.classList.add('collapsed');
label.classList.add('collapsed-label');
}

label.addEventListener('click', function() {
target.classList.toggle('collapsed');
label.classList.toggle('collapsed-label');
stored[id] = target.classList.contains('collapsed');
try { localStorage.setItem(KEY, JSON.stringify(stored)); } catch(e) {}
});
});
})();
</script>
</body>
</html>
Loading
Loading