Skip to content
Open
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
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,20 @@ To start a workflow from Django (a view, an admin action, a command), use
`pycon.resonate_app.start_workflow(name, workflow_id, ...)`. The workflow id is
the idempotency key: creating the same id twice joins the existing run.

Creating the next edition of the conference is one of those workflows: select
the conference to use as base in the admin and run the *Create new conference
using this one as base* action, which asks for the code, name, hostname and
dates of the new edition (prefilled with the next year's) and starts the
workflow.

It copies configuration only (deadlines, shifted by the new start date;
durations; the schedule days, laid out over the new dates with the rooms and
slots of the edition it copies; sponsor levels, benefits and special options;
email templates; menus, copy and FAQs; forms and their questions; voting
included events). Everything produced during an edition -- proposals, the talks
scheduled into those slots, keynotes, sponsors, grants, vouchers, answers -- is
not copied, and neither are the pretix ids or the logo.

## External repos

Repos used by this project are in separate repositories.
Expand Down
136 changes: 136 additions & 0 deletions backend/conferences/admin/actions.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,18 @@
from datetime import timedelta
from uuid import uuid4

from django import forms
from django.contrib import admin, messages
from django.contrib.admin import widgets
from django.core.exceptions import ValidationError
from django.template.response import TemplateResponse

from conferences.models import Conference
from conferences.tasks import send_conference_voucher_email
from conferences.workflows import CLONE_CONFERENCE
from custom_admin.admin import validate_single_conference_selection
from pretix import create_voucher
from pycon.resonate_app import start_workflow


@admin.action(description="Send voucher via email")
Expand Down Expand Up @@ -48,3 +59,128 @@ def create_conference_vouchers_on_pretix(modeladmin, request, queryset):
count = count + 1

messages.success(request, f"{count} Vouchers created on Pretix!")


class CloneConferenceForm(forms.Form):
"""What the new conference overrides; everything else comes from the base one."""

code = forms.CharField(max_length=100)
name = forms.CharField(max_length=100)
hostname = forms.CharField(max_length=255)
start = forms.SplitDateTimeField(widget=widgets.AdminSplitDateTime())
end = forms.SplitDateTimeField(widget=widgets.AdminSplitDateTime())

def clean_code(self):
code = self.cleaned_data["code"]

if Conference.objects.filter(code=code).exists():
raise ValidationError(f"Conference {code} already exists")

return code

def clean_hostname(self):
hostname = self.cleaned_data["hostname"]

if Conference.objects.filter(hostname=hostname).exists():
raise ValidationError(f"Hostname {hostname} is already used")

return hostname

def clean(self):
cleaned_data = super().clean()
start = cleaned_data.get("start")
end = cleaned_data.get("end")

if start and end and start > end:
raise ValidationError("Start date cannot be after end")

return cleaned_data


def next_edition_defaults(conference: Conference) -> dict:
"""Prefill the form with the next edition: same values, one year later."""
year = conference.start.year if conference.start else None

def bump_year(value: str) -> str:
if not year or str(year) not in value:
return value

return value.replace(str(year), str(year + 1), 1)

def next_year(value):
if not value:
return value

try:
return value.replace(year=value.year + 1)
except ValueError:
# 29th of February: the next edition has no such day.
return value + timedelta(days=365)

return {
"code": bump_year(conference.code),
"name": bump_year(str(conference.name)),
"hostname": bump_year(conference.hostname),
"start": next_year(conference.start),
"end": next_year(conference.end),
}


@admin.action(description="Create new conference using this one as base")
def clone_conference(modeladmin, request, queryset):
selected = list(queryset)

if len(selected) != 1:
messages.error(request, "Select the single conference to use as base")
return None

source = selected[0]

if request.POST.get("apply"):
form = CloneConferenceForm(request.POST)

if form.is_valid():
start_clone_conference(request, source, form.cleaned_data)
return None
else:
form = CloneConferenceForm(initial=next_edition_defaults(source))

return TemplateResponse(
request,
"admin/conferences/clone_conference.html",
{
**modeladmin.admin_site.each_context(request),
"title": f"New conference based on {source.code}",
"opts": modeladmin.model._meta,
"media": modeladmin.media + form.media,
"source": source,
"form": form,
# The page posts back to the changelist, so it has to name the
# action; taken from the function so a rename cannot break it.
"action_name": clone_conference.__name__,
},
)


def start_clone_conference(request, source: Conference, data: dict) -> None:
# A fresh id per submission, so a run that failed for good can be started
# again. Double submits are harmless anyway: every step of the workflow
# only creates what is not there yet.
workflow_id = f"clone-conference-{data['code']}-{uuid4().hex[:8]}"

start_workflow(
CLONE_CONFERENCE,
workflow_id,
source_code=source.code,
new_code=data["code"],
new_name=data["name"],
new_start=data["start"].isoformat(),
new_end=data["end"].isoformat(),
new_hostname=data["hostname"],
)

messages.success(
request,
f"Creating {data['code']} based on {source.code}. "
f"Follow the progress in Resonate (workflow {workflow_id}).",
)
7 changes: 6 additions & 1 deletion backend/conferences/admin/conference.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,11 @@
Topic,
)
from conferences.admin.views import grants_summary
from .actions import send_voucher_via_email, create_conference_vouchers_on_pretix
from .actions import (
clone_conference,
create_conference_vouchers_on_pretix,
send_voucher_via_email,
)


