From d4096bbaed2d7234596f7838300213c80e368823 Mon Sep 17 00:00:00 2001 From: Magnus Hagander Date: Tue, 3 Oct 2017 20:06:10 +0200 Subject: [PATCH] Implement volunteer schedule management This adds the concepts of VolunteerSlot and VolunteerAssignment. Each VolunteerSlot has a minimum and a maximum number of volunteers. Possible volunteers (listed in a new field on the conference itself) can sign up for specific slots they are interested in, which must then be confirmed by an admin (controlled by the admin field on the conference). Admins can also add volunteers to specific slots, which must then be confirmed by the volunteer. There is also a per-volunteer ical feed using a secret URL (this adds a regtoken field to all registrations, which is something we've already needed for some other things as well, so it'll be good to have regardless). No notifications are sent in this system, that's all intended to be manually handled by the volunteer manager, at least for now. --- postgresqleu/confreg/admin.py | 27 +++ postgresqleu/confreg/forms.py | 2 +- postgresqleu/confreg/jinjafunc.py | 3 +- postgresqleu/confreg/lookups.py | 2 +- .../confreg/migrations/0008_volunteers.py | 62 +++++++ postgresqleu/confreg/models.py | 49 ++++++ postgresqleu/confreg/views.py | 2 + postgresqleu/confreg/volsched.py | 158 ++++++++++++++++++ postgresqleu/urls.py | 2 + postgresqleu/util/random.py | 8 + .../confreg/registration_dashboard.html | 3 + .../confreg/volunteer_schedule.html | 120 +++++++++++++ template/confreg/admin_dashboard_single.html | 1 + template/confreg/volunteer_schedule.ical | 31 ++++ 14 files changed, 467 insertions(+), 3 deletions(-) create mode 100644 postgresqleu/confreg/migrations/0008_volunteers.py create mode 100644 postgresqleu/confreg/volsched.py create mode 100644 postgresqleu/util/random.py create mode 100644 template.jinja/confreg/volunteer_schedule.html create mode 100644 template/confreg/volunteer_schedule.ical diff --git a/postgresqleu/confreg/admin.py b/postgresqleu/confreg/admin.py index b332c04..e94d6bf 100644 --- a/postgresqleu/confreg/admin.py +++ b/postgresqleu/confreg/admin.py @@ -7,6 +7,7 @@ from django.db.models.fields.files import ImageFieldFile from django.db.models import Count from django.core import urlresolvers from django.utils.safestring import mark_safe +from django.contrib.postgres.forms.ranges import RangeWidget from models import Conference, ConferenceRegistration, RegistrationType, Speaker from models import ConferenceSession, Track, Room, ConferenceSessionScheduleSlot @@ -15,6 +16,7 @@ from models import ShirtSize, ConferenceAdditionalOption from models import ConferenceFeedbackQuestion, Speaker_Photo from models import PrepaidVoucher, PrepaidBatch, BulkPayment, DiscountCode from models import PendingAdditionalOrder +from models import VolunteerSlot from selectable.forms.widgets import AutoCompleteSelectWidget, AutoCompleteSelectMultipleWidget from postgresqleu.accountinfo.lookups import UserLookup @@ -81,11 +83,13 @@ class ConferenceAdminForm(SelectableWidgetAdminFormMixin, forms.ModelForm): 'testers': AutoCompleteSelectMultipleWidget(lookup_class=UserLookup), 'talkvoters': AutoCompleteSelectMultipleWidget(lookup_class=UserLookup), 'staff': AutoCompleteSelectMultipleWidget(lookup_class=UserLookup), + 'volunteers': AutoCompleteSelectMultipleWidget(lookup_class=RegistrationLookup) } accounting_object = forms.ChoiceField(choices=[], required=False) def __init__(self, *args, **kwargs): super(ConferenceAdminForm, self).__init__(*args, **kwargs) + self.fields['volunteers'].widget.widget.update_query_parameters({'conference': self.instance.id}) self.fields['accounting_object'].choices = [('', '----'),] + [(o.name, o.name) for o in Object.objects.filter(active=True)] def clean(self): @@ -573,6 +577,28 @@ class PendingAdditionalOrderAdmin(admin.ModelAdmin): form = PendingAdditionalOrderAdminForm list_display = ('reg', 'createtime', 'payconfirmedat') + +class VolunteerSlotAdminForm(forms.ModelForm): + class Meta: + model = VolunteerSlot + exclude = [] + widgets = { + 'timerange': RangeWidget(admin.widgets.AdminSplitDateTime()), + } + + def clean(self): + data = super(VolunteerSlotAdminForm, self).clean() + + if data['max_staff'] < data['min_staff']: + raise ValidationError("Max staff can't be less than min staff!") + return data + +class VolunteerSlotAdmin(admin.ModelAdmin): + form = VolunteerSlotAdminForm + list_filter = ['conference', ] + list_display = ('__unicode__', 'title') + + admin.site.register(Conference, ConferenceAdmin) admin.site.register(RegistrationClass, RegistrationClassAdmin) admin.site.register(RegistrationDay, RegistrationDayAdmin) @@ -593,3 +619,4 @@ admin.site.register(DiscountCode, DiscountCodeAdmin) admin.site.register(BulkPayment, BulkPaymentAdmin) admin.site.register(AttendeeMail, AttendeeMailAdmin) admin.site.register(PendingAdditionalOrder, PendingAdditionalOrderAdmin) +admin.site.register(VolunteerSlot, VolunteerSlotAdmin) diff --git a/postgresqleu/confreg/forms.py b/postgresqleu/confreg/forms.py index 321a04f..cb1a088 100644 --- a/postgresqleu/confreg/forms.py +++ b/postgresqleu/confreg/forms.py @@ -214,7 +214,7 @@ class ConferenceRegistrationForm(forms.ModelForm): class Meta: model = ConferenceRegistration - exclude = ('conference','attendee','payconfirmedat','payconfirmedby','created',) + exclude = ('conference','attendee','payconfirmedat','payconfirmedby','created', 'regtoken') @property def fieldsets(self): diff --git a/postgresqleu/confreg/jinjafunc.py b/postgresqleu/confreg/jinjafunc.py index 6ca3f4e..da58f08 100644 --- a/postgresqleu/confreg/jinjafunc.py +++ b/postgresqleu/confreg/jinjafunc.py @@ -186,7 +186,8 @@ def render_jinja_conference_response(request, conference, pagemagic, templatenam if not os.path.exists(os.path.join(conference.jinjadir, 'templates/base.html')): raise Http404() - env = ConfSandbox(loader=ConfTemplateLoader(conference, templatename)) + env = ConfSandbox(loader=ConfTemplateLoader(conference, templatename), + extensions=['jinja2.ext.with_']) env.filters.update({ 'currency_format': filter_currency_format, 'escapejs': defaultfilters.escapejs_filter, diff --git a/postgresqleu/confreg/lookups.py b/postgresqleu/confreg/lookups.py index 985a97d..cf765b4 100644 --- a/postgresqleu/confreg/lookups.py +++ b/postgresqleu/confreg/lookups.py @@ -17,7 +17,7 @@ class RegistrationLookup(ModelLookup): def get_query(self, request, term): q = super(RegistrationLookup, self).get_query(request, term) if request.GET.has_key('conference'): - return q.filter(conference_id=request.GET['conference']) + return q.filter(conference_id=request.GET['conference'], payconfirmedat__isnull=False) else: # Don't return anything if parameter not present return None diff --git a/postgresqleu/confreg/migrations/0008_volunteers.py b/postgresqleu/confreg/migrations/0008_volunteers.py new file mode 100644 index 0000000..804ad01 --- /dev/null +++ b/postgresqleu/confreg/migrations/0008_volunteers.py @@ -0,0 +1,62 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations, models +import django.core.validators +import django.contrib.postgres.fields.ranges + + +class Migration(migrations.Migration): + + dependencies = [ + ('confreg', '0007_new_specialtype'), + ] + + operations = [ + migrations.CreateModel( + name='VolunteerAssignment', + fields=[ + ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), + ('vol_confirmed', models.BooleanField(default=False, verbose_name=b'Confirmed by volunteer')), + ('org_confirmed', models.BooleanField(default=False, verbose_name=b'Confirmed by organizers')), + ], + ), + migrations.CreateModel( + name='VolunteerSlot', + fields=[ + ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), + ('timerange', django.contrib.postgres.fields.ranges.DateTimeRangeField()), + ('title', models.CharField(max_length=50)), + ('min_staff', models.IntegerField(default=1, validators=[django.core.validators.MinValueValidator(1)])), + ('max_staff', models.IntegerField(default=1, validators=[django.core.validators.MinValueValidator(1)])), + ], + ), + migrations.AddField( + model_name='conference', + name='volunteers', + field=models.ManyToManyField(help_text=b'Users who volunteer', related_name='volunteers_set', to='confreg.ConferenceRegistration', blank=True), + ), + migrations.AddField( + model_name='conferenceregistration', + name='regtoken', + field=models.TextField(unique=True, null=True, blank=True), + ), + migrations.AddField( + model_name='volunteerslot', + name='conference', + field=models.ForeignKey(to='confreg.Conference'), + ), + migrations.AddField( + model_name='volunteerassignment', + name='reg', + field=models.ForeignKey(to='confreg.ConferenceRegistration'), + ), + migrations.AddField( + model_name='volunteerassignment', + name='slot', + field=models.ForeignKey(to='confreg.VolunteerSlot'), + ), + migrations.RunSQL( + "CREATE INDEX confreg_volunteerslot_timerange_idx ON confreg_volunteerslot USING gist(timerange)", + ), + ] diff --git a/postgresqleu/confreg/models.py b/postgresqleu/confreg/models.py index 14c7472..3cd986c 100644 --- a/postgresqleu/confreg/models.py +++ b/postgresqleu/confreg/models.py @@ -8,6 +8,7 @@ from django.conf import settings from django.core.exceptions import ValidationError from django.core.validators import MinValueValidator from django.utils.dateformat import DateFormat +from django.contrib.postgres.fields import DateTimeRangeField from postgresqleu.util.validators import validate_lowercase @@ -92,6 +93,7 @@ class Conference(models.Model): testers = models.ManyToManyField(User, null=False, blank=True, related_name="testers_set") talkvoters = models.ManyToManyField(User, null=False, blank=True, related_name="talkvoters_set") staff = models.ManyToManyField(User, null=False, blank=True, related_name="staff_set", help_text="Users who can register as staff") + volunteers = models.ManyToManyField('ConferenceRegistration', null=False, blank=True, related_name="volunteers_set", help_text="Users who volunteer") asktshirt = models.BooleanField(blank=False, null=False, default=True) askfood = models.BooleanField(blank=False, null=False, default=True) askshareemail = models.BooleanField(null=False, blank=False, default=False) @@ -388,6 +390,10 @@ class ConferenceRegistration(models.Model): # foreign key :) vouchercode = models.CharField(max_length=100, null=False, blank=True, verbose_name='Voucher code') + # Token to uniquely identify this registration in case we want to + # access it without a login. + regtoken = models.TextField(null=True, blank=True, unique=True) + @property def fullname(self): return "%s %s" % (self.firstname, self.lastname) @@ -412,6 +418,10 @@ class ConferenceRegistration(models.Model): def additionaloptionlist(self): return ",\n".join([a.name for a in self.additionaloptions.all()]) + @property + def is_volunteer(self): + return self.volunteers_set.exists() + # For the admin interface (mainly) def __unicode__(self): return "%s: %s %s <%s>" % (self.conference, self.firstname, self.lastname, self.email) @@ -666,6 +676,45 @@ class ConferenceFeedbackAnswer(models.Model): class Meta: ordering = ['conference', 'attendee', 'question', ] +class VolunteerSlot(models.Model): + conference = models.ForeignKey(Conference, null=False, blank=False) + timerange = DateTimeRangeField(null=False, blank=False) + title = models.CharField(max_length=50, null=False, blank=False) + min_staff = models.IntegerField(null=False, blank=False, default=1, validators=[MinValueValidator(1)]) + max_staff = models.IntegerField(null=False, blank=False, default=1, validators=[MinValueValidator(1)]) + + def __unicode__(self): + return "{0} - {1}".format(self.timerange.lower, self.timerange.upper) + + @property + def countvols(self): + return self.volunteerassignment_set.all().count() + + @property + def weekday(self): + return self.timerange.lower.strftime('%A %Y-%m-%d') + + @property + def utcstarttime(self): + return self._utc_time(self.timerange.lower + datetime.timedelta(hours=self.conference.timediff)) + + @property + def utcendtime(self): + return self._utc_time(self.timerange.lower + datetime.timedelta(hours=self.conference.timediff)) + + def _utc_time(self, time): + if not hasattr(self, '_localtz'): + self._localtz = pytz.timezone(settings.TIME_ZONE) + return self._localtz.localize(time).astimezone(pytz.utc) + +class VolunteerAssignment(models.Model): + slot = models.ForeignKey(VolunteerSlot, null=False, blank=False) + reg = models.ForeignKey(ConferenceRegistration, null=False, blank=False) + vol_confirmed = models.BooleanField(null=False, blank=False, default=False, verbose_name="Confirmed by volunteer") + org_confirmed = models.BooleanField(null=False, blank=False, default=False, verbose_name="Confirmed by organizers") + + _safe_attributes = ('id', 'slot', 'reg', 'vol_confirmed', 'org_confirmed') + class PrepaidBatch(models.Model): conference = models.ForeignKey(Conference, null=False, blank=False) regtype = models.ForeignKey(RegistrationType, null=False, blank=False) diff --git a/postgresqleu/confreg/views.py b/postgresqleu/confreg/views.py index 2db224c..229d6a8 100644 --- a/postgresqleu/confreg/views.py +++ b/postgresqleu/confreg/views.py @@ -38,6 +38,7 @@ from regtypes import confirm_special_reg_type from jinjafunc import render_jinja_conference_response from postgresqleu.util.decorators import user_passes_test_or_error +from postgresqleu.util.random import generate_random_token from postgresqleu.invoices.models import Invoice, InvoicePaymentMethod, InvoiceRow from postgresqleu.confwiki.models import Wikipage from postgresqleu.invoices.util import InvoiceManager, InvoicePresentationWrapper @@ -195,6 +196,7 @@ def home(request, confname): reg.firstname = request.user.first_name reg.lastname = request.user.last_name reg.created = datetime.now() + reg.regtoken = generate_random_token() is_active = conference.active or conference.testers.filter(pk=request.user.id).exists() diff --git a/postgresqleu/confreg/volsched.py b/postgresqleu/confreg/volsched.py new file mode 100644 index 0000000..371c34d --- /dev/null +++ b/postgresqleu/confreg/volsched.py @@ -0,0 +1,158 @@ +from django.contrib.auth.decorators import login_required +from django.shortcuts import render_to_response, get_object_or_404 +from django.template import RequestContext +from django.contrib import messages +from django.http import HttpResponse, HttpResponseRedirect, Http404 +from django.db import transaction +from django.db.models import Count, Sum, F, Func + +from datetime import datetime + +from views import render_conference_response +from models import Conference, ConferenceRegistration +from models import VolunteerSlot, VolunteerAssignment + +def _check_admin(request, conference): + if request.user.is_superuser: + return True + else: + return conference.administrators.filter(pk=request.user.id).exists() + +def _get_conference_and_reg(request, urlname): + conference = get_object_or_404(Conference, urlname=urlname) + is_admin = _check_admin(request, conference) + if is_admin: + reg = ConferenceRegistration.objects.get(conference=conference, attendee=request.user) + else: + try: + reg = conference.volunteers.get(attendee=request.user) + except ConferenceRegistration.DoesNotExist: + raise Http404("Volunteer entry not found") + + return (conference, is_admin, reg) + +@login_required +def volunteerschedule(request, urlname): + try: + (conference, is_admin, reg) = _get_conference_and_reg(request, urlname) + except ConferenceRegistration.DoesNotExist: + return HttpResponse("Must be registered for conference to view volunteer schedule") + + slots = VolunteerSlot.objects.filter(conference=conference).order_by('timerange', 'title') + allregs = conference.volunteers.all() + + stats = ConferenceRegistration.objects.filter(conference=conference) \ + .annotate(num_assignments=Count('volunteerassignment')) \ + .annotate(total_time=Sum(Func( + Func(F('volunteerassignment__slot__timerange'), function='upper'), + Func(F('volunteerassignment__slot__timerange'), function='lower'), + function='age'))) \ + .filter(num_assignments__gt=0).order_by('lastname', 'firstname') + + return render_conference_response(request, conference, 'reg', 'confreg/volunteer_schedule.html', { + 'admin': is_admin, + 'reg': reg, + 'slots': slots, + 'allregs': allregs, + 'stats': stats, + 'rowerror': request.session.pop('rowerror', None), + }) + +@login_required +@transaction.atomic +def signup(request, urlname, slotid): + (conference, is_admin, reg) = _get_conference_and_reg(request, urlname) + + slot = get_object_or_404(VolunteerSlot, conference=conference, id=slotid) + if VolunteerAssignment.objects.filter(slot=slot, reg=reg).exists(): + request.session['rowerror'] = [int(slotid), "Already a volunteer for selected slot"] + elif slot.countvols >= slot.max_staff: + request.session['rowerror'] = [int(slotid), "Volunteer slot is already full"] + elif VolunteerAssignment.objects.filter(reg=reg, slot__timerange__overlap=slot.timerange).exists(): + request.session['rowerror'] = [int(slotid), "Cannot sign up for an overlapping slot"] + else: + VolunteerAssignment(slot=slot, reg=reg, vol_confirmed=True, org_confirmed=False).save() + return HttpResponseRedirect('../..') + +@login_required +@transaction.atomic +def add(request, urlname, slotid, regid): + (conference, is_admin, reg) = _get_conference_and_reg(request, urlname) + if not is_admin: + return HttpResponseRedirect("../..") + + addreg = get_object_or_404(ConferenceRegistration, conference=conference, id=regid) + slot = get_object_or_404(VolunteerSlot, conference=conference, id=slotid) + if VolunteerAssignment.objects.filter(slot=slot, reg=addreg).exists(): + request.session['rowerror'] = [int(slotid), "Already a volunteer for selected slot"] + elif slot.countvols >= slot.max_staff: + request.session['rowerror'] = [int(slotid), "Volunteer slot is already full"] + elif VolunteerAssignment.objects.filter(reg=addreg, slot__timerange__overlap=slot.timerange).exists(): + request.session['rowerror'] = [int(slotid), "Cannot add to an overlapping slot"] + else: + VolunteerAssignment(slot=slot, reg=addreg, vol_confirmed=False, org_confirmed=True).save() + return HttpResponseRedirect('../..') + +@login_required +@transaction.atomic +def remove(request, urlname, slotid, aid): + (conference, is_admin, reg) = _get_conference_and_reg(request, urlname) + + slot = get_object_or_404(VolunteerSlot, conference=conference, id=slotid) + if is_admin: + a = get_object_or_404(VolunteerAssignment, slot=slot, id=aid) + else: + a = get_object_or_404(VolunteerAssignment, slot=slot, reg=reg, id=aid) + if a.org_confirmed and not is_admin: + request.session['rowerror'] = [int(slotid), "Cannot remove a confirmed assignment. Please contact the volunteer schedule coordinator for manual processing."] + else: + a.delete() + return HttpResponseRedirect('../..') + + +@login_required +@transaction.atomic +def confirm(request, urlname, slotid, aid): + (conference, is_admin, reg) = _get_conference_and_reg(request, urlname) + + slot = get_object_or_404(VolunteerSlot, conference=conference, id=slotid) + if is_admin: + # Admins can make organization confirms + a = get_object_or_404(VolunteerAssignment, slot=slot, id=aid) + if a.org_confirmed: + messages.info(request, "Assignment already confirmed") + else: + a.org_confirmed = True + a.save() + else: + # Regular users can confirm their own sessions only + a = get_object_or_404(VolunteerAssignment, slot=slot, reg=reg, id=aid) + if a.vol_confirmed: + messages.info(request, "Assignment already confirmed") + else: + a.vol_confirmed = True + a.save() + return HttpResponseRedirect('../..') + + +def ical(request, urlname, token): + conference = get_object_or_404(Conference, urlname=urlname) + reg = get_object_or_404(ConferenceRegistration, regtoken=token) + assignments = VolunteerAssignment.objects.filter(reg=reg).order_by('slot__timerange') + return render_to_response('confreg/volunteer_schedule.ical', { + 'conference': conference, + 'assignments': assignments, + 'now': datetime.utcnow(), + }, content_type='text/calendar', context_instance=RequestContext(request)) + + +from django.conf.urls import url + +urlpatterns = ( + url(r'^$', volunteerschedule), + url(r'^signup/(?P\d+)/$', signup), + url(r'^remove/(?P\d+)-(?P\d+)/$', remove), + url(r'^confirm/(?P\d+)-(?P\d+)/$', confirm), + url(r'^add/(?P\d+)-(?P\d+)/$', add), + url(r'^ical/(?P[a-z0-9]{64})/$', ical), +) diff --git a/postgresqleu/urls.py b/postgresqleu/urls.py index 60e063f..4250f2c 100644 --- a/postgresqleu/urls.py +++ b/postgresqleu/urls.py @@ -11,6 +11,7 @@ import postgresqleu.confreg.reporting import postgresqleu.confreg.mobileviews import postgresqleu.confreg.feedback import postgresqleu.confreg.pdfschedule +import postgresqleu.confreg.volsched import postgresqleu.confwiki.views import postgresqleu.membership.views import postgresqleu.elections.views @@ -66,6 +67,7 @@ urlpatterns = patterns('', (r'^events/schedule/([^/]+)/create/$', postgresqleu.confreg.views.createschedule), (r'^events/schedule/([^/]+)/create/publish/$', postgresqleu.confreg.views.publishschedule), (r'^events/schedule/([^/]+)/jsonschedule/$', postgresqleu.confreg.views.schedulejson), + (r'^events/volunteer/(?P[^/]+)/', include(postgresqleu.confreg.volsched)), (r'^events/talkvote/([^/]+)/$', postgresqleu.confreg.views.talkvote), (r'^events/talkvote/([^/]+)/changestatus/$', postgresqleu.confreg.views.talkvote_status), (r'^events/reports/time/$', postgresqleu.confreg.reporting.timereport), diff --git a/postgresqleu/util/random.py b/postgresqleu/util/random.py new file mode 100644 index 0000000..dfea233 --- /dev/null +++ b/postgresqleu/util/random.py @@ -0,0 +1,8 @@ +from Crypto.Hash import SHA256 +from Crypto import Random + +def generate_random_token(): + s = SHA256.new() + r = Random.new() + s.update(r.read(250)) + return s.hexdigest() diff --git a/template.jinja/confreg/registration_dashboard.html b/template.jinja/confreg/registration_dashboard.html index 588a95d..619b258 100644 --- a/template.jinja/confreg/registration_dashboard.html +++ b/template.jinja/confreg/registration_dashboard.html @@ -48,6 +48,9 @@ news!
  • Call for papers
  • {%elif is_speaker%}
  • Your speaker profile
  • +{%endif%} +{%if reg.is_volunteer%} +
  • Volunteer schedule
  • {%endif%} diff --git a/template.jinja/confreg/volunteer_schedule.html b/template.jinja/confreg/volunteer_schedule.html new file mode 100644 index 0000000..6c4af3f --- /dev/null +++ b/template.jinja/confreg/volunteer_schedule.html @@ -0,0 +1,120 @@ +{%extends "base.html" %} +{%block title%}Volunteer Schedule - {{conference}}{%endblock%} +{%block extrahead%} + +{%endblock%} +{%block content%} +

    Volunteer Schedule

    +{% if messages %} +
      + {% for message in messages %} + {{ message }} + {% endfor %} +
    +{% endif %} + +

    Schedule

    +{%if reg.regtoken%} +

    + You can also get the ical version. +

    +

    + Green background indicates a slot assigned to you. +

    +{%endif%} +{%if admin%} +

    + NOTE! You are using the system as an administrator! +

    +{%endif%} + + + + + + + + +{%for day in slots|groupby('weekday')%} + + + +{%for s in day.list%} +{%with assigned_slot = s.volunteerassignment_set.filter(reg=reg).exists()%} +{%if rowerror[0] == s.id %} + +{%endif%} + + + + {{s.countvols}} / {{s.max_staff}}{%if s.max_staff != s.min_staff%} (min {{s.min_staff}}){%endif%} + + +{%endwith%} +{%endfor%} +{%endfor%} + +
    TimeTitleCountVolunteers
    {{day.grouper}}
    Error: {{rowerror.1}}
    {{s.timerange.lower.strftime("%H:%M")}} - {{s.timerange.upper.strftime("%H: %M")}}{{s.title}} +
    +{%for v in s.volunteerassignment_set.all()%} +
    +
    {{v.reg.fullname}}
    +
    +{%if admin %} + {%if v.vol_confirmed and v.org_confirmed%}confirmed{%else%}unconfirmed{%endif%} +{%if not v.org_confirmed%}confirm{%endif%} +delete +{%else%} +{# non-admin #} + {%if v.reg == reg %} + {%if not v.vol_confirmed%}confirm{%endif%} + {%if not v.org_confirmed%}remove{%endif%} + {%endif%} +{%endif%} +
    +
    +{%endfor%} +
    {# list of current volunteers #} +{%if s.countvols < s.max_staff%} + {%if not assigned_slot and not admin%}Sign up + {%endif%} + {%if admin %} +
    +
    + {%endif%} +{%endif%} +
    + +

    Statistics

    +

    + The following volunteers are currently assigned slots: +

    + + + + + + +{%for s in stats%} + + + + + +{%endfor%} +
    VolunteerAssignmentsTotal time
    {{s.fullname}}{{s.num_assignments}}{{s.total_time}}
    + + +{%endblock%} diff --git a/template/confreg/admin_dashboard_single.html b/template/confreg/admin_dashboard_single.html index 1c38d6c..3cb6879 100644 --- a/template/confreg/admin_dashboard_single.html +++ b/template/confreg/admin_dashboard_single.html @@ -25,6 +25,7 @@ + diff --git a/template/confreg/volunteer_schedule.ical b/template/confreg/volunteer_schedule.ical new file mode 100644 index 0000000..a2d52f0 --- /dev/null +++ b/template/confreg/volunteer_schedule.ical @@ -0,0 +1,31 @@ +BEGIN:VCALENDAR +PRODID:-//postgresqleu/confreg//NONSGML v1.0//EN +VERSION:2.0 +X-WR-CALNAME:{{conference}} volunteer schedule +CALSCALE:GREGORIAN +METHOD:PUBLISH +{%for assignment in assignments%}BEGIN:VEVENT +DTSTART:{{assignment.slot.utcstarttime|date:"Ymd"}}T{{assignment.slot.utcstarttime|time:"His"}}Z +DTEND:{{assignment.slot.utcendtime|date:"Ymd"}}T{{assignment.slot.utcendtime|time:"His"}}Z +DTSTAMP:{{now|date:"Ymd"}}T{{now|date:"His"}}Z +UID:{{conference.urlname}}-{{assignment.id}} +CREATED:19000101T120000Z +DESCRIPTION: +LAST-MODIFIED:{{now|date:"Ymd"}}T{{now|date:"His"}}Z +LOCATION: +SEQUENCE:0 +STATUS:CONFIRMED +SUMMARY:{{assignment.slot.title}} +TRANSP:OPAQUE +BEGIN:VALARM +ACTION:DISPLAY +DESCRIPTION:10 minute reminder! +TRIGGER:-P0DT0H10M0S +END:VALARM +BEGIN:VALARM +ACTION:DISPLAY +DESCRIPTION:30 minute reminder! +TRIGGER:-P0DT0H30M0S +END:VALARM +END:VEVENT +{%endfor%}END:VCALENDAR -- 2.39.5