SpeakerAvailability, EventSession, autoscheduler, and other goodies (#497)

* fix old bug where the get_days() method would return the wrong number of days, this was not discovered because our bootstrap script has been creating 9 day camps instead of 8 day camps (this has been fixed in a different commit)

* remove stray debug print

* output camp days in local timezone (CEST usually), not UTC

* speakeravailability commit of doom, originally intended for #385 but goes a bit further than that. Adds SpeakerAvailability and EventSession models, and models for the new autoscheduler. Update bootstrap script and more. New conference_autoscheduler dependency. Work in progress, but ready for playing around!

* add conference-scheduler to requirements

* rework migrations, work at bit with postgres range fields and bounds, change how speakeravailability is saved (continuous ranges instead of 1 hour chunks), add tests for utils/range_fields.py including adding hypothesis to requirements/dev.txt, add a test which runs our bootstrap script

* catch name collision in the right place, and load missing postgres extension in the migration

* add some verbosity to see what the travis issue might be

* manually create btree_gist extension in postgres, not sure why the BtreeGistExtension() operation in program/migrations/0085... isn't working in travis?

* create extension in the right database maybe

* lets try this then

* ok so the problem is not that the btree_gist extension isn't getting loaded, the problem is that GIST indexes do not work with uuid fields in postgres 9.6, lets take another stab at getting pg10 with postgis to work with in travis

* lets try normal socket connection

* add SPEAKER_AVAILABILITY_DAYCHUNK_HOURS=3 to travis environment_settings.py

* rework migrations, change so an autoschedule can work with multiple eventtypes, change AutoSlot model to use a DateTimeRangeField so we can use the database for more efficient lookups, add 'conflicts' self m2m for EventLocation to indicate when a room conflicts with another room, add a support_autoscheduling bool to EventType, add workshops to bootstrap script, add timing output to bootstrap script

* update README a bit, move some functionality to model methods, update jquery and jquery.datatables, include datatables in base.html instead of in each page, start adding backoffice schedule management views (unfinished), yolo commit so I can show valberg something

* Switch to a more simple way of using the autoscheduler, meaning we can remove the whole autoscheduler app and all models. All autoscheduler code is now in program/autoscheduler.py and a bit in backoffice views. Add more backoffice CRUD views for schedule management. Add datatables moment.js plugin to help table sorting of dates. Add Speaker{Proposal}EventConflict model to allow speakers to inform us which events they want to attend so we dont schedule them at the same time. Add EventTag model. New models not hooked up to anything yet.

* handle cases where there is no solution without failing, also dont return anything here

* wrong block kiddo

* switch from EventInstance to EventSlot as the way we schedule events. Finish backoffice content team views (mostly). Many small changes. Prod will need data migration of EventInstances -> EventSlots when the time comes.

* keep speakeravailability stuff a bit more DRY by using the AvailabilityMatrixViewMixin everywhere, add event_duration_minutes to EventSession create/update form, reverse the order we delete/create EventSlot objects when updating an EventSession

* go through all views, fix various little bugs here and there

* add missing migration

* add django-taggit, add tags for Events, add tags in bootstrap script, make AutoScheduler use tags. Add tags in forms and templates.

* fix taggit entry in requirements

* Fix our iCal view: Add uuid field to Event, add uuid property to EventSlot which calculates a consitent UUID for an event at a start time at a location. Use this as the schedule uuid. While here fix so our iCal export is valid, a few fields were missing, the iCal file now validates 100% OK.

* fix our FRAB xml export view

* comment the EventSlot.uuid property better

* typo in comment

* language

Co-Authored-By: Benjamin Balder Bach <benjamin@overtag.dk>

* language

Co-Authored-By: Benjamin Balder Bach <benjamin@overtag.dk>

* Update src/backoffice/templates/autoschedule_debug_events.html

Co-Authored-By: Benjamin Balder Bach <benjamin@overtag.dk>

* add a field to make this form look less weird. No difference in functionality.

* remove stray print and refactor this form init a bit

* fix ScheduleView

* only show slots where all speakers are available when scheduling events manually in backoffice

* make event list sortable by video recording column

* update description on {speaker|event}proposal models reason field

* remove badge showing number of scheduled slots for each event in backoffice eventlist. it was unclear what the number meant and it doesn't really fit

* remember to consider events in the same location when deciding whether a slot is available or not

* add is_available() method to EventLocation, add clean_location() method to EventSlot, call it from EventSlot.clean(), update a bit of text in eventslotunschedule template

* fix EventSession.get_available_slots() so it doesnt return busy slots as available, and since this means we can no longer schedule stuff in the lunchbreak lower the number of talks in the bootstrap script a bit so we have a better chance of having a solvable problem

* fix the excludefilter in EventSession.get_available_slots() for real this time, also fix an icon and add link in event schedule template in backoffice

* show message when no slots are available for manual scheduling in backoffice

* add event_conflicts to SpeakerUpdateView form in backoffice

* fix link to speaker object in speakerproposal list in backoffice

* allow blank tags

* make duration validation depend on the eventtype event_duration_minutes if we have one. fix help_text and label and placeholder for all duration fields

* allow music acts up to 180 mins in the bootstrap data

* fix wrong eventtype name for recreational events in speakerproposalform

* stretch the colspan one cell more

* save event_conflicts m2m when submitting speaker and event together

* form not self, and add succes message

* move js function toggleclass() to bornhack.js and rename to toggle_sa_form_class(), function is used in several templates and was missing when submitting combined proposals

* move the no-js removal to the top of ready() function

This will allow other javascript initialization (eg. DataTable) to see the elements and initialize accordingly (eg. column width for tables)

* Fixed problem with event feedback detail view

* Fixed problem with event feedback list view

* introduce a get_tzrange_days() function and use that to get the relevant days for the matrix instead of camp.get_days(), thereby fixing some display issues when eventsessions cross dates

* show submitting user and link to proposal on backoffice event detail page, change User to Submitter in backoffice speaker list table

* show warning by the buttons when a proposal cannot be approved, and show better text on approve/reject buttons

* disable js schedule, save m2m, prefetch some stuff

* fix broken date header in table

* remove use of djangos regular slugify function, use the new utils.slugs.unique_slugify() instead

Co-authored-by: Thomas Steen Rasmussen <tykling@bornhack.org>
Co-authored-by: Benjamin Balder Bach <benjamin@overtag.dk>
Co-authored-by: Thomas Flummer <tf@flummer.net>
This commit is contained in:
Thomas Steen Rasmussen 2020-06-03 21:18:06 +02:00 committed by GitHub
parent d00b0d8154
commit eff4bfaf1c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
186 changed files with 9383 additions and 2434 deletions

2
.gitignore vendored
View file

@ -1,5 +1,6 @@
.dev .dev
.idea/ .idea/
.hypothesis/
__pycache__/ __pycache__/
db.sqlite3 db.sqlite3
*.sw* *.sw*
@ -7,3 +8,4 @@ db.sqlite3
venv/ venv/
environment_settings.py environment_settings.py
elm-stuff/ elm-stuff/
.coverage

View file

@ -10,10 +10,12 @@ services:
- postgresql - postgresql
addons: addons:
postgresql: "9.6" postgresql: "10"
apt: apt:
packages: packages:
- postgresql-9.6-postgis-2.5 - postgresql-10
- postgresql-client-10
- postgresql-10-postgis-2.5
install: install:
- pip install -r src/requirements/dev.txt - pip install -r src/requirements/dev.txt

View file

@ -17,9 +17,9 @@ If you already cloned the repository without --recursive, you can change into th
git submodule update --init --recursive git submodule update --init --recursive
### Virtualenv ### Virtualenv
Create a Python 3 virtual environment and activate it: Create a Python 3.7 virtual environment and activate it:
``` ```
$ virtualenv venv -p python3 $ virtualenv venv -p python3.7
$ source venv/bin/activate $ source venv/bin/activate
``` ```
@ -57,7 +57,7 @@ Install pip packages:
### Postgres ### Postgres
You need to have a running Postgres instance (we use Postgres-specific fields and PostGIS/GeoDjango). Install Postgres and PostGIS, and add a database `bornhack` (or whichever you like) with some way for the application to connect to it, for instance adding a user with a password. Connect to the database as a superuser and run `create extension postgis`. You need to have a running Postgres instance (sqlite or mysql or others can't be used, because we use Postgres-specific fields and PostGIS/GeoDjango). Install Postgres and PostGIS, and add a database `bornhack` (or whichever you like) with some way for the application to connect to it, for instance adding a user with a password. Connect to the database as a superuser and run `create extension postgis`. The postgres version in production is 12 and the postgis version in production is 2.5. The minimum postgres version is 10, because we use GIST indexes on uuid fields (for ExclusionConstraints).
### Configuration file ### Configuration file

File diff suppressed because one or more lines are too long

85
src/backoffice/forms.py Normal file
View file

@ -0,0 +1,85 @@
from django import forms
from program.models import Event, Speaker
class AutoScheduleValidateForm(forms.Form):
schedule = forms.ChoiceField(
choices=(
(
"current",
"Validate Current Schedule (Load the AutoScheduler with the currently scheduled Events and validate)",
),
(
"similar",
"Validate Similar Schedule (Create and validate a new schedule based on the current schedule)",
),
("new", "Validate New Schedule (Create and validate a new schedule)"),
),
help_text="What to validate?",
)
class AutoScheduleApplyForm(forms.Form):
schedule = forms.ChoiceField(
choices=(
(
"similar",
"Apply Similar Schedule (Create and apply a new schedule similar to the current schedule)",
),
(
"new",
"Apply New Schedule (Create and apply a new schedule without considering the current schedule)",
),
),
help_text="Which schedule to apply?",
)
class EventScheduleForm(forms.Form):
""" The EventSlots are added in the view and help_text is not visible, just define the field """
slot = forms.ChoiceField()
class SpeakerForm(forms.ModelForm):
class Meta:
model = Speaker
fields = [
"name",
"email",
"biography",
"needs_oneday_ticket",
"event_conflicts",
]
def __init__(self, camp, matrix={}, *args, **kwargs):
"""
initialise the form and add availability fields to form
"""
super().__init__(*args, **kwargs)
# do we have a matrix to work with?
if not matrix:
return
# add speaker availability fields
for date in matrix.keys():
# do we need a column for this day?
if not matrix[date]:
# nothing on this day, skip it
continue
# loop over the daychunks for this day
for daychunk in matrix[date]:
if not matrix[date][daychunk]:
# no checkbox needed for this daychunk
continue
# add the field
self.fields[matrix[date][daychunk]["fieldname"]] = forms.BooleanField(
required=False
)
# add it to Meta.fields too
self.Meta.fields.append(matrix[date][daychunk]["fieldname"])
# only show events from this camp
self.fields["event_conflicts"].queryset = Event.objects.filter(
track__camp=camp, event_type__support_speaker_event_conflicts=True
)

View file

@ -11,12 +11,12 @@
<div class="lead"> <div class="lead">
The Content team can approve or reject pending EventFeedback from this page. The feedback will not be visible to the Event owner before it is approved. The Event owner will not be able to see the username of the feedback authors. The feedback author can see when the feedback has been approved or rejected by returning to the feedback page. The Content team can approve or reject pending EventFeedback from this page. The feedback will not be visible to the Event owner before it is approved. The Event owner will not be able to see the username of the feedback authors. The feedback author can see when the feedback has been approved or rejected by returning to the feedback page.
</div> </div>
{% if eventfeedback_list %} {% if event_feedback_list %}
<form method="post"> <form method="post">
{{ formset.management_form }} {{ formset.management_form }}
{% csrf_token %} {% csrf_token %}
{% for form, feedback in formset|zip:eventfeedback_list %} {% for form, feedback in formset|zip:event_feedback_list %}
{% include "includes/eventfeedback_detail_panel.html" with eventfeedback=feedback event=feedback.event %} {% include "includes/event_feedback_detail_panel.html" with event_feedback=feedback event=feedback.event %}
{% endfor %} {% endfor %}
<button type="submit" class="btn btn-success"><i class="fas fa-check"></i> Submit</button> <button type="submit" class="btn btn-success"><i class="fas fa-check"></i> Submit</button>
<a href="{% url 'backoffice:index' camp_slug=camp.slug %}" class="btn btn-primary"><i class="fas fa-undo"></i> Cancel</a> <a href="{% url 'backoffice:index' camp_slug=camp.slug %}" class="btn btn-primary"><i class="fas fa-undo"></i> Cancel</a>

View file

@ -2,10 +2,6 @@
{% load commonmark %} {% load commonmark %}
{% load static %} {% load static %}
{% load imageutils %} {% load imageutils %}
{% block extra_head %}
<script src="{% static "js/jquery.dataTables.min.js" %}"></script>
<link rel="stylesheet" href="{% static 'css/jquery.dataTables.min.css' %}">
{% endblock extra_head %}
{% block content %} {% block content %}
<div class="row"> <div class="row">
<h2>Approve Public Credit Names</h2> <h2>Approve Public Credit Names</h2>
@ -15,7 +11,7 @@
</div> </div>
<br> <br>
<div class="row"> <div class="row">
<table class="table table-hover"> <table class="table table-hover datatable">
<thead> <thead>
<tr> <tr>
<th>Username</th> <th>Username</th>

View file

@ -0,0 +1,23 @@
{% extends 'base.html' %}
{% load bootstrap3 %}
{% block content %}
<div class="panel panel-default">
<div class="panel-heading">
<h3 class="panel-title">Really Apply AutoSchedule?</h3>
</div>
<div class="panel-body">
<p>Applying the AutoSchedule will schedule Events in EventSlots to match the result of the AutoScheduler calculation. Any existing autoscheduled Events will be unscheduled.</P>
<form method="POST">
{% csrf_token %}
{% bootstrap_form form %}
<button type="submit" class="btn btn-success">
<i class="fas fa-check"></i> Apply Schedule
</button>
<a href="{% url 'backoffice:autoschedule_manage' camp_slug=camp.slug %}" class="btn btn-default">
<i class="fas fa-undo"></i> Cancel
</a>
</form>
</div>
</div>
{% endblock content %}

View file

@ -0,0 +1,31 @@
{% extends 'base.html' %}
{% block content %}
<div class="panel panel-default">
<div class="panel-heading">
<h3 class="panel-title">AutoScheduler Crash Course</h3>
</div>
<div class="panel-body">
<p>The AutoScheduler needs a list of <i>AutoScheduler Slots</i> and a list of <i>AutoScheduler Events</i> to do its thing.</p>
<p>We create one <i>AutoScheduler Slot</i> for each <i>EventSlot</i> in all <i>EventSessions</i> for the AutoScheduler-enabled <i>EventTypes</i>.</p>
<p>We create one <i>AutoScheduler Event</i> for each <i>EventType</i> which support autoscheduling, excluding any Events which have been scheduled manually.</p>
<p>All scheduling constraints are attached to <i>AutoScheduler Events</i>, and they come from many sources:
<ul>
<li><b>Slot Conflicts</b>: If a Slot is unavailable because something else has been scheduled in it the Slot is removed and not considered by the AutoScheduler. The same applies if something has been scheduled in a slot in a conflicting EventLocation.</li>
<li><b>SpeakerAvailability</b>: For each Event we mark any Slots as unavailable if one or more speakers for that event are unavailable for any part of the Slot.</li>
<li><b>Speaker Conflicts</b>: For each Event we check for each speaker if they are also hosting other Events, and register conflicts as needed, so noone has to be in two places at the same time.</li>
<li><b>Speaker&lt;&gt;Event Conflicts</b>: If a speaker has expressed desire to attend other Events we register conflicts as needed, so we don't schedule the speakers events at the same time as something the speaker wants to attend.</li>
<li><b>Event Tags</b>: Events which share a tag are scheduled in the same session if possible, and may not overlap. In other words, all events tagged "Django" will be scheduled together if they can, and if that is not possible they will at least be scheduled without overlaps so it is possible to attend all of them.</li>
</ul>
</p>
<p>We use the term <i>published schedule</i> when referring the currently published Events (meaning the current schedule on the live site). We use the term <i>draft schedule</i> when referring to the "potential" schedule calculated based on the current data (EventSessions and EventSlots, Events, Speakers, SpeakerAvailability, Event conflicts, Speaker&lt;&gt;Event Conflicts, Event tags) in the database.</p>
<p>The <i>draft schedule</i> can optionally be created similar to the <i>published schedule</i>. This makes the solver keep the number of schedule changes as low as possible.</p>
<p>It is fully supported to mix manual scheduling with using the AutoScheduler. The AutoScheduler can be used as needed: It can act strictly as a validator for a manually planned schedule. It could also be used for scheduling some events, while manually scheduling others. It can also be used to do the full schedule. The AutoScheduler will avoid scheduling over manually scheduled events, and will consider manually scheduled events when looking to avoid conflicts.</p>
<a href="{% url 'backoffice:autoschedule_manage' camp_slug=camp.slug %}" class="btn btn-default"><i class="fas fa-undo"></i> AutoSchedule Management</a>
</div>
</div>
{% endblock content %}

View file

@ -0,0 +1,29 @@
{% extends 'base.html' %}
{% load bornhack %}
{% block body %}
<p class="lead">The following conflicts have been found between events in the AutoScheduler, events with a red table cell will not be scheduled together.</p>
<p><a class="btn btn-default" href="{% url 'backoffice:autoschedule_manage' camp_slug=camp.slug %}"><i class="fas fa-undo"></i> Back</a></p>
<table class="table table-condensed table-bordered">
<thead>
<th><br></th>
{% for autoevent,event in scheduler.autoevents|zip:scheduler.events.all %}
<th><a href="{% url 'backoffice:event_detail' camp_slug=camp.slug slug=event.slug %}">{{ event.title }}</a></th>
{% endfor %}
</thead>
<tbody>
{% for autoevent,event in scheduler.autoevents|zip:scheduler.events.all %}
<tr>
<th><a href="{% url 'backoffice:event_detail' camp_slug=camp.slug slug=event.slug %}">{{ event.title }}</a></th>
{% for rowautoevent,rowevent in scheduler.autoevents|zip:scheduler.events.all %}
{% if rowautoevent in autoevent.unavailability or rowautoevent == autoevent or autoevent in rowautoevent.unavailability %}
<td class="text-center danger"><i class="fas fa-times"></i></td>
{% else %}
<td class="text-center success"><i class="fas fa-check"></i></td>
{% endif %}
{% endfor %}
</tr>
{% endfor %}
</table>
<p><a class="btn btn-default" href="{% url 'backoffice:autoschedule_manage' camp_slug=camp.slug %}"><i class="fas fa-undo"></i> Back</a></p>
{% endblock body %}

View file

@ -0,0 +1,30 @@
{% extends 'base.html' %}
{% load bornhack %}
{% block body %}
<p class="lead">The following availability matrix has been generated for the Event/Slot combinations in the AutoScheduler</p>
<p><a class="btn btn-default" href="{% url 'backoffice:autoschedule_manage' camp_slug=camp.slug %}"><i class="fas fa-undo"></i> Back</a></p>
<table class="table table-condensed table-bordered">
<thead>
<th>fedt nok</th>
{% for slot in scheduler.autoslots %}
<th>{{ slot.starts_at }} (venue {{ slot.venue }}, cap {{ slot.capacity }})</th>
{% endfor %}
</thead>
<tbody>
{% for autoevent, event in scheduler.autoevents|zip:scheduler.events.all %}
<tr>
<th><a href="{% url 'backoffice:event_detail' camp_slug=camp.slug slug=event.slug %}">{{ event.title }}</a> (demand {{ autoevent.demand }})</th>
{% for slot in scheduler.autoslots %}
{% if slot in autoevent.unavailability %}
<td class="text-center danger"><i class="fas fa-times" data-toggle='tooltip' data-html=true data-placement='right' title='Event "{{ event.title }}" will not be scheduled in the slot {{ slot.starts_at }}'></i></td>
{% else %}
<td class="text-center success"><i class="fas fa-check" data-toggle='tooltip' data-html=true data-placement='right' title='Event "{{ event.title }}" might be scheduled in the slot {{ slot.starts_at }}'></i></td>
{% endif %}
{% endfor %}
</tr>
{% endfor %}
</tbody>
</table>
<p><a class="btn btn-default" href="{% url 'backoffice:autoschedule_manage' camp_slug=camp.slug %}"><i class="fas fa-undo"></i> Back</a></p>
{% endblock body %}

View file

@ -0,0 +1,15 @@
{% extends 'base.html' %}
{% load bootstrap3 %}
{% block content %}
<div class="panel panel-default">
<div class="panel-heading">
<h3 class="panel-title">Show schedule diff</h3>
</div>
<div class="panel-body">
<p class="lead">Showing the diff between the current schedule (calculated from currently published Events), and the new schedule based on current database object.</p>
{% include 'includes/autoschedule_diff_table.html' %}
<a href="{% url 'backoffice:autoschedule_manage' camp_slug=camp.slug %}" class="btn btn-default"><i class="fas fa-undo"></i> Back</a>
</div>
</div>
{% endblock content %}

View file

@ -0,0 +1,58 @@
{% extends 'base.html' %}
{% block content %}
<div class="panel panel-default">
<div class="panel-heading">
<h3 class="panel-title">Manage AutoScheduler</h3>
</div>
<div class="panel-body">
<p class="lead">Select your desired action below</p>
<div class="list-group">
<a href="{% url 'backoffice:autoschedule_validate' camp_slug=camp.slug %}" class="list-group-item">
<h4 class="list-group-item-heading">
<i class="fas fa-fire-extinguisher fa-fw"></i> Validate Schedule
</h4>
<p class="list-group-item-text">
Validate published or draft schedule. Useful to check if the published schedule is still valid after making changes to the underlying data. Can also check the draft schedule.
</p>
</a>
<a href="{% url 'backoffice:autoschedule_diff' camp_slug=camp.slug %}" class="list-group-item">
<h4 class="list-group-item-heading">
<i class="fas fa-random fa-fw"></i> Show Schedule Diff
</h4>
<p class="list-group-item-text">
Show the differences between the published schedule and the similar draft schedule. Use this to predict what schedule changes would happen if the draft schedule was applied now. <i>This view takes a while to load.</i>
</p>
</a>
<a href="{% url 'backoffice:autoschedule_apply' camp_slug=camp.slug %}" class="list-group-item">
<h4 class="list-group-item-heading">
<i class="fas fa-check fa-fw"></i> Apply Schedule
</h4>
<p class="list-group-item-text">
Apply the draft schedule by unscheduling any currently autoscheduled Events and scheduling new Events in EventSlots to match the Slot/Event combinations in the draft schedule. It is prudent to check the validity and diff for the draft schedule before applying!
</p>
</a>
<a href="{% url 'backoffice:autoschedule_debug_event_slot_unavailability' camp_slug=camp.slug %}" class="list-group-item">
<h4 class="list-group-item-heading">
<i class="fas fa-chess-board fa-fw"></i> Debug Event/Slot Unavailability
</h4>
<p class="list-group-item-text">
This debug view shows a matrix/table with all AutoScheduler Events on the Y axis and all AutoScheduler Slots on the X axis, and red/green table cells to indicate whether an event can be scheduled in that Slot. <i>This view takes a while to load.</i>
</p>
</a>
<a href="{% url 'backoffice:autoschedule_debug_event_conflicts' camp_slug=camp.slug %}" class="list-group-item">
<h4 class="list-group-item-heading">
<i class="fas fa-chess-board fa-fw"></i> Debug Event Conflicts
</h4>
<p class="list-group-item-text">
This view shows a matrix/table with all Events on both axis, and red/green table cells to indicate conflicts between Events. Event conflicts can happen either because a Speaker on an Event is also a Speaker on other Events, or because of a SpeakerEventConflict, or because the events share one or more tags. <i>This view takes a while to load.</i>
</p>
</a>
</div>
<p><i>If you are new to the AutoScheduler you might wish to consult the <a href="{% url 'backoffice:autoschedule_crash_course' camp_slug=camp.slug %}">crash course</a> for a five minute introduction to the terminology and concepts used.</i></p>
<p><a href="{% url 'backoffice:index' camp_slug=camp.slug %}" class="btn btn-default"><i class="fas fa-undo"></i> Backoffice</a></p>
</div>
</div>
{% endblock content %}

View file

@ -0,0 +1,26 @@
{% extends 'base.html' %}
{% load bootstrap3 %}
{% block content %}
<div class="panel panel-default">
<div class="panel-heading">
<h3 class="panel-title">Validate Schedule</h3>
</div>
<div class="panel-body">
<p class="lead">What do you want to validate?</p>
<p>Validating the <i>current</i> schedule means looking at the EventSessions, EventLocations, EventTypes, Events and their conflicts, Speakers, SpeakerAvailability, and Speaker&lt;&gt;EventConflicts, and comparing all that with the currently published schedule. Maybe some speakers changed availability or added some event conflicts? Use this tool to check if we still have a valid schedule!</p>
<p>Validating the <i>similar</i> schedule means looking at the same things as above, then calculating a new schedule (as similar to the current one as possible), and checking if the new schedule is (would be) valid. Use this to find out if it is feasible to create a new similar schedule from the current data in the database.</p>
<p>Validating the <i>new</i> schedule means looking at the same things as above, then calculating a new schedule, without considering the existing schedule. The new schedule will likely be very different from the existing one.</p>
<form method="POST">
{% csrf_token %}
{% bootstrap_form form %}
<button type="submit" class="btn btn-success">
<i class="fas fa-check"></i> Validate Schedule
</button>
<a href="{% url 'backoffice:autoschedule_manage' camp_slug=camp.slug %}" class="btn btn-default">
<i class="fas fa-undo"></i> Cancel
</a>
</form>
</div>
</div>
{% endblock content %}

View file

@ -2,10 +2,6 @@
{% load commonmark %} {% load commonmark %}
{% load static %} {% load static %}
{% load imageutils %} {% load imageutils %}
{% block extra_head %}
<script src="{% static "js/jquery.dataTables.min.js" %}"></script>
<link rel="stylesheet" href="{% static 'css/jquery.dataTables.min.css' %}">
{% endblock extra_head %}
{% block content %} {% block content %}
<div class="row"> <div class="row">
<h2>Hand Out Badges</h2> <h2>Hand Out Badges</h2>
@ -18,7 +14,7 @@
</div> </div>
<br> <br>
<div class="row"> <div class="row">
<table class="table table-hover"> <table class="table table-hover datatable">
<thead> <thead>
<tr> <tr>
<th>Ticket UUID</th> <th>Ticket UUID</th>

View file

@ -2,11 +2,6 @@
{% load static %} {% load static %}
{% load bootstrap3 %} {% load bootstrap3 %}
{% block extra_head %}
<script src="{% static "js/jquery.dataTables.min.js" %}"></script>
<link rel="stylesheet" href="{% static 'css/jquery.dataTables.min.css' %}">
{% endblock extra_head %}
{% block title %} {% block title %}
Select Chain | {{ block.super }} Select Chain | {{ block.super }}
{% endblock %} {% endblock %}
@ -16,7 +11,7 @@ Select Chain | {{ block.super }}
<p><a href="{% url "backoffice:index" camp_slug=camp.slug %}">Back to Backoffice Index</a></p> <p><a href="{% url "backoffice:index" camp_slug=camp.slug %}">Back to Backoffice Index</a></p>
{% if chain_list %} {% if chain_list %}
<table class="table table-hover"> <table class="table table-hover datatable">
<thead> <thead>
<tr> <tr>
<th>Chain Name</th> <th>Chain Name</th>

View file

@ -0,0 +1,20 @@
{% extends 'base.html' %}
{% load bootstrap3 %}
{% block content %}
<div class="panel panel-default">
<div class="panel-heading">
<h2 class="panel-title">Really Delete Event?</h2>
</div>
<div class="panel-body">
<p class="lead">Really delete event <i>{{ object.title }}</i> ?</p>
<p class="lead">You can recreate the Event (but not the Events scheduling!) later by re-approving the EventProposal this Event came from.</p>
<form method="POST">
{% csrf_token %}
<button type="submit" class="btn btn-danger"><i class='fas fa-times'></i> Yes, Delete it</button>
<a href="{% url 'backoffice:event_detail' camp_slug=camp.slug slug=object.slug %}" class="btn btn-default"><i class="fas fa-undo"></i> Back</a>
</form>
</div>
</div>
{% endblock content %}

View file

@ -0,0 +1,77 @@
{% extends 'base.html' %}
{% load bootstrap3 %}
{% load bornhack %}
{% load commonmark %}
{% block content %}
<div class="panel panel-default">
<div class="panel-heading">
<h3 class="panel-title">Event: {{ event.title }}</h3>
</div>
<div class="panel-body">
{{ event.abstract|untrustedcommonmark }}
<hr>
<h4>Details for <i>{{ event.title }}</i></h4>
<table class="table">
<tr>
<th>EventType</th>
<td>{{ event.event_type.icon_html }} <a href="{% url 'backoffice:event_type_detail' camp_slug=camp.slug slug=event.event_type.slug %}">{{ event.event_type }}</a></td>
</tr>
<tr>
<th>Event Track</th>
<td>{{ event.track.name }}</td>
</tr>
<tr>
<th>Tags</th>
<td>
{% for tag in event.tags.all %}
<span class="badge">{{ tag }}</span>
{% empty %}
N/A
{% endfor %}
</td>
</tr>
<tr>
<th>Video Recording</th>
<td>{{ event.video_recording|truefalseicon }}</td>
</tr>
<tr>
<th>Demand</th>
<td>{% if event.demand %}{{ event.demand }} participants expected{% else %}0 (The autoscheduler will not consider capacity/demand when scheduling this event){% endif %}</td>
</tr>
<tr>
<th>Proposal</th>
<td><a href="{% url 'backoffice:event_proposal_detail' camp_slug=camp.slug pk=event.proposal.pk %}" class="btn btn-default btn-xs"><i class="fas fa-search"></i> Show</a></td>
</tr>
<tr>
<th>Submitted by</th>
<td><i class="fas fa-user"></i> {{ event.proposal.user.username }}</td>
</tr>
</table>
<hr>
<h4>Speakers for <i>{{ event.title }}</i></h4>
{% include "includes/speaker_list_table_backoffice.html" with speaker_list=event.speakers.all noactions=True nodatatable=True %}
<hr>
<h4>Schedule for <i>{{ event.title }}</i></h4>
{% if event.event_slots.exists %}
{% include "includes/event_slot_list_backoffice.html" with event_slot_list=event.event_slots.all notitle=True %}
{% else %}
N/A
{% endif %}
<a href="{% url 'backoffice:event_schedule' camp_slug=camp.slug slug=event.slug %}" class="btn btn-success pull-right"><i class="fas fa-plus"></i> Schedule Event</a>
<br>
<hr>
<a href="{% url 'backoffice:event_update' camp_slug=camp.slug slug=event.slug %}" class="btn btn-primary"><i class="fas fa-edit"></i> Update Event</a>
<a href="{% url 'backoffice:event_delete' camp_slug=camp.slug slug=event.slug %}" class="btn btn-danger"><i class="fas fa-times"></i> Delete Event</a>
<a href="{% url 'backoffice:event_list' camp_slug=camp.slug %}" class="btn btn-default"><i class="fas fa-undo"></i> Event List</a>
</div>
</div>
{% endblock content %}

View file

@ -0,0 +1,28 @@
{% extends 'base.html' %}
{% load commonmark %}
{% load static %}
{% load bornhack %}
{% block title %}
Event List | Backoffice | {{ block.super }}
{% endblock %}
{% block content %}
<div class="panel panel-default">
<div class="panel-heading"><h3 class="panel-title">Event List - BackOffice</h3></div>
<div class="panel-body">
<p><i>Events</i> are the result of approving an <i>EventProposal</i>. They contain data for the event like title, abstract, duration and more. Search for an event title, speaker names, tags or event type to filter the list. <b>Any changes made here will be reflected immediately on the live site!</b></p>
{% if not event_list %}
<p class="lead">No Events found.</p>
{% else %}
<p>
<a class="btn btn-default" href="{% url 'backoffice:index' camp_slug=camp.slug %}"><i class="fas fa-undo"></i> Backoffice</a>
{% include "includes/event_list_table_backoffice.html" %}
</p>
{% endif %}
<p>
<a class="btn btn-default" href="{% url 'backoffice:index' camp_slug=camp.slug %}"><i class="fas fa-undo"></i> Backoffice</a>
</p>
</div>
</div>
{% endblock content %}

View file

@ -0,0 +1,18 @@
{% extends 'base.html' %}
{% load bootstrap3 %}
{% block content %}
<div class="panel panel-default">
<div class="panel-heading">
<h2 class="panel-title">Delete EventLocation {{ event_location.name }}?</h2>
</div>
<div class="panel-body">
<p class="lead">This EventLocation has <b>{{ event_location.event_sessions.count }}</b> EventSessions with <b>{{ event_location.event_slots.count }}</b> EventSlots which have a total of <b>{{ event_location.scheduled_event_slots.count }}</b> Events scheduled. Deleting the EventLocation will also delete the EventSessions and EventSlots, removing the Events from the schedule!</p>
<form method="POST">
{% csrf_token %}
{% bootstrap_button "<i class='fas fa-times'></i> Yes, Delete it" button_type="submit" button_class="btn-danger" name="Delete" %}
<a href="{% url 'backoffice:event_location_detail' camp_slug=camp.slug slug=event_location.slug %}" class="btn btn-primary"><i class="fas fa-undo"></i> Cancel</a>
</form>
</div>
</div>
{% endblock content %}

View file

@ -0,0 +1,89 @@
{% extends 'base.html' %}
{% load bootstrap3 %}
{% block content %}
<div class="panel panel-default">
<div class="panel-heading">
<h3 class="panel-title">EventLocation Details</h3>
</div>
<div class="panel-body">
<table class="table">
<tr>
<th>Name</th>
<td><i class="fas fa-{{ event_location.icon }}"></i> {{ event_location.name }}</td>
</tr>
<tr>
<th>Icon</th>
<td>fas fa-{{ event_location.icon }}</td>
</tr>
<tr>
<th>Capacity</th>
<td><i class="fas fa-users"></i> {{ event_location.capacity }}</td>
</tr>
<tr>
<th>Conflicts</th>
<td>
{% if event_location.conflicts.all %}
<table class="table table-condensed">
<thead>
<tr>
<th>Name</th>
<th>Capacity</th>
<th>Sessions</th>
</tr>
</thead>
<tbody>
{% for conflict in event_location.conflicts.all %}
<tr>
<td><a href="{% url 'backoffice:event_location_detail' camp_slug=camp.slug slug=conflict.slug %}">{{ conflict.name }}</a></td>
<td><span class="badge">{{ conflict.capacity }}</span></td>
<td><span class="badge">{{ conflict.event_sessions.count }}</span></td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
N/A
{% endif %}
</td>
</tr>
<tr>
<th>Sessions</th>
<td>
{% if event_location.event_sessions.all %}
<table class="table table-condensed">
<thead>
<tr>
<th>Session</th>
<th>EventType</th>
<th>When</th>
<th>Description</th>
<th class="text-center">Slots</th>
<th class="text-center">Events</th>
</tr>
</thead>
<tbody>
{% for session in event_location.event_sessions.all %}
<tr>
<td><a href="{% url 'backoffice:event_session_detail' camp_slug=camp.slug pk=session.pk %}">{{ session.pk }}</a></td>
<td><a href="{% url 'backoffice:event_type_detail' camp_slug=camp.slug slug=session.event_type.slug %}">{{ session.event_type.icon_html }} {{ session.event_type.name }}</a></td>
<td>{{ session.when.lower }} to<br> {{ session.when.upper }}</td>
<td>{{ session.description|default:"N/A" }}</td>
<td class="text-center"><span class="badge">{{ session.event_slots.count }}</span></td>
<td class="text-center"><span class="badge">{{ session.scheduled_event_slots.count }}</span></td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
N/A
{% endif %}
</td>
</tr>
</table>
<a href="{% url 'backoffice:event_location_update' camp_slug=camp.slug slug=event_location.slug %}" class="btn btn-primary"><i class="fas fa-edit"></i> Update Location</a>
<a href="{% url 'backoffice:event_location_delete' camp_slug=camp.slug slug=event_location.slug %}" class="btn btn-danger"><i class="fas fa-times"></i> Delete Location</a>
<a href="{% url 'backoffice:event_location_list' camp_slug=camp.slug %}" class="btn btn-default"><i class="fas fa-undo"></i> EventLocation List</a>
</div>
</div>
{% endblock content %}

View file

@ -0,0 +1,19 @@
{% extends 'base.html' %}
{% load bootstrap3 %}
{% block content %}
<div class="panel panel-default">
<div class="panel-heading">
<h3 class="panel-title">{% if form.instance.pk %}Update{% else %}Create new{% endif %} EventLocation</h3>
</div>
<div class="panel-body">
<p class="lead">{% if form.instance.pk %}Update{% else %}Create{% endif %} EventLocation</p>
<form method="POST">
{% csrf_token %}
{% bootstrap_form form %}
<button type="submit" class="btn btn-success"><i class="fas fa-check"></i> Save</button>
<a href="{% url 'backoffice:event_session_list' camp_slug=camp.slug %}" class="btn btn-default"><i class="fas fa-undo"></i> Cancel</a>
</form>
</div>
</div>
{% endblock content %}

View file

@ -0,0 +1,69 @@
{% extends 'base.html' %}
{% block content %}
<div class="panel panel-default">
<div class="panel-heading">
<span class="h3">BackOffice - EventLocations</span>
</div>
<div class="panel-body">
<p><i>EventLocations</i> - the places where stuff happens!</p>
{% if not event_location_list %}
<p class="lead">
No EventLocations found. Create some!
<a href="{% url 'backoffice:event_location_create' camp_slug=camp.slug %}" class="btn btn-success"><i class="fas fa-plus"></i> Create EventLocation</a>
</p>
{% else %}
<p>
<a href="{% url 'backoffice:event_location_create' camp_slug=camp.slug %}" class="btn btn-success"><i class="fas fa-plus"></i> Create EventLocation</a>
<a class="btn btn-default" href="{% url 'backoffice:index' camp_slug=camp.slug %}"><i class="fas fa-undo"></i> Backoffice</a>
</p>
<table class="table table-hover datatable">
<thead>
<tr>
<th>Name</th>
<th class="text-center">Capacity</th>
<th>Location Conflicts</th>
<th class="text-center">Sessions</th>
<th class="text-center">Slots</th>
<th class="text-center">Events</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for location in event_location_list %}
<tr>
<td>{{ location.icon_html }} <a href="{% url 'backoffice:event_location_detail' camp_slug=camp.slug slug=location.slug %}">{{ location.name }}</a></td>
<td class="text-center"><span class="badge">{{ location.capacity }}</span></td>
<td>
{% if location.conflicts.exists %}
<ul class="list-group">
{% for conflict in location.conflicts.all %}
<a href="{% url 'backoffice:event_location_detail' camp_slug=camp.slug slug=conflict.slug %}"class="list-group-item"><i class="fas fa-{{ conflict.icon }}"></i> {{ conflict.name }}</a>
{% endfor %}
{% else %}
N/A
{% endif %}
</ul>
</td>
<td class="text-center"><span class="badge">{{ location.event_sessions.count }}</span></td>
<td class="text-center"><span class="badge">{{ location.event_slots.count }}</span></td>
<td class="text-center"><span class="badge">{{ location.scheduled_event_slots.count }}</span></td>
<td>
<div class="btn-group-vertical">
<a href="{% url 'backoffice:event_location_detail' camp_slug=camp.slug slug=location.slug %}" class="btn btn-primary"><i class="fas fa-search"></i> Details</a>
<a href="{% url 'backoffice:event_location_update' camp_slug=camp.slug slug=location.slug %}" class="btn btn-primary"><i class="fas fa-edit"></i> Update</a>
<a href="{% url 'backoffice:event_location_delete' camp_slug=camp.slug slug=location.slug %}" class="btn btn-danger"><i class="fas fa-times"></i> Delete</a>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
<p>
<a href="{% url 'backoffice:event_location_create' camp_slug=camp.slug %}" class="btn btn-success"><i class="fas fa-plus"></i> Create EventLocation</a>
<a class="btn btn-default" href="{% url 'backoffice:index' camp_slug=camp.slug %}"><i class="fas fa-undo"></i> Backoffice</a>
</p>
</div>
</div>
{% endblock content %}

View file

@ -0,0 +1,28 @@
{% extends 'base.html' %}
{% load bootstrap3 %}
{% block content %}
{% if not form.instance.can_be_approved %}
<br>
<p><span class="alert alert-warning">NOTE: Not all SpeakerProposals associated with this EventProposal have been approved. EventProposal can not be approved! It can still be rejected though.</span></p>
<br>
{% endif %}
<div class="panel panel-default">
<div class="panel-heading">
<h3 class="panel-title">Approve or Reject {{ form.instance.event_type.name }} Proposal</h3>
</div>
<div class="panel-body">
<p>Submitter {{ event_proposal.user }} will receive an email when the EventProposal is approved or rejected. It is possible to include an extra message in the form below explaining why the proposal was accepted or rejected. If the field is left blank a standard email is sent.</p>
<form method="POST">
{% csrf_token %}
{% bootstrap_form form %}
{% if form.instance.can_be_approved %}
<button type="submit" class="btn btn-success" name="approve"><i class='fas fa-check'></i> Approve Proposal</button>
{% endif %}
<button type="reject" class="btn btn-danger" name="reject"><i class='fas fa-times'></i> Reject Proposal</button>
<a href="{% url 'backoffice:pending_proposals' camp_slug=camp.slug %}" class="btn btn-primary"><i class="fas fa-undo"></i> Cancel</a>
</form>
</div>
</div>
{% endblock content %}

View file

@ -0,0 +1,95 @@
{% extends 'base.html' %}
{% load commonmark %}
{% load bornhack %}
{% block content %}
<p><a href="{% url 'backoffice:event_proposal_list' camp_slug=camp.slug %}" class="btn btn-default"><i class="fas fa-undo"></i> Back to EventProposal List</a></p>
{% if not event_proposal.can_be_approved %}
<br>
<p><span class="alert alert-warning">NOTE: Not all SpeakerProposals associated with this EventProposal have been approved. EventProposal can not be approved! It can still be rejected though.</span></p>
<br>
{% endif %}
<div class="panel panel-default">
<div class="panel-heading"><i class="fas fa-{{ event_proposal.event_type.icon }} fa-lg" style="color: {{ event_proposal.event_type.color }}"></i> <span class="h3">{{ event_proposal.event_type.name }} Proposal: <i>{{ event_proposal.title }}</i></span></div>
<div class="panel-body">
{{ event_proposal.abstract|untrustedcommonmark }}
<hr>
<h4>Details for <i>{{ event_proposal.title }}</i></h4>
<table class="table">
<tbody>
<tr>
<th>UUID</th>
<td>{{ event_proposal.uuid }}</td>
</tr>
<tr>
<th>Status</th>
<td>{{ event_proposal.proposal_status }}</td>
</tr>
<tr>
<th>EventType</th>
<td>{{ event_proposal.event_type.icon_html }} <a href="{% url 'backoffice:event_type_detail' camp_slug=camp.slug slug=event_proposal.event_type.slug %}">{{ event_proposal.event_type }}</a></td>
</tr>
<tr>
<th>Duration</th>
<td>{{ event_proposal.duration }} minutes</td>
</tr>
<tr>
<th>Tags</th>
<td>
{% for tag in event_proposal.tags.all %}
<span class="badge">{{ tag }}</span>
{% empty %}
N/A
{% endfor %}
</td>
</tr>
<tr>
<th>Use provided laptop?</th>
<td>{{ event_proposal.use_provided_speaker_laptop|truefalseicon }}</td>
</tr>
<tr>
<th>Submission Notes</th>
<td>{{ event_proposal.submission_notes|untrustedcommonmark|default:"N/A" }}</td>
</tr>
</tbody>
</table>
<hr>
<h4>URLs for <i>{{ event_proposal.title }}</i></h4>
{% if event_proposal.urls.exists %}
{% include 'includes/event_proposal_url_table.html' %}
{% else %}
<i>Nothing found.</i>
{% endif %}
<hr>
<h4>{{ event_proposal.event_type.host_title }} Proposals for <i>{{ event_proposal.title }}</i></h4>
{% if event_proposal.speakers.exists %}
{% include 'includes/speaker_proposal_list_table_backoffice.html' with speaker_proposal_list=event_proposal.speakers.all nodatatable=True %}
{% else %}
<i>Nothing found.</i>
{% endif %}
</div>
<div class="panel-footer">Status: <span class="badge">{{ event_proposal.proposal_status }}</span> | ID: <span class="badge">{{ event_proposal.uuid }}</span></div>
</div>
{% if not event_proposal.can_be_approved %}
<p><span class="alert alert-warning">NOTE: Not all SpeakerProposals associated with this EventProposal have been approved. EventProposal can not be approved! It can still be rejected though.</span></p>
<br>
{% endif %}
<p>
{% if event_proposal.proposal_status == "pending" %}
{% if form.instance.can_be_approved %}
<a href="{% url 'backoffice:event_proposal_approve_reject' camp_slug=camp.slug pk=event_proposal.uuid %}" class="btn btn-success"><i class="fas fa-check"></i> Approve EventProposal</a>
{% endif %}
<a href="{% url 'backoffice:event_proposal_approve_reject' camp_slug=camp.slug pk=event_proposal.uuid %}" class="btn btn-danger"><i class="fas fa-times"></i> Reject EventProposal</a>
{% endif %}
<a href="{% url 'backoffice:event_proposal_list' camp_slug=camp.slug %}" class="btn btn-default"><i class="fas fa-undo"></i> Back to EventProposal List</a>
</p>
{% endblock content %}

View file

@ -0,0 +1,22 @@
{% extends 'base.html' %}
{% block title %}
EventProposal List | Backoffice | {{ block.super }}
{% endblock %}
{% block content %}
{% if event_proposal_list %}
<div class="panel panel-default">
<div class="panel-heading"><h3 class="panel-title">EventProposal List - BackOffice</h3></div>
<div class="panel-body">
<p>This is a list of all EventProposal objects in the system. Search for title, type, username, speakers or status to filter the table.</p>
<p><a href="{% url 'backoffice:index' camp_slug=camp.slug %}" class="btn btn-default"><i class="fas fa-undo"></i> Backoffice</a></p>
{% if event_proposal_list %}
{% include 'includes/event_proposal_list_table_backoffice.html' %}
{% else %}
No EventProposals found.
{% endif %}
</div>
</div>
{% endif %}
{% endblock %}

View file

@ -0,0 +1,48 @@
{% extends 'base.html' %}
{% load bootstrap3 %}
{% load static %}
{% load bornhack %}
{% block content %}
<div class="panel panel-default">
<div class="panel-heading">
<h3 class="panel-title">Select EventSlot for <i>{{ event.title }}</i></h3>
</div>
<div class="panel-body">
<p>The following EventSlots are available and suitable for scheduling <i>{{ event.title }}</i></p>
<p>This event has a demand of {{ event.demand }} people.</p>
<form method="POST">
{% csrf_token %}
{% if event_slots %}
<table class="table table-hover datatable">
<thead>
<tr>
<th>Pick</th>
<th>Start</th>
<th>End</th>
<th>Location</th>
<th class="text-center">Capacity</th>
</tr>
</thead>
<tbody>
{% for slot in event_slots %}
<tr>
<td><input id="id_slot_{{ slot.index }}" name="slot" value="{{ slot.index }}" type="radio" required></td>
<td>{{ slot.slot.when.lower }}</td>
<td>{{ slot.slot.when.upper }}</td>
<td><i class="fas fa-{{ slot.slot.event_session.event_location.icon }}"></i> <a href="{% url 'backoffice:event_location_detail' camp_slug=camp.slug slug=slot.slot.event_location.slug %}">{{ slot.slot.event_session.event_location.name }}</a></td>
<td class="text-center{% if event.demand > slot.slot.event_session.event_location.capacity %} danger{% endif %}"><span class="badge">{{ slot.slot.event_session.event_location.capacity }}</span></td>
</tr>
{% endfor %}
</tbody>
</table>
<button type="submit" class="btn btn-success"><i class="fas fa-check"></i> Schedule Event</button>
{% else %}
<p class="lead">No available slots found</p>
{% endif %}
<a href="{% url 'backoffice:event_detail' camp_slug=camp.slug slug=event.slug %}" class="btn btn-default"><i class="fas fa-undo"></i> Cancel</a>
</form>
</div>
</div>
{% endblock content %}

View file

@ -0,0 +1,25 @@
{% extends 'base.html' %}
{% load bootstrap3 %}
{% block content %}
<div class="panel panel-default">
<div class="panel-heading">
<h3 class="panel-title">Create New EventSession - Select Event Location</h3>
</div>
<div class="panel-body">
<p class="lead">Select Event Location for the new EventSession</p>
<div class="list-group">
{% for location in event_location_list %}
<a href="{%url 'backoffice:event_session_create' camp_slug=camp.slug event_type_slug=event_type.slug event_location_slug=location.slug %}" class="list-group-item">
<h4 class="list-group-item-heading">
<i class="fas fa-{{ location.icon }} fa-2x fa-pull-left fa-fw"></i>
{{ location.name }}<span class="pull-right"><i class="fas fa-plus fa-2x fa-pull-right"></i></span>
</h4>
<p class="list-group-item-text">Create EventSession for {{ location.name }}</p>
</a>
{% endfor %}
</div>
</div>
</div>
{% endblock content %}

View file

@ -0,0 +1,25 @@
{% extends 'base.html' %}
{% load bootstrap3 %}
{% block content %}
<div class="panel panel-default">
<div class="panel-heading">
<h3 class="panel-title">Create New EventSession - Select Event Type</h3>
</div>
<div class="panel-body">
<p class="lead">Select Event Type for the new EventSession</p>
<div class="list-group">
{% for event_type in event_type_list %}
<a href="{%url 'backoffice:event_session_create_location_select' camp_slug=camp.slug event_type_slug=event_type.slug %}" class="list-group-item">
<h4 class="list-group-item-heading">
<i class="fas fa-{{ event_type.icon }} fa-2x fa-pull-left fa-fw" style="color: {{ event_type.color }};"></i>
{{ event_type.name }}<span class="pull-right"><i class="fas fa-plus fa-2x fa-pull-right" style="color: {{ event_type.color }};"></i></span>
</h4>
{% if event_type.description %}<p class="list-group-item-text">{{ event_type.description }}</p>{% endif %}
</a>
{% endfor %}
</div>
</div>
</div>
{% endblock content %}

View file

@ -0,0 +1,19 @@
{% extends 'base.html' %}
{% load bootstrap3 %}
{% block content %}
<div class="panel panel-default">
<div class="panel-heading">
<h2 class="panel-title">Delete EventSession?</h2>
</div>
<div class="panel-body">
<p class="lead">This EventSession runs from <b>{{ session.when.lower }}</b> to <b>{{ session.when.upper }}</b>. We currently have <b>{{ session.scheduled_event_slots.count }}</b> Events scheduled in this session, leaving <b>{{ session.free_time }}</b> time unused. Deleting an EventSession means deleting all the EventSlots, which means anything scheduled will no longer be scheduled.</p>
<form method="POST">
{% csrf_token %}
{% bootstrap_button "<i class='fas fa-times'></i> Yes, Delete it" button_type="submit" button_class="btn-danger" name="Delete" %}
<a href="{% url 'backoffice:event_session_list' camp_slug=camp.slug %}" class="btn btn-primary"><i class="fas fa-undo"></i> Cancel</a>
</form>
</div>
</div>
{% endblock content %}

View file

@ -0,0 +1,82 @@
{% extends 'base.html' %}
{% load bootstrap3 %}
{% block content %}
<div class="panel panel-default">
<div class="panel-heading">
<h3 class="panel-title">EventSession Details</h3>
</div>
<div class="panel-body">
<table class="table">
<tr>
<th>Session ID</th>
<td>{{ session.pk }}</td>
</tr>
<tr>
<th>Event Type</th>
<td><i class="fas fa-{{ session.event_type.icon }}" style="color: {{ session.event_type.color }};"></i> <a href="{% url 'backoffice:event_type_detail' camp_slug=camp.slug slug=session.event_type.slug %}">{{ session.event_type }}</a></td>
</tr>
<tr>
<th>Event Location</th>
<td><i class="fas fa-{{ session.event_location.icon }}"></i> <a href="{% url 'backoffice:event_location_detail' camp_slug=camp.slug slug=session.event_location.slug %}">{{ session.event_location.name }}</td>
</tr>
<tr>
<th>Session Start</th>
<td>{{ session.when.lower }}</td>
</tr>
<tr>
<th>Session End</th>
<td>{{ session.when.upper }}</td>
</tr>
<tr>
<th>Session Duration</th>
<td>{{ session.duration }}</td>
</tr>
<tr>
<th>EventSlots and Events</th>
<td>
<table class="table table-condensed">
<thead>
<tr>
<th>Start</th>
<th>End</th>
<th>Event</th>
<th>Speakers</th>
</tr>
</thead>
<tbody>
{% for slot in session.event_slots.all %}
<tr>
<td><a href="{% url 'backoffice:event_slot_detail' camp_slug=camp.slug pk=slot.pk %}">{{ slot.when.lower }}</a></td>
<td>{{ slot.when.upper }}</td>
<td>
{% if slot.event %}
<a href="{% url 'backoffice:event_detail' camp_slug=camp.slug slug=slot.event.slug %}">{{ slot.event.event_type.icon_html }} {{ slot.event.title }}</a>
{% else %}
N/A
{% endif %}
</td>
<td>
{% if slot.event.speakers.exists %}
<ul class="list-group">
{% for speaker in slot.event.speakers.all %}
<a href="{% url 'backoffice:speaker_detail' camp_slug=camp.slug slug=speaker.slug %}" class="list-group-item"><i class="fas fa-user"></i> {{ speaker.name }}</a>
{% endfor %}
</ul>
{% else %}
N/A
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</td>
</tr>
</table>
<a href="{% url 'backoffice:event_session_update' camp_slug=camp.slug pk=session.id %}" class="btn btn-primary"><i class="fas fa-edit"></i> Update EventSession</a>
<a href="{% url 'backoffice:event_session_delete' camp_slug=camp.slug pk=session.id %}" class="btn btn-danger"><i class="fas fa-times"></i> Delete EventSession</a>
<a href="{% url 'backoffice:event_session_list' camp_slug=camp.slug %}" class="btn btn-default"><i class="fas fa-undo"></i> List EventSessions</a>
</div>
</div>
{% endblock content %}

View file

@ -0,0 +1,63 @@
{% extends 'base.html' %}
{% load bootstrap3 %}
{% load tz %}
{% block content %}
<div class="panel panel-default">
<div class="panel-heading">
<h3 class="panel-title">{% if form.instance.pk %}Update{% else %}Create new{% endif %} EventSession</h3>
</div>
<div class="panel-body">
<p class="lead">{% if form.instance.pk %}Update{% else %}Create new{% endif %} EventSession for Event Type <b>{{ event_type }}</b> at location <b>{{ event_location.name }}</b>.</p>
<form method="POST">
{% csrf_token %}
{% bootstrap_form form %}
{% get_current_timezone as TIME_ZONE %}
<div class="alert alert-info">Note: Input will be interpreted as being in time zone {{ TIME_ZONE }}.</div>
<button type="submit" class="btn btn-success"><i class="fas fa-check"></i> Save</button>
<a href="{% url 'backoffice:event_session_list' camp_slug=camp.slug %}" class="btn btn-default"><i class="fas fa-undo"></i> Cancel</a>
</form>
</div>
</div>
{% if sessions %}
<div class="panel panel-default">
<div class="panel-heading">
<h4 class="panel-title">We have {{ sessions.count }} existing EventSessions for EventType <b>{{ event_type }}</b> at location <b>{{ event_location.name }}</b>:</h4>
</div>
<div class="panel-body">
<table class="table table-striped table-hover">
<thead>
<tr>
<th>id</th>
<th>Session Start</th>
<th>Session End</th>
<th>Session Length</th>
<th>Events</th>
<th>Free Time</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for session in sessions %}
<tr>
<td>{{ session.id }}</td>
<td>{{ session.when.lower }}</td>
<td>{{ session.when.upper }}</td>
<td>{{ session.duration }}</td>
<td>{{ session.event_count }}</td>
<td>{{ session.free_time }}</td>
<td>
<div class="btn-group-vertical">
<a href="{% url 'backoffice:event_session_detail' camp_slug=camp.slug pk=session.id %}" class="btn btn-primary btn-xs"><i class="fas fa-search"></i> Details</a>
<a href="{% url 'backoffice:event_session_update' camp_slug=camp.slug pk=session.id %}" class="btn btn-primary btn-xs"><i class="fas fa-edit"></i> Update</a>
<a href="{% url 'backoffice:event_session_delete' camp_slug=camp.slug pk=session.id %}" class="btn btn-danger btn-xs"><i class="fas fa-times"></i> Delete</a>
</div>
</td>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endif %}
{% endblock content %}

