diff --git a/src/pretix/api/views/device.py b/src/pretix/api/views/device.py index b1aaf99598..cd17b524dc 100644 --- a/src/pretix/api/views/device.py +++ b/src/pretix/api/views/device.py @@ -8,7 +8,7 @@ from rest_framework.views import APIView from pretix.api.auth.device import DeviceTokenAuthentication from pretix.base.models import Device -from pretix.base.models.devices import generate_api_token +from pretix.base.models.devices import Gate, generate_api_token logger = logging.getLogger(__name__) @@ -28,14 +28,25 @@ class UpdateRequestSerializer(serializers.Serializer): software_version = serializers.CharField(max_length=190) +class GateSerializer(serializers.ModelSerializer): + class Meta: + model = Gate + fields = [ + 'id', + 'name', + 'identifier', + ] + + class DeviceSerializer(serializers.ModelSerializer): organizer = serializers.SlugRelatedField(slug_field='slug', read_only=True) + gate = GateSerializer(read_only=True) class Meta: model = Device fields = [ 'organizer', 'device_id', 'unique_serial', 'api_token', - 'name', 'security_profile' + 'name', 'security_profile', 'gate' ] diff --git a/src/pretix/base/migrations/0168_auto_20201023_1447.py b/src/pretix/base/migrations/0168_auto_20201023_1447.py new file mode 100644 index 0000000000..f3166b3b4d --- /dev/null +++ b/src/pretix/base/migrations/0168_auto_20201023_1447.py @@ -0,0 +1,33 @@ +# Generated by Django 3.0.9 on 2020-10-23 14:47 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('pretixbase', '0167_checkinlist_exit_all_at'), + ] + + operations = [ + migrations.CreateModel( + name='Gate', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)), + ('name', models.CharField(max_length=190)), + ('identifier', models.CharField(max_length=190)), + ('organizer', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='gates', to='pretixbase.Organizer')), + ], + ), + migrations.AddField( + model_name='checkin', + name='gate', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='checkins', to='pretixbase.Gate'), + ), + migrations.AddField( + model_name='device', + name='gate', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='devices', to='pretixbase.Gate'), + ), + ] diff --git a/src/pretix/base/models/__init__.py b/src/pretix/base/models/__init__.py index a980d6aefc..ac4d3cae32 100644 --- a/src/pretix/base/models/__init__.py +++ b/src/pretix/base/models/__init__.py @@ -2,7 +2,7 @@ from ..settings import GlobalSettingsObject_SettingsStore from .auth import U2FDevice, User, WebAuthnDevice from .base import CachedFile, LoggedModel, cachedfile_name from .checkin import Checkin, CheckinList -from .devices import Device +from .devices import Device, Gate from .event import ( Event, Event_SettingsStore, EventLock, EventMetaProperty, EventMetaValue, RequiredAction, SubEvent, SubEventMetaValue, generate_invite_token, @@ -19,8 +19,8 @@ from .notifications import NotificationSetting from .orders import ( AbstractPosition, CachedCombinedTicket, CachedTicket, CartPosition, InvoiceAddress, Order, OrderFee, OrderPayment, OrderPosition, OrderRefund, - QuestionAnswer, cachedcombinedticket_name, cachedticket_name, - generate_position_secret, generate_secret, + QuestionAnswer, RevokedTicketSecret, cachedcombinedticket_name, + cachedticket_name, generate_position_secret, generate_secret, ) from .organizer import ( Organizer, Organizer_SettingsStore, Team, TeamAPIToken, TeamInvite, diff --git a/src/pretix/base/models/checkin.py b/src/pretix/base/models/checkin.py index c350e703cc..8afad4360a 100644 --- a/src/pretix/base/models/checkin.py +++ b/src/pretix/base/models/checkin.py @@ -159,6 +159,9 @@ class Checkin(models.Model): device = models.ForeignKey( 'pretixbase.Device', related_name='checkins', on_delete=models.PROTECT, null=True, blank=True ) + gate = models.ForeignKey( + 'pretixbase.Gate', related_name='checkins', on_delete=models.SET_NULL, null=True, blank=True + ) auto_checked_in = models.BooleanField(default=False) objects = ScopedManager(organizer='position__order__event__organizer') diff --git a/src/pretix/base/models/devices.py b/src/pretix/base/models/devices.py index 5347e98acc..ae659a9d01 100644 --- a/src/pretix/base/models/devices.py +++ b/src/pretix/base/models/devices.py @@ -1,5 +1,6 @@ import string +from django.core.exceptions import ValidationError from django.db import models from django.db.models import Max from django.utils.crypto import get_random_string @@ -34,12 +35,64 @@ def generate_api_token(): return token +class Gate(LoggedModel): + organizer = models.ForeignKey( + 'pretixbase.Organizer', + on_delete=models.PROTECT, + related_name='gates' + ) + name = models.CharField( + verbose_name=_("Name"), + max_length=190, + ) + identifier = models.CharField( + max_length=190, blank=True, + verbose_name=_("Internal identifier"), + help_text=_('You can enter any value here to make it easier to match the data with other sources. If you do ' + 'not input one, we will generate one automatically.') + ) + + class Meta: + ordering = ('name',) + + def __str__(self): + return self.name + + def clean_identifier(self, code): + Gate._clean_identifier(self.organizer, code, self) + + @staticmethod + def _clean_identifier(organizer, code, instance=None): + qs = Gate.objects.filter(organizer=organizer, identifier__iexact=code) + if instance: + qs = qs.exclude(pk=instance.pk) + if qs.exists(): + raise ValidationError(_('This identifier is already used for a different question.')) + + def save(self, *args, **kwargs): + if not self.identifier: + charset = list('ABCDEFGHJKLMNPQRSTUVWXYZ3789') + while True: + code = get_random_string(length=8, allowed_chars=charset) + if not Gate.objects.filter(organizer=self.organizer, identifier=code).exists(): + self.identifier = code + break + return super().save(*args, **kwargs) + + class Device(LoggedModel): organizer = models.ForeignKey( 'pretixbase.Organizer', on_delete=models.PROTECT, related_name='devices' ) + gate = models.ForeignKey( + 'pretixbase.Gate', + verbose_name=_('Gate'), + on_delete=models.SET_NULL, + null=True, blank=True, + related_name='devices' + ) device_id = models.PositiveIntegerField() unique_serial = models.CharField(max_length=190, default=generate_serial, unique=True) initialization_token = models.CharField(max_length=190, default=generate_initialization_token, unique=True) diff --git a/src/pretix/base/services/checkin.py b/src/pretix/base/services/checkin.py index 03c3e17491..dda99cb7d5 100644 --- a/src/pretix/base/services/checkin.py +++ b/src/pretix/base/services/checkin.py @@ -230,6 +230,7 @@ def perform_checkin(op: OrderPosition, clist: CheckinList, given_answers: dict, list=clist, datetime=dt, device=device, + gate=device.gate if device else None, nonce=nonce, forced=force and not entry_allowed, ) diff --git a/src/pretix/control/forms/organizer.py b/src/pretix/control/forms/organizer.py index 38bc312b62..edc76145a2 100644 --- a/src/pretix/control/forms/organizer.py +++ b/src/pretix/control/forms/organizer.py @@ -15,7 +15,7 @@ from pretix.api.models import WebHook from pretix.api.webhooks import get_all_webhook_events from pretix.base.forms import I18nModelForm, SettingsForm from pretix.base.forms.widgets import SplitDateTimePickerWidget -from pretix.base.models import Device, GiftCard, Organizer, Team +from pretix.base.models import Device, Gate, GiftCard, Organizer, Team from pretix.control.forms import ( ExtFileField, FontSelect, MultipleLanguagesWidget, SplitDateTimeField, ) @@ -175,6 +175,17 @@ class TeamForm(forms.ModelForm): return data +class GateForm(forms.ModelForm): + + def __init__(self, *args, **kwargs): + kwargs.pop('organizer') + super().__init__(*args, **kwargs) + + class Meta: + model = Gate + fields = ['name', 'identifier'] + + class DeviceForm(forms.ModelForm): def __init__(self, *args, **kwargs): @@ -183,6 +194,7 @@ class DeviceForm(forms.ModelForm): self.fields['limit_events'].queryset = organizer.events.all().order_by( '-has_subevents', '-date_from' ) + self.fields['gate'].queryset = organizer.gates.all() def clean(self): d = super().clean() @@ -193,7 +205,7 @@ class DeviceForm(forms.ModelForm): class Meta: model = Device - fields = ['name', 'all_events', 'limit_events', 'security_profile'] + fields = ['name', 'all_events', 'limit_events', 'security_profile', 'gate'] widgets = { 'limit_events': forms.CheckboxSelectMultiple(attrs={ 'data-inverse-dependency': '#id_all_events', diff --git a/src/pretix/control/logdisplay.py b/src/pretix/control/logdisplay.py index 0767a93844..4ca2621245 100644 --- a/src/pretix/control/logdisplay.py +++ b/src/pretix/control/logdisplay.py @@ -412,6 +412,9 @@ def pretixcontrol_logentry_display(sender: Event, logentry: LogEntry, **kwargs): 'pretix.team.created': _('The team has been created.'), 'pretix.team.changed': _('The team settings have been changed.'), 'pretix.team.deleted': _('The team has been deleted.'), + 'pretix.gate.created': _('The gate has been created.'), + 'pretix.gate.changed': _('The gate has been changed.'), + 'pretix.gate.deleted': _('The gate has been deleted.'), 'pretix.subevent.deleted': pgettext_lazy('subevent', 'The event date has been deleted.'), 'pretix.subevent.canceled': pgettext_lazy('subevent', 'The event date has been canceled.'), 'pretix.subevent.changed': pgettext_lazy('subevent', 'The event date has been changed.'), diff --git a/src/pretix/control/navigation.py b/src/pretix/control/navigation.py index aae6aef891..5d59d368f7 100644 --- a/src/pretix/control/navigation.py +++ b/src/pretix/control/navigation.py @@ -454,8 +454,23 @@ def get_organizer_navigation(request): 'url': reverse('control:organizer.devices', kwargs={ 'organizer': request.organizer.slug }), - 'active': 'organizer.device' in url.url_name, 'icon': 'tablet', + 'children': [ + { + 'label': _('Devices'), + 'url': reverse('control:organizer.devices', kwargs={ + 'organizer': request.organizer.slug + }), + 'active': 'organizer.device' in url.url_name, + }, + { + 'label': _('Gates'), + 'url': reverse('control:organizer.gates', kwargs={ + 'organizer': request.organizer.slug + }), + 'active': 'organizer.gate' in url.url_name, + } + ] }) if 'can_manage_gift_cards' in request.orgapermset: nav.append({ diff --git a/src/pretix/control/templates/pretixcontrol/order/index.html b/src/pretix/control/templates/pretixcontrol/order/index.html index c7eb5e524e..41325dd689 100644 --- a/src/pretix/control/templates/pretixcontrol/order/index.html +++ b/src/pretix/control/templates/pretixcontrol/order/index.html @@ -302,14 +302,14 @@ {% if c.auto_checked_in %} {% else %} - + {% endif %} {% elif c.forced %} - + {% elif c.auto_checked_in %} - + {% else %} - + {% endif %} {% endfor %} {% endif %} diff --git a/src/pretix/control/templates/pretixcontrol/organizers/device_edit.html b/src/pretix/control/templates/pretixcontrol/organizers/device_edit.html index 56075a7d56..45b2f6dbed 100644 --- a/src/pretix/control/templates/pretixcontrol/organizers/device_edit.html +++ b/src/pretix/control/templates/pretixcontrol/organizers/device_edit.html @@ -14,10 +14,17 @@ {% endif %} {% csrf_token %} {% bootstrap_form_errors form %} - {% bootstrap_field form.name layout="control" %} - {% bootstrap_field form.all_events layout="control" %} - {% bootstrap_field form.limit_events layout="control" %} - {% bootstrap_field form.security_profile layout="control" %} +
+ {% trans "General" %} + {% bootstrap_field form.name layout="control" %} + {% bootstrap_field form.all_events layout="control" %} + {% bootstrap_field form.limit_events layout="control" %} +
+
+ {% trans "Advanced settings" %} + {% bootstrap_field form.security_profile layout="control" %} + {% bootstrap_field form.gate layout="control" %} +
+
+ +{% endblock %} diff --git a/src/pretix/control/templates/pretixcontrol/organizers/gate_edit.html b/src/pretix/control/templates/pretixcontrol/organizers/gate_edit.html new file mode 100644 index 0000000000..2475afbaac --- /dev/null +++ b/src/pretix/control/templates/pretixcontrol/organizers/gate_edit.html @@ -0,0 +1,20 @@ +{% extends "pretixcontrol/organizers/base.html" %} +{% load i18n %} +{% load bootstrap3 %} +{% block inner %} + {% if gate %} +