def validate_deadlines_form(forms):
Expand Down Expand Up @@ -192,6 +196,7 @@ class ConferenceAdmin(
("YouTube", {"fields": ("video_title_template", "video_description_template")}),
)
inlines = [DeadlineInline, DurationInline, SponsorLevelInline, IncludedEventInline]
actions = [clone_conference]

@admin.display(description="Schedule Builder")
def schedule_builder_link(self, obj):
Expand Down
4 changes: 3 additions & 1 deletion backend/conferences/models/conference.py
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,9 @@ class Conference(GeoLocalizedModel, TimeFramedModel, TimeStampedModel):
_("max proposals per user"),
null=True,
blank=True,
help_text=_("Maximum number of proposals a user can submit. Leave empty for no limit."),
help_text=_(
"Maximum number of proposals a user can submit. Leave empty for no limit."
),
)

def get_slack_oauth_token(self):
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
{% extends "admin/base_site.html" %}
{% load i18n admin_urls %}

{% block extrahead %}{{ block.super }}{{ media }}{% endblock %}

{% block breadcrumbs %}
<div class="breadcrumbs">
<a href="{% url 'admin:index' %}">{% translate 'Home' %}</a>
&rsaquo; <a href="{% url 'admin:app_list' app_label=opts.app_label %}">{{ opts.app_config.verbose_name }}</a>
&rsaquo; <a href="{% url opts|admin_urlname:'changelist' %}">{{ opts.verbose_name_plural|capfirst }}</a>
&rsaquo; {{ title }}
</div>
{% endblock %}

{% block content %}
<p>
The new conference starts as a copy of <strong>{{ source }}</strong>: deadlines
(shifted by the new start date), durations, the schedule days (laid out over the
dates below, with the rooms and slots of the edition they copy), sponsor levels,
benefits and special options, email templates, menus, copy and FAQs, forms and
their questions, and voting included events. Proposals, the talks scheduled into
those slots, keynotes, sponsors, grants, vouchers and answers are not copied,
and neither are the pretix ids or the logo.
</p>

<form method="post">
{% csrf_token %}

<input type="hidden" name="_selected_action" value="{{ source.pk }}">
<input type="hidden" name="action" value="{{ action_name }}">
<input type="hidden" name="apply" value="1">

<fieldset class="module aligned">
{% for field in form %}
<div class="form-row{% if field.errors %} errors{% endif %}">
{{ field.errors }}
<div>
{{ field.label_tag }}
{{ field }}
</div>
</div>
{% endfor %}
{% if form.non_field_errors %}
<p class="errornote">{{ form.non_field_errors }}</p>
{% endif %}
</fieldset>

<div class="submit-row">
<input type="submit" class="default" value="Create conference">
<a href="{% url opts|admin_urlname:'changelist' %}" class="button cancel-link">
{% translate 'Cancel' %}
</a>
</div>
</form>
{% endblock %}
Loading
Loading