View file

@ -0,0 +1,70 @@
{% extends 'base.html' %}
{% load commonmark %}
{% load static %}
{% load bornhack %}
{% block content %}
<div class="panel panel-default">
<div class="panel-heading">
<span class="h3">BackOffice - EventSessions</span>
</div>
<div class="panel-body">
<p><i>EventSessions</i> define the <i>EventSlots</i> where <i>Events</i> of a certain <i>EventType</i> at a certain <i>EventLocation</i> can be scheduled. <i>EventSessions</i> and <i>EventSlots</i> are used in the following ways in the system:</p>
<ul>
<li>To customise the speaker availability form shown when users submit speakers.</li>
<li>They are also used to assist and restrict manual scheduling in backoffice.</li>
<li>They are also used by the AutoScheduler.</li>
</ul>
<p>Search for an EventType or EventLocation to filter the list.</p>
{% if not event_session_list %}
<p class="lead">
No EventSessions found. Go create one!
<a href="{% url 'backoffice:event_session_create_type_select' camp_slug=camp.slug %}" class="btn btn-success"><i class="fas fa-plus"></i> Create New EventSession</a>
</p>
{% else %}
<p>
<a class="btn btn-success" href="{% url 'backoffice:event_session_create_type_select' camp_slug=camp.slug %}"><i class="fas fa-plus"></i> Create New EventSession</a>
<a class="btn btn-default" href="{% url 'backoffice:index' camp_slug=camp.slug %}"><i class="fas fa-undo"></i> Backoffice</a>
</p>
<table class="table table-hover datatable">
<thead>
<tr>
<th>Session ID</th>
<th>When</th>
<th>Description</th>
<th class="text-center">Duration</th>
<th class="text-center">Scheduled Events</th>
<th>Event Type</th>
<th>Event Location</th>
<th>Action</th>
</tr>
</thead>
<tbody>
{% for session in event_session_list %}
<tr>
<td><a href="{% url 'backoffice:event_session_detail' camp_slug=camp.slug pk=session.id %}"> {{ session.id }}</a></td>
<td>{{ session.when.lower }} to<br>{{ session.when.upper }}</td>
<td>{{ session.description|default:"N/A" }}</td>
<td class="text-center"><span class="badge">{{ session.duration }}</span><br><span class="badge">{{ session.event_slots.count }} slots</span></td>
<td class="text-center"><span class="badge">{{ session.scheduled_event_slots.count }}</td>
<td><i class="fas fa-{{ session.event_type.icon }}" style="color: {{ session.event_type.color }};"></i> <a href="{% url 'backoffice:event_type_detail' camp_slug=camp.slug slug=session.event_type.slug %}">{{ session.event_type }}</a></td>
<td><i class="fas fa-{{ session.event_location.icon }}"></i> <a href="{% url 'backoffice:event_location_detail' camp_slug=camp.slug slug=session.event_location.slug %}">{{ session.event_location.name }}</a></td>
<td>
<div class="btn-group-vertical">
<a href="{% url 'backoffice:event_session_detail' camp_slug=camp.slug pk=session.id %}" class="btn btn-primary"><i class="fas fa-search"></i> Details</a>
<a href="{% url 'backoffice:event_session_update' camp_slug=camp.slug pk=session.id %}" class="btn btn-primary"><i class="fas fa-edit"></i> Update</a>
<a href="{% url 'backoffice:event_session_delete' camp_slug=camp.slug pk=session.id %}" class="btn btn-danger"><i class="fas fa-times"></i> Delete</a>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
<p>
<a class="btn btn-success" href="{% url 'backoffice:event_session_create_type_select' camp_slug=camp.slug %}"><i class="fas fa-plus"></i> Create New Session</a>
<a class="btn btn-default" href="{% url 'backoffice:index' camp_slug=camp.slug %}"><i class="fas fa-undo"></i> Backoffice</a>
</p>
</div>
</div>
{% endblock content %}

View file