{% trans "Gate:" %} {{ gate.name }}

+ {% else %} +

{% trans "Create a new gate" %}

+ {% endif %} +
+ {% csrf_token %} + {% bootstrap_form form layout="control" %} +
+ +
+ +
+{% endblock %} diff --git a/src/pretix/control/templates/pretixcontrol/organizers/gates.html b/src/pretix/control/templates/pretixcontrol/organizers/gates.html new file mode 100644 index 0000000000..5ad40eaeda --- /dev/null +++ b/src/pretix/control/templates/pretixcontrol/organizers/gates.html @@ -0,0 +1,38 @@ +{% extends "pretixcontrol/organizers/base.html" %} +{% load i18n %} +{% load bootstrap3 %} +{% block inner %} +

{% trans "Gates" %}

+

+ {% trans "The list below shows gates that youc an use to group check-in devices." %} +

+ + + {% trans "Create a new gate" %} + + + + + + + + + + {% for g in gates %} + + + + + {% endfor %} + +
{% trans "Gate" %}
+ + {{ g.name }} + + + + +
+{% endblock %} diff --git a/src/pretix/control/urls.py b/src/pretix/control/urls.py index 4304e16dd0..048f6d3972 100644 --- a/src/pretix/control/urls.py +++ b/src/pretix/control/urls.py @@ -99,6 +99,12 @@ urlpatterns = [ name='organizer.device.revoke'), url(r'^organizer/(?P[^/]+)/device/(?P[^/]+)/logs$', organizer.DeviceLogView.as_view(), name='organizer.device.logs'), + url(r'^organizer/(?P[^/]+)/gates$', organizer.GateListView.as_view(), name='organizer.gates'), + url(r'^organizer/(?P[^/]+)/gate/add$', organizer.GateCreateView.as_view(), name='organizer.gate.add'), + url(r'^organizer/(?P[^/]+)/gate/(?P[^/]+)/edit$', organizer.GateUpdateView.as_view(), + name='organizer.gate.edit'), + url(r'^organizer/(?P[^/]+)/gate/(?P[^/]+)/delete$', organizer.GateDeleteView.as_view(), + name='organizer.gate.delete'), url(r'^organizer/(?P[^/]+)/teams$', organizer.TeamListView.as_view(), name='organizer.teams'), url(r'^organizer/(?P[^/]+)/team/add$', organizer.TeamCreateView.as_view(), name='organizer.team.add'), url(r'^organizer/(?P[^/]+)/team/(?P[^/]+)/$', organizer.TeamMemberView.as_view(), diff --git a/src/pretix/control/views/organizer.py b/src/pretix/control/views/organizer.py index 43084645db..a53329996a 100644 --- a/src/pretix/control/views/organizer.py +++ b/src/pretix/control/views/organizer.py @@ -28,8 +28,8 @@ from django.views.generic import ( from pretix.api.models import WebHook from pretix.base.auth import get_auth_backends from pretix.base.models import ( - CachedFile, Device, GiftCard, LogEntry, OrderPayment, Organizer, Team, - TeamInvite, User, + CachedFile, Device, Gate, GiftCard, LogEntry, OrderPayment, Organizer, + Team, TeamInvite, User, ) from pretix.base.models.event import Event, EventMetaProperty, EventMetaValue from pretix.base.models.giftcards import ( @@ -46,9 +46,9 @@ from pretix.control.forms.filter import ( ) from pretix.control.forms.orders import ExporterForm from pretix.control.forms.organizer import ( - DeviceForm, EventMetaPropertyForm, GiftCardCreateForm, GiftCardUpdateForm, - OrganizerDeleteForm, OrganizerForm, OrganizerSettingsForm, - OrganizerUpdateForm, TeamForm, WebHookForm, + DeviceForm, EventMetaPropertyForm, GateForm, GiftCardCreateForm, + GiftCardUpdateForm, OrganizerDeleteForm, OrganizerForm, + OrganizerSettingsForm, OrganizerUpdateForm, TeamForm, WebHookForm, ) from pretix.control.permissions import ( AdministratorPermissionRequiredMixin, OrganizerPermissionRequiredMixin, @@ -1265,3 +1265,105 @@ class ExportView(OrganizerPermissionRequiredMixin, ExportMixin, TemplateView): ctx = super().get_context_data(**kwargs) ctx['exporters'] = self.exporters return ctx + + +class GateListView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin, ListView): + model = Gate + template_name = 'pretixcontrol/organizers/gates.html' + permission = 'can_change_organizer_settings' + context_object_name = 'gates' + + def get_queryset(self): + return self.request.organizer.gates.all() + + +class GateCreateView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin, CreateView): + model = Gate + template_name = 'pretixcontrol/organizers/gate_edit.html' + permission = 'can_change_organizer_settings' + form_class = GateForm + + def get_form_kwargs(self): + kwargs = super().get_form_kwargs() + kwargs['organizer'] = self.request.organizer + return kwargs + + def get_object(self, queryset=None): + return get_object_or_404(Gate, organizer=self.request.organizer, pk=self.kwargs.get('gate')) + + def get_success_url(self): + return reverse('control:organizer.gates', kwargs={ + 'organizer': self.request.organizer.slug, + }) + + def form_valid(self, form): + messages.success(self.request, _('The gate has been created.')) + form.instance.organizer = self.request.organizer + ret = super().form_valid(form) + form.instance.log_action('pretix.gate.created', user=self.request.user, data={ + k: getattr(self.object, k) for k in form.changed_data + }) + return ret + + def form_invalid(self, form): + messages.error(self.request, _('Your changes could not be saved.')) + return super().form_invalid(form) + + +class GateUpdateView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin, UpdateView): + model = Gate + template_name = 'pretixcontrol/organizers/gate_edit.html' + permission = 'can_change_organizer_settings' + context_object_name = 'gate' + form_class = GateForm + + def get_form_kwargs(self): + kwargs = super().get_form_kwargs() + kwargs['organizer'] = self.request.organizer + return kwargs + + def get_object(self, queryset=None): + return get_object_or_404(Gate, organizer=self.request.organizer, pk=self.kwargs.get('gate')) + + def get_success_url(self): + return reverse('control:organizer.gate', kwargs={ + 'organizer': self.request.organizer.slug, + 'gate': self.object.pk + }) + + def form_valid(self, form): + if form.has_changed(): + self.object.log_action('pretix.gate.changed', user=self.request.user, data={ + k: getattr(self.object, k) + for k in form.changed_data + }) + messages.success(self.request, _('Your changes have been saved.')) + return super().form_valid(form) + + def form_invalid(self, form): + messages.error(self.request, _('Your changes could not be saved.')) + return super().form_invalid(form) + + +class GateDeleteView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin, DeleteView): + model = Gate + template_name = 'pretixcontrol/organizers/gate_delete.html' + permission = 'can_change_organizer_settings' + context_object_name = 'gate' + + def get_object(self, queryset=None): + return get_object_or_404(Gate, organizer=self.request.organizer, pk=self.kwargs.get('gate')) + + def get_success_url(self): + return reverse('control:organizer.gates', kwargs={ + 'organizer': self.request.organizer.slug, + }) + + @transaction.atomic + def delete(self, request, *args, **kwargs): + success_url = self.get_success_url() + self.object = self.get_object() + self.object.log_action('pretix.gate.deleted', user=self.request.user) + self.object.delete() + messages.success(request, _('The selected gate has been deleted.')) + return redirect(success_url) diff --git a/src/tests/control/test_permissions.py b/src/tests/control/test_permissions.py index b9bdc84300..dcdbe7bc04 100644 --- a/src/tests/control/test_permissions.py +++ b/src/tests/control/test_permissions.py @@ -142,6 +142,10 @@ organizer_urls = [ 'organizer/abc/device/1/edit', 'organizer/abc/device/1/connect', 'organizer/abc/device/1/revoke', + 'organizer/abc/gates', + 'organizer/abc/gate/add', + 'organizer/abc/gate/1/edit', + 'organizer/abc/gate/1/delete', 'organizer/abc/webhooks', 'organizer/abc/webhook/add', 'organizer/abc/webhook/1/edit', @@ -416,6 +420,10 @@ organizer_permission_urls = [ ("can_change_organizer_settings", "organizer/dummy/device/1/edit", 404), ("can_change_organizer_settings", "organizer/dummy/device/1/connect", 404), ("can_change_organizer_settings", "organizer/dummy/device/1/revoke", 404), + ("can_change_organizer_settings", "organizer/dummy/gates", 200), + ("can_change_organizer_settings", "organizer/dummy/gate/add", 200), + ("can_change_organizer_settings", "organizer/dummy/gate/1/edit", 404), + ("can_change_organizer_settings", "organizer/dummy/gate/1/delete", 404), ("can_manage_gift_cards", "organizer/dummy/giftcards", 200), ("can_manage_gift_cards", "organizer/dummy/giftcard/add", 200), ("can_manage_gift_cards", "organizer/dummy/giftcard/1/", 404),