@ -0,0 +1,54 @@
{% extends 'base.html' %}
{% load bootstrap3 %}
{% block content %}
<div class="panel panel-default">
<div class="panel-heading">
<h3 class="panel-title">EventSlot {{ event_slot.pk }} Details</h3>
</div>
<div class="panel-body">
<table class="table">
<tr>
<th>EventSlot ID</th>
<td>{{ event_slot.pk }}</td>
</tr>
<tr>
<th>EventSession</th>
<td>This EventSlot is part of <a href="{% url 'backoffice:event_session_detail' camp_slug=camp.slug pk=event_slot.event_session.pk %}">EventSession {{ event_slot.event_session.pk }}</a> which happens at <i class="fas fa-{{ event_slot.event_session.event_location.icon }}"></i> {{ event_slot.event_session.event_location.name }} from {{ event_slot.event_session.when.lower }} to {{ event_slot.event_session.when.upper }}.</td>
</tr>
<tr>
<th>Event Type</th>
<td><i class="fas fa-{{ event_slot.event_session.event_type.icon }}" style="color: {{ event_slot.event_session.event_type.color }};"></i> <a href="{% url 'backoffice:event_type_detail' camp_slug=camp.slug slug=event_slot.event_session.event_type.slug %}">{{ event_slot.event_session.event_type }}</a></td>
</tr>
<tr>
<th>Event Location</th>
<td><i class="fas fa-{{ event_slot.event_session.event_location.icon }}"></i> <a href="{% url 'backoffice:event_location_detail' camp_slug=camp.slug slug=event_slot.event_session.event_location.slug %}">{{ event_slot.event_session.event_location.name }}</td>
</tr>
<tr>
<th>EventSlot Start</th>
<td>{{ event_slot.when.lower }}</td>
</tr>
<tr>
<th>EventSlot End</th>
<td>{{ event_slot.when.upper }}</td>
</tr>
<tr>
<th>EventSlot Duration</th>
<td>{{ event_slot.duration }}</td>
</tr>
<tr>
<th>Scheduled Event</th>
<td>
{% if event_slot.event %}
<a href="{% url 'backoffice:event_detail' camp_slug=camp.slug slug=event_slot.event.slug %}">{{ event_slot.event_type.icon_html }} {{ event_slot.event.title }}</a>
<a href="{% url 'backoffice:event_slot_unschedule' camp_slug=camp.slug pk=event_slot.pk %}" class="btn btn-danger btn-xs"><i class="fas fa-times"></i> Unschedule Event</a>
{% else %}
N/A
{% endif %}
</td>
</tr>
</table>
<a href="{% url 'backoffice:event_slot_list' camp_slug=camp.slug %}" class="btn btn-default"><i class="fas fa-undo"></i> List EventSlots</a>
</div>
</div>
{% endblock content %}

View file

@ -0,0 +1,82 @@
{% extends 'base.html' %}
{% load commonmark %}
{% load static %}
{% load bornhack %}
{% block content %}
<div class="panel panel-default">
<div class="panel-heading">
<span class="h3">BackOffice - EventSlots</span>
</div>
<div class="panel-body">
<p><i>EventSlots</i> are what we schedule <i>Events</i> in. Search for an Event, Speaker, EventType, EventLocation, or day to filter the list.</p>
{% if not event_slot_list %}
<p class="lead">
No EventSlots found. Go create some EventSessions!
<a href="{% url 'backoffice:event_session_create_type_select' camp_slug=camp.slug %}" class="btn btn-success"><i class="fas fa-plus"></i> Create EventSession</a>
<a class="btn btn-default" href="{% url 'backoffice:index' camp_slug=camp.slug %}"><i class="fas fa-undo"></i> Backoffice</a>
</p>
{% else %}
<p>
<a class="btn btn-default" href="{% url 'backoffice:index' camp_slug=camp.slug %}"><i class="fas fa-undo"></i> Backoffice</a>
</p>
<table class="table table-striped datatable">
<thead>
<tr>
<th>Start</th>
<th>End</th>
<th>Session</th>
<th>Type</th>
<th>Location</th>
<th>Event</th>
<th>Speakers</th>
<th>Overflow</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for slot in event_slot_list %}
<tr>
<td>{{ slot.when.lower }}</td>
<td>{{ slot.when.upper }}</td>
<td><a href="{% url 'backoffice:event_session_detail' camp_slug=camp.slug pk=slot.event_session.id %}" class="btn btn-primary"><i class="fas fa-chess-board"></i> {{ slot.event_session.id }}</a></td>
<td>{{ slot.event_type.icon_html }} <a href="{% url 'backoffice:event_type_detail' camp_slug=camp.slug slug=slot.event_type.slug %}">{{ slot.event_type.name }}</a></td>
<td>{{ slot.event_location.icon_html }} <a href="{% url 'backoffice:event_location_detail' camp_slug=camp.slug slug=slot.event_location.slug %}">{{ slot.event_location.name }}</a></td>
{% if slot.event %}
<td><a href="{% url 'backoffice:event_detail' camp_slug=camp.slug slug=slot.event.slug %}">{{ slot.event.title }}</a><br>({% if slot.autoscheduled %}autoscheduled{% else %}manual{% endif %})</td>
<td>
{% if slot.event.speakers.exists %}
<ul class="list-group">
{% for speaker in slot.event.speakers.all %}
<a href="{% url 'backoffice:speaker_detail' camp_slug=camp.slug slug=speaker.slug %}" class="list-group-item"><i class="fas fa-user"></i> {{ speaker.name }}</li></a>
{% endfor %}
{% else %}
N/A
{% endif %}
</ul>
</td>
<td class="text-center {% if slot.overflow %}danger{% endif %}"><span class="badge">{{ slot.overflow }}</span></td>
{% else %}
<td>N/A</td>
<td>N/A</td>
<td>N/A</td>
{% endif %}
<td>
<div class="btn-group-vertical">
<a href="{% url 'backoffice:event_slot_detail' camp_slug=camp.slug pk=slot.id %}" class="btn btn-primary"><i class="fas fa-search"></i> Details</a>
{% if slot.event %}
<a href="{% url 'backoffice:event_slot_unschedule' camp_slug=camp.slug pk=slot.id %}" class="btn btn-danger"><i class="fas fa-times"></i> Unschedule</a>
{% endif %}
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
<p>
<a class="btn btn-default" href="{% url 'backoffice:index' camp_slug=camp.slug %}"><i class="fas fa-undo"></i> Backoffice</a>
</p>
</div>
</div>
{% endblock content %}

View file

@ -0,0 +1,24 @@
{% extends 'base.html' %}
{% load bootstrap3 %}
{% block content %}
<div class="panel panel-default">
<div class="panel-heading">
<h3 class="panel-title">Unschedule Event <i>{{ event_slot.event.title }}</i>?</h3>
</div>
<div class="panel-body">
<p>This EventSlot runs from <b>{{ event_slot.when.lower }} to {{ event_slot.when.upper }}</b>. It is part of an EventSession for {{ event_slot.event_type.icon_html }} {{ event_slot.event_type }} at location {{ event_slot.event_location }}.</p>
<p>The scheduled Event is titled <i>{{ event_slot.event.title }}</i></p>
<p>Really unschedule this Event?</p>
<form method="POST">
{% csrf_token %}
{% bootstrap_form form %}
<button type="submit" class="btn btn-danger"><i class='fas fa-times'></i> Yes, Unschedule It</button>
<a href="{% url 'backoffice:event_slot_detail' camp_slug=camp.slug pk=event_slot.pk %}" class="btn btn-primary"><i class="fas fa-undo"></i> Cancel</a>
</form>
</div>
</div>
{% endblock content %}

View file

@ -0,0 +1,91 @@
{% extends 'base.html' %}
{% load bootstrap3 %}
{% block content %}
<div class="panel panel-default">
<div class="panel-heading">
<h3 class="panel-title">Details for EventType: {{ event_type.name }}</h3>
</div>
<div class="panel-body">
<table class="table">
<tr>
<th>Event Type</th>
<td>
<i class="fas fa-{{ event_type.icon }}" style="color: {{ event_type.color }};"></i> {{ event_type }}
</tr>
<tr>
<th>EventSessions</th>
<td>
{% if event_sessions %}
<table class="table table-condensed table-striped">
<thead>
<tr>
<th>Location</th>
<th>Start</th>
<th>End</th>
<th class="text-center">Slots</th>
<th class="text-center">Events</th>
</tr>
</thead>
<tbody>
{% for session in event_sessions %}
<tr>
<td>
{{ session.event_location.icon_html }} <a href="{% url 'backoffice:event_location_detail' camp_slug=camp.slug slug=session.event_location.slug %}">{{ session.event_location.name }}</a>
</td>
<td>{{ session.when.lower }}</td>
<td>{{ session.when.upper }}</td>
<td class="text-center"><span class="badge">{{ session.event_slots.count }}</span></td>
<td class="text-center"><span class="badge">{{ session.scheduled_event_slots.count }}</span></td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
N/A
{% endif %}
</td>
</tr>
<tr>
<th>Events</th>
<td>
{% if events %}
<table class="table table-condensed table-striped">
<thead>
<tr>
<th>Title</th>
<th>People</th>
<th>Slots</th>
</tr>
</thead>
<tbody>
{% for event in events %}
<tr>
<td>{{ event.event_type.icon_html }} <a href="{% url 'backoffice:event_detail' camp_slug=camp.slug slug=event.slug %}">{{ event.title }}</a></td>
<td>
<ul class="list-group">
{% for speaker in event.speakers.all %}
<a href="{% url 'backoffice:speaker_detail' camp_slug=camp.slug slug=speaker.slug %}" class="list-group-item"><i class="fas fa-user"></i> {{ speaker.name }}</a>
{% empty %}
N/A
{% endfor %}
</ul>
</td>
<td>
<ul class="list-group">
{% for slot in event.event_slots.all %}
<a href="{% url 'backoffice:event_slot_detail' camp_slug=camp.slug pk=slot.pk %}" class="list-group-item">From {{ slot.when.lower }} to {{ slot.when.upper }} at {{ slot.event_location.name }}</a>{% endfor %}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
N/A
{% endif %}
</td>
</tr>
</table>
<a href="{% url 'backoffice:event_type_list' camp_slug=camp.slug %}" class="btn btn-default"><i class="fas fa-undo"></i> EventType List</a>
</div>
</div>
{% endblock content %}

View file

@ -0,0 +1,50 @@
{% extends 'base.html' %}
{% block content %}
<div class="panel panel-default">
<div class="panel-heading">
<span class="h3">BackOffice - EventTypes</span>
</div>
<div class="panel-body">
<p><i>EventTypes</i> define categories or types of events, like "Music Act" or "Workshop" or "Talk". They are not camp specific, so they are not editable here.</p>
{% if not event_type_list %}
<p class="lead">
No EventTypes found. Is this the twillight zone?
</p>
{% else %}
<p>
<a class="btn btn-default" href="{% url 'backoffice:index' camp_slug=camp.slug %}"><i class="fas fa-undo"></i> Backoffice</a>
</p>
<table class="table table-hover datatable">
<thead>
<tr>
<th>Name</th>
<th class="text-center">Sessions</th>
<th class="text-center">Slots</th>
<th class="text-center">Events</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for type in event_type_list %}
<tr>
<td>{{ type.icon_html }} <a href="{% url 'backoffice:event_type_detail' camp_slug=camp.slug slug=type.slug %}">{{ type.name }}</a></td>
<td class="text-center"><span class="badge">{{ type.event_sessions_count }}</span></td>
<td class="text-center"><span class="badge">{{ type.event_slots_count }}</span></td>
<td class="text-center"><span class="badge">{{ type.event_count }}</span></td>
<td>
<div class="btn-group-vertical">
<a href="{% url 'backoffice:event_type_detail' camp_slug=camp.slug slug=type.slug %}" class="btn btn-primary"><i class="fas fa-search"></i> Details</a>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
<p>
<a class="btn btn-default" href="{% url 'backoffice:index' camp_slug=camp.slug %}"><i class="fas fa-undo"></i> Backoffice</a>
</p>
</div>
</div>
{% endblock content %}

View file

@ -0,0 +1,21 @@
{% extends 'base.html' %}
{% load bootstrap3 %}
{% load static %}
{% load bornhack %}
{% block content %}
<div class="panel panel-default">
<div class="panel-heading">
<h3 class="panel-title">Update Event: {{ event.title }}</h3>
</div>
<div class="panel-body">
<p class="lead">Note: Any changes made here will be overwritten if the EventProposal for this event is later re-approved.</p>
<form method="POST">
{% csrf_token %}
{% bootstrap_form form %}
<button type="submit" class="btn btn-success"><i class="fas fa-check"></i> Update Event</button>
<a href="{% url 'backoffice:event_list' camp_slug=camp.slug %}" class="btn btn-default"><i class="fas fa-undo"></i> Cancel</a>
</form>
</div>
</div>
{% endblock content %}

View file

@ -1,11 +1,6 @@
{% extends 'base.html' %} {% extends 'base.html' %}
{% load static %} {% load static %}
{% block extra_head %}
<script src="{% static "js/jquery.dataTables.min.js" %}"></script>
<link rel="stylesheet" href="{% static 'css/jquery.dataTables.min.css' %}">
{% endblock extra_head %}
{% block content %} {% block content %}
<h2>Manage Expenses for {{ camp.title }}</h2> <h2>Manage Expenses for {{ camp.title }}</h2>

View file

@ -0,0 +1,63 @@
<table class="table">
<caption>Event Differences - each row shows an event which changed time or location</caption>
<thead>
<tr>
<th>Event</th>
<th>Old Location</th>
<th>New Location</th>
<th>Old Start Time</th>
<th>New Start Time</th>
</tr>
</thead>
<tbody>
{% for eventdiff in diff.event_diffs %}
<tr>
<td>
{% if eventdiff.event.slug %}
<a href="{% url "program:event_detail" camp_slug=camp.slug event_slug=eventdiff.event.slug %}">{{ eventdiff.event.title }}</a>
{% else %}
Event ID {{ eventdiff.event }} (deleted)
{% endif %}
</td>
<td>{{ eventdiff.old.event_location.name|default:"N/A" }}</td>
<td>{{ eventdiff.new.event_location.name|default:"N/A" }}</td>
<td>{{ eventdiff.old.starttime|default:"N/A" }}</td>
<td>{{ eventdiff.new.starttime|default:"N/A" }}</td>
</tr>
{% endfor %}
</tbody>
</table>
<table class="table">
<caption>Slot Differences - each row represents a slot with changed content</caption>
<thead>
<tr>
<th>EventLocation</th>
<th>Start Time</th>
<th>Old Event</th>
<th>New Event</th>
</tr>
</thead>
<tbody>
{% for slotdiff in diff.slot_diffs %}
<tr>
<td>{{ slotdiff.event_location.name }}</td>
<td>{{ slotdiff.starttime }}</td>
<td>
{% if slotdiff.old.event %}
{% if slotdiff.old.event.title %}{{ slotdiff.old.event.title }}{% else %}Event ID {{ slotdiff.old.event }} (deleted){% endif %}
{% else %}
N/A
{% endif %}
</td>
<td>
{% if slotdiff.new.event %}
{% if slotdiff.new.event.title %}{{ slotdiff.new.event.title }}{% else %}Event ID {{ slotdiff.new.event }} (deleted){% endif %}
{% else %}
N/A
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>

View file

@ -0,0 +1,57 @@
<table class="table table-striped{% if not nodatatable %} datatable{% endif %}">
<thead>
<tr>
<th>Title</th>
<th>Event Type</th>
<th>Tags</th>
{% if not nopeople %}<th>People</th>{% endif %}
{% if not noschedule %}<th>Scheduled</th>{% endif %}
{% if not noactions %}<th>Actions</th>{% endif %}
</tr>
</thead>
<tbody>
{% for event in event_list %}
<tr>
<td><a href="{% url 'backoffice:event_detail' camp_slug=camp.slug slug=event.slug %}">{{ event.title }}</a></td>
<td>{{ event.event_type.icon_html }} <a href="{% url 'backoffice:event_type_detail' camp_slug=camp.slug slug=event.event_type.slug %}">{{ event.event_type }}</a></td>
<td>
{% for tag in event.tags.all %}
<span class="badge">{{ tag }}</span><br>
{% empty %}
N/A
{% endfor %}
</td>
{% if not nopeople %}
<td>
{% for speaker in event.speakers.all %}
<i class="fas fa-user"></i> <a href="{% url 'backoffice:speaker_detail' camp_slug=camp.slug slug=speaker.slug %}">{{ speaker.name }}</a><br>
{% empty %}
N/A
{% endfor %}
</td>
{% endif %}
{% if not noschedule %}
<td>
{% if event.event_slots.exists %}
{% for slot in event.event_slots.all %}
{{ slot.event_location.icon_html }}
<a href="{% url 'backoffice:event_slot_detail' camp_slug=camp.slug pk=slot.pk %}">{{ slot.event_location.name }}, {{ slot.when.lower }}</a><br>
{% endfor %}
{% else %}
Not scheduled yet
{% endif %}
</td>
{% endif %}
{% if not noactions %}
<td>
<div class="btn-group-vertical">
<a href="{% url 'backoffice:event_detail' camp_slug=camp.slug slug=event.slug %}" class="btn btn-primary"><i class="fas fa-search"></i> Details</a>
<a href="{% url 'backoffice:event_update' camp_slug=camp.slug slug=event.slug %}" class="btn btn-primary"><i class="fas fa-edit"></i> Update</a>
<a href="{% url 'backoffice:event_delete' camp_slug=camp.slug slug=event.slug %}" class="btn btn-danger"><i class="fas fa-times"></i> Delete</a>
</div>
</td>
{% endif %}
</tr>
{% endfor %}
</tbody>
</table>

View file

@ -0,0 +1,58 @@
<table class="table table-hover {% if not nodatatable %}datatable{% endif %}">
<thead>
<tr>
<th>Title</th>
<th>Status</th>
<th>Type</th>
<th>Tags</th>
<th>Speaker Proposals</th>
<th>Event?</th>
<th>User</th>
<th>Action</th>
</tr>
</thead>
<tbody>
{% for proposal in event_proposal_list %}
<tr>
<td><a href="{% url 'backoffice:event_proposal_detail' camp_slug=camp.slug pk=proposal.pk %}">{{ proposal.title }}</a></td>
<td><span class="badge">{{ proposal.proposal_status }}</span></td>
<td><i class="fas fa-{{ proposal.event_type.icon }} fa-lg" style="color: {{ proposal.event_type.color }};"></i> <a href="{% url 'backoffice:event_type_detail' camp_slug=camp.slug slug=proposal.event_type.slug %}">{{ proposal.event_type }}</a></td>
<td>
{% for tag in proposal.tags.all %}
<span class="badge">{{ tag }}</span><br>
{% empty %}
N/A
{% endfor %}
</td>
<td>
{% if proposal.speakers.exists %}
<ul class="list-group">
{% for speaker in proposal.speakers.all %}
<a href="{% url 'backoffice:speaker_proposal_detail' camp_slug=camp.slug pk=speaker.pk %}" class="list-group-item"><i class="fas fa-user{% if speaker.proposal_status == "approved" %} text-success{% elif speaker.proposal_status == "rejected" %} text-danger{% endif %}" data-toggle="tooltip" title="Proposal {{ speaker.proposal_status }}"></i> {{ speaker.name }}</a>
{% endfor %}
{% else %}
N/A
{% endif %}
</td>
{% if proposal.event %}
<td>
<a href="{% url 'backoffice:event_detail' camp_slug=camp.slug slug=proposal.event.slug %}" class="btn btn-default btn-sm">{{ proposal.event_type.icon_html }} Event</a>
</td>
{% else %}
<td class="text-center"><i class="fas fa-times fa-2x text-danger"></i></td>
{% endif %}
<td>{{ proposal.user }}</td>
<td>
<div class="btn-group-vertical">
<a href="{% url 'backoffice:event_proposal_detail' camp_slug=camp.slug pk=proposal.uuid %}" class="btn btn-primary"><i class="fas fa-search"></i> Details</a>
{% if proposal.proposal_status == "pending" %}
<a href="{% url 'backoffice:event_proposal_approve_reject' camp_slug=camp.slug pk=proposal.uuid %}" class="btn btn-success"><i class="fas fa-check"></i> Approve</a>
<a href="{% url 'backoffice:event_proposal_approve_reject' camp_slug=camp.slug pk=proposal.uuid %}" class="btn btn-danger"><i class="fas fa-times"></i> Reject</a>
{% endif %}
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>

View file

@ -0,0 +1,29 @@
<table class="table table-condensed">
<thead>
<tr>
{% if not notitle %}<th>Event</th>{% endif %}
<th>Slot</th>
<th>Location</th>
<th>Duration</th>
<th class="text-center">Overflow</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for slot in event_slot_list %}
<tr>
{% if not notitle %}
<td><a href="{% url 'backoffice:event_detail' camp_slug=camp.slug slug=slot.event.slug %}">{{ slot.event.title }}</a></td>
{% endif %}
<td><a href="{% url 'backoffice:event_slot_detail' camp_slug=camp.slug pk=slot.pk %}">{{ slot.when.lower }}</a></td>
<td>{{ slot.event_location.icon_html }} <a href="{% url 'backoffice:event_location_detail' camp_slug=camp.slug slug=slot.event_location.slug %}">{{ slot.event_location.name }}</a></td>
<td>{{ slot.duration }}</td>
<td class="text-center"><span class="badge">{{ slot.overflow }}</span></td>
<td>
<a class="btn btn-primary" href="{% url 'backoffice:event_slot_detail' camp_slug=camp.slug pk=slot.pk %}"><i class="fas fa-search"></i> Show</a>
<a class="btn btn-danger" href="{% url 'backoffice:event_slot_unschedule' camp_slug=camp.slug pk=slot.pk %}"><i class="fas fa-times"></i> Unschedule</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>

View file

@ -0,0 +1,46 @@
<table class="table table-striped{% if not nodatatable %} datatable{% endif %}">
<thead>
<tr>
<th>Name</th>
<th>Email</th>
<th>Submitter</th>
<th>Proposal</th>
<th class="text-center">Event Conflicts</th>
<th class="text-center">Events</th>
{% if not noactions %}<th>Actions</th>{% endif %}
</tr>
</thead>
<tbody>
{% for speaker in speaker_list %}
<tr>
<td><i class="fas fa-user"></i> <a href="{% url 'backoffice:speaker_detail' camp_slug=camp.slug slug=speaker.slug %}">{{ speaker.name }}</a></td>
<td>{{ speaker.email }}</td>
<td><i class="fas fa-user"></i> {{ speaker.proposal.user }}</td>
<td><a href="{% url 'backoffice:speaker_proposal_detail' camp_slug=camp.slug pk=speaker.proposal.pk %}" class="btn btn-default"><i class="fas fa-search"></i> Show</a></td>
<td class="text-center"><span class="badge">{{ speaker.event_conflicts.count }}</span></td>
<td>
{% if speaker.events.all %}
<ul class="list-group">
{% for event in speaker.events.all %}
<a href="{% url 'backoffice:event_detail' camp_slug=camp.slug slug=event.slug %}" class="list-group-item">
<i class="fas fa-{{ event.event_type.icon }} fa-lg fa-fw" style="color: {{ event.event_type.color }};"></i> {{ event.title }}
</a>
{% endfor %}
</ul>
{% else %}
N/A
{% endif %}
</td>
{% if not noactions %}
<td>
<div class="btn-group-vertical">
<a href="{% url 'backoffice:speaker_detail' camp_slug=camp.slug slug=speaker.slug %}" class="btn btn-primary"><i class="fas fa-search"></i> Details</a>
<a href="{% url 'backoffice:speaker_update' camp_slug=camp.slug slug=speaker.slug %}" class="btn btn-primary"><i class="fas fa-edit"></i> Update</a>
<a href="{% url 'backoffice:speaker_delete' camp_slug=camp.slug slug=speaker.slug %}" class="btn btn-danger"><i class="fas fa-times"></i> Delete</a>
</div>
</td>
{% endif %}
</tr>
{% endfor %}
</tbody>
</table>

View file

@ -0,0 +1,41 @@
{% load bornhack %}
<table class="table table-hover {% if not nodatatable %}datatable{% endif %}">
<thead>
<tr>
<th>Name</th>
<th>Email</th>
<th class="text-center">Needs Ticket?</th>
<th>Proposal Status</th>
<th class="text-center">Has Speaker?</th>
<th>Submitting User</th>
<th>Action</th>
</tr>
</thead>
<tbody>
{% for proposal in speaker_proposal_list %}
<tr>
<td><a href="{% url 'backoffice:speaker_proposal_detail' camp_slug=camp.slug pk=proposal.uuid %}">{{ proposal.name }}</a></td>
<td>{{ proposal.email }}</td>
<td class="text-center">{{ proposal.needs_oneday_ticket|truefalseicon }}</td>
<td class="text-center"><span class="badge">{{ proposal.proposal_status }}</span></td>
<td class="text-center">
{% if proposal.speaker %}
<a href="{% url 'backoffice:speaker_detail' camp_slug=camp.slug slug=proposal.speaker.slug %}" class="btn btn-default btn-sm"><i class="fas fa-user"></i> Show Speaker</a>
{% else %}
<i class="fas fa-times"></i>
{% endif %}
</td>
<td>{{ proposal.user }}</td>
<td>
<div class="btn-group-vertical">
<a href="{% url 'backoffice:speaker_proposal_detail' camp_slug=camp.slug pk=proposal.uuid %}" class="btn btn-primary"><i class="fas fa-search"></i> Details</a>
{% if proposal.proposal_status == "pending" %}
<a href="{% url 'backoffice:speaker_proposal_approve_reject' camp_slug=camp.slug pk=proposal.uuid %}" class="btn btn-success"><i class="fas fa-check"></i> Approve</a>
<a href="{% url 'backoffice:speaker_proposal_approve_reject' camp_slug=camp.slug pk=proposal.uuid %}" class="btn btn-danger"><i class="fas fa-times"></i> Reject</a>
{% endif %}
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>

View file

@ -4,15 +4,12 @@
{% load imageutils %} {% load imageutils %}
{% block content %} {% block content %}
<div class="row"> <div class="panel panel-default">
<h2>{{ camp.title }} Backoffice</h2> <div class="panel-heading">
<div class="lead"> <h3 class="panel-title">{{ camp.title }} Backoffice</h3>
Welcome to the promised land! Please select your desired action below:
</div> </div>
</div> <div class="panel-body">
<p class="lead">Welcome to the promised land! Please select your desired action below:</p>
<div class="row">
<p>
<div class="list-group"> <div class="list-group">
{% for team in facilityfeedback_teams %} {% for team in facilityfeedback_teams %}
{% if "camps."|add:team.permission_set in perms %} {% if "camps."|add:team.permission_set in perms %}
@ -52,13 +49,49 @@
{% if perms.camps.contentteam_permission %} {% if perms.camps.contentteam_permission %}
<h3>Content Team</h3> <h3>Content Team</h3>
<a href="{% url 'backoffice:manage_proposals' camp_slug=camp.slug %}" class="list-group-item"> <a href="{% url 'backoffice:approve_event_feedback' camp_slug=camp.slug %}" class="list-group-item">
<h4 class="list-group-item-heading">Manage Proposals</h4>
<p class="list-group-item-text">Use this view to manage SpeakerProposals and EventProposals</p>
</a>
<a href="{% url 'backoffice:approve_eventfeedback' camp_slug=camp.slug %}" class="list-group-item">
<h4 class="list-group-item-heading">Approve Feedback</h4> <h4 class="list-group-item-heading">Approve Feedback</h4>
<p class="list-group-item-text">Use this view to approve or reject EventFeedback</p> <p class="list-group-item-text">Use these views to approve or reject EventFeedback</p>
</a>
<a href="{% url 'backoffice:pending_proposals' camp_slug=camp.slug %}" class="list-group-item">
<h4 class="list-group-item-heading">Approve/Reject Pending Proposals</h4>
<p class="list-group-item-text">Use these views to approve/reject pending SpeakerProposals and EventProposals</p>
</a>
<a href="{% url 'backoffice:autoschedule_manage' camp_slug=camp.slug %}" class="list-group-item">
<h4 class="list-group-item-heading">AutoScheduler</h4>
<p class="list-group-item-text">Use these views to manage the AutoScheduler</p>
</a>
<a href="{% url 'backoffice:event_list' camp_slug=camp.slug %}" class="list-group-item">
<h4 class="list-group-item-heading">Events</h4>
<p class="list-group-item-text">Use these views to manage Events</p>
</a>
<a href="{% url 'backoffice:event_location_list' camp_slug=camp.slug %}" class="list-group-item">
<h4 class="list-group-item-heading">EventLocations</h4>
<p class="list-group-item-text">Use these views to manage EventLocations</p>
</a>
<a href="{% url 'backoffice:event_proposal_list' camp_slug=camp.slug %}" class="list-group-item">
<h4 class="list-group-item-heading">EventProposals</h4>
<p class="list-group-item-text">Use these views to see all EventProposals</p>
</a>
<a href="{% url 'backoffice:event_session_list' camp_slug=camp.slug %}" class="list-group-item">
<h4 class="list-group-item-heading">EventSessions</h4>
<p class="list-group-item-text">Use these views to manage <i>EventSession</i> objects. Each <i>EventSession</i> is parent to one or more related <i>EventSlot</i> objects, which is what <i>Events</i> are scheduled in.</p>
</a>
<a href="{% url 'backoffice:event_slot_list' camp_slug=camp.slug %}" class="list-group-item">
<h4 class="list-group-item-heading">EventSlots</h4>
<p class="list-group-item-text">Use these views to see <i>EventSlot</i> objects. <i>Events</i> are scheduled in <i>EventSlots</i>. An <i>EventSlot</i> belong to an <i>EventSession</i>, which is what defines the <i>EventType</i> and <i>EventLocation</i> of the <i>EventSlot</i>.</p>
</a>
<a href="{% url 'backoffice:event_type_list' camp_slug=camp.slug %}" class="list-group-item">
<h4 class="list-group-item-heading">EventTypes</h4>
<p class="list-group-item-text">Use these views to manage EventTypes</p>
</a>
<a href="{% url 'backoffice:speaker_list' camp_slug=camp.slug %}" class="list-group-item">
<h4 class="list-group-item-heading">Speakers</h4>
<p class="list-group-item-text">Use these views to manage Speakers</p>
</a>
<a href="{% url 'backoffice:speaker_proposal_list' camp_slug=camp.slug %}" class="list-group-item">
<h4 class="list-group-item-heading">SpeakerProposals</h4>
<p class="list-group-item-text">Use these views to see all SpeakerProposals</p>
</a> </a>
{% endif %} {% endif %}
@ -116,6 +149,7 @@
<p class="list-group-item-text">Use this view to see proxied content</p> <p class="list-group-item-text">Use this view to see proxied content</p>
</a> </a>
</div> </div>
</div>
</div> </div>
{% endblock content %} {% endblock content %}

View file

@ -1,15 +0,0 @@
{% extends 'base.html' %}
{% load bootstrap3 %}
{% block content %}
<h3>Manage {{ form.instance.event_type.name }} Proposal</h3>
{% include 'includes/eventproposal_detail.html' with camp=camp %}
<form method="POST">
{% csrf_token %}
{% bootstrap_form form %}
{% bootstrap_button "<i class='fas fa-check'></i> Approve" button_type="submit" button_class="btn-success" name="approve" %}
{% bootstrap_button "<i class='fas fa-times'></i> Reject" button_type="submit" button_class="btn-danger" name="reject" %}
<a href="{% url 'backoffice:manage_proposals' camp_slug=camp.slug %}" class="btn btn-primary"><i class="fas fa-undo"></i> Cancel</a>
</form>
{% endblock content %}

View file

@ -1,83 +0,0 @@
{% extends 'base.html' %}
{% load commonmark %}
{% load static %}
{% load imageutils %}
{% load bornhack %}
{% block extra_head %}
<script src="{% static "js/jquery.dataTables.min.js" %}"></script>
<link rel="stylesheet" href="{% static 'css/jquery.dataTables.min.css' %}">
{% endblock extra_head %}
{% block content %}
<div class="row">
<h2>BackOffice - Manage Speaker+EventProposals</h2>
<div class="lead">
The Content team can approve or reject pending SpeakerProposals and EventProposals from this page.
</div>
</div>
<br>
<div class="row">
<h3>SpeakerProposals</h3>
{% if not speakerproposals %}
<p class="lead">No pending SpeakerProposals found</p>
{% else %}
<table class="table table-hover">
<thead>
<tr>
<th>Name</th>
<th>Email</th>
<th class="text-center">Ticket?</th>
<th class="text-center">Speaker?</th>
<th>Submitting User</th>
<th>Action</th>
</tr>
</thead>
<tbody>
{% for proposal in speakerproposals %}
<tr>
<td>{{ proposal.name }}</td>
<td>{{ proposal.email }}</td>
<td class="text-center">{{ proposal.needs_oneday_ticket|truefalseicon }}</td>
<td class="text-center">{{ proposal.event|truefalseicon }}</td>
<td>{{ proposal.user }}</td>
<td><a href="{% url 'backoffice:speakerproposal_manage' camp_slug=camp.slug pk=proposal.uuid %}" class="btn btn-primary"><i class="fas fa-cog"></i> Manage</a></td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
<h3>EventProposals</h3>
{% if not eventproposals %}
<p class="lead">No pending SpeakerProposals found</p>
{% else %}
<table class="table table-hover">
<thead>
<tr>
<th>Title</th>
<th>Track</th>
<th>Type</th>
<th>Speakers</th>
<th class="text-center">Event?</th>
<th>Submitting User</th>
<th>Action</th>
</tr>
</thead>
<tbody>
{% for proposal in eventproposals %}
<tr>
<td>{{ proposal.title }}</td>
<td>{{ proposal.track }}</td>
<td><i class="fas fa-{{ proposal.event_type.icon }} fa-lg" style="color: {{ proposal.event_type.color }};"></i> {{ proposal.event_type }}</td>
<td>{% for speaker in proposal.speakers.all %}<i class="fas fa-user{% if speaker.proposal_status == "approved" %} text-success{% elif speaker.proposal_status == "rejected" %} text-danger{% endif %}" data-toggle="tooltip" title="{{ speaker.name }} ({{ speaker.proposal_status }})"></i> {% endfor %}</td>
<td class="text-center">{{ proposal.speaker|truefalseicon }}</td>
<td>{{ proposal.user }}</td>
<td><a href="{% url 'backoffice:eventproposal_manage' camp_slug=camp.slug pk=proposal.uuid %}" class="btn btn-primary"><i class="fas fa-cog"></i> Manage</a></td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
</div>
{% endblock content %}

View file

@ -1,15 +0,0 @@
{% extends 'base.html' %}
{% load bootstrap3 %}
{% block content %}
<h3>Manage Speaker Proposal</h3>
{% include 'includes/speakerproposal_detail.html' with camp=camp %}
<form method="POST">
{% csrf_token %}
{% bootstrap_form form %}
{% bootstrap_button "<i class='fas fa-check'></i> Approve" button_type="submit" button_class="btn-success" name="approve" %}
{% bootstrap_button "<i class='fas fa-times'></i> Reject" button_type="submit" button_class="btn-danger" name="reject" %}
<a href="{% url 'backoffice:manage_proposals' camp_slug=camp.slug %}" class="btn btn-primary"><i class="fas fa-undo"></i> Cancel</a>
</form>
{% endblock content %}

View file

@ -2,10 +2,6 @@
{% load commonmark %} {% load commonmark %}
{% load static %} {% load static %}
{% load imageutils %} {% load imageutils %}
{% block extra_head %}
<script src="{% static "js/jquery.dataTables.min.js" %}"></script>
<link rel="stylesheet" href="{% static 'css/jquery.dataTables.min.css' %}">
{% endblock extra_head %}
{% block content %} {% block content %}
<div class="row"> <div class="row">
<h2>Merchandise To Order</h2> <h2>Merchandise To Order</h2>
@ -18,7 +14,7 @@
</div> </div>
<br> <br>
<div class="row"> <div class="row">
<table class="table table-hover"> <table class="table table-hover datatable">
<thead> <thead>
<tr> <tr>
<th>Merchandise Type</th> <th>Merchandise Type</th>

View file

@ -2,10 +2,6 @@
{% load commonmark %} {% load commonmark %}
{% load static %} {% load static %}
{% load imageutils %} {% load imageutils %}
{% block extra_head %}
<script src="{% static "js/jquery.dataTables.min.js" %}"></script>
<link rel="stylesheet" href="{% static 'css/jquery.dataTables.min.css' %}">
{% endblock extra_head %}
{% block content %} {% block content %}
<div class="row"> <div class="row">
<h2>Merchandise Orders</h2> <h2>Merchandise Orders</h2>
@ -17,7 +13,7 @@
</div> </div>
<br> <br>
<div class="row"> <div class="row">
<table class="table table-hover"> <table class="table table-hover datatable">
<thead> <thead>
<tr> <tr>
<th>Order</th> <th>Order</th>

View file

@ -2,10 +2,6 @@
{% load commonmark %} {% load commonmark %}
{% load static %} {% load static %}
{% load imageutils %} {% load imageutils %}
{% block extra_head %}
<script src="{% static "js/jquery.dataTables.min.js" %}"></script>
<link rel="stylesheet" href="{% static 'css/jquery.dataTables.min.css' %}">
{% endblock extra_head %}
{% block content %} {% block content %}
<div class="row"> <div class="row">
<h2>Village Orders</h2> <h2>Village Orders</h2>
@ -17,7 +13,7 @@
</div> </div>
<br> <br>
<div class="row"> <div class="row">
<table class="table table-hover"> <table class="table table-hover datatable">
<thead> <thead>
<tr> <tr>
<th>Order</th> <th>Order</th>

View file

@ -0,0 +1,32 @@
{% extends 'base.html' %}
{% load commonmark %}
{% load static %}
{% load imageutils %}
{% load bornhack %}
{% block content %}
<div class="panel panel-default">
<div class="panel-heading">
<h3 class="panel-title">BackOffice - Pending Proposals</h3>
</div>
<div class="panel-body">
<a href="{% url 'backoffice:index' camp_slug=camp.slug%}" class="btn btn-default"><i class="fas fa-undo"></i> Backoffice</a>
<p>
<h3>Pending SpeakerProposals</h3>
{% if not speaker_proposal_list %}
<p class="lead">No pending SpeakerProposals found</p>
{% else %}
{% include 'includes/speaker_proposal_list_table_backoffice.html' %}
{% endif %}
<hr>
<h3>Pending EventProposals</h3>
{% if not event_proposal_list %}
<p class="lead">No pending EventProposals found</p>
{% else %}
{% include 'includes/event_proposal_list_table_backoffice.html' %}
{% endif %}
</div>
</div>
{% endblock content %}

View file

@ -2,10 +2,6 @@
{% load commonmark %} {% load commonmark %}
{% load static %} {% load static %}
{% load imageutils %} {% load imageutils %}
{% block extra_head %}
<script src="{% static "js/jquery.dataTables.min.js" %}"></script>
<link rel="stylesheet" href="{% static 'css/jquery.dataTables.min.css' %}">
{% endblock extra_head %}
{% block content %} {% block content %}
<div class="row"> <div class="row">
<h2>Hand Out Products</h2> <h2>Hand Out Products</h2>
@ -18,7 +14,7 @@
</div> </div>
<br> <br>
<div class="row"> <div class="row">
<table class="table table-hover"> <table class="table table-hover datatable">
<thead> <thead>
<tr> <tr>
<th>Order</th> <th>Order</th>

View file

@ -1,11 +1,6 @@
{% extends 'base.html' %} {% extends 'base.html' %}
{% load static %} {% load static %}
{% block extra_head %}
<script src="{% static "js/jquery.dataTables.min.js" %}"></script>
<link rel="stylesheet" href="{% static 'css/jquery.dataTables.min.css' %}">
{% endblock extra_head %}
{% block content %} {% block content %}
<h2>Reimbursements for {{ camp.title }}</h2> <h2>Reimbursements for {{ camp.title }}</h2>

View file

@ -1,11 +1,6 @@
{% extends 'base.html' %} {% extends 'base.html' %}
{% load static %} {% load static %}
{% block extra_head %}
<script src="{% static "js/jquery.dataTables.min.js" %}"></script>
<link rel="stylesheet" href="{% static 'css/jquery.dataTables.min.css' %}">
{% endblock extra_head %}
{% block content %} {% block content %}
<h2>Manage Revenues for {{ camp.title }}</h2> <h2>Manage Revenues for {{ camp.title }}</h2>

View file

@ -2,10 +2,6 @@
{% load commonmark %} {% load commonmark %}
{% load static %} {% load static %}
{% load imageutils %} {% load imageutils %}
{% block extra_head %}
<script src="{% static "js/jquery.dataTables.min.js" %}"></script>
<link rel="stylesheet" href="{% static 'css/jquery.dataTables.min.css' %}">
{% endblock extra_head %}
{% block content %} {% block content %}
<div class="row"> <div class="row">
<h2>Shop Tickets</h2> <h2>Shop Tickets</h2>
@ -15,7 +11,7 @@
<span class="clearfix"></span> <span class="clearfix"></span>
<hr class="clearfix"/> <hr class="clearfix"/>
<table class="table table-hover"> <table class="table table-hover datatable">
<thead> <thead>
<tr> <tr>
<th>Ticket Type</th> <th>Ticket Type</th>

View file

@ -0,0 +1,19 @@
{% extends 'base.html' %}
{% load bootstrap3 %}
{% block content %}
<div class="panel panel-default">
<div class="panel-heading">
<h2 class="panel-title">Delete Speaker {{ object.name }}?</h2>
</div>
<div class="panel-body">
<p class="lead">Deleting a Speaker object will remove the person from all Events, scheduled or not. The change will immediately take effect on the live site. The speaker can later be recreated by re-approving the SpeakerProposal if needed.</p>
<form method="POST">
{% csrf_token %}
<button type="submit" class="btn btn-danger"><i class='fas fa-times'></i> Yes, Delete Speaker</button>
<a href="{% url 'backoffice:speaker_list' camp_slug=camp.slug %}" class="btn btn-default"><i class="fas fa-undo"></i> Cancel</a>
</form>
</div>
</div>
{% endblock content %}

View file

@ -0,0 +1,68 @@
{% extends 'base.html' %}
{% load bootstrap3 %}
{% load bornhack %}
{% load program %}
{% load commonmark %}
{% block content %}
<div class="panel panel-default">
<div class="panel-heading">
<h3 class="panel-title">Speaker: {{ speaker.name }}</h3>
</div>
<div class="panel-body">
{{ speaker.biography|untrustedcommonmark }}
<hr>
<h4>Details for <i>{{ speaker.name }}</i></h4>
<table class="table">
<tr>
<th>Email</th>
<td>{{ speaker.email }}</td>
</tr>
<tr>
<th>Proposal</th>
<td><a href="{% url 'backoffice:speaker_proposal_detail' camp_slug=camp.slug pk=speaker.proposal.pk %}">{{ speaker.proposal.pk }}</a></td>
</tr>
</table>
<hr>
<h4>Availability for <i>{{ speaker.name }}</i></h4>
{% availabilitytable matrix=matrix %}
<hr>
<h4>Events for <i>{{ speaker.name }}</i></h4>
{% if speaker.events.exists %}
{% include "includes/event_list_table_backoffice.html" with event_list=speaker.events.all noactions=True noschedule=True nodatatable=True %}
{% else %}
N/A
{% endif %}
<hr>
<h4>Schedule for <i>{{ speaker.name }}</i></h4>
{% if speaker.scheduled_event_slots.exists %}
{% include "includes/event_slot_list_backoffice.html" with event_slot_list=speaker.scheduled_event_slots.all %}
{% else %}
N/A
{% endif %}
<hr>
<h4>Event Conflicts for <i>{{ speaker.name }}</i></h4>
{% if speaker.event_conflicts.exists %}
{% include "includes/event_list_table_backoffice.html" with event_list=speaker.event_conflicts.all nodatatable=True nopeople=True noactions=True noschedule=True %}
{% else %}
N/A
{% endif %}
<hr>
<a href="{% url 'backoffice:speaker_update' camp_slug=camp.slug slug=speaker.slug %}" class="btn btn-primary"><i class="fas fa-edit"></i> Update</a>
<a href="{% url 'backoffice:speaker_delete' camp_slug=camp.slug slug=speaker.slug %}" class="btn btn-danger"><i class="fas fa-times"></i> Delete</a>
<a href="{% url 'backoffice:speaker_list' camp_slug=camp.slug %}" class="btn btn-default"><i class="fas fa-undo"></i> Speaker List</a>
</div>
</div>
{% endblock content %}

View file

@ -0,0 +1,20 @@
{% extends 'program_base.html' %}
{% block title %}
Speaker List | Backoffice | {{ block.super }}
{% endblock %}
{% block content %}
{% if speaker_list %}
<div class="panel panel-default">
<div class="panel-heading"><h3 class="panel-title">Speaker List - Backoffice</h3></div>
<div class="panel-body">
<p><i>Speakers</i> are the result of approving a <i>SpeakerProposal</i>. This is an alphabetical list of all speakers, workshop hosts, artists and other event anchors at {{ camp.title }}. <b>Any changes made here will be reflected immediately on the live site!</b></p>
<p><a class="btn btn-default" href="{% url 'backoffice:index' camp_slug=camp.slug %}"><i class="fas fa-undo"></i> Backoffice</a></p>
{% include "includes/speaker_list_table_backoffice.html" %}
<p><a class="btn btn-default" href="{% url 'backoffice:index' camp_slug=camp.slug %}"><i class="fas fa-undo"></i> Backoffice</a></p>
</div>
{% else %}
<p class="lead">No speakers found for {{ camp.title }}</p>
{% endif %}
{% endblock content %}

View file

@ -0,0 +1,21 @@
{% extends 'base.html' %}
{% load bootstrap3 %}
{% block content %}
<div class="panel panel-default">
<div class="panel-heading">
<h3 class="panel-title">Approve or Reject SpeakerProposal: {{ speaker_proposal.name }}</h3>
</div>
<div class="panel-body">
<p>{{ speaker_proposal.name }} will receive an email when the proposal is approved or rejected. It is possible to include an extra message in the form below explaining why the proposal was accepted or rejected. If the field is left blank a standard email is sent.</p>
<form method="POST">
{% csrf_token %}
{% bootstrap_form form %}
<button type="submit" class="btn btn-success" name="approve"><i class='fas fa-check'></i> Approve Proposal</button>
<button type="reject" class="btn btn-danger" name="reject"><i class='fas fa-times'></i> Reject Proposal</button>
<a href="{% url 'backoffice:pending_proposals' camp_slug=camp.slug %}" class="btn btn-default"><i class="fas fa-undo"></i> Cancel</a>
</form>
</div>
</div>
{% endblock content %}

View file

@ -0,0 +1,90 @@
{% extends 'base.html' %}
{% load commonmark %}
{% load program %}
{% block content %}
<p><a href="{% url 'backoffice:speaker_proposal_list' camp_slug=camp.slug %}" class="btn btn-default"><i class="fas fa-undo"></i> Back to SpeakerProposal List</a></p>
<div class="panel panel-default">
<div class="panel-heading"><span class="h3">{{ speaker_proposal.title }} Proposal: {{ speaker_proposal.name }}</span></div>
<div class="panel-body">
{{ speaker_proposal.biography|untrustedcommonmark }}
<hr>
<h4>Details for <i>{{ speaker_proposal.name }}</i></h4>
<table class="table">
<tbody>
<tr>
<th>UUID</th>
<td>{{ speaker_proposal.uuid }}</td>
</tr>
<tr>
<th>Status</th>
<td>{{ speaker_proposal.proposal_status }}</td>
</tr>
<tr>
<th>Speaker Object</th>
<td>
{% if speaker_proposal.speaker %}
<a href="{% url 'backoffice:speaker_detail' camp_slug=camp.slug slug=speaker_proposal.speaker.slug %}" class="btn btn-default"><i class="fas fa-user"></i> Show</a>
{% else %}
N/A
{% endif %}
</td>
</tr>
<tr>
<th>Username</th>
<td>{{ speaker_proposal.user }}</td>
</tr>
<tr>
<th>Submission Notes</th>
<td>{{ speaker_proposal.submission_notes|untrustedcommonmark|default:"N/A" }}</td>
</tr>
</tbody>
</table>
<hr>
<h4>Availability for <i>{{ speaker_proposal.name }}</i></h4>
{% availabilitytable matrix=matrix %}
<hr>
<h4>URLs for <i>{{ speaker_proposal.name }}</i></h4>
{% if speaker_proposal.urls.exists %}
{% include 'includes/speaker_proposal_url_table.html' %}
{% else %}
<i>Nothing found.</i>
{% endif %}
<hr>
<h4>EventProposals involving <i>{{ speaker_proposal.name }}</i></h4>
{% if speaker_proposal.event_proposals.exists %}
{% include 'includes/event_proposal_list_table_backoffice.html' with event_proposal_list=speaker_proposal.event_proposals.all nodatatable=True %}
{% else %}
<i>Nothing found.</i>
{% endif %}
<hr>
<h4>Event Conflicts for <i>{{ speaker_proposal.name }}</i></h4>
{% if speaker_proposal.event_conflicts.exists %}
{% include 'includes/event_list_table_backoffice.html' with event_list=speaker_proposal.event_conflicts.all nodatatable=True nopeople=True noactions=True noschedule=True %}
{% else %}
<i>Nothing found.</i>
{% endif %}
</div>
<div class="panel-footer">Status: <span class="badge">{{ speaker_proposal.proposal_status }}</span> | ID: <span class="badge">{{ speaker_proposal.uuid }}</span></div>
</div>
<p>
{% if speaker_proposal.proposal_status == "pending" %}
<a href="{% url 'backoffice:speaker_proposal_approve_reject' camp_slug=camp.slug pk=speaker_proposal.uuid %}" class="btn btn-success"><i class="fas fa-check"></i> Approve SpeakerProposal</a>
<a href="{% url 'backoffice:speaker_proposal_approve_reject' camp_slug=camp.slug pk=speaker_proposal.uuid %}" class="btn btn-danger"><i class="fas fa-times"></i> Reject SpeakerProposal</a>
{% endif %}
<a href="{% url 'backoffice:speaker_proposal_list' camp_slug=camp.slug %}" class="btn btn-default"><i class="fas fa-undo"></i> Back to SpeakerProposal List</a>
</p>
{% endblock content %}

View file

@ -0,0 +1,24 @@
{% extends 'base.html' %}
{% block title %}
SpeakerProposal List | Backoffice | {{ block.super }}
{% endblock %}
{% block content %}
{% if speaker_proposal_list %}
<div class="panel panel-default">
<div class="panel-heading"><h3 class="panel-title">SpeakerProposal List - Backoffice</h3></div>
<div class="panel-body">
<p>This is a list of all <i>SpeakerProposal</i> objects in the system. Search for name, email, username, or status to filter the table.</p>
<p><a href="{% url 'backoffice:index' camp_slug=camp.slug %}" class="btn btn-default"><i class="fas fa-undo"></i> Backoffice</a></p>
{% if speaker_proposal_list %}
{% include 'includes/speaker_proposal_list_table_backoffice.html' %}
{% else %}
No SpeakerProposals found.
{% endif %}
</div>
</div>
{% endif %}
{% endblock %}

View file

@ -0,0 +1,32 @@
{% extends 'base.html' %}
{% load bootstrap3 %}
{% load static %}
{% load bornhack %}
{% load program %}
{% block content %}
<div class="panel panel-default">
<div class="panel-heading">
<h3 class="panel-title">Update Speaker: {{ speaker.name }}</h3>
</div>
<div class="panel-body">
<p class="lead">Note: All changes made here take effect immediately on the live site. Any changes will be overwritten if the SpeakerProposal is later re-approved.</p>
<form method="POST">
{% csrf_token %}
{% for field in form %}
{% if field.id_for_label == "id_email" %}
{% availabilitytable form=form matrix=matrix %}
{% bootstrap_field field %}
{% else %}
{% if field.name|slice:":12" != "availability" %}
{% bootstrap_field field %}
{% endif %}
{% endif %}
{% endfor %}
<button type="submit" class="btn btn-success"><i class="fas fa-check"></i> Update Speaker</button>
<a href="{% url 'backoffice:speaker_detail' camp_slug=camp.slug slug=speaker.slug %}" class="btn btn-default"><i class="fas fa-undo"></i> Cancel</a>
</form>
</div>
</div>
{% endblock content %}

View file

@ -2,10 +2,6 @@
{% load commonmark %} {% load commonmark %}
{% load static %} {% load static %}
{% load imageutils %} {% load imageutils %}
{% block extra_head %}
<script src="{% static "js/jquery.dataTables.min.js" %}"></script>
<link rel="stylesheet" href="{% static 'css/jquery.dataTables.min.css' %}">
{% endblock extra_head %}
{% block content %} {% block content %}
<div class="row"> <div class="row">
<h2>Ticket Check-In</h2> <h2>Ticket Check-In</h2>
@ -18,7 +14,7 @@
</div> </div>
<br> <br>
<div class="row"> <div class="row">
<table class="table table-hover"> <table class="table table-hover datatable">
<thead> <thead>
<tr> <tr>
<th>Ticket UUID</th> <th>Ticket UUID</th>

View file

@ -2,10 +2,6 @@
{% load commonmark %} {% load commonmark %}
{% load static %} {% load static %}
{% load imageutils %} {% load imageutils %}
{% block extra_head %}
<script src="{% static "js/jquery.dataTables.min.js" %}"></script>
<link rel="stylesheet" href="{% static 'css/jquery.dataTables.min.css' %}">
{% endblock extra_head %}
{% block content %} {% block content %}
<div class="row"> <div class="row">
<h2>Village Gear To Order</h2> <h2>Village Gear To Order</h2>
@ -18,7 +14,7 @@
</div> </div>
<br> <br>
<div class="row"> <div class="row">
<table class="table table-hover"> <table class="table table-hover datatable">
<thead> <thead>
<tr> <tr>
<th>Type</th> <th>Type</th>

View file

@ -3,19 +3,50 @@ from django.urls import include, path
from .views import ( from .views import (
ApproveFeedbackView, ApproveFeedbackView,
ApproveNamesView, ApproveNamesView,
AutoScheduleApplyView,
AutoScheduleCrashCourseView,
AutoScheduleDebugEventConflictsView,
AutoScheduleDebugEventSlotUnavailabilityView,
AutoScheduleDiffView,
AutoScheduleManageView,
AutoScheduleValidateView,
BackofficeIndexView, BackofficeIndexView,
BackofficeProxyView, BackofficeProxyView,
BadgeHandoutView, BadgeHandoutView,
ChainDetailView, ChainDetailView,
ChainListView, ChainListView,
CredebtorDetailView, CredebtorDetailView,
EventProposalManageView, EventDeleteView,
EventDetailView,
EventListView,
EventLocationCreateView,
EventLocationDeleteView,
EventLocationDetailView,
EventLocationListView,
EventLocationUpdateView,
EventProposalApproveRejectView,
EventProposalDetailView,
EventProposalListView,
EventScheduleView,
EventSessionCreateLocationSelectView,
EventSessionCreateTypeSelectView,
EventSessionCreateView,
EventSessionDeleteView,
EventSessionDetailView,
EventSessionListView,
EventSessionUpdateView,
EventSlotDetailView,
EventSlotListView,
EventSlotUnscheduleView,
EventTypeDetailView,
EventTypeListView,
EventUpdateView,
ExpenseDetailView, ExpenseDetailView,
ExpenseListView, ExpenseListView,
FacilityFeedbackView, FacilityFeedbackView,
ManageProposalsView,
MerchandiseOrdersView, MerchandiseOrdersView,
MerchandiseToOrderView, MerchandiseToOrderView,
PendingProposalsView,
ProductHandoutView, ProductHandoutView,
ReimbursementCreateUserSelectView, ReimbursementCreateUserSelectView,
ReimbursementCreateView, ReimbursementCreateView,
@ -27,7 +58,13 @@ from .views import (
RevenueListView, RevenueListView,
ScanTicketsView, ScanTicketsView,
ShopTicketOverview, ShopTicketOverview,
SpeakerProposalManageView, SpeakerDeleteView,
SpeakerDetailView,
SpeakerListView,
SpeakerProposalApproveRejectView,
SpeakerProposalDetailView,
SpeakerProposalListView,
SpeakerUpdateView,
TicketCheckinView, TicketCheckinView,
VillageOrdersView, VillageOrdersView,
VillageToOrderView, VillageToOrderView,
@ -72,28 +109,314 @@ urlpatterns = [
# village orders # village orders
path("village_orders/", VillageOrdersView.as_view(), name="village_orders"), path("village_orders/", VillageOrdersView.as_view(), name="village_orders"),
path("village_to_order/", VillageToOrderView.as_view(), name="village_to_order"), path("village_to_order/", VillageToOrderView.as_view(), name="village_to_order"),
# manage proposals # manage SpeakerProposals and EventProposals
path( path(
"manage_proposals/", "proposals/",
include( include(
[ [
path("", ManageProposalsView.as_view(), name="manage_proposals"),
path( path(
"speakers/<uuid:pk>/", "pending/", PendingProposalsView.as_view(), name="pending_proposals"
SpeakerProposalManageView.as_view(),
name="speakerproposal_manage",
), ),
path( path(
"events/<uuid:pk>/", "speakers/",
EventProposalManageView.as_view(), include(
name="eventproposal_manage", [
path(
"",
SpeakerProposalListView.as_view(),
name="speaker_proposal_list",
),
path(
"<uuid:pk>/",
include(
[
path(
"",
SpeakerProposalDetailView.as_view(),
name="speaker_proposal_detail",
),
path(
"approve_reject/",
SpeakerProposalApproveRejectView.as_view(),
name="speaker_proposal_approve_reject",
),
]
),
),
]
),
),
path(
"events/",
include(
[
path(
"",
EventProposalListView.as_view(),
name="event_proposal_list",
),
path(
"<uuid:pk>/",
include(
[
path(
"",
EventProposalDetailView.as_view(),
name="event_proposal_detail",
),
path(
"approve_reject/",
EventProposalApproveRejectView.as_view(),
name="event_proposal_approve_reject",
),
]
),
),
]
),
), ),
] ]
), ),
), ),
# approve eventfeedback objects # manage EventSession objects
path( path(
"approve_feedback", ApproveFeedbackView.as_view(), name="approve_eventfeedback", "event_sessions/",
include(
[
path("", EventSessionListView.as_view(), name="event_session_list"),
path(
"create/",
include(
[
path(
"",
EventSessionCreateTypeSelectView.as_view(),
name="event_session_create_type_select",
),
path(
"<slug:event_type_slug>/",
include(
[
path(
"",
EventSessionCreateLocationSelectView.as_view(),
name="event_session_create_location_select",
),
path(
"<slug:event_location_slug>/",
EventSessionCreateView.as_view(),
name="event_session_create",
),
]
),
),
]
),
),
path(
"<int:pk>/",
include(
[
path(
"",
EventSessionDetailView.as_view(),
name="event_session_detail",
),
path(
"update/",
EventSessionUpdateView.as_view(),
name="event_session_update",
),
path(
"delete/",
EventSessionDeleteView.as_view(),
name="event_session_delete",
),
]
),
),
]
),
),
# manage EventSlot objects
path(
"event_slots/",
include(
[
path("", EventSlotListView.as_view(), name="event_slot_list"),
path(
"<int:pk>/",
include(
[
path(
"",
EventSlotDetailView.as_view(),
name="event_slot_detail",
),
path(
"unschedule/",
EventSlotUnscheduleView.as_view(),
name="event_slot_unschedule",
),
]
),
),
]
),
),
# manage Speaker objects
path(
"speakers/",
include(
[
path("", SpeakerListView.as_view(), name="speaker_list"),
path(
"<slug:slug>/",
include(
[
path(
"", SpeakerDetailView.as_view(), name="speaker_detail",
),
path(
"update/",
SpeakerUpdateView.as_view(),
name="speaker_update",
),
path(
"delete/",
SpeakerDeleteView.as_view(),
name="speaker_delete",
),
]
),
),
]
),
),
# manage EventType objects
path(
"event_types/",
include(
[
path("", EventTypeListView.as_view(), name="event_type_list"),
path(
"<slug:slug>/",
EventTypeDetailView.as_view(),
name="event_type_detail",
),
]
),
),
# manage EventLocation objects
path(
"event_locations/",
include(
[
path("", EventLocationListView.as_view(), name="event_location_list"),
path(
"create/",
EventLocationCreateView.as_view(),
name="event_location_create",
),
path(
"<slug:slug>/",
include(
[
path(
"",
EventLocationDetailView.as_view(),
name="event_location_detail",
),
path(
"update/",
EventLocationUpdateView.as_view(),
name="event_location_update",
),
path(
"delete/",
EventLocationDeleteView.as_view(),
name="event_location_delete",
),
]
),
),
]
),
),
# manage Event objects
path(
"events/",
include(
[
path("", EventListView.as_view(), name="event_list"),
path(
"<slug:slug>/",
include(
[
path("", EventDetailView.as_view(), name="event_detail",),
path(
"update/",
EventUpdateView.as_view(),
name="event_update",
),
path(
"schedule/",
EventScheduleView.as_view(),
name="event_schedule",
),
path(
"delete/",
EventDeleteView.as_view(),
name="event_delete",
),
]
),
),
]
),
),
# manage AutoScheduler
path(
"autoscheduler/",
include(
[
path("", AutoScheduleManageView.as_view(), name="autoschedule_manage",),
path(
"crashcourse/",
AutoScheduleCrashCourseView.as_view(),
name="autoschedule_crash_course",
),
path(
"validate/",
AutoScheduleValidateView.as_view(),
name="autoschedule_validate",
),
path(
"diff/", AutoScheduleDiffView.as_view(), name="autoschedule_diff",
),
path(
"apply/",
AutoScheduleApplyView.as_view(),
name="autoschedule_apply",
),
path(
"debug-event-slot-unavailability/",
AutoScheduleDebugEventSlotUnavailabilityView.as_view(),
name="autoschedule_debug_event_slot_unavailability",
),
path(
"debug-event-conflicts/",
AutoScheduleDebugEventConflictsView.as_view(),
name="autoschedule_debug_event_conflicts",
),
]
),
),
# approve EventFeedback objects
path(
"approve_feedback",
ApproveFeedbackView.as_view(),
name="approve_event_feedback",
), ),
# economy # economy
path( path(

View file

@ -4,28 +4,49 @@ from itertools import chain
import requests import requests
from camps.mixins import CampViewMixin from camps.mixins import CampViewMixin
from django import forms
from django.conf import settings from django.conf import settings
from django.contrib import messages from django.contrib import messages
from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.auth.mixins import LoginRequiredMixin
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.core.exceptions import PermissionDenied from django.core.exceptions import PermissionDenied
from django.core.files import File from django.core.files import File
from django.db.models import Sum from django.db.models import Count, Q, Sum
from django.forms import modelformset_factory from django.forms import modelformset_factory
from django.http import Http404, HttpResponse from django.http import Http404, HttpResponse
from django.shortcuts import get_object_or_404, redirect from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse from django.urls import reverse
from django.utils import timezone from django.utils import timezone
from django.utils.safestring import mark_safe
from django.views.generic import DetailView, ListView, TemplateView from django.views.generic import DetailView, ListView, TemplateView
from django.views.generic.edit import CreateView, DeleteView, FormView, UpdateView from django.views.generic.edit import CreateView, DeleteView, FormView, UpdateView
from economy.models import Chain, Credebtor, Expense, Reimbursement, Revenue from economy.models import Chain, Credebtor, Expense, Reimbursement, Revenue
from facilities.models import FacilityFeedback from facilities.models import FacilityFeedback
from profiles.models import Profile from profiles.models import Profile
from program.models import EventFeedback, EventProposal, SpeakerProposal from program.autoscheduler import AutoScheduler
from program.mixins import AvailabilityMatrixViewMixin
from program.models import (
Event,
EventFeedback,
EventLocation,
EventProposal,
EventSession,
EventSlot,
EventType,
Speaker,
SpeakerProposal,
)
from program.utils import save_speaker_availability
from shop.models import Order, OrderProductRelation from shop.models import Order, OrderProductRelation
from teams.models import Team from teams.models import Team
from tickets.models import DiscountTicket, ShopTicket, SponsorTicket, TicketType from tickets.models import DiscountTicket, ShopTicket, SponsorTicket, TicketType
from .forms import (
AutoScheduleApplyForm,
AutoScheduleValidateForm,
EventScheduleForm,
SpeakerForm,
)
from .mixins import ( from .mixins import (
ContentTeamPermissionMixin, ContentTeamPermissionMixin,
EconomyTeamPermissionMixin, EconomyTeamPermissionMixin,
@ -188,7 +209,7 @@ class ApproveFeedbackView(CampViewMixin, ContentTeamPermissionMixin, FormView):
Why the hell do the forms in the formset not include the object? Why the hell do the forms in the formset not include the object?
""" """
context = super().get_context_data(*args, **kwargs) context = super().get_context_data(*args, **kwargs)
context["eventfeedback_list"] = self.queryset context["event_feedback_list"] = self.queryset
context["formset"] = self.form_class(queryset=self.queryset) context["formset"] = self.form_class(queryset=self.queryset)
return context return context
@ -202,34 +223,37 @@ class ApproveFeedbackView(CampViewMixin, ContentTeamPermissionMixin, FormView):
def get_success_url(self, *args, **kwargs): def get_success_url(self, *args, **kwargs):
return reverse( return reverse(
"backoffice:approve_eventfeedback", kwargs={"camp_slug": self.camp.slug} "backoffice:approve_event_feedback", kwargs={"camp_slug": self.camp.slug}
) )
class ManageProposalsView(CampViewMixin, ContentTeamPermissionMixin, ListView): #######################################
""" # MANAGE SPEAKER/EVENT PROPOSAL VIEWS
This view shows a list of pending SpeakerProposal and EventProposals.
"""
template_name = "manage_proposals.html"
context_object_name = "speakerproposals" class PendingProposalsView(CampViewMixin, ContentTeamPermissionMixin, ListView):
""" This convenience view shows a list of pending proposals """
model = SpeakerProposal
template_name = "pending_proposals.html"
context_object_name = "speaker_proposal_list"
def get_queryset(self, **kwargs): def get_queryset(self, **kwargs):
return SpeakerProposal.objects.filter( qs = super().get_queryset(**kwargs).filter(proposal_status="pending")
camp=self.camp, proposal_status=SpeakerProposal.PROPOSAL_PENDING qs = qs.prefetch_related("user", "urls", "speaker")
) return qs
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs) context = super().get_context_data(**kwargs)
context["eventproposals"] = EventProposal.objects.filter( context["event_proposal_list"] = self.camp.event_proposals.filter(
track__camp=self.camp, proposal_status=EventProposal.PROPOSAL_PENDING proposal_status=EventProposal.PROPOSAL_PENDING
) ).prefetch_related("event_type", "track", "speakers", "tags", "user", "event")
return context return context
class ProposalManageBaseView(CampViewMixin, ContentTeamPermissionMixin, UpdateView): class ProposalApproveBaseView(CampViewMixin, ContentTeamPermissionMixin, UpdateView):
""" """
This class contains the shared logic between SpeakerProposalManageView and EventProposalManageView Shared logic between SpeakerProposalApproveView and EventProposalApproveView
""" """
fields = ["reason"] fields = ["reason"]
@ -247,26 +271,855 @@ class ProposalManageBaseView(CampViewMixin, ContentTeamPermissionMixin, UpdateVi
else: else:
messages.error(self.request, "Unknown submit action") messages.error(self.request, "Unknown submit action")
return redirect( return redirect(
reverse("backoffice:manage_proposals", kwargs={"camp_slug": self.camp.slug}) reverse(
"backoffice:pending_proposals", kwargs={"camp_slug": self.camp.slug}
)
) )
class SpeakerProposalManageView(ProposalManageBaseView): class SpeakerProposalListView(CampViewMixin, ContentTeamPermissionMixin, ListView):
""" """ This view permits Content Team members to list SpeakerProposals """
This view allows an admin to approve/reject SpeakerProposals
"""
model = SpeakerProposal model = SpeakerProposal
template_name = "manage_speakerproposal.html" template_name = "speaker_proposal_list.html"
context_object_name = "speaker_proposal_list"
def get_queryset(self, **kwargs):
qs = super().get_queryset(**kwargs)
qs = qs.prefetch_related("user", "urls", "speaker")
return qs
class EventProposalManageView(ProposalManageBaseView): class SpeakerProposalDetailView(
""" AvailabilityMatrixViewMixin, ContentTeamPermissionMixin, DetailView,
This view allows an admin to approve/reject EventProposals ):
""" """ This view permits Content Team members to see SpeakerProposal details """
model = SpeakerProposal
template_name = "speaker_proposal_detail_backoffice.html"
context_object_name = "speaker_proposal"
def get_queryset(self, *args, **kwargs):
qs = super().get_queryset(*args, **kwargs)
qs = qs.prefetch_related("user", "urls")
return qs
class SpeakerProposalApproveRejectView(ProposalApproveBaseView):
""" This view allows ContentTeam members to approve/reject SpeakerProposals """
model = SpeakerProposal
template_name = "speaker_proposal_approve_reject.html"
context_object_name = "speaker_proposal"
class EventProposalListView(CampViewMixin, ContentTeamPermissionMixin, ListView):
""" This view permits Content Team members to list EventProposals """
model = EventProposal model = EventProposal
template_name = "manage_eventproposal.html" template_name = "event_proposal_list.html"
context_object_name = "event_proposal_list"
def get_queryset(self, *args, **kwargs):
qs = super().get_queryset(*args, **kwargs)
qs = qs.prefetch_related(
"user",
"urls",
"event",
"event_type",
"speakers__event_proposals",
"track",
"tags",
)
return qs
class EventProposalDetailView(CampViewMixin, ContentTeamPermissionMixin, DetailView):
""" This view permits Content Team members to see EventProposal details """
model = EventProposal
template_name = "event_proposal_detail_backoffice.html"
context_object_name = "event_proposal"
class EventProposalApproveRejectView(ProposalApproveBaseView):
""" This view allows ContentTeam members to approve/reject EventProposals """
model = EventProposal
template_name = "event_proposal_approve_reject.html"
context_object_name = "event_proposal"
################################
# MANAGE SPEAKER VIEWS
class SpeakerListView(CampViewMixin, ContentTeamPermissionMixin, ListView):
""" This view is used by the Content Team to see Speaker objects. """
model = Speaker
template_name = "speaker_list_backoffice.html"
def get_queryset(self, *args, **kwargs):
qs = super().get_queryset(*args, **kwargs)
qs = qs.prefetch_related(
"proposal__user",
"events__event_slots",
"events__event_type",
"event_conflicts",
)
return qs
class SpeakerDetailView(
AvailabilityMatrixViewMixin, ContentTeamPermissionMixin, DetailView
):
""" This view is used by the Content Team to see details for Speaker objects """
model = Speaker
template_name = "speaker_detail_backoffice.html"
def get_queryset(self, *args, **kwargs):
qs = super().get_queryset(*args, **kwargs)
qs = qs.prefetch_related(
"event_conflicts", "events__event_slots", "events__event_type"
)
return qs
class SpeakerUpdateView(
AvailabilityMatrixViewMixin, ContentTeamPermissionMixin, UpdateView
):
""" This view is used by the Content Team to update Speaker objects """
model = Speaker
template_name = "speaker_update.html"
form_class = SpeakerForm
def get_form_kwargs(self):
""" Set camp for the form """
kwargs = super().get_form_kwargs()
kwargs.update({"camp": self.camp})
return kwargs
def form_valid(self, form):
""" Save object and availability """
speaker = form.save()
save_speaker_availability(form, obj=speaker)
messages.success(self.request, "Speaker has been updated")
return redirect(
reverse(
"backoffice:speaker_detail",
kwargs={"camp_slug": self.camp.slug, "slug": self.get_object().slug},
)
)
class SpeakerDeleteView(CampViewMixin, ContentTeamPermissionMixin, DeleteView):
""" This view is used by the Content Team to delete Speaker objects """
model = Speaker
template_name = "speaker_delete.html"
def delete(self, *args, **kwargs):
speaker = self.get_object()
# delete related objects first
speaker.availabilities.all().delete()
speaker.urls.all().delete()
return super().delete(*args, **kwargs)
def get_success_url(self):
messages.success(
self.request, f"Speaker '{self.get_object().name}' has been deleted"
)
return reverse("backoffice:speaker_list", kwargs={"camp_slug": self.camp.slug})
################################
# MANAGE EVENTTYPE VIEWS
class EventTypeListView(CampViewMixin, ContentTeamPermissionMixin, ListView):
""" This view is used by the Content Team to list EventTypes """
model = EventType
template_name = "event_type_list.html"
context_object_name = "event_type_list"
def get_queryset(self, *args, **kwargs):
qs = super().get_queryset(*args, **kwargs)
qs = qs.annotate(
# only count events for the current camp
event_count=Count(
"events", distinct=True, filter=Q(events__track__camp=self.camp)
),
# only count EventSessions for the current camp
event_sessions_count=Count(
"event_sessions",
distinct=True,
filter=Q(event_sessions__camp=self.camp),
),
# only count EventSlots for the current camp
event_slots_count=Count(
"event_sessions__event_slots",
distinct=True,
filter=Q(event_sessions__camp=self.camp),
),
)
return qs
class EventTypeDetailView(CampViewMixin, ContentTeamPermissionMixin, DetailView):
""" This view is used by the Content Team to see details for EventTypes """
model = EventType
template_name = "event_type_detail.html"
context_object_name = "event_type"
def get_context_data(self, *args, **kwargs):
context = super().get_context_data(*args, **kwargs)
context["event_sessions"] = self.camp.event_sessions.filter(
event_type=self.get_object()
).prefetch_related("event_location", "event_slots")
context["events"] = self.camp.events.filter(
event_type=self.get_object()
).prefetch_related(
"speakers", "event_slots__event_session__event_location", "event_type"
)
return context
################################
# MANAGE EVENTLOCATION VIEWS
class EventLocationListView(CampViewMixin, ContentTeamPermissionMixin, ListView):
""" This view is used by the Content Team to list EventLocation objects. """
model = EventLocation
template_name = "event_location_list.html"
context_object_name = "event_location_list"
def get_queryset(self, *args, **kwargs):
qs = super().get_queryset(*args, **kwargs)
qs = qs.prefetch_related("event_sessions__event_slots", "conflicts")
return qs
class EventLocationDetailView(CampViewMixin, ContentTeamPermissionMixin, DetailView):
""" This view is used by the Content Team to see details for EventLocation objects """
model = EventLocation
template_name = "event_location_detail.html"
context_object_name = "event_location"
def get_queryset(self, *args, **kwargs):
qs = super().get_queryset(*args, **kwargs)
qs = qs.prefetch_related(
"conflicts", "event_sessions__event_slots", "event_sessions__event_type"
)
return qs
class EventLocationCreateView(CampViewMixin, ContentTeamPermissionMixin, CreateView):
""" This view is used by the Content Team to create EventLocation objects """
model = EventLocation
fields = ["name", "icon", "capacity", "conflicts"]
template_name = "event_location_form.html"
def get_form(self, *args, **kwargs):
form = super().get_form(*args, **kwargs)
form.fields["conflicts"].queryset = self.camp.event_locations.all()
return form
def form_valid(self, form):
location = form.save(commit=False)
location.camp = self.camp
location.save()
form.save_m2m()
messages.success(
self.request, f"EventLocation {location.name} has been created"
)
return redirect(
reverse(
"backoffice:event_location_detail",
kwargs={"camp_slug": self.camp.slug, "slug": location.slug},
)
)
class EventLocationUpdateView(CampViewMixin, ContentTeamPermissionMixin, UpdateView):
""" This view is used by the Content Team to update EventLocation objects """
model = EventLocation
fields = ["name", "icon", "capacity", "conflicts"]
template_name = "event_location_form.html"
def get_form(self, *args, **kwargs):
form = super().get_form(*args, **kwargs)
form.fields["conflicts"].queryset = self.camp.event_locations.exclude(
pk=self.get_object().pk
)
return form
def get_success_url(self):
messages.success(
self.request, f"EventLocation {self.get_object().name} has been updated"
)
return reverse(
"backoffice:event_location_detail",
kwargs={"camp_slug": self.camp.slug, "slug": self.get_object().slug},
)
class EventLocationDeleteView(CampViewMixin, ContentTeamPermissionMixin, DeleteView):
""" This view is used by the Content Team to delete EventLocation objects """
model = EventLocation
template_name = "event_location_delete.html"
context_object_name = "event_location"
def delete(self, *args, **kwargs):
slotsdeleted, slotdetails = self.get_object().event_slots.all().delete()
sessionsdeleted, sessiondetails = (
self.get_object().event_sessions.all().delete()
)
return super().delete(*args, **kwargs)
def get_success_url(self):
messages.success(
self.request, f"EventLocation '{self.get_object().name}' has been deleted."
)
return reverse(
"backoffice:event_location_list", kwargs={"camp_slug": self.camp.slug}
)
################################
# MANAGE EVENT VIEWS
class EventListView(CampViewMixin, ContentTeamPermissionMixin, ListView):
""" This view is used by the Content Team to see Event objects. """
model = Event
template_name = "event_list_backoffice.html"
def get_queryset(self, *args, **kwargs):
qs = super().get_queryset(*args, **kwargs)
qs = qs.prefetch_related(
"speakers__events",
"event_type",
"event_slots__event_session__event_location",
"tags",
)
return qs
class EventDetailView(CampViewMixin, ContentTeamPermissionMixin, DetailView):
""" This view is used by the Content Team to see details for Event objects """
model = Event
template_name = "event_detail_backoffice.html"
class EventUpdateView(CampViewMixin, ContentTeamPermissionMixin, UpdateView):
""" This view is used by the Content Team to update Event objects """
model = Event
fields = [
"title",
"abstract",
"video_recording",
"duration_minutes",
"demand",
"tags",
]
template_name = "event_update.html"
def get_success_url(self):
messages.success(self.request, "Event has been updated")
return reverse(
"backoffice:event_detail",
kwargs={"camp_slug": self.camp.slug, "slug": self.get_object().slug},
)
class EventDeleteView(CampViewMixin, ContentTeamPermissionMixin, DeleteView):
""" This view is used by the Content Team to delete Event objects """
model = Event
template_name = "event_delete.html"
def delete(self, *args, **kwargs):
self.get_object().urls.all().delete()
return super().delete(*args, **kwargs)
def get_success_url(self):
messages.success(
self.request, f"Event '{self.get_object().title}' has been deleted!",
)
return reverse("backoffice:event_list", kwargs={"camp_slug": self.camp.slug})
class EventScheduleView(CampViewMixin, ContentTeamPermissionMixin, FormView):
""" This view is used by the Content Team to manually schedule Events.
It shows a table with radioselect buttons for the available slots for the
EventType of the Event """
form_class = EventScheduleForm
template_name = "event_schedule.html"
def setup(self, *args, **kwargs):
super().setup(*args, **kwargs)
self.event = get_object_or_404(Event, slug=kwargs["slug"])
def get_form(self, *args, **kwargs):
form = super().get_form(*args, **kwargs)
self.slots = []
slotindex = 0
# loop over sessions, get free slots
for session in self.camp.event_sessions.filter(
event_type=self.event.event_type
):
for slot in session.get_available_slots():
# loop over speakers to see if they are all available
for speaker in self.event.speakers.all():
if not speaker.is_available(slot.when):
# this speaker is not available, skip this slot
break
else:
# all speakers are available for this slot
self.slots.append({"index": slotindex, "slot": slot})
slotindex += 1
# add the slot choicefield
form.fields["slot"] = forms.ChoiceField(
widget=forms.RadioSelect,
choices=[(s["index"], s["index"]) for s in self.slots],
)
return form
def get_context_data(self, *args, **kwargs):
"""
Add event to context
"""
context = super().get_context_data(*args, **kwargs)
context["event"] = self.event
context["event_slots"] = self.slots
return context
def form_valid(self, form):
"""
Set needed values, save slot and return
"""
slot = self.slots[int(form.cleaned_data["slot"])]["slot"]
slot.event = self.event
slot.autoscheduled = False
slot.save()
messages.success(
self.request,
f"{self.event.title} has been scheduled to begin at {slot.when.lower} at location {slot.event_location.name} successfully!",
)
return redirect(
reverse(
"backoffice:event_detail",
kwargs={"camp_slug": self.camp.slug, "slug": self.event.slug},
)
)
################################
# MANAGE EVENTSESSION VIEWS
class EventSessionCreateTypeSelectView(
CampViewMixin, ContentTeamPermissionMixin, ListView
):
"""
This view is shown first when creating a new EventSession
"""
model = EventType
template_name = "event_session_create_type_select.html"
context_object_name = "event_type_list"
class EventSessionCreateLocationSelectView(
CampViewMixin, ContentTeamPermissionMixin, ListView
):
"""
This view is shown second when creating a new EventSession
"""
model = EventLocation
template_name = "event_session_create_location_select.html"
context_object_name = "event_location_list"
def setup(self, *args, **kwargs):
super().setup(*args, **kwargs)
self.event_type = get_object_or_404(EventType, slug=kwargs["event_type_slug"])
def get_context_data(self, *args, **kwargs):
"""
Add event_type to context
"""
context = super().get_context_data(*args, **kwargs)
context["event_type"] = self.event_type
return context
class EventSessionFormViewMixin:
"""
A mixin with the stuff shared between EventSession{Create|Update}View
"""
def get_form(self, *args, **kwargs):
"""
The default range widgets are a bit shit because they eat the help_text and
have no indication of which field is for what. So we add a nice placeholder.
We also limit the event_location dropdown to only the current camps locations.
"""
form = super().get_form(*args, **kwargs)
form.fields["when"].widget.widgets[0].attrs = {
"placeholder": f"Start Date and Time (YYYY-MM-DD HH:MM). Time zone is {settings.TIME_ZONE}.",
}
form.fields["when"].widget.widgets[1].attrs = {
"placeholder": f"End Date and Time (YYYY-MM-DD HH:MM). Time zone is {settings.TIME_ZONE}.",
}
if hasattr(form.fields, "event_location"):
form.fields["event_location"].queryset = EventLocation.objects.filter(
camp=self.camp
)
return form
def get_context_data(self, *args, **kwargs):
"""
Add event_type and location and existing sessions to context
"""
context = super().get_context_data(*args, **kwargs)
if not hasattr(self, "event_type"):
self.event_type = self.get_object().event_type
context["event_type"] = self.event_type
if not hasattr(self, "event_location"):
self.event_location = self.get_object().event_location
context["event_location"] = self.event_location
context["sessions"] = self.event_type.event_sessions.filter(camp=self.camp)
return context
class EventSessionCreateView(
CampViewMixin, ContentTeamPermissionMixin, EventSessionFormViewMixin, CreateView
):
"""
This view is used by the Content Team to create EventSession objects
"""
model = EventSession
fields = ["description", "when", "event_duration_minutes"]
template_name = "event_session_form.html"
def setup(self, *args, **kwargs):
super().setup(*args, **kwargs)
self.event_type = get_object_or_404(EventType, slug=kwargs["event_type_slug"])
self.event_location = get_object_or_404(
EventLocation, camp=self.camp, slug=kwargs["event_location_slug"]
)
def form_valid(self, form):
"""
Set camp and event_type, check for overlaps and save
"""
session = form.save(commit=False)
session.event_type = self.event_type
session.event_location = self.event_location
session.camp = self.camp
session.save()
messages.success(self.request, f"{session} has been created successfully!")
return redirect(
reverse(
"backoffice:event_session_list", kwargs={"camp_slug": self.camp.slug}
)
)
class EventSessionListView(CampViewMixin, ContentTeamPermissionMixin, ListView):
"""
This view is used by the Content Team to see EventSession objects.
"""
model = EventSession
template_name = "event_session_list.html"
context_object_name = "event_session_list"
def get_queryset(self, *args, **kwargs):
qs = super().get_queryset(*args, **kwargs)
qs = qs.prefetch_related("event_type", "event_location", "event_slots")
return qs
class EventSessionDetailView(CampViewMixin, ContentTeamPermissionMixin, DetailView):
"""
This view is used by the Content Team to see details for EventSession objects
"""
model = EventSession
template_name = "event_session_detail.html"
context_object_name = "session"
class EventSessionUpdateView(
CampViewMixin, ContentTeamPermissionMixin, EventSessionFormViewMixin, UpdateView
):
"""
This view is used by the Content Team to update EventSession objects
"""
model = EventSession
fields = ["when", "description", "event_duration_minutes"]
template_name = "event_session_form.html"
def form_valid(self, form):
"""
Just save, we have a post_save signal which takes care of fixing EventSlots
"""
session = form.save()
messages.success(self.request, f"{session} has been updated successfully!")
return redirect(
reverse(
"backoffice:event_session_list", kwargs={"camp_slug": self.camp.slug}
)
)
class EventSessionDeleteView(CampViewMixin, ContentTeamPermissionMixin, DeleteView):
"""
This view is used by the Content Team to delete EventSession objects
"""
model = EventSession
template_name = "event_session_delete.html"
context_object_name = "session"
def get(self, *args, **kwargs):
""" Show a warning if we have something scheduled in this EventSession """
if self.get_object().event_slots.filter(event__isnull=False).exists():
messages.warning(
self.request,
"NOTE: One or more EventSlots in this EventSession has an Event scheduled. Make sure you are deleting the correct session!",
)
return super().get(*args, **kwargs)
def delete(self, *args, **kwargs):
session = self.get_object()
session.event_slots.all().delete()
return super().delete(*args, **kwargs)
def get_success_url(self):
messages.success(
self.request,
"EventSession and related EventSlots was deleted successfully!",
)
return reverse(
"backoffice:event_session_list", kwargs={"camp_slug": self.camp.slug}
)
################################
# MANAGE EVENTSLOT VIEWS
class EventSlotListView(CampViewMixin, ContentTeamPermissionMixin, ListView):
""" This view is used by the Content Team to see EventSlot objects. """
model = EventSlot
template_name = "event_slot_list.html"
context_object_name = "event_slot_list"
def get_queryset(self, *args, **kwargs):
qs = super().get_queryset(*args, **kwargs)
qs = qs.prefetch_related(
"event__speakers",
"event_session__event_location",
"event_session__event_type",
)
return qs
class EventSlotDetailView(CampViewMixin, ContentTeamPermissionMixin, DetailView):
""" This view is used by the Content Team to see details for EventSlot objects """
model = EventSlot
template_name = "event_slot_detail.html"
context_object_name = "event_slot"
class EventSlotUnscheduleView(CampViewMixin, ContentTeamPermissionMixin, UpdateView):
""" This view is used by the Content Team to remove an Event from the schedule/EventSlot """
model = EventSlot
template_name = "event_slot_unschedule.html"
fields = []
context_object_name = "event_slot"
def form_valid(self, form):
event_slot = self.get_object()
event = event_slot.event
event_slot.unschedule()
messages.success(
self.request,
f"The Event '{event.title}' has been removed from the slot {event_slot}",
)
return redirect(
reverse(
"backoffice:event_detail",
kwargs={"camp_slug": self.camp.slug, "slug": event.slug},
)
)
################################
# AUTOSCHEDULER VIEWS
class AutoScheduleManageView(CampViewMixin, ContentTeamPermissionMixin, TemplateView):
""" Just an index type view with links to the various actions """
template_name = "autoschedule_index.html"
class AutoScheduleCrashCourseView(
CampViewMixin, ContentTeamPermissionMixin, TemplateView
):
""" A short crash course on the autoscheduler """
template_name = "autoschedule_crash_course.html"
class AutoScheduleValidateView(CampViewMixin, ContentTeamPermissionMixin, FormView):
""" This view is used to validate schedules. It uses the AutoScheduler and can
either validate the currently applied schedule or a new similar schedule, or a
brand new schedule """
template_name = "autoschedule_validate.html"
form_class = AutoScheduleValidateForm
def form_valid(self, form):
# initialise AutoScheduler
scheduler = AutoScheduler(camp=self.camp)
# get autoschedule
if form.cleaned_data["schedule"] == "current":
autoschedule = scheduler.build_current_autoschedule()
message = f"The currently scheduled Events form a valid schedule! AutoScheduler has {len(scheduler.autoslots)} Slots based on {scheduler.event_sessions.count()} EventSessions for {scheduler.event_types.count()} EventTypes. {scheduler.events.count()} Events in the schedule."
elif form.cleaned_data["schedule"] == "similar":
original_autoschedule = scheduler.build_current_autoschedule()
autoschedule, diff = scheduler.calculate_similar_autoschedule(
original_autoschedule
)
message = f"The new similar schedule is valid! AutoScheduler has {len(scheduler.autoslots)} Slots based on {scheduler.event_sessions.count()} EventSessions for {scheduler.event_types.count()} EventTypes. Differences to the current schedule: {len(diff['event_diffs'])} Event diffs and {len(diff['slot_diffs'])} Slot diffs."
elif form.cleaned_data["schedule"] == "new":
autoschedule = scheduler.calculate_autoschedule()
message = f"The new schedule is valid! AutoScheduler has {len(scheduler.autoslots)} Slots based on {scheduler.event_sessions.count()} EventSessions for {scheduler.event_types.count()} EventTypes. {scheduler.events.count()} Events in the schedule."
# check validity
valid, violations = scheduler.is_valid(autoschedule, return_violations=True)
if valid:
messages.success(self.request, message)
else:
messages.error(self.request, "Schedule is NOT valid!")
message = "Schedule violations:<br>"
for v in violations:
message += v + "<br>"
messages.error(self.request, mark_safe(message))
return redirect(
reverse(
"backoffice:autoschedule_validate", kwargs={"camp_slug": self.camp.slug}
)
)
class AutoScheduleDiffView(CampViewMixin, ContentTeamPermissionMixin, TemplateView):
template_name = "autoschedule_diff.html"
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
scheduler = AutoScheduler(camp=self.camp)
autoschedule, diff = scheduler.calculate_similar_autoschedule()
context["diff"] = diff
context["scheduler"] = scheduler
return context
class AutoScheduleApplyView(CampViewMixin, ContentTeamPermissionMixin, FormView):
""" This view is used by the Content Team to apply a new schedules by unscheduling
all autoscheduled Events, and scheduling all Event/Slot combinations in the schedule.
TODO: see comment in program.autoscheduler.AutoScheduler.apply() method.
"""
template_name = "autoschedule_apply.html"
form_class = AutoScheduleApplyForm
def form_valid(self, form):
# initialise AutoScheduler
scheduler = AutoScheduler(camp=self.camp)
# get autoschedule
if form.cleaned_data["schedule"] == "similar":
autoschedule, diff = scheduler.calculate_similar_autoschedule()
elif form.cleaned_data["schedule"] == "new":
autoschedule = scheduler.calculate_autoschedule()
# check validity
valid, violations = scheduler.is_valid(autoschedule, return_violations=True)
if valid:
# schedule is valid, apply it
deleted, created = scheduler.apply(autoschedule)
messages.success(
self.request,
f"Schedule has been applied! {deleted} Events removed from schedule, {created} new Events scheduled. Differences to the previous schedule: {len(diff['event_diffs'])} Event diffs and {len(diff['slot_diffs'])} Slot diffs.",
)
else:
messages.error(self.request, "Schedule is NOT valid, cannot apply!")
return redirect(
reverse(
"backoffice:autoschedule_apply", kwargs={"camp_slug": self.camp.slug}
)
)
class AutoScheduleDebugEventSlotUnavailabilityView(
CampViewMixin, ContentTeamPermissionMixin, TemplateView
):
template_name = "autoschedule_debug_slots.html"
def get_context_data(self, **kwargs):
scheduler = AutoScheduler(camp=self.camp)
context = {
"scheduler": scheduler,
}
return context
class AutoScheduleDebugEventConflictsView(
CampViewMixin, ContentTeamPermissionMixin, TemplateView
):
template_name = "autoschedule_debug_events.html"
def get_context_data(self, **kwargs):
scheduler = AutoScheduler(camp=self.camp)
context = {
"scheduler": scheduler,
}
return context
################################
# MERCHANDISE VIEWS
class MerchandiseOrdersView(CampViewMixin, OrgaTeamPermissionMixin, ListView): class MerchandiseOrdersView(CampViewMixin, OrgaTeamPermissionMixin, ListView):
@ -315,6 +1168,10 @@ class MerchandiseToOrderView(CampViewMixin, OrgaTeamPermissionMixin, TemplateVie
return context return context
################################
# VILLAGE VIEWS
class VillageOrdersView(CampViewMixin, OrgaTeamPermissionMixin, ListView): class VillageOrdersView(CampViewMixin, OrgaTeamPermissionMixin, ListView):
template_name = "orders_village.html" template_name = "orders_village.html"
@ -763,11 +1620,8 @@ class ScanTicketsView(
class ShopTicketOverview(LoginRequiredMixin, CampViewMixin, ListView): class ShopTicketOverview(LoginRequiredMixin, CampViewMixin, ListView):
model = ShopTicket model = ShopTicket
template_name = "shop_ticket_overview.html" template_name = "shop_ticket_overview.html"
context_object_name = "shop_tickets" context_object_name = "shop_tickets"
def get_context_data(self, *, object_list=None, **kwargs): def get_context_data(self, *, object_list=None, **kwargs):

View file

@ -67,6 +67,7 @@ TICKET_CATEGORY_NAME='Tickets'
SCHEDULE_MIDNIGHT_OFFSET_HOURS=9 SCHEDULE_MIDNIGHT_OFFSET_HOURS=9
SCHEDULE_TIMESLOT_LENGTH_MINUTES=30 SCHEDULE_TIMESLOT_LENGTH_MINUTES=30
SCHEDULE_EVENT_NOTIFICATION_MINUTES=10 SCHEDULE_EVENT_NOTIFICATION_MINUTES=10
SPEAKER_AVAILABILITY_DAYCHUNK_HOURS=3 # how many hours per speaker_availability form checkbox
# irc bot settings # irc bot settings
IRCBOT_CHECK_MESSAGE_INTERVAL_SECONDS=10 IRCBOT_CHECK_MESSAGE_INTERVAL_SECONDS=10

View file

@ -17,9 +17,9 @@ DATABASES = {
"ENGINE": 'django.contrib.gis.db.backends.postgis', "ENGINE": 'django.contrib.gis.db.backends.postgis',
"NAME": "bornhack", "NAME": "bornhack",
"USER": "bornhack", "USER": "bornhack",
# Comment back in if you are connecting via TCP #"PASSWORD": "bornhack",
# "PASSWORD": "bornhack", #"HOST": "localhost",
# "HOST": "localhost", #"PORT": 5433,
} }
} }
DEBUG = True DEBUG = True
@ -37,6 +37,7 @@ MEDIA_ROOT = os.path.join(
SCHEDULE_MIDNIGHT_OFFSET_HOURS = 9 SCHEDULE_MIDNIGHT_OFFSET_HOURS = 9
SCHEDULE_TIMESLOT_LENGTH_MINUTES = 30 SCHEDULE_TIMESLOT_LENGTH_MINUTES = 30
SCHEDULE_EVENT_NOTIFICATION_MINUTES = 10 SCHEDULE_EVENT_NOTIFICATION_MINUTES = 10
SPEAKER_AVAILABILITY_DAYCHUNK_HOURS=3
PDF_LETTERHEAD_FILENAME = "bornhack-2017_test_letterhead.pdf" PDF_LETTERHEAD_FILENAME = "bornhack-2017_test_letterhead.pdf"
PDF_ARCHIVE_PATH = os.path.join(MEDIA_ROOT, "pdf_archive") PDF_ARCHIVE_PATH = os.path.join(MEDIA_ROOT, "pdf_archive")

View file

@ -1,5 +1,8 @@
import os import os
# monkeypatch postgres Range object to support lookups
from utils import range_fields # noqa: F401
from .environment_settings import * # noqa: F403 from .environment_settings import * # noqa: F403
@ -66,6 +69,7 @@ INSTALLED_APPS = [
"reversion", "reversion",
"leaflet", "leaflet",
"oauth2_provider", "oauth2_provider",
"taggit",
] ]
# MEDIA_URL = '/media/' # MEDIA_URL = '/media/'
@ -79,7 +83,7 @@ USE_TZ = True
SHORT_DATE_FORMAT = "Ymd" SHORT_DATE_FORMAT = "Ymd"
DATE_FORMAT = "l, M jS, Y" DATE_FORMAT = "l, M jS, Y"
DATETIME_FORMAT = "l, M jS, Y, H:i (e)" DATETIME_FORMAT = "l, M jS, Y, H:i (e)"
TIME_FORMAT = "H:i (e)" TIME_FORMAT = "H:i"
TEMPLATES = [ TEMPLATES = [
{ {
@ -117,7 +121,7 @@ LOGIN_URL = "/login/"
ACCOUNT_DEFAULT_HTTP_PROTOCOL = "https" ACCOUNT_DEFAULT_HTTP_PROTOCOL = "https"
BOOTSTRAP3 = { BOOTSTRAP3 = {
"jquery_url": "/static/js/jquery.min.js", "jquery_url": "/static/js/jquery-3.3.1.min.js",
"javascript_url": "/static/js/bootstrap.min.js", "javascript_url": "/static/js/bootstrap.min.js",
} }
MIDDLEWARE = [ MIDDLEWARE = [

View file

@ -1,11 +1,12 @@
import logging import logging
from datetime import timedelta from datetime import timedelta
from django.apps import apps
from django.contrib.postgres.fields import DateTimeRangeField from django.contrib.postgres.fields import DateTimeRangeField
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.db import models from django.db import models
from django.urls import reverse from django.urls import reverse
from program.models import EventLocation, EventType from django.utils import timezone
from psycopg2.extras import DateTimeTZRange from psycopg2.extras import DateTimeTZRange
from utils.models import CreatedUpdatedModel, UUIDModel from utils.models import CreatedUpdatedModel, UUIDModel
@ -153,20 +154,6 @@ class Camp(CreatedUpdatedModel, UUIDModel):
def __str__(self): def __str__(self):
return "%s - %s" % (self.title, self.tagline) return "%s - %s" % (self.title, self.tagline)
@property
def event_types(self):
""" Return all event types with at least one event in this camp """
return EventType.objects.filter(
event__instances__isnull=False, event__camp=self
).distinct()
@property
def event_locations(self):
""" Return all event locations with at least one event in this camp"""
return EventLocation.objects.filter(
eventinstances__isnull=False, camp=self
).distinct()
@property @property
def logo_small(self): def logo_small(self):
return "img/%(slug)s/logo/%(slug)s-logo-s.png" % {"slug": self.slug} return "img/%(slug)s/logo/%(slug)s-logo-s.png" % {"slug": self.slug}
@ -201,31 +188,46 @@ class Camp(CreatedUpdatedModel, UUIDModel):
logger.error("this attribute is not a datetimetzrange field: %s" % field) logger.error("this attribute is not a datetimetzrange field: %s" % field)
return False return False
daycount = (field.upper - field.lower).days # count how many unique dates we have in this range
daycount = 1
while True:
if field.lower.date() + timedelta(days=daycount) > field.upper.date():
break
daycount += 1
# loop through the required number of days, append to list as we go
days = [] days = []
for i in range(0, daycount): for i in range(0, daycount):
if i == 0: if i == 0:
# on the first day use actual start time instead of midnight # on the first day use actual start time instead of midnight (local time)
days.append( days.append(
DateTimeTZRange( DateTimeTZRange(
field.lower, timezone.localtime(field.lower),
(field.lower + timedelta(days=i + 1)).replace(hour=0), timezone.localtime(
(field.lower + timedelta(days=i + 1))
).replace(hour=0),
) )
) )
elif i == daycount - 1: elif i == daycount - 1:
# on the last day use actual end time instead of midnight # on the last day use actual end time instead of midnight (local time)
days.append( days.append(
DateTimeTZRange( DateTimeTZRange(
(field.lower + timedelta(days=i)).replace(hour=0), timezone.localtime((field.lower + timedelta(days=i))).replace(
field.lower + timedelta(days=i + 1), hour=0
),
timezone.localtime(field.lower + timedelta(days=i)),
) )
) )
else: else:
# neither first nor last day, goes from midnight to midnight # neither first nor last day, goes from midnight to midnight (local time)
days.append( days.append(
DateTimeTZRange( DateTimeTZRange(
(field.lower + timedelta(days=i)).replace(hour=0), timezone.localtime((field.lower + timedelta(days=i))).replace(
(field.lower + timedelta(days=i + 1)).replace(hour=0), hour=0
),
timezone.localtime(
(field.lower + timedelta(days=i + 1))
).replace(hour=0),
) )
) )
return days return days
@ -250,3 +252,33 @@ class Camp(CreatedUpdatedModel, UUIDModel):
Returns a list of DateTimeTZRanges representing the days during the buildup. Returns a list of DateTimeTZRanges representing the days during the buildup.
""" """
return self.get_days("teardown") return self.get_days("teardown")
# convenience properties to access Camp-related stuff easily from the Camp object
@property
def event_types(self):
""" Return all event types with at least one event in this camp """
EventType = apps.get_model("program", "EventType")
return EventType.objects.filter(
events__isnull=False, event__track__camp=self
).distinct()
@property
def event_proposals(self):
EventProposal = apps.get_model("program", "EventProposal")
return EventProposal.objects.filter(track__camp=self)
@property
def events(self):
Event = apps.get_model("program", "Event")
return Event.objects.filter(track__camp=self)
@property
def event_sessions(self):
EventSession = apps.get_model("program", "EventSession")
return EventSession.objects.filter(camp=self)
@property
def event_slots(self):
EventSlot = apps.get_model("program", "EventSlot")
return EventSlot.objects.filter(event_session__in=self.event_sessions.all())

View file

@ -3,9 +3,8 @@ import os
from django.contrib import messages from django.contrib import messages
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.db import models from django.db import models
from django.utils.text import slugify
from utils.models import CampRelatedModel, CreatedUpdatedModel, UUIDModel from utils.models import CampRelatedModel, CreatedUpdatedModel, UUIDModel
from utils.slugs import unique_slugify
from .email import ( from .email import (
send_accountingsystem_expense_email, send_accountingsystem_expense_email,
@ -62,8 +61,13 @@ class Chain(CreatedUpdatedModel, UUIDModel):
def save(self, **kwargs): def save(self, **kwargs):
if not self.slug: if not self.slug:
self.slug = slugify(self.name) self.slug = unique_slugify(
super(Chain, self).save(**kwargs) self.name,
slugs_in_use=self.__class__.objects.all().values_list(
"slug", flat=True
),
)
super().save(**kwargs)
@property @property
def expenses(self): def expenses(self):
@ -133,8 +137,13 @@ class Credebtor(CreatedUpdatedModel, UUIDModel):
Generate slug as needed Generate slug as needed
""" """
if not self.slug: if not self.slug:
self.slug = slugify(self.name) self.slug = unique_slugify(
super(Credebtor, self).save(**kwargs) self.name,
slugs_in_use=self.__class__.objects.filter(
chain=self.chain
).values_list("slug", flat=True),
)
super().save(**kwargs)
class Revenue(CampRelatedModel, UUIDModel): class Revenue(CampRelatedModel, UUIDModel):

View file

@ -5,11 +5,6 @@
Expenses | {{ block.super }} Expenses | {{ block.super }}
{% endblock %} {% endblock %}
{% block extra_head %}
<script src="{% static "js/jquery.dataTables.min.js" %}"></script>
<link rel="stylesheet" href="{% static 'css/jquery.dataTables.min.css' %}">
{% endblock extra_head %}
<{% block content %} <{% block content %}
<h3>Your {{ camp.title }} Expenses</h3> <h3>Your {{ camp.title }} Expenses</h3>

View file

@ -5,11 +5,6 @@
Expenses | {{ block.super }} Expenses | {{ block.super }}
{% endblock %} {% endblock %}
{% block extra_head %}
<script src="{% static "js/jquery.dataTables.min.js" %}"></script>
<link rel="stylesheet" href="{% static 'css/jquery.dataTables.min.css' %}">
{% endblock extra_head %}
<{% block content %} <{% block content %}
<h3>Your {{ camp.title }} Reimbursements</h3> <h3>Your {{ camp.title }} Reimbursements</h3>

View file

@ -5,11 +5,6 @@
Revenues | {{ block.super }} Revenues | {{ block.super }}
{% endblock %} {% endblock %}
{% block extra_head %}
<script src="{% static "js/jquery.dataTables.min.js" %}"></script>
<link rel="stylesheet" href="{% static 'css/jquery.dataTables.min.css' %}">
{% endblock extra_head %}
<{% block content %} <{% block content %}
<h3>Your {{ camp.title }} Revenues</h3> <h3>Your {{ camp.title }} Revenues</h3>

View file

@ -4,12 +4,11 @@ import logging
import qrcode import qrcode
from django.contrib.gis.db.models import PointField from django.contrib.gis.db.models import PointField
from django.core.exceptions import ValidationError
from django.db import models from django.db import models
from django.shortcuts import reverse from django.shortcuts import reverse
from django.utils.text import slugify
from maps.utils import LeafletMarkerChoices from maps.utils import LeafletMarkerChoices
from utils.models import CampRelatedModel, UUIDModel from utils.models import CampRelatedModel, UUIDModel
from utils.slugs import unique_slugify
logger = logging.getLogger("bornhack.%s" % __name__) logger = logging.getLogger("bornhack.%s" % __name__)
@ -42,6 +41,7 @@ class FacilityType(CampRelatedModel):
""" """
class Meta: class Meta:
# we need a unique slug for each team due to the url structure in backoffice
unique_together = [("slug", "responsible_team")] unique_together = [("slug", "responsible_team")]
name = models.CharField(max_length=100, help_text="The name of this facility type") name = models.CharField(max_length=100, help_text="The name of this facility type")
@ -89,9 +89,12 @@ class FacilityType(CampRelatedModel):
def save(self, **kwargs): def save(self, **kwargs):
if not self.slug: if not self.slug:
self.slug = slugify(self.name) self.slug = unique_slugify(
if not self.slug: self.name,
raise ValidationError("Unable to slugify") slugs_in_use=self.__class__.objects.filter(
responsible_team=self.responsible_team
).values_list("slug", flat=True),
)
super().save(**kwargs) super().save(**kwargs)

View file

@ -1,8 +1,8 @@
from django.db import models from django.db import models
from django.urls import reverse from django.urls import reverse
from django.utils.text import slugify from django.utils.text import slugify
from utils.models import CreatedUpdatedModel from utils.models import CreatedUpdatedModel
from utils.slugs import unique_slugify
class NewsItem(CreatedUpdatedModel): class NewsItem(CreatedUpdatedModel):
@ -29,20 +29,13 @@ class NewsItem(CreatedUpdatedModel):
published_at_string = self.published_at.strftime("%Y-%m-%d") published_at_string = self.published_at.strftime("%Y-%m-%d")
base_slug = slugify(self.title) base_slug = slugify(self.title)
slug = "{}-{}".format(published_at_string, base_slug) slug = "{}-{}".format(published_at_string, base_slug)
incrementer = 1 self.slug = unique_slugify(
slug,
# We have to make sure that the slug won't clash with current slugs slugs_in_use=self.__class__.objects.all().values_list(
while NewsItem.objects.filter(slug=slug).exists(): "slug", flat=True
if incrementer == 1: ),
slug = "{}-1".format(slug) )
else: super().save(**kwargs)
slug = "{}-{}".format(
"-".join(slug.split("-")[:-1]), incrementer
)
incrementer += 1
self.slug = slug
super(NewsItem, self).save(**kwargs)
def get_absolute_url(self): def get_absolute_url(self):
return reverse("news:detail", kwargs={"slug": self.slug}) return reverse("news:detail", kwargs={"slug": self.slug})

View file

@ -5,11 +5,6 @@
Phonebook | {{ block.super }} Phonebook | {{ block.super }}
{% endblock %} {% endblock %}
{% block extra_head %}
<script src="{% static "js/jquery.dataTables.min.js" %}"></script>
<link rel="stylesheet" href="{% static 'css/jquery.dataTables.min.css' %}">
{% endblock extra_head %}
{% block content %} {% block content %}
<h2>{{ camp.title }} Phonebook</h2> <h2>{{ camp.title }} Phonebook</h2>
@ -26,7 +21,7 @@ This is a list of all the registered DECT numbers in our phonebook for {{ camp.t
{% endif %} {% endif %}
{% if dectregistration_list %} {% if dectregistration_list %}
<table class="table table-hover table-striped"> <table class="table table-hover table-striped datatable">
<thead> <thead>
<tr> <tr>
<th>Number</th> <th>Number</th>

View file

@ -7,27 +7,43 @@ from .models import (
EventInstance, EventInstance,
EventLocation, EventLocation,
EventProposal, EventProposal,
EventSession,
EventSlot,
EventTrack, EventTrack,
EventType, EventType,
Favorite,
Speaker, Speaker,
SpeakerAvailability,
SpeakerProposal, SpeakerProposal,
SpeakerProposalAvailability,
Url, Url,
UrlType, UrlType,
) )
@admin.register(SpeakerProposalAvailability)
class SpeakerProposalAvailabilityAdmin(admin.ModelAdmin):
list_display = ["speaker_proposal", "available", "when"]
list_filter = ["speaker_proposal__camp", "available", "speaker_proposal"]
@admin.register(SpeakerAvailability)
class SpeakerAvailabilityAdmin(admin.ModelAdmin):
list_display = ["speaker", "available", "when"]
list_filter = ["speaker__camp", "available", "speaker"]
readonly_fields = ["speaker"]
@admin.register(SpeakerProposal) @admin.register(SpeakerProposal)
class SpeakerProposalAdmin(admin.ModelAdmin): class SpeakerProposalAdmin(admin.ModelAdmin):
def mark_speakerproposal_as_approved(self, request, queryset): def mark_speaker_proposal_as_approved(self, request, queryset):
for sp in queryset: for sp in queryset:
sp.mark_as_approved(request) sp.mark_as_approved(request)
mark_speakerproposal_as_approved.description = ( mark_speaker_proposal_as_approved.description = (
"Approve and create Speaker object(s)" "Approve and create Speaker object(s)"
) )
actions = ["mark_speakerproposal_as_approved"] actions = ["mark_speaker_proposal_as_approved"]
list_filter = ("camp", "proposal_status", "user") list_filter = ("camp", "proposal_status", "user")
@ -40,7 +56,7 @@ get_speakers_string.short_description = "Speakers"
@admin.register(EventProposal) @admin.register(EventProposal)
class EventProposalAdmin(admin.ModelAdmin): class EventProposalAdmin(admin.ModelAdmin):
def mark_eventproposal_as_approved(self, request, queryset): def mark_event_proposal_as_approved(self, request, queryset):
for ep in queryset: for ep in queryset:
if not ep.speakers.all(): if not ep.speakers.all():
messages.error( messages.error(
@ -54,12 +70,12 @@ class EventProposalAdmin(admin.ModelAdmin):
messages.error(request, e) messages.error(request, e)
return False return False
mark_eventproposal_as_approved.description = "Approve and create Event object(s)" mark_event_proposal_as_approved.description = "Approve and create Event object(s)"
def get_speakers(self): def get_speakers(self):
return return
actions = ["mark_eventproposal_as_approved"] actions = ["mark_event_proposal_as_approved"]
list_filter = ("event_type", "proposal_status", "track", "user") list_filter = ("event_type", "proposal_status", "track", "user")
list_display = ["title", get_speakers_string, "event_type", "proposal_status"] list_display = ["title", get_speakers_string, "event_type", "proposal_status"]
@ -67,7 +83,7 @@ class EventProposalAdmin(admin.ModelAdmin):
@admin.register(EventLocation) @admin.register(EventLocation)
class EventLocationAdmin(admin.ModelAdmin): class EventLocationAdmin(admin.ModelAdmin):
list_filter = ("camp",) list_filter = ("camp",)
list_display = ("name", "camp") list_display = ("name", "camp", "capacity")
@admin.register(EventTrack) @admin.register(EventTrack)
@ -76,10 +92,23 @@ class EventTrackAdmin(admin.ModelAdmin):
list_display = ("name", "camp") list_display = ("name", "camp")
@admin.register(EventSession)
class EventSessionAdmin(admin.ModelAdmin):
list_display = ("camp", "event_type", "event_location", "when")
list_filter = ("camp", "event_type", "event_location")
search_fields = ["event__type", "event__location"]
@admin.register(EventSlot)
class EventSlotAdmin(admin.ModelAdmin):
list_display = ("id", "event_session", "when", "event")
list_filter = ("event_session__camp", "event_session__event_type", "event_session")
@admin.register(EventInstance) @admin.register(EventInstance)
class EventInstanceAdmin(admin.ModelAdmin): class EventInstanceAdmin(admin.ModelAdmin):
list_display = ("event", "when", "location") list_display = ("event", "when", "location", "autoscheduled")
list_filter = ("event__track__camp", "event") list_filter = ("event__track__camp", "event", "autoscheduled")
search_fields = ["event__title"] search_fields = ["event__title"]
@ -91,12 +120,7 @@ class EventTypeAdmin(admin.ModelAdmin):
@admin.register(Speaker) @admin.register(Speaker)
class SpeakerAdmin(admin.ModelAdmin): class SpeakerAdmin(admin.ModelAdmin):
list_filter = ("camp",) list_filter = ("camp",)
readonly_fields = ["proposal"] readonly_fields = ["proposal", "camp"]
@admin.register(Favorite)
class FavoriteAdmin(admin.ModelAdmin):
raw_id_fields = ("event_instance",)
class SpeakerInline(admin.StackedInline): class SpeakerInline(admin.StackedInline):
@ -105,8 +129,8 @@ class SpeakerInline(admin.StackedInline):
@admin.register(Event) @admin.register(Event)
class EventAdmin(admin.ModelAdmin): class EventAdmin(admin.ModelAdmin):
list_filter = ("track", "speakers") list_display = ["title", "event_type", "duration_minutes", "demand"]
list_display = ["title", "event_type"] list_filter = ("track", "event_type", "speakers")
inlines = [SpeakerInline] inlines = [SpeakerInline]

View file

@ -1,24 +1,19 @@
from django.apps import AppConfig from django.apps import AppConfig
from django.db.models.signals import m2m_changed, post_save, pre_save from django.db.models.signals import m2m_changed, post_save
class ProgramConfig(AppConfig): class ProgramConfig(AppConfig):
name = "program" name = "program"
def ready(self): def ready(self):
from .models import Speaker, EventInstance from .models import Speaker, EventSession
from .signal_handlers import ( from .signal_handlers import (
check_speaker_event_camp_consistency, check_speaker_event_camp_consistency,
check_speaker_camp_change, event_session_post_save,
eventinstance_pre_save,
eventinstance_post_save,
) )
m2m_changed.connect( m2m_changed.connect(
check_speaker_event_camp_consistency, sender=Speaker.events.through check_speaker_event_camp_consistency, sender=Speaker.events.through
) )
pre_save.connect(check_speaker_camp_change, sender=Speaker) post_save.connect(event_session_post_save, sender=EventSession)
pre_save.connect(eventinstance_pre_save, sender=EventInstance)
post_save.connect(eventinstance_post_save, sender=EventInstance)

View file

@ -0,0 +1,390 @@
import logging
from datetime import timedelta
from conference_scheduler import resources, scheduler
from conference_scheduler.lp_problem import objective_functions
from conference_scheduler.validator import is_valid_schedule, schedule_violations
from psycopg2.extras import DateTimeTZRange
from .models import EventType
logger = logging.getLogger("bornhack.%s" % __name__)
class AutoScheduler:
"""
The BornHack AutoScheduler. Made with love by Tykling.
Built around https://github.com/PyconUK/ConferenceScheduler which works with lists
of conference_scheduler.resources.Slot and conference_scheduler.resources.Event objects.
Most of the code in this class deals with massaging our data into a list of Slot and
Event objects defining the data and constraints for the scheduler.
Initialising this class takes a while because all the objects have to be created.
"""
def __init__(self, camp):
""" Get EventTypes, EventSessions and Events, build autoslot and autoevent objects """
self.camp = camp
# Get all EventTypes which support autoscheduling
self.event_types = self.get_event_types()
# Get all EventSessions for the current event_types
self.event_sessions = self.get_event_sessions(self.event_types)
# Build a lookup dict of lists of EventSession IDs per EventType (for easy lookups later)
self.event_type_sessions = {}
for session in self.event_sessions:
if session.event_type not in self.event_type_sessions:
self.event_type_sessions[session.event_type] = []
self.event_type_sessions[session.event_type].append(session.id)
# Get all Events for the current event_types
self.events = self.get_events(self.event_types)
# Get autoslots
self.autoslots = self.get_autoslots(self.event_sessions)
# Build a lookup dict of autoslots per EventType
self.event_type_slots = {}
for autoslot in self.autoslots:
# loop over event_type_sessions dict and find our
for et, sessions in self.event_type_sessions.items():
if autoslot.session in sessions:
if et not in self.event_type_slots:
self.event_type_slots[et] = []
self.event_type_slots[et].append(autoslot)
break
# get autoevents and a lookup dict which maps Event id to autoevent index
self.autoevents, self.autoeventindex = self.get_autoevents(self.events)
def get_event_types(self):
""" Return all EventTypes which support autoscheduling """
return EventType.objects.filter(support_autoscheduling=True)
def get_event_sessions(self, event_types):
""" Return all EventSessions for these EventTypes """
return self.camp.event_sessions.filter(
event_type__in=event_types,
).prefetch_related("event_type", "event_location")
def get_events(self, event_types):
""" Return all Events that need scheduling """
# return all events for these event_types, but..
return self.camp.events.filter(event_type__in=event_types).exclude(
# exclude Events that have been sceduled already...
event_slots__isnull=False,
# ...unless those events are autoscheduled
event_slots__autoscheduled=False,
)
def get_autoslots(self, event_sessions):
""" Return a list of autoslots for all slots in all EventSessions """
autoslots = []
# loop over the sessions
for session in event_sessions:
# loop over available slots in this session
for slot in session.get_available_slots(count_autoscheduled_as_free=True):
autoslots.append(slot.get_autoscheduler_slot())
return autoslots
def get_autoevents(self, events):
""" Return a list of resources.Event objects, one for each Event """
autoevents = []
autoeventindex = {}
eventindex = {}
for event in events:
autoevents.append(
resources.Event(
name=event.id,
duration=event.duration_minutes,
tags=event.tags.names(),
demand=event.demand,
)
)
# create a dict of events with the autoevent index as key and the Event as value
autoeventindex[autoevents.index(autoevents[-1])] = event
# create a dict of events with the Event as key and the autoevent index as value
eventindex[event] = autoevents.index(autoevents[-1])
# loop over all autoevents to add unavailability...
# (we have to do this in a seperate loop because we need all the autoevents to exist)
for autoevent in autoevents:
# get the Event
event = autoeventindex[autoevents.index(autoevent)]
# loop over all other event_types...
for et in self.event_types.all().exclude(pk=event.event_type.pk):
if et in self.event_type_slots:
# and add all slots for this EventType as unavailable for this event,
# this means we don't schedule a talk in a workshop slot and vice versa.
autoevent.add_unavailability(*self.event_type_slots[et])
# loop over all speakers for this event and add event conflicts
for speaker in event.speakers.all():
# loop over other events featuring this speaker, register each conflict,
# this means we dont schedule two events for the same speaker at the same time
conflict_ids = speaker.events.exclude(id=event.id).values_list(
"id", flat=True
)
for conflictevent in autoevents:
if conflictevent.name in conflict_ids:
# only the event with the lowest index gets the unavailability,
if autoevents.index(conflictevent) > autoevents.index(
autoevent
):
autoevent.add_unavailability(conflictevent)
# loop over event_conflicts for this speaker, register unavailability for each,
# this means we dont schedule this event at the same time as something the
# speaker wishes to attend.
# Only process Events which the AutoScheduler is handling
for conflictevent in speaker.event_conflicts.filter(
pk__in=events.values_list("pk", flat=True)
):
# only the event with the lowest index gets the unavailability
if eventindex[conflictevent] > autoevents.index(autoevent):
autoevent.add_unavailability(
autoevents[eventindex[conflictevent]]
)
# loop over event_conflicts for this speaker, register unavailability for each,
# only process Events which the AutoScheduler is not handling, and which have
# been scheduled in one or more EventSlots
for conflictevent in speaker.event_conflicts.filter(
event_slots__isnull=False
).exclude(pk__in=events.values_list("pk", flat=True)):
# loop over the EventSlots this conflict is scheduled in
for conflictslot in conflictevent.event_slots.all():
# loop over all slots
for slot in self.autoslots:
# check if this slot overlaps with the conflictevents slot
if conflictslot.when & DateTimeTZRange(
slot.starts_at,
slot.starts_at + timedelta(minutes=slot.duration),
):
# this slot overlaps with the conflicting event
autoevent.add_unavailability(slot)
# Register all slots where we have no positive availability
# for this speaker as unavailable
available = []
for availability in speaker.availabilities.filter(
available=True
).values_list("when", flat=True):
availability = DateTimeTZRange(
availability.lower, availability.upper, "()"
)
for slot in self.autoslots:
slotrange = DateTimeTZRange(
slot.starts_at,
slot.starts_at + timedelta(minutes=slot.duration),
"()",
)
if slotrange in availability:
# the speaker is available for this slot
available.append(self.autoslots.index(slot))
autoevent.add_unavailability(
*[
s
for s in self.autoslots
if not self.autoslots.index(s) in available
]
)
return autoevents, autoeventindex
def build_current_autoschedule(self):
""" Build an autoschedule object based on the existing published schedule.
Returns an autoschedule, which is a list of conference_scheduler.resources.ScheduledItem
objects, one for each scheduled Event. This function is useful for creating an "original
schedule" to base a new similar schedule off of. """
# loop over scheduled events and create a ScheduledItem object for each
autoschedule = []
for slot in self.camp.event_slots.filter(
autoscheduled=True, event__in=self.events
):
# loop over all autoevents to find the index of this event
for autoevent in self.autoevents:
if autoevent.name == slot.event.id:
# we need the index number of the event
eventindex = self.autoevents.index(autoevent)
break
# loop over the autoslots to find the index of the autoslot this event is scheduled in
scheduled = False
for autoslot in self.autoslots:
if (
autoslot.venue == slot.event_location.id
and autoslot.starts_at == slot.when.lower
and autoslot.session
in self.event_type_sessions[slot.event.event_type]
):
# This autoslot starts at the same time as the EventSlot, and at the same
# location. It also has the session ID of a session with the right EventType.
autoschedule.append(
resources.ScheduledItem(
event=self.autoevents[eventindex],
slot=self.autoslots[self.autoslots.index(autoslot)],
)
)
scheduled = True
break
# did we find a slot matching this EventInstance?
if not scheduled:
print(f"Could not find an autoslot for slot {slot} - skipping")
# The returned schedule might not be valid! For example if a speaker is no
# longer available when their talk is scheduled. This is fine though, an invalid
# schedule can still be used as a basis for creating a new similar schedule.
return autoschedule
def calculate_autoschedule(self, original_schedule=None):
""" Calculate autoschedule based on self.autoevents and self.autoslots,
optionally using original_schedule to minimise changes """
kwargs = {}
kwargs["events"] = self.autoevents
kwargs["slots"] = self.autoslots
# include another schedule in the calculation?
if original_schedule:
kwargs["original_schedule"] = original_schedule
kwargs["objective_function"] = objective_functions.number_of_changes
else:
# otherwise use the capacity demand difference thing
kwargs[
"objective_function"
] = objective_functions.efficiency_capacity_demand_difference
# calculate the new schedule
autoschedule = scheduler.schedule(**kwargs)
return autoschedule
def calculate_similar_autoschedule(self, original_schedule=None):
""" Convenience method for creating similar schedules. If original_schedule
is omitted the new schedule is based on the current schedule instead """
if not original_schedule:
# we do not have an original_schedule, use current EventInstances
original_schedule = self.build_current_autoschedule()
# calculate and return
autoschedule = self.calculate_autoschedule(original_schedule=original_schedule)
diff = self.diff(original_schedule, autoschedule)
return autoschedule, diff
def apply(self, autoschedule):
""" Apply an autoschedule by creating EventInstance objects to match it """
# "The Clean Slate protocol sir?" - delete any existing autoscheduled Events
# TODO: investigate how this affects the FRAB XML export (for which we added a UUID on
# EventInstance objects). Make sure "favourite" functionality or bookmarks or w/e in
# FRAB clients still work after a schedule "re"apply. We might need a smaller hammer here.
deleted = self.camp.event_slots.filter(
# get all autoscheduled EventSlots
autoscheduled=True
).update(
# clear the Event
event=None,
# and autoscheduled status
autoscheduled=None,
)
# loop and schedule events
scheduled = 0
for item in autoschedule:
# each item is an instance of conference_scheduler.resources.ScheduledItem
event = self.camp.events.get(id=item.event.name)
slot = self.camp.event_slots.get(
event_session_id=item.slot.session,
when=DateTimeTZRange(
item.slot.starts_at,
item.slot.starts_at + timedelta(minutes=item.slot.duration),
"[)", # remember to use the correct bounds when comparing
),
)
slot.event = event
slot.autoscheduled = True
slot.save()
scheduled += 1
# return the numbers
return deleted, scheduled
def diff(self, original_schedule, new_schedule):
"""
This method returns a dict of Event differences and Slot differences between
the two schedules.
"""
slot_diff = scheduler.slot_schedule_difference(original_schedule, new_schedule,)
slot_output = []
for item in slot_diff:
slot_output.append(
{
"event_location": self.camp.event_locations.get(pk=item.slot.venue),
"starttime": item.slot.starts_at,
"old": {},
"new": {},
}
)
if item.old_event:
try:
old_event = self.camp.events.get(pk=item.old_event.name)
except self.camp.events.DoesNotExist:
old_event = item.old_event.name
slot_output[-1]["old"]["event"] = old_event
if item.new_event:
try:
new_event = self.camp.events.get(pk=item.new_event.name)
except self.camp.events.DoesNotExist:
new_event = item.old_event.name
slot_output[-1]["new"]["event"] = new_event
# then get a list of differences per event
event_diff = scheduler.event_schedule_difference(
original_schedule, new_schedule,
)
event_output = []
# loop over the differences and build the dict
for item in event_diff:
try:
event = self.camp.events.get(pk=item.event.name)
except self.camp.events.DoesNotExist:
event = item.event.name
event_output.append(
{"event": event, "old": {}, "new": {},}
)
# do we have an old slot for this event?
if item.old_slot:
event_output[-1]["old"][
"event_location"
] = self.camp.event_locations.get(id=item.old_slot.venue)
event_output[-1]["old"]["starttime"] = item.old_slot.starts_at
# do we have a new slot for this event?
if item.new_slot:
event_output[-1]["new"][
"event_location"
] = self.camp.event_locations.get(id=item.new_slot.venue)
event_output[-1]["new"]["starttime"] = item.new_slot.starts_at
# all good
return {"event_diffs": event_output, "slot_diffs": slot_output}
def is_valid(self, autoschedule, return_violations=False):
""" Check if a schedule is valid, optionally returning a list of violations if invalid """
valid = is_valid_schedule(
autoschedule, slots=self.autoslots, events=self.autoevents
)
if not return_violations:
return valid
return (
valid,
schedule_violations(
autoschedule, slots=self.autoslots, events=self.autoevents
),
)

View file

@ -7,123 +7,123 @@ from utils.email import add_outgoing_email
logger = logging.getLogger("bornhack.%s" % __name__) logger = logging.getLogger("bornhack.%s" % __name__)
def add_new_speakerproposal_email(speakerproposal): def add_new_speaker_proposal_email(speaker_proposal):
formatdict = {"proposal": speakerproposal} formatdict = {"proposal": speaker_proposal}
try: try:
content_team = Team.objects.get(camp=speakerproposal.camp, name="Content") content_team = Team.objects.get(camp=speaker_proposal.camp, name="Content")
except ObjectDoesNotExist as e: except ObjectDoesNotExist as e:
logger.info("There is no team with name Content: {}".format(e)) logger.info("There is no team with name Content: {}".format(e))
return False return False
return add_outgoing_email( return add_outgoing_email(
text_template="emails/new_speakerproposal.txt", text_template="emails/new_speaker_proposal.txt",
html_template="emails/new_speakerproposal.html", html_template="emails/new_speaker_proposal.html",
to_recipients=content_team.mailing_list, to_recipients=content_team.mailing_list,
formatdict=formatdict, formatdict=formatdict,
subject="New speaker proposal '%s' was just submitted" % speakerproposal.name, subject="New speaker proposal '%s' was just submitted" % speaker_proposal.name,
) )
def add_new_eventproposal_email(eventproposal): def add_new_event_proposal_email(event_proposal):
formatdict = {"proposal": eventproposal} formatdict = {"proposal": event_proposal}
try: try:
content_team = Team.objects.get(camp=eventproposal.camp, name="Content") content_team = Team.objects.get(camp=event_proposal.camp, name="Content")
except ObjectDoesNotExist as e: except ObjectDoesNotExist as e:
logger.info("There is no team with name Content: {}".format(e)) logger.info("There is no team with name Content: {}".format(e))
return False return False
return add_outgoing_email( return add_outgoing_email(
text_template="emails/new_eventproposal.txt", text_template="emails/new_event_proposal.txt",
html_template="emails/new_eventproposal.html", html_template="emails/new_event_proposal.html",
to_recipients=content_team.mailing_list, to_recipients=content_team.mailing_list,
formatdict=formatdict, formatdict=formatdict,
subject="New event proposal '%s' was just submitted" % eventproposal.title, subject="New event proposal '%s' was just submitted" % event_proposal.title,
) )
def add_speakerproposal_updated_email(speakerproposal): def add_speaker_proposal_updated_email(speaker_proposal):
formatdict = {"proposal": speakerproposal} formatdict = {"proposal": speaker_proposal}
try: try:
content_team = Team.objects.get(camp=speakerproposal.camp, name="Content") content_team = Team.objects.get(camp=speaker_proposal.camp, name="Content")
except ObjectDoesNotExist as e: except ObjectDoesNotExist as e:
logger.info("There is no team with name Content: {}".format(e)) logger.info("There is no team with name Content: {}".format(e))
return False return False
return add_outgoing_email( return add_outgoing_email(
text_template="emails/update_speakerproposal.txt", text_template="emails/update_speaker_proposal.txt",
html_template="emails/update_speakerproposal.html", html_template="emails/update_speaker_proposal.html",
to_recipients=content_team.mailing_list, to_recipients=content_team.mailing_list,
formatdict=formatdict, formatdict=formatdict,
subject="Speaker proposal '%s' was just updated" % speakerproposal.name, subject="Speaker proposal '%s' was just updated" % speaker_proposal.name,
) )
def add_eventproposal_updated_email(eventproposal): def add_event_proposal_updated_email(event_proposal):
formatdict = {"proposal": eventproposal} formatdict = {"proposal": event_proposal}
try: try:
content_team = Team.objects.get(camp=eventproposal.camp, name="Content") content_team = Team.objects.get(camp=event_proposal.camp, name="Content")
except ObjectDoesNotExist as e: except ObjectDoesNotExist as e:
logger.info("There is no team with name Content: {}".format(e)) logger.info("There is no team with name Content: {}".format(e))
return False return False
return add_outgoing_email( return add_outgoing_email(
text_template="emails/update_eventproposal.txt", text_template="emails/update_event_proposal.txt",
html_template="emails/update_eventproposal.html", html_template="emails/update_event_proposal.html",
to_recipients=content_team.mailing_list, to_recipients=content_team.mailing_list,
formatdict=formatdict, formatdict=formatdict,
subject="Event proposal '%s' was just updated" % eventproposal.title, subject="Event proposal '%s' was just updated" % event_proposal.title,
) )
def add_speakerproposal_rejected_email(speakerproposal): def add_speaker_proposal_rejected_email(speaker_proposal):
formatdict = {"proposal": speakerproposal} formatdict = {"proposal": speaker_proposal}
return add_outgoing_email( return add_outgoing_email(
text_template="emails/speakerproposal_rejected.txt", text_template="emails/speaker_proposal_rejected.txt",
html_template="emails/speakerproposal_rejected.html", html_template="emails/speaker_proposal_rejected.html",
to_recipients=speakerproposal.user.email, to_recipients=speaker_proposal.user.email,
formatdict=formatdict, formatdict=formatdict,
subject=f"Your {speakerproposal.camp.title} speaker proposal '{speakerproposal.name}' was rejected", subject=f"Your {speaker_proposal.camp.title} speaker proposal '{speaker_proposal.name}' was rejected",
) )
def add_speakerproposal_accepted_email(speakerproposal): def add_speaker_proposal_accepted_email(speaker_proposal):
formatdict = {"proposal": speakerproposal} formatdict = {"proposal": speaker_proposal}
return add_outgoing_email( return add_outgoing_email(
text_template="emails/speakerproposal_accepted.txt", text_template="emails/speaker_proposal_accepted.txt",
html_template="emails/speakerproposal_accepted.html", html_template="emails/speaker_proposal_accepted.html",
to_recipients=speakerproposal.user.email, to_recipients=speaker_proposal.user.email,
formatdict=formatdict, formatdict=formatdict,
subject=f"Your {speakerproposal.camp.title} speaker proposal '{speakerproposal.name}' was accepted", subject=f"Your {speaker_proposal.camp.title} speaker proposal '{speaker_proposal.name}' was accepted",
) )
def add_eventproposal_rejected_email(eventproposal): def add_event_proposal_rejected_email(event_proposal):
formatdict = {"proposal": eventproposal} formatdict = {"proposal": event_proposal}
return add_outgoing_email( return add_outgoing_email(
text_template="emails/eventproposal_rejected.txt", text_template="emails/event_proposal_rejected.txt",
html_template="emails/eventproposal_rejected.html", html_template="emails/event_proposal_rejected.html",
to_recipients=eventproposal.user.email, to_recipients=event_proposal.user.email,
formatdict=formatdict, formatdict=formatdict,
subject=f"Your {eventproposal.camp.title} event proposal '{eventproposal.title}' was rejected", subject=f"Your {event_proposal.camp.title} event proposal '{event_proposal.title}' was rejected",
) )
def add_eventproposal_accepted_email(eventproposal): def add_event_proposal_accepted_email(event_proposal):
formatdict = {"proposal": eventproposal} formatdict = {"proposal": event_proposal}
return add_outgoing_email( return add_outgoing_email(
text_template="emails/eventproposal_accepted.txt", text_template="emails/event_proposal_accepted.txt",
html_template="emails/eventproposal_accepted.html", html_template="emails/event_proposal_accepted.html",
to_recipients=eventproposal.user.email, to_recipients=event_proposal.user.email,
formatdict=formatdict, formatdict=formatdict,
subject=f"Your {eventproposal.camp.title} event proposal '{eventproposal.title}' was accepted!", subject=f"Your {event_proposal.camp.title} event proposal '{event_proposal.title}' was accepted!",
) )

View file

@ -3,14 +3,15 @@ import logging
from django import forms from django import forms
from django.core.exceptions import ImproperlyConfigured from django.core.exceptions import ImproperlyConfigured
from .models import EventProposal, EventTrack, SpeakerProposal, Url, UrlType from .models import Event, EventProposal, EventTrack, SpeakerProposal
logger = logging.getLogger("bornhack.%s" % __name__) logger = logging.getLogger("bornhack.%s" % __name__)
class SpeakerProposalForm(forms.ModelForm): class SpeakerProposalForm(forms.ModelForm):
""" """
The SpeakerProposalForm. Takes an EventType in __init__ and changes fields accordingly. The SpeakerProposalForm. Takes a list of EventTypes in __init__,
and changes fields accordingly if the list has 1 element.
""" """
class Meta: class Meta:
@ -21,17 +22,41 @@ class SpeakerProposalForm(forms.ModelForm):
"biography", "biography",
"needs_oneday_ticket", "needs_oneday_ticket",
"submission_notes", "submission_notes",
"event_conflicts",
] ]
def __init__(self, camp, eventtype=None, *args, **kwargs): def __init__(self, camp, event_type=None, matrix={}, *args, **kwargs):
# initialise the form """
initialise the form and adapt based on event_type
"""
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
# only show events from this camp
self.fields["event_conflicts"].queryset = Event.objects.filter(
track__camp=camp, event_type__support_speaker_event_conflicts=True,
)
if matrix:
# add speaker availability fields
for date in matrix.keys():
# do we need a column for this day?
if matrix[date]:
# loop over the daychunks for this day
for daychunk in matrix[date]:
if matrix[date][daychunk]:
# add the field
self.fields[
matrix[date][daychunk]["fieldname"]
] = forms.BooleanField(required=False)
# add it to Meta.fields too
self.Meta.fields.append(matrix[date][daychunk]["fieldname"])
# adapt form based on EventType? # adapt form based on EventType?
if not eventtype: if not event_type:
# we have no event_type to customize the form, use the default form
return return
if eventtype.name == "Debate": if event_type.name == "Debate":
# fix label and help_text for the name field # fix label and help_text for the name field
self.fields["name"].label = "Guest Name" self.fields["name"].label = "Guest Name"
self.fields[ self.fields[
@ -54,10 +79,10 @@ class SpeakerProposalForm(forms.ModelForm):
"submission_notes" "submission_notes"
].help_text = "Private notes regarding this guest. Only visible to yourself and the BornHack organisers." ].help_text = "Private notes regarding this guest. Only visible to yourself and the BornHack organisers."
# no free tickets for workshops # no free tickets for debates
del self.fields["needs_oneday_ticket"] del self.fields["needs_oneday_ticket"]
elif eventtype.name == "Lightning Talk": elif event_type.name == "Lightning Talk":
# fix label and help_text for the name field # fix label and help_text for the name field
self.fields["name"].label = "Speaker Name" self.fields["name"].label = "Speaker Name"
self.fields[ self.fields[
@ -83,7 +108,7 @@ class SpeakerProposalForm(forms.ModelForm):
# no free tickets for lightning talks # no free tickets for lightning talks
del self.fields["needs_oneday_ticket"] del self.fields["needs_oneday_ticket"]
elif eventtype.name == "Music Act": elif event_type.name == "Music Act":
# fix label and help_text for the name field # fix label and help_text for the name field
self.fields["name"].label = "Artist Name" self.fields["name"].label = "Artist Name"
self.fields[ self.fields[
@ -109,33 +134,7 @@ class SpeakerProposalForm(forms.ModelForm):
# no oneday tickets for music acts # no oneday tickets for music acts
del self.fields["needs_oneday_ticket"] del self.fields["needs_oneday_ticket"]
elif eventtype.name == "Recreational Event": elif event_type.name == "Talk" or event_type.name == "Keynote":
# fix label and help_text for the name field
self.fields["name"].label = "Host Name"
self.fields[
"name"
].help_text = "The name of the event host. Can be a real name or an alias."
# fix label and help_text for the email field
self.fields["email"].label = "Host Email"
self.fields[
"email"
].help_text = "The email for the host. Will default to the logged-in users email if left empty."
# fix label and help_text for the biograpy field
self.fields["biography"].label = "Host Biography"
self.fields["biography"].help_text = "The biography of the host."
# fix label and help_text for the submission_notes field
self.fields["submission_notes"].label = "Host Notes"
self.fields[
"submission_notes"
].help_text = "Private notes regarding this host. Only visible to yourself and the BornHack organisers."
# no oneday tickets for music acts
del self.fields["needs_oneday_ticket"]
elif eventtype.name == "Talk":
# fix label and help_text for the name field # fix label and help_text for the name field
self.fields["name"].label = "Speaker Name" self.fields["name"].label = "Speaker Name"
self.fields[ self.fields[
@ -158,7 +157,7 @@ class SpeakerProposalForm(forms.ModelForm):
"submission_notes" "submission_notes"
].help_text = "Private notes regarding this speaker. Only visible to yourself and the BornHack organisers." ].help_text = "Private notes regarding this speaker. Only visible to yourself and the BornHack organisers."
elif eventtype.name == "Workshop": elif event_type.name == "Workshop":
# fix label and help_text for the name field # fix label and help_text for the name field
self.fields["name"].label = "Host Name" self.fields["name"].label = "Host Name"
self.fields[ self.fields[
@ -186,7 +185,7 @@ class SpeakerProposalForm(forms.ModelForm):
# no free tickets for workshops # no free tickets for workshops
del self.fields["needs_oneday_ticket"] del self.fields["needs_oneday_ticket"]
elif eventtype.name == "Slacking Off": elif event_type.name == "Recreational":
# fix label and help_text for the name field # fix label and help_text for the name field
self.fields["name"].label = "Host Name" self.fields["name"].label = "Host Name"
self.fields["name"].help_text = "Can be a real name or an alias." self.fields["name"].help_text = "Can be a real name or an alias."
@ -207,10 +206,10 @@ class SpeakerProposalForm(forms.ModelForm):
"submission_notes" "submission_notes"
].help_text = "Private notes regarding this host. Only visible to yourself and the BornHack organisers." ].help_text = "Private notes regarding this host. Only visible to yourself and the BornHack organisers."
# no free tickets for workshops # no free tickets for recreational events
del self.fields["needs_oneday_ticket"] del self.fields["needs_oneday_ticket"]
elif eventtype.name == "Meetup": elif event_type.name == "Meetup":
# fix label and help_text for the name field # fix label and help_text for the name field
self.fields["name"].label = "Host Name" self.fields["name"].label = "Host Name"
self.fields[ self.fields[
@ -233,12 +232,12 @@ class SpeakerProposalForm(forms.ModelForm):
"submission_notes" "submission_notes"
].help_text = "Private notes regarding this host. Only visible to yourself and the BornHack organisers." ].help_text = "Private notes regarding this host. Only visible to yourself and the BornHack organisers."
# no free tickets for workshops # no free tickets for meetups
del self.fields["needs_oneday_ticket"] del self.fields["needs_oneday_ticket"]
else: else:
raise ImproperlyConfigured( raise ImproperlyConfigured(
"Unsupported event type, don't know which form class to use" f"Unsupported event type '{event_type.name}', don't know which form class to use"
) )
@ -258,6 +257,7 @@ class EventProposalForm(forms.ModelForm):
"abstract", "abstract",
"allow_video_recording", "allow_video_recording",
"duration", "duration",
"tags",
"slides_url", "slides_url",
"submission_notes", "submission_notes",
"track", "track",
@ -265,52 +265,35 @@ class EventProposalForm(forms.ModelForm):
] ]
def clean_duration(self): def clean_duration(self):
duration = self.cleaned_data["duration"] """ Make sure duration has been specified, and make sure it is not too long """
if not duration or duration < 60 or duration > 180: if not self.cleaned_data["duration"]:
raise forms.ValidationError(f"Please specify a duration.")
if (
self.event_type.event_duration_minutes
and self.cleaned_data["duration"] > self.event_type.event_duration_minutes
):
raise forms.ValidationError( raise forms.ValidationError(
"Please keep duration between 60 and 180 minutes." f"Please keep duration under {self.event_type.event_duration_minutes} minutes."
) )
return duration return self.cleaned_data["duration"]
def clean_track(self): def clean_track(self):
track = self.cleaned_data["track"] track = self.cleaned_data["track"]
# TODO: make sure the track is part of the current camp, needs camp as form kwarg to verify # TODO: make sure the track is part of the current camp, needs camp as form kwarg to verify
return track return track
def save(self, commit=True, user=None, event_type=None): def __init__(self, camp, event_type=None, matrix=None, *args, **kwargs):
eventproposal = super().save(commit=False)
if user:
eventproposal.user = user
if event_type:
eventproposal.event_type = event_type
eventproposal.save()
if not event_type and hasattr(eventproposal, "event_type"):
event_type = eventproposal.event_type
if self.cleaned_data.get("slides_url") and event_type.name in [
"Talk",
"Lightning Talk",
]:
url = self.cleaned_data.get("slides_url")
if not eventproposal.urls.filter(url=url).exists():
slides_url = Url()
slides_url.eventproposal = eventproposal
slides_url.url = url
slides_url.urltype = UrlType.objects.get(name="Slides")
slides_url.save()
return eventproposal
def __init__(self, camp, eventtype=None, *args, **kwargs):
# initialise form # initialise form
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
# we need event_type for cleaning later
self.event_type = event_type
TALK = "Talk" TALK = "Talk"
LIGHTNING_TALK = "Lightning Talk" LIGHTNING_TALK = "Lightning Talk"
DEBATE = "Debate" DEBATE = "Debate"
MUSIC_ACT = "Music Act" MUSIC_ACT = "Music Act"
RECREATIONAL_EVENT = "Recreational Event" RECREATIONAL_EVENT = "Recreational"
WORKSHOP = "Workshop" WORKSHOP = "Workshop"
SLACKING_OFF = "Slacking Off" SLACKING_OFF = "Slacking Off"
MEETUP = "Meetup" MEETUP = "Meetup"
@ -322,15 +305,34 @@ class EventProposalForm(forms.ModelForm):
# make sure video_recording checkbox defaults to checked # make sure video_recording checkbox defaults to checked
self.fields["allow_video_recording"].initial = True self.fields["allow_video_recording"].initial = True
if eventtype.name not in [TALK, LIGHTNING_TALK]: if event_type.name not in [TALK, LIGHTNING_TALK]:
# Only talk or lightning talk should show the slides_url field # Only talk or lightning talk should show the slides_url field
del self.fields["slides_url"] del self.fields["slides_url"]
if not eventtype.name == LIGHTNING_TALK: # better placeholder text for duration field
self.fields["duration"].label = f"{event_type.name} Duration"
if event_type.event_duration_minutes:
self.fields[
"duration"
].help_text = f"Please enter the duration of this {event_type.name} (in minutes, max {event_type.event_duration_minutes})"
self.fields["duration"].widget.attrs[
"placeholder"
] = f"{event_type.name} Duration (in minutes, max {event_type.event_duration_minutes})"
else:
self.fields[
"duration"
].help_text = (
f"Please enter the duration of this {event_type.name} (in minutes)"
)
self.fields["duration"].widget.attrs[
"placeholder"
] = f"{event_type.name} Duration (in minutes)"
if not event_type.name == LIGHTNING_TALK:
# Only lightning talks submissions will have to choose whether to use provided speaker laptop # Only lightning talks submissions will have to choose whether to use provided speaker laptop
del self.fields["use_provided_speaker_laptop"] del self.fields["use_provided_speaker_laptop"]
if eventtype.name == DEBATE: if event_type.name == DEBATE:
# fix label and help_text for the title field # fix label and help_text for the title field
self.fields["title"].label = "Title of debate" self.fields["title"].label = "Title of debate"
self.fields["title"].help_text = "The title of this debate" self.fields["title"].help_text = "The title of this debate"
@ -340,17 +342,12 @@ class EventProposalForm(forms.ModelForm):
self.fields["abstract"].help_text = "The description of this debate" self.fields["abstract"].help_text = "The description of this debate"
# fix label and help_text for the submission_notes field # fix label and help_text for the submission_notes field
self.fields["submission_notes"].label = "Debate Act Notes" self.fields["submission_notes"].label = "Debate Notes"
self.fields[ self.fields[
"submission_notes" "submission_notes"
].help_text = "Private notes regarding this debate. Only visible to yourself and the BornHack organisers." ].help_text = "Private notes regarding this debate. Only visible to yourself and the BornHack organisers."
# better placeholder text for duration field elif event_type.name == MUSIC_ACT:
self.fields["duration"].widget.attrs[
"placeholder"
] = "Debate Duration (minutes)"
elif eventtype.name == MUSIC_ACT:
# fix label and help_text for the title field # fix label and help_text for the title field
self.fields["title"].label = "Title of music act" self.fields["title"].label = "Title of music act"
self.fields["title"].help_text = "The title of this music act/concert/set." self.fields["title"].help_text = "The title of this music act/concert/set."
@ -368,10 +365,7 @@ class EventProposalForm(forms.ModelForm):
# no video recording for music acts # no video recording for music acts
del self.fields["allow_video_recording"] del self.fields["allow_video_recording"]
# better placeholder text for duration field elif event_type.name == RECREATIONAL_EVENT:
self.fields["duration"].widget.attrs["placeholder"] = "Duration (minutes)"
elif eventtype.name == RECREATIONAL_EVENT:
# fix label and help_text for the title field # fix label and help_text for the title field
self.fields["title"].label = "Event Title" self.fields["title"].label = "Event Title"
self.fields["title"].help_text = "The title of this recreational event" self.fields["title"].help_text = "The title of this recreational event"
@ -391,11 +385,7 @@ class EventProposalForm(forms.ModelForm):
# no video recording for music acts # no video recording for music acts
del self.fields["allow_video_recording"] del self.fields["allow_video_recording"]
# better placeholder text for duration field elif event_type.name in [TALK, LIGHTNING_TALK]:
self.fields["duration"].label = "Event Duration"
self.fields["duration"].widget.attrs["placeholder"] = "Duration (minutes)"
elif eventtype.name in [TALK, LIGHTNING_TALK]:
# fix label and help_text for the title field # fix label and help_text for the title field
self.fields["title"].label = "Title of Talk" self.fields["title"].label = "Title of Talk"
self.fields["title"].help_text = "The title of this talk/presentation." self.fields["title"].help_text = "The title of this talk/presentation."
@ -412,7 +402,7 @@ class EventProposalForm(forms.ModelForm):
"submission_notes" "submission_notes"
].help_text = "Private notes regarding this talk. Only visible to yourself and the BornHack organisers." ].help_text = "Private notes regarding this talk. Only visible to yourself and the BornHack organisers."
if self.fields.get("slides_url") and eventtype.name == LIGHTNING_TALK: if self.fields.get("slides_url") and event_type.name == LIGHTNING_TALK:
self.fields[ self.fields[
"slides_url" "slides_url"
].help_text += " You will only get assigned a slot if you have provided slides (a title slide is enough if you don't use slides for the talk). You can add an URL later if need be." ].help_text += " You will only get assigned a slot if you have provided slides (a title slide is enough if you don't use slides for the talk). You can add an URL later if need be."
@ -420,7 +410,7 @@ class EventProposalForm(forms.ModelForm):
# no duration for talks # no duration for talks
del self.fields["duration"] del self.fields["duration"]
elif eventtype.name == WORKSHOP: elif event_type.name == WORKSHOP:
# fix label and help_text for the title field # fix label and help_text for the title field
self.fields["title"].label = "Workshop Title" self.fields["title"].label = "Workshop Title"
self.fields["title"].help_text = "The title of this workshop." self.fields["title"].help_text = "The title of this workshop."
@ -440,13 +430,7 @@ class EventProposalForm(forms.ModelForm):
# no video recording for workshops # no video recording for workshops
del self.fields["allow_video_recording"] del self.fields["allow_video_recording"]
# duration field elif event_type.name == SLACKING_OFF:
self.fields["duration"].label = "Workshop Duration"
self.fields[
"duration"
].help_text = "How much time (in minutes) should we set aside for this workshop? Please keep it between 60 and 180 minutes (1-3 hours)."
elif eventtype.name == SLACKING_OFF:
# fix label and help_text for the title field # fix label and help_text for the title field
self.fields["title"].label = "Event Title" self.fields["title"].label = "Event Title"
self.fields["title"].help_text = "The title of this recreational event." self.fields["title"].help_text = "The title of this recreational event."
@ -466,13 +450,7 @@ class EventProposalForm(forms.ModelForm):
# no video recording for recreational events # no video recording for recreational events
del self.fields["allow_video_recording"] del self.fields["allow_video_recording"]
# duration field elif event_type.name == MEETUP:
self.fields["duration"].label = "Event Duration"
self.fields[
"duration"
].help_text = "How much time (in minutes) should we set aside for this event? Please keep it between 60 and 180 minutes (1-3 hours)."
elif eventtype.name == MEETUP:
# fix label and help_text for the title field # fix label and help_text for the title field
self.fields["title"].label = "Meetup Title" self.fields["title"].label = "Meetup Title"
self.fields["title"].help_text = "The title of this meetup." self.fields["title"].help_text = "The title of this meetup."
@ -492,12 +470,6 @@ class EventProposalForm(forms.ModelForm):
# no video recording for meetups # no video recording for meetups
del self.fields["allow_video_recording"] del self.fields["allow_video_recording"]
# duration field
self.fields["duration"].label = "Meetup Duration"
self.fields[
"duration"
].help_text = "How much time (in minutes) should we set aside for this meetup? Please keep it between 60 and 180 minutes (1-3 hours)."
else: else:
raise ImproperlyConfigured( raise ImproperlyConfigured(
"Unsupported event type, don't know which form class to use" "Unsupported event type, don't know which form class to use"

View file

@ -0,0 +1,15 @@
# Generated by Django 3.0.3 on 2020-04-12 18:15
from django.contrib.postgres.operations import BtreeGistExtension
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("program", "0084_auto_20200229_1801"),
]
operations = [
BtreeGistExtension(),
]

View file

@ -0,0 +1,426 @@
# Generated by Django 3.0.3 on 2020-04-12 18:17
import uuid
import django.contrib.postgres.constraints
import django.contrib.postgres.fields.ranges
import django.db.models.deletion
import django.db.models.expressions
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("camps", "0034_add_team_permission_sets"),
("program", "0085_btree_gist_extension"),
]
operations = [
migrations.CreateModel(
name="EventSession",
fields=[
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("created", models.DateTimeField(auto_now_add=True)),
("updated", models.DateTimeField(auto_now=True)),
(
"when",
django.contrib.postgres.fields.ranges.DateTimeRangeField(
help_text="A period of time where this type of event can be scheduled. Input format is <i>YYYY-MM-DD HH:MM</i>"
),
),
(
"event_duration_minutes",
models.PositiveIntegerField(
blank=True,
help_text="The duration of events in this EventSession. Defaults to the value from the EventType of this EventSession.",
),
),
(
"description",
models.TextField(
blank=True, help_text="Description of this session (optional)."
),
),
],
options={"ordering": ["when", "event_type", "event_location"],},
),
migrations.CreateModel(
name="EventSlot",
fields=[
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("created", models.DateTimeField(auto_now_add=True)),
("updated", models.DateTimeField(auto_now=True)),
(
"when",
django.contrib.postgres.fields.ranges.DateTimeRangeField(
help_text="Start and end time of this slot"
),
),
(
"autoscheduled",
models.NullBooleanField(
default=None,
help_text="True if the Event was scheduled by the AutoScheduler, False if it was scheduled manually, None if there is nothing scheduled in this EventSlot.",
),
),
],
options={"ordering": ["when"],},
),
migrations.CreateModel(
name="SpeakerAvailability",
fields=[
(
"uuid",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
("created", models.DateTimeField(auto_now_add=True)),
("updated", models.DateTimeField(auto_now=True)),
(
"when",
django.contrib.postgres.fields.ranges.DateTimeRangeField(
db_index=True,
help_text="The period when this speaker is available or unavailable. Must be 1 hour!",
),
),
(
"available",
models.BooleanField(
db_index=True,
help_text="Is the speaker available or unavailable during this hour? Check for available, uncheck for unavailable.",
),
),
],
),
migrations.CreateModel(
name="SpeakerEventConflict",
fields=[
(
"uuid",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
("created", models.DateTimeField(auto_now_add=True)),
("updated", models.DateTimeField(auto_now=True)),
],
options={"abstract": False,},
),
migrations.CreateModel(
name="SpeakerProposalAvailability",
fields=[
(
"uuid",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
("created", models.DateTimeField(auto_now_add=True)),
("updated", models.DateTimeField(auto_now=True)),
(
"when",
django.contrib.postgres.fields.ranges.DateTimeRangeField(
db_index=True,
help_text="The period when this speaker is available or unavailable. Must be 1 hour!",
),
),
(
"available",
models.BooleanField(
db_index=True,
help_text="Is the speaker available or unavailable during this hour? Check for available, uncheck for unavailable.",
),
),
],
),
migrations.CreateModel(
name="SpeakerProposalEventConflict",
fields=[
(
"uuid",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
("created", models.DateTimeField(auto_now_add=True)),
("updated", models.DateTimeField(auto_now=True)),
],
options={"abstract": False,},
),
migrations.AddField(
model_name="event",
name="demand",
field=models.PositiveIntegerField(
default=0,
help_text="The estimated demand for this event. Used by the autoscheduler to pick the optimal location for events. Set to 0 to disable demand constraints for this event.",
),
),
migrations.AddField(
model_name="event",
name="duration_minutes",
field=models.PositiveIntegerField(
blank=True,
default=None,
help_text="The duration of this event in minutes. Leave blank to use the default from the eventtype.",
),
),
migrations.AddField(
model_name="eventinstance",
name="autoscheduled",
field=models.BooleanField(
default=False,
help_text="True if this was created by the autoscheduler.",
),
),
migrations.AddField(
model_name="eventlocation",
name="capacity",
field=models.PositiveIntegerField(
default=20,
help_text="The capacity of this location. Used by the autoscheduler.",
),
),
migrations.AddField(
model_name="eventlocation",
name="conflicts",
field=models.ManyToManyField(
help_text="Select the locations which this location conflicts with. Nothing can be scheduled in a location if a conflicting location has an EventInstance at the same time. Example: If one room can be split into two, then the big room would conflict with each of the two small rooms (but the small rooms would not conflict with eachother).",
related_name="_eventlocation_conflicts_+",
to="program.EventLocation",
),
),
migrations.AddField(
model_name="eventtype",
name="event_duration_minutes",
field=models.PositiveIntegerField(
blank=True,
help_text="The default duration of an event of this type, in minutes. Optional. This default can be overridden in individual EventSessions as needed.",
null=True,
),
),
migrations.AddField(
model_name="eventtype",
name="support_autoscheduling",
field=models.BooleanField(
default=False,
help_text="Check to enable this EventType in the autoscheduler",
),
),
migrations.AlterField(
model_name="eventinstance",
name="location",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.PROTECT,
related_name="eventinstances",
to="program.EventLocation",
),
),
migrations.AlterField(
model_name="eventinstance",
name="when",
field=django.contrib.postgres.fields.ranges.DateTimeRangeField(
blank=True, null=True
),
),
migrations.AlterField(
model_name="eventproposal",
name="event_type",
field=models.ForeignKey(
help_text="The type of event",
on_delete=django.db.models.deletion.PROTECT,
related_name="eventproposals",
to="program.EventType",
),
),
migrations.AddConstraint(
model_name="eventinstance",
constraint=django.contrib.postgres.constraints.ExclusionConstraint(
expressions=[("when", "&&"), ("location", "=")],
name="prevent_eventinstance_location_overlaps",
),
),
migrations.AddField(
model_name="speakerproposaleventconflict",
name="events",
field=models.ManyToManyField(
help_text="The conflict events",
related_name="speakerproposalconflicts",
to="program.Event",
),
),
migrations.AddField(
model_name="speakerproposaleventconflict",
name="speakerproposal",
field=models.OneToOneField(
help_text="The SpeakerProposal",
on_delete=django.db.models.deletion.PROTECT,
related_name="eventconflicts",
to="program.SpeakerProposal",
),
),
migrations.AddField(
model_name="speakerproposalavailability",
name="speakerproposal",
field=models.ForeignKey(
blank=True,
help_text="The speaker proposal object this availability belongs to",
null=True,
on_delete=django.db.models.deletion.PROTECT,
related_name="availabilities",
to="program.SpeakerProposal",
),
),
migrations.AddField(
model_name="speakereventconflict",
name="events",
field=models.ManyToManyField(
help_text="The conflict events",
related_name="speakerconflicts",
to="program.Event",
),
),
migrations.AddField(
model_name="speakereventconflict",
name="speaker",
field=models.OneToOneField(
help_text="The Speaker",
on_delete=django.db.models.deletion.PROTECT,
related_name="eventconflicts",
to="program.Speaker",
),
),
migrations.AddField(
model_name="speakeravailability",
name="speaker",
field=models.ForeignKey(
blank=True,
help_text="The speaker object this availability belongs to (if any)",
null=True,
on_delete=django.db.models.deletion.PROTECT,
related_name="availabilities",
to="program.Speaker",
),
),
migrations.AddField(
model_name="eventslot",
name="event",
field=models.ForeignKey(
blank=True,
help_text="The Event scheduled in this EventSlot",
null=True,
on_delete=django.db.models.deletion.PROTECT,
related_name="event_slots",
to="program.Event",
),
),
migrations.AddField(
model_name="eventslot",
name="event_session",
field=models.ForeignKey(
help_text="The EventSession this EventSlot belongs to",
on_delete=django.db.models.deletion.PROTECT,
related_name="event_slots",
to="program.EventSession",
),
),
migrations.AddField(
model_name="eventsession",
name="camp",
field=models.ForeignKey(
help_text="The Camp this EventSession belongs to",
on_delete=django.db.models.deletion.PROTECT,
related_name="eventsessions",
to="camps.Camp",
),
),
migrations.AddField(
model_name="eventsession",
name="event_location",
field=models.ForeignKey(
help_text="The event location this session is for",
on_delete=django.db.models.deletion.PROTECT,
related_name="eventsessions",
to="program.EventLocation",
),
),
migrations.AddField(
model_name="eventsession",
name="event_type",
field=models.ForeignKey(
help_text="The type of event this session is for",
on_delete=django.db.models.deletion.PROTECT,
related_name="eventsessions",
to="program.EventType",
),
),
migrations.AddConstraint(
model_name="speakerproposalavailability",
constraint=django.contrib.postgres.constraints.ExclusionConstraint(
expressions=[
(django.db.models.expressions.F("speakerproposal"), "="),
("when", "&&"),
],
name="prevent_speakerproposalavailability_overlaps",
),
),
migrations.AddConstraint(
model_name="speakeravailability",
constraint=django.contrib.postgres.constraints.ExclusionConstraint(
expressions=[
(django.db.models.expressions.F("speaker"), "="),
("when", "&&"),
],
name="prevent_speakeravailability_overlaps",
),
),
migrations.AddConstraint(
model_name="eventslot",
constraint=django.contrib.postgres.constraints.ExclusionConstraint(
expressions=[("when", "&&"), ("event_session", "=")],
name="prevent_slot_session_overlaps",
),
),
migrations.AddConstraint(
model_name="eventsession",
constraint=django.contrib.postgres.constraints.ExclusionConstraint(
expressions=[
("when", "&&"),
("event_location", "="),
("event_type", "="),
],
name="prevent_eventsession_eventtype_eventlocation_overlaps",
),
),
]

View file

@ -0,0 +1,215 @@
# Generated by Django 3.0.3 on 2020-04-13 12:43
import django.contrib.postgres.constraints
import django.db.models.deletion
import django.db.models.expressions
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("camps", "0034_add_team_permission_sets"),
("program", "0086_sessions_slots_availability"),
]
operations = [
migrations.RemoveConstraint(
model_name="eventsession",
name="prevent_eventsession_eventtype_eventlocation_overlaps",
),
migrations.RemoveConstraint(
model_name="speakeravailability",
name="prevent_speakeravailability_overlaps",
),
migrations.RemoveConstraint(
model_name="speakerproposalavailability",
name="prevent_speakerproposalavailability_overlaps",
),
migrations.RenameField(
model_name="speakerproposalavailability",
old_name="speakerproposal",
new_name="speaker_proposal",
),
migrations.RenameField(
model_name="url", old_name="eventproposal", new_name="event_proposal",
),
migrations.RenameField(
model_name="url", old_name="speakerproposal", new_name="speaker_proposal",
),
migrations.RenameField(
model_name="url", old_name="urltype", new_name="url_type",
),
migrations.AlterField(
model_name="event",
name="duration_minutes",
field=models.PositiveIntegerField(
blank=True,
default=None,
help_text="The duration of this event in minutes. Leave blank to use the default from the event_type.",
),
),
migrations.AlterField(
model_name="eventlocation",
name="camp",
field=models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT,
related_name="event_locations",
to="camps.Camp",
),
),
migrations.AlterField(
model_name="eventproposal",
name="event_type",
field=models.ForeignKey(
help_text="The type of event",
on_delete=django.db.models.deletion.PROTECT,
related_name="event_proposals",
to="program.EventType",
),
),
migrations.AlterField(
model_name="eventproposal",
name="speakers",
field=models.ManyToManyField(
blank=True,
help_text="Pick the speaker(s) for this event. If you cannot see anything here you need to go back and create Speaker Proposal(s) first.",
related_name="event_proposals",
to="program.SpeakerProposal",
),
),
migrations.AlterField(
model_name="eventproposal",
name="track",
field=models.ForeignKey(
help_text="The track this event belongs to",
on_delete=django.db.models.deletion.PROTECT,
related_name="event_proposals",
to="program.EventTrack",
),
),
migrations.AlterField(
model_name="eventsession",
name="camp",
field=models.ForeignKey(
help_text="The Camp this EventSession belongs to",
on_delete=django.db.models.deletion.PROTECT,
related_name="event_sessions",
to="camps.Camp",
),
),
migrations.AlterField(
model_name="eventsession",
name="event_location",
field=models.ForeignKey(
help_text="The event location this session is for",
on_delete=django.db.models.deletion.PROTECT,
related_name="event_sessions",
to="program.EventLocation",
),
),
migrations.AlterField(
model_name="eventsession",
name="event_type",
field=models.ForeignKey(
help_text="The type of event this session is for",
on_delete=django.db.models.deletion.PROTECT,
related_name="event_sessions",
to="program.EventType",
),
),
migrations.AlterField(
model_name="eventtrack",
name="camp",
field=models.ForeignKey(
help_text="The Camp this Track belongs to",
on_delete=django.db.models.deletion.PROTECT,
related_name="event_tracks",
to="camps.Camp",
),
),
migrations.AlterField(
model_name="speakereventconflict",
name="events",
field=models.ManyToManyField(
help_text="The conflict events",
related_name="speaker_conflicts",
to="program.Event",
),
),
migrations.AlterField(
model_name="speakereventconflict",
name="speaker",
field=models.OneToOneField(
help_text="The Speaker",
on_delete=django.db.models.deletion.PROTECT,
related_name="event_conflicts",
to="program.Speaker",
),
),
migrations.AlterField(
model_name="speakerproposal",
name="camp",
field=models.ForeignKey(
editable=False,
on_delete=django.db.models.deletion.PROTECT,
related_name="speaker_proposals",
to="camps.Camp",
),
),
migrations.AlterField(
model_name="speakerproposaleventconflict",
name="events",
field=models.ManyToManyField(
help_text="The conflict events",
related_name="speaker_proposal_conflicts",
to="program.Event",
),
),
migrations.AlterField(
model_name="speakerproposaleventconflict",
name="speakerproposal",
field=models.OneToOneField(
help_text="The SpeakerProposal",
on_delete=django.db.models.deletion.PROTECT,
related_name="event_conflicts",
to="program.SpeakerProposal",
),
),
migrations.RenameField(
model_name="speakerproposaleventconflict",
old_name="speakerproposal",
new_name="speaker_proposal",
),
migrations.AddConstraint(
model_name="eventsession",
constraint=django.contrib.postgres.constraints.ExclusionConstraint(
expressions=[
("when", "&&"),
("event_location", "="),
("event_type", "="),
],
name="prevent_event_session_event_type_event_location_overlaps",
),
),
migrations.AddConstraint(
model_name="speakeravailability",
constraint=django.contrib.postgres.constraints.ExclusionConstraint(
expressions=[
(django.db.models.expressions.F("speaker"), "="),
("when", "&&"),
],
name="prevent_speaker_availability_overlaps",
),
),
migrations.AddConstraint(
model_name="speakerproposalavailability",
constraint=django.contrib.postgres.constraints.ExclusionConstraint(
expressions=[
(django.db.models.expressions.F("speaker_proposal"), "="),
("when", "&&"),
],
name="prevent_speaker_proposal_availability_overlaps",
),
),
]

View file

@ -0,0 +1,114 @@
# Generated by Django 3.0.3 on 2020-04-20 22:59
import django.contrib.postgres.constraints
import django.db.models.deletion
import utils.database
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("program", "0087_fk_and_related_name_underscores"),
]
operations = [
migrations.RemoveField(model_name="speakereventconflict", name="events",),
migrations.RemoveField(model_name="speakereventconflict", name="speaker",),
migrations.RemoveField(
model_name="speakerproposaleventconflict", name="events",
),
migrations.RemoveField(
model_name="speakerproposaleventconflict", name="speaker_proposal",
),
migrations.AddField(
model_name="eventtype",
name="support_speaker_event_conflicts",
field=models.BooleanField(
default=True,
help_text="True if Events of this type should be selectable in the EventConflict m2m for SpeakerProposal and Speaker objects.",
),
),
migrations.AddField(
model_name="speaker",
name="event_conflicts",
field=models.ManyToManyField(
help_text="The Events this person wishes to attend. The AutoScheduler will avoid conflicts.",
related_name="speaker_conflicts",
to="program.Event",
),
),
migrations.AddField(
model_name="speakerproposal",
name="event_conflicts",
field=models.ManyToManyField(
help_text="Pick the Events this person wishes to attend, and we will attempt to avoid scheduling conflicts.",
related_name="speaker_proposal_conflicts",
to="program.Event",
),
),
migrations.AlterField(
model_name="event",
name="event_type",
field=models.ForeignKey(
help_text="The type of this event",
on_delete=django.db.models.deletion.PROTECT,
related_name="events",
to="program.EventType",
),
),
migrations.AlterField(
model_name="eventlocation",
name="conflicts",
field=models.ManyToManyField(
blank=True,
help_text="Select the locations which this location conflicts with. Nothing can be scheduled in a location if a conflicting location has a scheduled Event at the same time. Example: If one room can be split into two, then the big room would conflict with each of the two small rooms (but the small rooms would not conflict with eachother).",
related_name="_eventlocation_conflicts_+",
to="program.EventLocation",
),
),
migrations.AlterField(
model_name="eventproposal",
name="duration",
field=models.IntegerField(
blank=True,
help_text="How much time (in minutes) should we set aside for this act? Please keep it between 60 and 180 minutes (1-3 hours).",
),
),
migrations.AlterField(
model_name="eventslot",
name="event",
field=models.ForeignKey(
blank=True,
help_text="The Event scheduled in this EventSlot",
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="event_slots",
to="program.Event",
),
),
migrations.AddConstraint(
model_name="speakeravailability",
constraint=django.contrib.postgres.constraints.ExclusionConstraint(
expressions=[
("speaker", "="),
(utils.database.CastToInteger("available"), "="),
("when", "-|-"),
],
name="prevent_speaker_availability_adjacent_mergeable",
),
),
migrations.AddConstraint(
model_name="speakerproposalavailability",
constraint=django.contrib.postgres.constraints.ExclusionConstraint(
expressions=[
("speaker_proposal", "="),
(utils.database.CastToInteger("available"), "="),
("when", "-|-"),
],
name="prevent_speaker_proposal_availability_adjacent_mergeable",
),
),
migrations.DeleteModel(name="SpeakerEventConflict",),
migrations.DeleteModel(name="SpeakerProposalEventConflict",),
]

View file

@ -0,0 +1,23 @@
# Generated by Django 3.0.3 on 2020-04-21 17:32
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("program", "0088_speaker_event_conflicts_and_more"),
]
operations = [
migrations.AlterField(
model_name="speakerproposal",
name="event_conflicts",
field=models.ManyToManyField(
blank=True,
help_text="Pick the Events this person wishes to attend, and we will attempt to avoid scheduling conflicts.",
related_name="speaker_proposal_conflicts",
to="program.Event",
),
),
]

View file

@ -0,0 +1,25 @@
# Generated by Django 3.0.3 on 2020-04-21 20:54
import taggit.managers
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("taggit", "0003_taggeditem_add_unique_index"),
("program", "0089_event_conflicts_blank"),
]
operations = [
migrations.AddField(
model_name="event",
name="tags",
field=taggit.managers.TaggableManager(
help_text="A comma-separated list of tags.",
through="taggit.TaggedItem",
to="taggit.Tag",
verbose_name="Tags",
),
),
]

View file

@ -0,0 +1,26 @@
# Generated by Django 3.0.3 on 2020-04-21 20:54
import taggit.managers
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("taggit", "0003_taggeditem_add_unique_index"),
("utils", "0004_uuidtaggeditem"),
("program", "0090_event_tags"),
]
operations = [
migrations.AddField(
model_name="eventproposal",
name="tags",
field=taggit.managers.TaggableManager(
help_text="A comma-separated list of tags.",
through="utils.UUIDTaggedItem",
to="taggit.Tag",
verbose_name="Tags",
),
),
]

View file

@ -0,0 +1,25 @@
# Generated by Django 3.0.3 on 2020-04-22 06:16
import uuid
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("program", "0091_eventproposal_tags"),
]
operations = [
migrations.AddField(
model_name="event",
name="uuid",
field=models.UUIDField(
default=uuid.uuid4,
editable=False,
help_text="This field is not the PK of the model. It is used to create EventSlot UUID for FRAB and iCal and other calendaring purposes.",
unique=True,
),
),
]

View file

@ -0,0 +1,29 @@
# Generated by Django 3.0.3 on 2020-05-03 12:36
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("program", "0092_event_uuid"),
]
operations = [
migrations.AlterField(
model_name="eventproposal",
name="reason",
field=models.TextField(
blank=True,
help_text="The reason this proposal was accepted or rejected. This text will be included in the email to the submitter. Leave blank to send a standard email.",
),
),
migrations.AlterField(
model_name="speakerproposal",
name="reason",
field=models.TextField(
blank=True,
help_text="The reason this proposal was accepted or rejected. This text will be included in the email to the submitter. Leave blank to send a standard email.",
),
),
]

View file

@ -0,0 +1,27 @@
# Generated by Django 3.0.3 on 2020-05-03 16:57
import taggit.managers
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("utils", "0004_uuidtaggeditem"),
("taggit", "0003_taggeditem_add_unique_index"),
("program", "0093_proposal_reason_description"),
]
operations = [
migrations.AlterField(
model_name="eventproposal",
name="tags",
field=taggit.managers.TaggableManager(
blank=True,
help_text="A comma-separated list of tags.",
through="utils.UUIDTaggedItem",
to="taggit.Tag",
verbose_name="Tags",
),
),
]

View file

@ -4,6 +4,10 @@ from django.http import Http404
from django.shortcuts import get_object_or_404, redirect from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse from django.urls import reverse
from django.views.generic.detail import SingleObjectMixin from django.views.generic.detail import SingleObjectMixin
from program.utils import (
add_existing_availability_to_matrix,
get_speaker_availability_form_matrix,
)
from . import models from . import models
@ -77,11 +81,11 @@ class UrlViewMixin(object):
""" """
# get the proposal # get the proposal
if "event_uuid" in self.kwargs: if "event_uuid" in self.kwargs:
self.eventproposal = get_object_or_404( self.event_proposal = get_object_or_404(
models.EventProposal, uuid=self.kwargs["event_uuid"], user=request.user models.EventProposal, uuid=self.kwargs["event_uuid"], user=request.user
) )
elif "speaker_uuid" in self.kwargs: elif "speaker_uuid" in self.kwargs:
self.speakerproposal = get_object_or_404( self.speaker_proposal = get_object_or_404(
models.SpeakerProposal, models.SpeakerProposal,
uuid=self.kwargs["speaker_uuid"], uuid=self.kwargs["speaker_uuid"],
user=request.user, user=request.user,
@ -96,20 +100,20 @@ class UrlViewMixin(object):
Include the proposal in the template context Include the proposal in the template context
""" """
context = super().get_context_data(**kwargs) context = super().get_context_data(**kwargs)
if hasattr(self, "eventproposal") and self.eventproposal: if hasattr(self, "event_proposal") and self.event_proposal:
context["eventproposal"] = self.eventproposal context["event_proposal"] = self.event_proposal
else: else:
context["speakerproposal"] = self.speakerproposal context["speaker_proposal"] = self.speaker_proposal
return context return context
def get_success_url(self): def get_success_url(self):
""" """
Return to the detail view of the proposal Return to the detail view of the proposal
""" """
if hasattr(self, "eventproposal"): if hasattr(self, "event_proposal"):
return self.eventproposal.get_absolute_url() return self.event_proposal.get_absolute_url()
else: else:
return self.speakerproposal.get_absolute_url() return self.speaker_proposal.get_absolute_url()
class EventViewMixin(CampViewMixin): class EventViewMixin(CampViewMixin):
@ -136,9 +140,69 @@ class EventFeedbackViewMixin(EventViewMixin):
def setup(self, *args, **kwargs): def setup(self, *args, **kwargs):
super().setup(*args, **kwargs) super().setup(*args, **kwargs)
self.eventfeedback = get_object_or_404( self.event_feedback = get_object_or_404(
models.EventFeedback, event=self.event, user=self.request.user, models.EventFeedback, event=self.event, user=self.request.user,
) )
def get_object(self): def get_object(self):
return self.eventfeedback return self.event_feedback
class AvailabilityMatrixViewMixin(CampViewMixin):
"""
Mixin with shared code between all availability matrix views,
meaning all views that show an availability matrix (form or not)
for a SpeakerProposal or Speaker object. Used by SpeakerProposal
submitters and in backoffice.
"""
def setup(self, *args, **kwargs):
""" Get the availability matrix"""
super().setup(*args, **kwargs)
# do we have an Event or an EventProposal?
if hasattr(self.get_object(), "events"):
# we have an Event
event_types = models.EventType.objects.filter(
events__in=self.get_object().events.all()
).distinct()
else:
# we have an EventProposal
event_types = models.EventType.objects.filter(
event_proposals__in=self.get_object().event_proposals.all()
).distinct()
# get the matrix and add any existing availability to it
self.matrix = get_speaker_availability_form_matrix(
sessions=self.camp.event_sessions.filter(event_type__in=event_types)
)
add_existing_availability_to_matrix(self.matrix, self.get_object())
def get_form_kwargs(self):
""" Add the matrix to form kwargs, only used if the view has a form """
kwargs = super().get_form_kwargs()
kwargs["matrix"] = self.matrix
return kwargs
def get_initial(self, *args, **kwargs):
""" Populate the speaker_availability checkboxes, only used if the view has a form """
initial = super().get_initial(*args, **kwargs)
# add initial checkbox states
for date in self.matrix.keys():
# loop over daychunks and check if we need a checkbox
for daychunk in self.matrix[date].keys():
if not self.matrix[date][daychunk]:
# we have no event_session here, carry on
continue
if self.matrix[date][daychunk]["initial"] in [True, None]:
initial[self.matrix[date][daychunk]["fieldname"]] = True
else:
initial[self.matrix[date][daychunk]["fieldname"]] = False
# we are ready to render the form
return initial
def get_context_data(self, **kwargs):
""" Add the matrix to context """
context = super().get_context_data(**kwargs)
context["matrix"] = self.matrix
return context

File diff suppressed because it is too large Load diff

View file

@ -82,7 +82,7 @@ class UrlNode(DjangoObjectType):
class Meta: class Meta:
model = Url model = Url
interfaces = (relay.Node,) interfaces = (relay.Node,)
only_fields = ("url", "urltype") only_fields = ("url", "url_type")
class UrlTypeNode(DjangoObjectType): class UrlTypeNode(DjangoObjectType):

View file

@ -2,8 +2,6 @@ import logging
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from .email import add_event_scheduled_email
logger = logging.getLogger("bornhack.%s" % __name__) logger = logging.getLogger("bornhack.%s" % __name__)
@ -36,32 +34,6 @@ def check_speaker_event_camp_consistency(sender, instance, **kwargs):
) )
def check_speaker_camp_change(sender, instance, **kwargs): def event_session_post_save(sender, instance, created, **kwargs):
if instance.pk: """ Make sure we have the number of EventSlots we need to have, adjust if not """
for event in instance.events.all(): instance.fixup_event_slots()
if event.camp != instance.camp:
raise ValidationError(
{
"camp": "You cannot change the camp a speaker belongs to if the speaker is associated with one or more events."
}
)
def eventinstance_pre_save(sender, instance, **kwargs):
""" Save the old instance.when value so we can later determine if it changed """
try:
# get the old instance from the database, if we have one
instance.old_when = sender.objects.get(pk=instance.pk).when
except sender.DoesNotExist:
# nothing found in the DB with this pk, this is a new eventinstance
instance.old_when = instance.when
def eventinstance_post_save(sender, instance, created, **kwargs):
""" Send an email if this is a new eventinstance, or if the "when" field changed """
if created:
add_event_scheduled_email(eventinstance=instance, action="scheduled")
else:
if instance.old_when != instance.when:
# date/time for this eventinstance changed, send a rescheduled email
add_event_scheduled_email(eventinstance=instance, action="rescheduled")

View file

@ -1,25 +1,25 @@
{% extends 'program_base.html' %} {% extends 'program_base.html' %}
{% block title %} {% block title %}
Use Existing {{ eventtype.host_title }} or Add New? | {{ block.super }} Use Existing {{ event_type.host_title }} or Add New? | {{ block.super }}
{% endblock %} {% endblock %}
{% block program_content %} {% block program_content %}
<h3>Use Existing {{ eventtype.host_title }}?</h3> <h3>Use Existing {{ event_type.host_title }}?</h3>
<p class="lead">Pick a {{ eventtype.host_title }} from the list below, or press the button at the bottom to add a new {{ eventtype.host_title }} for this {{ eventtype.name }}.</p> <p class="lead">Pick a {{ event_type.host_title }} from the list below, or press the button at the bottom to add a new {{ event_type.host_title }} for this {{ event_type.name }}.</p>
<div class="panel panel-default"> <div class="panel panel-default">
<div class="panel-heading"> <div class="panel-heading">
<h3 class="panel-title">Use an Existing {{ eventtype.host_title }}</h3> <h3 class="panel-title">Use an Existing {{ event_type.host_title }}</h3>
</div> </div>
<div class="panel-body"> <div class="panel-body">
<div class="list-group"> <div class="list-group">
{% for speakerproposal in speakerproposal_list %} {% for speaker_proposal in speaker_proposal_list %}
<a href="{% url 'program:eventproposal_create' camp_slug=camp.slug event_type_slug=eventtype.slug speaker_uuid=speakerproposal.uuid %}" class="list-group-item"> <a href="{% url 'program:event_proposal_create' camp_slug=camp.slug event_type_slug=event_type.slug speaker_uuid=speaker_proposal.uuid %}" class="list-group-item">
<h4 class="list-group-item-heading"> <h4 class="list-group-item-heading">
Use {{ speakerproposal.name }} as {{ eventtype.host_title }} Use {{ speaker_proposal.name }} as {{ event_type.host_title }}
</h4> </h4>
</a> </a>
{% endfor %} {% endfor %}
@ -27,7 +27,7 @@ Use Existing {{ eventtype.host_title }} or Add New? | {{ block.super }}
</div> </div>
</div> </div>
<a href="{% url 'program:proposal_combined_submit' camp_slug=camp.slug event_type_slug=eventtype.slug %}" class="btn btn-primary btn-success"><i class="fas fa-plus"></i> Add New {{ eventtype.host_title }}</a> <a href="{% url 'program:proposal_combined_submit' camp_slug=camp.slug event_type_slug=event_type.slug %}" class="btn btn-primary btn-success"><i class="fas fa-plus"></i> Add New {{ event_type.host_title }}</a>
<a href="{% url 'program:proposal_list' camp_slug=camp.slug %}" class="btn btn-primary"><i class="fas fa-undo"></i> Cancel</a> <a href="{% url 'program:proposal_list' camp_slug=camp.slug %}" class="btn btn-primary"><i class="fas fa-undo"></i> Cancel</a>
{% endblock %} {% endblock %}

Some files were not shown because too many files have changed in this diff Show more