From cab360bdb658796beca2ef493fb5d96eb8f99995 Mon Sep 17 00:00:00 2001 From: Raphael Michel Date: Mon, 29 Jul 2024 09:46:53 +0200 Subject: [PATCH] Move auto check-in to plugin with more functionality (#4331) * Move auto check-in to plugin with more functionality * Rename field * Add to MANIFEST.in --- MANIFEST.in | 2 + doc/api/resources/auto_checkin_rules.rst | 259 +++++++++++++++ doc/api/resources/checkinlists.rst | 1 + doc/api/resources/index.rst | 1 + doc/api/resources/sendmail_rules.rst | 3 + src/pretix/_base_settings.py | 1 + src/pretix/base/models/checkin.py | 6 +- src/pretix/base/services/checkin.py | 4 +- src/pretix/plugins/autocheckin/__init__.py | 21 ++ src/pretix/plugins/autocheckin/api.py | 140 ++++++++ src/pretix/plugins/autocheckin/apps.py | 43 +++ src/pretix/plugins/autocheckin/forms.py | 253 ++++++++++++++ .../autocheckin/migrations/0001_initial.py | 61 ++++ .../autocheckin/migrations/__init__.py | 0 src/pretix/plugins/autocheckin/models.py | 85 +++++ src/pretix/plugins/autocheckin/signals.py | 194 +++++++++++ .../pretixplugins/autocheckin/add.html | 34 ++ .../pretixplugins/autocheckin/delete.html | 19 ++ .../pretixplugins/autocheckin/edit.html | 34 ++ .../pretixplugins/autocheckin/index.html | 100 ++++++ src/pretix/plugins/autocheckin/urls.py | 51 +++ src/pretix/plugins/autocheckin/views.py | 210 ++++++++++++ src/pretix/settings.py | 2 +- src/tests/plugins/autocheckin/__init__.py | 21 ++ src/tests/plugins/autocheckin/conftest.py | 96 ++++++ src/tests/plugins/autocheckin/test_api.py | 155 +++++++++ src/tests/plugins/autocheckin/test_checkin.py | 310 ++++++++++++++++++ src/tests/plugins/autocheckin/test_control.py | 185 +++++++++++ 28 files changed, 2285 insertions(+), 6 deletions(-) create mode 100644 doc/api/resources/auto_checkin_rules.rst create mode 100644 src/pretix/plugins/autocheckin/__init__.py create mode 100644 src/pretix/plugins/autocheckin/api.py create mode 100644 src/pretix/plugins/autocheckin/apps.py create mode 100644 src/pretix/plugins/autocheckin/forms.py create mode 100644 src/pretix/plugins/autocheckin/migrations/0001_initial.py create mode 100644 src/pretix/plugins/autocheckin/migrations/__init__.py create mode 100644 src/pretix/plugins/autocheckin/models.py create mode 100644 src/pretix/plugins/autocheckin/signals.py create mode 100644 src/pretix/plugins/autocheckin/templates/pretixplugins/autocheckin/add.html create mode 100644 src/pretix/plugins/autocheckin/templates/pretixplugins/autocheckin/delete.html create mode 100644 src/pretix/plugins/autocheckin/templates/pretixplugins/autocheckin/edit.html create mode 100644 src/pretix/plugins/autocheckin/templates/pretixplugins/autocheckin/index.html create mode 100644 src/pretix/plugins/autocheckin/urls.py create mode 100644 src/pretix/plugins/autocheckin/views.py create mode 100644 src/tests/plugins/autocheckin/__init__.py create mode 100644 src/tests/plugins/autocheckin/conftest.py create mode 100644 src/tests/plugins/autocheckin/test_api.py create mode 100644 src/tests/plugins/autocheckin/test_checkin.py create mode 100644 src/tests/plugins/autocheckin/test_control.py diff --git a/MANIFEST.in b/MANIFEST.in index 331555b61..adf0476f8 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -10,6 +10,8 @@ recursive-include src/pretix/helpers/locale * recursive-include src/pretix/base/templates * recursive-include src/pretix/control/templates * recursive-include src/pretix/presale/templates * +recursive-include src/pretix/plugins/autocheckin/templates * +recursive-include src/pretix/plugins/autocheckin/static * recursive-include src/pretix/plugins/banktransfer/templates * recursive-include src/pretix/plugins/banktransfer/static * recursive-include src/pretix/plugins/manualpayment/templates * diff --git a/doc/api/resources/auto_checkin_rules.rst b/doc/api/resources/auto_checkin_rules.rst new file mode 100644 index 000000000..83c20c82f --- /dev/null +++ b/doc/api/resources/auto_checkin_rules.rst @@ -0,0 +1,259 @@ +.. _rest-autocheckinrules: + +Auto check-in rules +=================== + +This feature requires the bundled ``pretix.plugins.autocheckin`` plugin to be active for the event in order to work properly. + +Resource description +-------------------- + +Auto check-in rules specify that tickets should under specific conditions automatically be considered checked in after +they have been purchased. + +.. rst-class:: rest-resource-table + +===================================== ========================== ======================================================= +Field Type Description +===================================== ========================== ======================================================= +id integer Internal ID of the rule +list integer ID of the check-in list to check the ticket in on. If + ``None``, the system will select all matching check-in lists. +mode string ``"placed"`` if the rule should be evaluated right after + an order has been created, ``"paid"`` if the rule should + be evaluated after the order has been fully paid. +all_sales_channels boolean If ``true`` (default), the rule applies to tickets sold on all sales channels. +limit_sales_channels list of strings List of sales channel identifiers the rule should apply to + if ``all_sales_channels`` is ``false``. +all_products boolean If ``true`` (default), the rule affects all products and variations. +limit_products list of integers List of item IDs, if ``all_products`` is not set. If the + product listed here has variations, all variations will be matched. +limit_variations list of integers List of product variation IDs, if ``all_products`` is not set. + The parent product does not need to be part of ``limit_products``. +all_payment_methods boolean If ``true`` (default), the rule applies to tickets paid with all payment methods. +limit_payment_methods list of strings List of payment method identifiers the rule should apply to + if ``all_payment_methods`` is ``false``. +===================================== ========================== ======================================================= + +.. versionadded:: 2024.7 + +Endpoints +--------- + +.. http:get:: /api/v1/organizers/(organizer)/events/(event)/auto_checkin_rules/ + + Returns a list of all rules configured for an event. + + **Example request**: + + .. sourcecode:: http + + GET /api/v1/organizers/bigevents/events/sampleconf/auto_checkin_rules/ HTTP/1.1 + Host: pretix.eu + Accept: application/json, text/javascript + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Vary: Accept + Content-Type: application/json + + { + "count": 1, + "next": null, + "previous": null, + "results": [ + { + "id": 1, + "list": 12345, + "mode": "placed", + "all_sales_channels": false, + "limit_sales_channels": ["web"], + "all_products": False, + "limit_products": [2, 3], + "limit_variations": [456], + "all_payment_methods": true, + "limit_payment_methods": [] + } + ] + } + + :query page: The page number in case of a multi-page result set, default is 1 + :param organizer: The ``slug`` field of a valid organizer + :param event: The ``slug`` field of the event to fetch + :statuscode 200: no error + :statuscode 401: Authentication failure + :statuscode 403: The requested organizer does not exist **or** you have no permission to view it. + +.. http:get:: /api/v1/organizers/(organizer)/events/(event)/auto_checkin_rules/(id)/ + + Returns information on one rule, identified by its ID. + + **Example request**: + + .. sourcecode:: http + + GET /api/v1/organizers/bigevents/events/sampleconf/auto_checkin_rules/1/ HTTP/1.1 + Host: pretix.eu + Accept: application/json, text/javascript + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Vary: Accept + Content-Type: application/json + + { + "id": 1, + "list": 12345, + "mode": "placed", + "all_sales_channels": false, + "limit_sales_channels": ["web"], + "all_products": False, + "limit_products": [2, 3], + "limit_variations": [456], + "all_payment_methods": true, + "limit_payment_methods": [] + } + + :param organizer: The ``slug`` field of the organizer to fetch + :param event: The ``slug`` field of the event to fetch + :param id: The ``id`` field of the rule to fetch + :statuscode 200: no error + :statuscode 401: Authentication failure + :statuscode 403: The requested organizer/event/rule does not exist **or** you have no permission to view it. + +.. http:post:: /api/v1/organizers/(organizer)/events/(event)/auto_checkin_rules/ + + Create a new rule. + + **Example request**: + + .. sourcecode:: http + + POST /api/v1/organizers/bigevents/events/sampleconf/auto_checkin_rules/ HTTP/1.1 + Host: pretix.eu + Accept: application/json, text/javascript + Content-Type: application/json + Content-Length: 166 + + { + "list": 12345, + "mode": "placed", + "all_sales_channels": false, + "limit_sales_channels": ["web"], + "all_products": False, + "limit_products": [2, 3], + "limit_variations": [456], + "all_payment_methods": true, + "limit_payment_methods": [] + } + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 201 Created + Vary: Accept + Content-Type: application/json + + { + "id": 1, + "list": 12345, + "mode": "placed", + "all_sales_channels": false, + "limit_sales_channels": ["web"], + "all_products": False, + "limit_products": [2, 3], + "limit_variations": [456], + "all_payment_methods": true, + "limit_payment_methods": [] + } + + :param organizer: The ``slug`` field of the organizer to create a rule for + :param event: The ``slug`` field of the event to create a rule for + :statuscode 201: no error + :statuscode 400: The rule could not be created due to invalid submitted data. + :statuscode 401: Authentication failure + :statuscode 403: The requested organizer/event does not exist **or** you have no permission to create rules. + + +.. http:patch:: /api/v1/organizers/(organizer)/events/(event)/auto_checkin_rules/(id)/ + + Update a rule. You can also use ``PUT`` instead of ``PATCH``. With ``PUT``, you have to provide all fields of + the resource, other fields will be reset to default. With ``PATCH``, you only need to provide the fields that you + want to change. + + **Example request**: + + .. sourcecode:: http + + PATCH /api/v1/organizers/bigevents/events/sampleconf/auto_checkin_rules/1/ HTTP/1.1 + Host: pretix.eu + Accept: application/json, text/javascript + Content-Type: application/json + Content-Length: 34 + + { + "mode": "paid", + } + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Vary: Accept + Content-Type: text/javascript + + { + "id": 1, + "list": 12345, + "mode": "placed", + "all_sales_channels": false, + "limit_sales_channels": ["web"], + "all_products": False, + "limit_products": [2, 3], + "limit_variations": [456], + "all_payment_methods": true, + "limit_payment_methods": [] + } + + :param organizer: The ``slug`` field of the organizer to modify + :param event: The ``slug`` field of the event to modify + :param id: The ``id`` field of the rule to modify + :statuscode 200: no error + :statuscode 400: The rule could not be modified due to invalid submitted data. + :statuscode 401: Authentication failure + :statuscode 403: The requested organizer/event/rule does not exist **or** you have no permission to change it. + + +.. http:delete:: /api/v1/organizers/(organizer)/events/(event)/auto_checkin_rules/(id)/ + + Delete a rule. + + **Example request**: + + .. sourcecode:: http + + DELETE /api/v1/organizers/bigevents/events/sampleconf/auto_checkin_rules/1/ HTTP/1.1 + Host: pretix.eu + Accept: application/json, text/javascript + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 204 No Content + Vary: Accept + + :param organizer: The ``slug`` field of the organizer to modify + :param event: The ``slug`` field of the event to modify + :param id: The ``id`` field of the rule to delete + :statuscode 204: no error + :statuscode 401: Authentication failure + :statuscode 403: The requested organizer/event/rule does not exist **or** you have no permission to change it **or** this rule cannot be deleted since it is currently in use. diff --git a/doc/api/resources/checkinlists.rst b/doc/api/resources/checkinlists.rst index 4f3b57838..a6bd3327b 100644 --- a/doc/api/resources/checkinlists.rst +++ b/doc/api/resources/checkinlists.rst @@ -32,6 +32,7 @@ position_count integer Number of ticke checkin_count integer Number of check-ins performed on this list (read-only). include_pending boolean If ``true``, the check-in list also contains tickets from orders in pending state. auto_checkin_sales_channels list of strings All items on the check-in list will be automatically marked as checked-in when purchased through any of the listed sales channels. + **Deprecated, will be removed in pretix 2024.10.** Use :ref:`rest-autocheckinrules`: instead. allow_multiple_entries boolean If ``true``, subsequent scans of a ticket on this list should not show a warning but instead be stored as an additional check-in. allow_entry_after_exit boolean If ``true``, subsequent scans of a ticket on this list are valid if the last scan of the ticket was an exit scan. rules object Custom check-in logic. The contents of this field are currently not considered a stable API and modifications through the API are highly discouraged. diff --git a/doc/api/resources/index.rst b/doc/api/resources/index.rst index 89130bfaa..160047e83 100644 --- a/doc/api/resources/index.rst +++ b/doc/api/resources/index.rst @@ -44,5 +44,6 @@ at :ref:`plugin-docs`. scheduled_exports shredders sendmail_rules + auto_checkin_rules billing_invoices billing_var \ No newline at end of file diff --git a/doc/api/resources/sendmail_rules.rst b/doc/api/resources/sendmail_rules.rst index 89afe46b9..17d6096e4 100644 --- a/doc/api/resources/sendmail_rules.rst +++ b/doc/api/resources/sendmail_rules.rst @@ -1,6 +1,8 @@ Scheduled email rules ===================== +This feature requires the bundled ``pretix.plugins.sendmail`` plugin to be active for the event in order to work properly. + Resource description -------------------- @@ -48,6 +50,7 @@ send_to string Can be ``"order or ``"both"``. date. Otherwise it is relative to the event start date. ===================================== ========================== ======================================================= + .. versionchanged:: 2023.7 The ``include_pending`` field has been deprecated. diff --git a/src/pretix/_base_settings.py b/src/pretix/_base_settings.py index 37dac95fa..af5cf5bf1 100644 --- a/src/pretix/_base_settings.py +++ b/src/pretix/_base_settings.py @@ -62,6 +62,7 @@ INSTALLED_APPS = [ 'pretix.plugins.badges', 'pretix.plugins.manualpayment', 'pretix.plugins.returnurl', + 'pretix.plugins.autocheckin', 'pretix.plugins.webcheckin', 'django_countries', 'oauth2_provider', diff --git a/src/pretix/base/models/checkin.py b/src/pretix/base/models/checkin.py index f69da4275..3f0f3bea7 100644 --- a/src/pretix/base/models/checkin.py +++ b/src/pretix/base/models/checkin.py @@ -102,9 +102,9 @@ class CheckinList(LoggedModel): auto_checkin_sales_channels = models.ManyToManyField( "SalesChannel", verbose_name=_('Sales channels to automatically check in'), - help_text=_('All items on this check-in list will be automatically marked as checked-in when purchased through ' - 'any of the selected sales channels. This option can be useful when tickets sold at the box office ' - 'are not checked again before entry and should be considered validated directly upon purchase.'), + help_text=_('This option is deprecated and will be removed in the next months. As a replacement, our new plugin ' + '"Auto check-in" can be used. When we remove this option, we will automatically migrate your event ' + 'to use the new plugin.'), blank=True, ) rules = models.JSONField(default=dict, blank=True) diff --git a/src/pretix/base/services/checkin.py b/src/pretix/base/services/checkin.py index b8d94c7df..7028add1f 100644 --- a/src/pretix/base/services/checkin.py +++ b/src/pretix/base/services/checkin.py @@ -1154,7 +1154,7 @@ def perform_checkin(op: OrderPosition, clist: CheckinList, given_answers: dict, ) -@receiver(order_placed, dispatch_uid="autocheckin_order_placed") +@receiver(order_placed, dispatch_uid="legacy_autocheckin_order_placed") def order_placed(sender, **kwargs): order = kwargs['order'] event = sender @@ -1171,7 +1171,7 @@ def order_placed(sender, **kwargs): checkin_created.send(event, checkin=ci) -@receiver(periodic_task, dispatch_uid="autocheckin_exit_all") +@receiver(periodic_task, dispatch_uid="autocheckout_exit_all") @scopes_disabled() def process_exit_all(sender, **kwargs): qs = CheckinList.objects.filter( diff --git a/src/pretix/plugins/autocheckin/__init__.py b/src/pretix/plugins/autocheckin/__init__.py new file mode 100644 index 000000000..9fd5bdc50 --- /dev/null +++ b/src/pretix/plugins/autocheckin/__init__.py @@ -0,0 +1,21 @@ +# +# This file is part of pretix (Community Edition). +# +# Copyright (C) 2014-2020 Raphael Michel and contributors +# Copyright (C) 2020-2021 rami.io GmbH and contributors +# +# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General +# Public License as published by the Free Software Foundation in version 3 of the License. +# +# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are +# applicable granting you additional permissions and placing additional restrictions on your usage of this software. +# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive +# this file, see . +# +# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied +# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +# details. +# +# You should have received a copy of the GNU Affero General Public License along with this program. If not, see +# . +# diff --git a/src/pretix/plugins/autocheckin/api.py b/src/pretix/plugins/autocheckin/api.py new file mode 100644 index 000000000..c20a61546 --- /dev/null +++ b/src/pretix/plugins/autocheckin/api.py @@ -0,0 +1,140 @@ +# +# This file is part of pretix (Community Edition). +# +# Copyright (C) 2014-2020 Raphael Michel and contributors +# Copyright (C) 2020-2021 rami.io GmbH and contributors +# +# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General +# Public License as published by the Free Software Foundation in version 3 of the License. +# +# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are +# applicable granting you additional permissions and placing additional restrictions on your usage of this software. +# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive +# this file, see . +# +# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied +# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +# details. +# +# You should have received a copy of the GNU Affero General Public License along with this program. If not, see +# . +# +from django.core.exceptions import ValidationError +from django_filters.rest_framework import DjangoFilterBackend +from rest_framework import serializers, viewsets + +from pretix.api.pagination import TotalOrderingFilter +from pretix.api.serializers.i18n import I18nAwareModelSerializer +from pretix.base.models import ItemVariation +from pretix.plugins.autocheckin.models import AutoCheckinRule +from pretix.plugins.sendmail.models import Rule + + +class AutoCheckinRuleSerializer(I18nAwareModelSerializer): + + class Meta: + model = AutoCheckinRule + fields = [ + "id", + "list", + "mode", + "all_sales_channels", + "limit_sales_channels", + "all_products", + "limit_products", + "limit_variations", + "all_payment_methods", + "limit_payment_methods", + ] + read_only_fields = ["id"] + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.fields["limit_sales_channels"].child_relation.queryset = self.context[ + "event" + ].organizer.sales_channels.all() + self.fields["limit_products"].child_relation.queryset = self.context[ + "event" + ].items.all() + self.fields["limit_variations"].child_relation.queryset = ( + ItemVariation.objects.filter(item__event=self.context["event"]) + ) + self.fields["limit_payment_methods"] = serializers.MultipleChoiceField( + choices=[ + (f.identifier, f.verbose_name) + for f in self.context["event"].get_payment_providers().values() + ], + required=False, + allow_empty=True, + ) + + def validate(self, data): + data = super().validate(data) + + full_data = ( + self.to_internal_value(self.to_representation(self.instance)) + if self.instance + else {} + ) + full_data.update(data) + + if full_data.get("mode") == AutoCheckinRule.MODE_PLACED and not full_data.get( + "all_payment_methods" + ): + raise ValidationError("all_payment_methods should be used for mode=placed") + + if isinstance(full_data.get("limit_payment_methods"), set): + full_data["limit_payment_methods"] = list( + full_data["limit_payment_methods"] + ) + + return full_data + + def save(self, **kwargs): + return super().save(event=self.context["request"].event) + + +class RuleViewSet(viewsets.ModelViewSet): + queryset = Rule.objects.none() + serializer_class = AutoCheckinRuleSerializer + filter_backends = (DjangoFilterBackend, TotalOrderingFilter) + ordering = ("id",) + ordering_fields = ("id",) + permission = "can_change_event_settings" + + def get_queryset(self): + return AutoCheckinRule.objects.filter(event=self.request.event) + + def get_serializer_context(self): + return { + **super().get_serializer_context(), + "event": self.request.event, + } + + def perform_create(self, serializer): + super().perform_create(serializer) + serializer.instance.log_action( + "pretix.plugins.autocheckin.rule.added", + user=self.request.user, + auth=self.request.auth, + data=self.request.data, + ) + + def perform_update(self, serializer): + super().perform_update(serializer) + serializer.instance.log_action( + "pretix.plugins.autocheckin.rule.changed", + user=self.request.user, + auth=self.request.auth, + data=self.request.data, + ) + + def perform_destroy(self, instance): + instance.log_action( + "pretix.plugins.autocheckin.rule.deleted", + user=self.request.user, + auth=self.request.auth, + data=self.request.data, + ) + super().perform_destroy(instance) diff --git a/src/pretix/plugins/autocheckin/apps.py b/src/pretix/plugins/autocheckin/apps.py new file mode 100644 index 000000000..2960abe4e --- /dev/null +++ b/src/pretix/plugins/autocheckin/apps.py @@ -0,0 +1,43 @@ +# +# This file is part of pretix (Community Edition). +# +# Copyright (C) 2014-2020 Raphael Michel and contributors +# Copyright (C) 2020-2021 rami.io GmbH and contributors +# +# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General +# Public License as published by the Free Software Foundation in version 3 of the License. +# +# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are +# applicable granting you additional permissions and placing additional restrictions on your usage of this software. +# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive +# this file, see . +# +# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied +# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +# details. +# +# You should have received a copy of the GNU Affero General Public License along with this program. If not, see +# . +# +from django.apps import AppConfig +from django.utils.translation import gettext_lazy as _ + +from pretix import __version__ as version + + +class AutoCheckinApp(AppConfig): + name = "pretix.plugins.autocheckin" + verbose_name = _("Automated check-in") + + class PretixPluginMeta: + name = _("Automated check-in") + author = _("the pretix team") + version = version + experimental = False + category = "FEATURE" + description = _( + "Automatically check-in specific tickets after they have been sold." + ) + + def ready(self): + from . import signals # NOQA diff --git a/src/pretix/plugins/autocheckin/forms.py b/src/pretix/plugins/autocheckin/forms.py new file mode 100644 index 000000000..f7ddcb807 --- /dev/null +++ b/src/pretix/plugins/autocheckin/forms.py @@ -0,0 +1,253 @@ +# +# This file is part of pretix (Community Edition). +# +# Copyright (C) 2014-2020 Raphael Michel and contributors +# Copyright (C) 2020-2021 rami.io GmbH and contributors +# +# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General +# Public License as published by the Free Software Foundation in version 3 of the License. +# +# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are +# applicable granting you additional permissions and placing additional restrictions on your usage of this software. +# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive +# this file, see . +# +# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied +# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +# details. +# +# You should have received a copy of the GNU Affero General Public License along with this program. If not, see +# . +# + +# This file is based on an earlier version of pretix which was released under the Apache License 2.0. The full text of +# the Apache License 2.0 can be obtained at . +# +# This file may have since been changed and any changes are released under the terms of AGPLv3 as described above. A +# full history of changes and contributors is available at . +# +# This file contains Apache-licensed contributions copyrighted by: Alexey Kislitsin, Daniel, Flavia Bastos, Sanket +# Dasgupta, Sohalt, pajowu +# +# Unless required by applicable law or agreed to in writing, software distributed under the Apache License 2.0 is +# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations under the License. + +from django import forms +from django.core.exceptions import ValidationError +from django.urls import reverse +from django.utils.html import escape +from django.utils.safestring import mark_safe +from django.utils.translation import gettext_lazy as _ +from django_scopes.forms import ( + SafeModelChoiceField, SafeModelMultipleChoiceField, +) + +from pretix.base.models import ItemVariation +from pretix.control.forms import SalesChannelCheckboxSelectMultiple +from pretix.control.forms.widgets import Select2 +from pretix.plugins.autocheckin.models import AutoCheckinRule + +from pretix.base.services.placeholders import FormPlaceholderMixin # noqa + + +class AutoCheckinRuleForm(forms.ModelForm): + itemvars = forms.MultipleChoiceField( + label=_("Products"), + required=False, + ) + limit_payment_methods = forms.MultipleChoiceField( + label=_("Only including usage of payment providers"), + choices=[], + required=False, + widget=forms.RadioSelect, + ) + + class Meta: + model = AutoCheckinRule + + fields = [ + "list", + "mode", + "all_sales_channels", + "limit_sales_channels", + "all_products", + "all_payment_methods", + ] + field_classes = { + "mode": forms.RadioSelect, + "list": SafeModelChoiceField, + "limit_sales_channels": SafeModelMultipleChoiceField, + } + + def __init__(self, *args, **kwargs): + self.event = kwargs.pop("event") + self.instance = kwargs.get("instance", None) + + initial = kwargs.get("initial", {}) + if self.instance and self.instance.pk and "itemvars" not in initial: + initial["itemvars"] = [ + str(i.pk) for i in self.instance.limit_products.all() + ] + [ + "{}-{}".format(v.item_id, v.pk) + for v in self.instance.limit_variations.all() + ] + if ( + self.instance + and self.instance.pk + and "limit_payment_methods" not in initial + ): + initial["limit_payment_methods"] = self.instance.limit_payment_methods + kwargs["initial"] = initial + + super().__init__(*args, **kwargs) + + self.fields["limit_sales_channels"].queryset = ( + self.event.organizer.sales_channels.all() + ) + self.fields["limit_sales_channels"].widget = SalesChannelCheckboxSelectMultiple( + self.event, + attrs={ + "data-inverse-dependency": "<[name$=all_sales_channels]", + "class": "scrolling-multiple-choice", + }, + choices=self.fields["limit_sales_channels"].widget.choices, + ) + + choices = [] + for item in self.event.items.all(): + if len(item.variations.all()) > 0: + allvars = _("All variations") + choices.append( + ( + "{}".format(item.pk), + ( + f"{item} – {allvars}" + if item.active + else mark_safe( + f'{escape(item)} – {allvars}' + ) + ), + ) + ) + else: + choices.append( + ( + "{}".format(item.pk), + ( + str(item) + if item.active + else mark_safe( + f'{escape(item)}' + ) + ), + ) + ) + for v in item.variations.all(): + choices.append( + ( + "{}-{}".format(item.pk, v.pk), + ( + "{} – {}".format(item, v.value) + if item.active + else mark_safe( + f'{escape(item)} – {escape(v.value)}' + ) + ), + ) + ) + + self.fields["itemvars"].widget = forms.CheckboxSelectMultiple( + attrs={ + "data-inverse-dependency": "<[name$=all_products]", + "class": "scrolling-multiple-choice", + }, + ) + self.fields["itemvars"].choices = choices + + self.fields["list"].queryset = self.event.checkin_lists.all() + self.fields["list"].widget = Select2( + attrs={ + "data-model-select2": "generic", + "data-select2-url": reverse( + "control:event.orders.checkinlists.select2", + kwargs={ + "event": self.event.slug, + "organizer": self.event.organizer.slug, + }, + ), + "data-placeholder": _("Check-in list"), + } + ) + self.fields["list"].widget.choices = self.fields["list"].choices + self.fields["list"].label = _("Check-in list") + + self.fields["list"].widget.choices = self.fields["list"].choices + + self.fields["limit_payment_methods"].choices += [ + (p.identifier, p.verbose_name) + for p in self.event.get_payment_providers().values() + ] + self.fields["limit_payment_methods"].widget = forms.CheckboxSelectMultiple( + attrs={ + "data-inverse-dependency": "<[name$=all_payment_methods]", + "class": "scrolling-multiple-choice", + }, + choices=self.fields["limit_payment_methods"].choices, + ) + + def save(self, *args, **kwargs): + creating = not self.instance.pk + + self.instance.limit_payment_methods = ( + self.cleaned_data.get("limit_payment_methods") or [] + ) + + inst = super().save(*args, **kwargs) + + selected_items = set( + list( + self.event.items.filter( + id__in=[i for i in self.cleaned_data["itemvars"] if "-" not in i] + ) + ) + ) + selected_variations = list( + ItemVariation.objects.filter( + item__event=self.event, + id__in=[ + i.split("-")[1] for i in self.cleaned_data["itemvars"] if "-" in i + ], + ) + ) + + current_items = [] if creating else self.instance.limit_products.all() + current_variations = [] if creating else self.instance.limit_variations.all() + + self.instance.limit_products.remove( + *[i for i in current_items if i not in selected_items] + ) + self.instance.limit_products.add( + *[i for i in selected_items if i not in current_items] + ) + self.instance.limit_variations.remove( + *[i for i in current_variations if i not in selected_variations] + ) + self.instance.limit_variations.add( + *[i for i in selected_variations if i not in current_variations] + ) + return inst + + def clean(self): + d = super().clean() + + if d["mode"] == AutoCheckinRule.MODE_PLACED and not d["all_payment_methods"]: + raise ValidationError( + { + "mode": _( + "When restricting by payment method, the rule should run after the payment was received." + ) + } + ) + + return d diff --git a/src/pretix/plugins/autocheckin/migrations/0001_initial.py b/src/pretix/plugins/autocheckin/migrations/0001_initial.py new file mode 100644 index 000000000..2eba29ebf --- /dev/null +++ b/src/pretix/plugins/autocheckin/migrations/0001_initial.py @@ -0,0 +1,61 @@ +# Generated by Django 4.2.14 on 2024-07-24 08:36 + +import django.db.models.deletion +from django.db import migrations, models + +import pretix.base.models.fields + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ("pretixbase", "0269_order_api_meta"), + ] + + operations = [ + migrations.CreateModel( + name="AutoCheckinRule", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, primary_key=True, serialize=False + ), + ), + ("mode", models.CharField(default="placed", max_length=100)), + ("all_sales_channels", models.BooleanField(default=True)), + ("all_products", models.BooleanField(default=True)), + ("all_payment_methods", models.BooleanField(default=True)), + ( + "limit_payment_methods", + pretix.base.models.fields.MultiStringField(null=True), + ), + ( + "event", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="pretixbase.event", + ), + ), + ("limit_products", models.ManyToManyField(to="pretixbase.item")), + ( + "limit_sales_channels", + models.ManyToManyField(to="pretixbase.saleschannel"), + ), + ( + "limit_variations", + models.ManyToManyField(to="pretixbase.itemvariation"), + ), + ( + "list", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.CASCADE, + to="pretixbase.checkinlist", + ), + ), + ], + ), + ] diff --git a/src/pretix/plugins/autocheckin/migrations/__init__.py b/src/pretix/plugins/autocheckin/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/pretix/plugins/autocheckin/models.py b/src/pretix/plugins/autocheckin/models.py new file mode 100644 index 000000000..6149a5938 --- /dev/null +++ b/src/pretix/plugins/autocheckin/models.py @@ -0,0 +1,85 @@ +# +# This file is part of pretix (Community Edition). +# +# Copyright (C) 2014-2020 Raphael Michel and contributors +# Copyright (C) 2020-2021 rami.io GmbH and contributors +# +# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General +# Public License as published by the Free Software Foundation in version 3 of the License. +# +# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are +# applicable granting you additional permissions and placing additional restrictions on your usage of this software. +# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive +# this file, see . +# +# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied +# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +# details. +# +# You should have received a copy of the GNU Affero General Public License along with this program. If not, see +# . +# + +from django.db import models +from django.utils.translation import gettext_lazy as _ + +from pretix.base.models import ( + CheckinList, Event, Item, ItemVariation, LoggedModel, SalesChannel, +) +from pretix.base.models.fields import MultiStringField + + +class AutoCheckinRule(LoggedModel): + MODE_PLACED = "placed" + MODE_PAID = "paid" + MODE_CHOICES = ( + (MODE_PLACED, _("After order was placed")), + (MODE_PAID, _("After order was paid")), + ) + + event = models.ForeignKey(Event, on_delete=models.CASCADE) + list = models.ForeignKey( + CheckinList, + on_delete=models.CASCADE, + null=True, + blank=True, + verbose_name=_("Check-in list"), + help_text=_( + "If you keep this empty, all lists that match the purchased product will be used." + ), + ) + + mode = models.CharField( + max_length=100, + choices=MODE_CHOICES, + default=MODE_PLACED, + ) + + all_sales_channels = models.BooleanField( + verbose_name=_("All sales channels"), + default=True, + ) + limit_sales_channels = models.ManyToManyField( + SalesChannel, + verbose_name=_("Sales channels"), + blank=True, + ) + + all_products = models.BooleanField( + verbose_name=_("All products and variations"), + default=True, + ) + limit_products = models.ManyToManyField(Item, verbose_name=_("Products"), blank=True) + limit_variations = models.ManyToManyField( + ItemVariation, blank=True, verbose_name=_("Variations") + ) + + all_payment_methods = models.BooleanField( + verbose_name=_("All payment methods"), + default=True, + ) + limit_payment_methods = MultiStringField( + verbose_name=_("Only including usage of payment providers"), + null=True, + blank=True, + ) diff --git a/src/pretix/plugins/autocheckin/signals.py b/src/pretix/plugins/autocheckin/signals.py new file mode 100644 index 000000000..41380c9ce --- /dev/null +++ b/src/pretix/plugins/autocheckin/signals.py @@ -0,0 +1,194 @@ +# +# This file is part of pretix (Community Edition). +# +# Copyright (C) 2014-2020 Raphael Michel and contributors +# Copyright (C) 2020-2021 rami.io GmbH and contributors +# +# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General +# Public License as published by the Free Software Foundation in version 3 of the License. +# +# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are +# applicable granting you additional permissions and placing additional restrictions on your usage of this software. +# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive +# this file, see . +# +# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied +# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +# details. +# +# You should have received a copy of the GNU Affero General Public License along with this program. If not, see +# . +# +import copy + +from django.db.models import Q +from django.dispatch import receiver +from django.urls import reverse +from django.utils.translation import gettext_lazy as _ + +from pretix.base.models import Checkin, OrderPayment +from pretix.base.signals import ( + checkin_created, event_copy_data, item_copy_data, logentry_display, + order_paid, order_placed, +) +from pretix.control.signals import nav_event +from pretix.plugins.autocheckin.models import AutoCheckinRule + + +@receiver(nav_event, dispatch_uid="autocheckin_nav_event") +def nav_event_receiver(sender, request, **kwargs): + url = request.resolver_match + if not request.user.has_event_permission( + request.organizer, request.event, "can_change_event_settings", request=request + ): + return [] + return [ + { + "label": _("Auto check-in"), + "url": reverse( + "plugins:autocheckin:index", + kwargs={ + "event": request.event.slug, + "organizer": request.organizer.slug, + }, + ), + "parent": reverse( + "control:event.orders.checkinlists", + kwargs={ + "event": request.event.slug, + "organizer": request.event.organizer.slug, + }, + ), + "active": url.namespace == "plugins:autocheckin", + } + ] + + +@receiver(signal=logentry_display) +def logentry_display_receiver(sender, logentry, **kwargs): + plains = { + "pretix.plugins.autocheckin.rule.added": _("An auto check-in rule was created"), + "pretix.plugins.autocheckin.rule.changed": _( + "An auto check-in rule was updated" + ), + "pretix.plugins.autocheckin.rule.deleted": _( + "An auto check-in rule was deleted" + ), + } + if logentry.action_type in plains: + return plains[logentry.action_type] + + +@receiver(item_copy_data, dispatch_uid="autocheckin_item_copy") +def item_copy_data_receiver(sender, source, target, **kwargs): + for acr in AutoCheckinRule.objects.filter(limit_products=source): + acr.limit_products.add(target) + + +@receiver(signal=event_copy_data, dispatch_uid="autocheckin_copy_data") +def event_copy_data_receiver( + sender, other, item_map, variation_map, checkin_list_map, **kwargs +): + for acr in other.autocheckinrule_set.all(): + if acr.list and acr.list.subevent: + continue # Impossible to copy + + oldacr = acr + + acr = copy.copy(acr) + acr.pk = None + acr.event = sender + + if acr.list_id: + acr.list = checkin_list_map[acr.list_id] + + acr.save() + + if not acr.all_sales_channels: + acr.limit_sales_channels.set( + sender.organizer.sales_channels.filter( + identifier__in=oldacr.limit_sales_channels.values_list( + "identifier", flat=True + ) + ) + ) + + if not acr.all_products: + acr.limit_products.set([item_map[o.pk] for o in oldacr.limit_products.all()]) + acr.limit_variations.set( + [variation_map[o.pk] for o in oldacr.limit_variations.all()] + ) + + +def perform_auto_checkin(sender, order, mode, payment_methods): + positions = list(order.positions.all()) + payment_q = Q(all_payment_methods=True) + for p in payment_methods: + payment_q = payment_q | Q(limit_payment_methods__contains=p) + + rules = list( + sender.autocheckinrule_set.filter( + Q(all_sales_channels=True) | Q(limit_sales_channels=order.sales_channel_id), + Q(all_products=True) + | Q(limit_products__in=[op.item_id for op in positions]) + | Q(limit_variations__in=[op.variation_id for op in positions]), + payment_q, + mode=mode, + ) + .distinct() + .select_related("list") + ) + + if any(r.list is None for r in rules): + all_lists = sender.checkin_lists.filter( + Q(subevent__isnull=True) + | Q(subevent__in=[op.subevent_id for op in positions]) + ).prefetch_related("limit_products") + else: + all_lists = [] + + for r in rules: + if r.list is not None: + lists = [r.list] + else: + lists = all_lists + + for cl in lists: + for op in positions: + if not cl.all_products and op.item_id not in { + i.pk for i in cl.limit_products.all() + }: + continue + if cl.subevent_id and cl.subevent_id != op.subevent_id: + continue + + ci, created = Checkin.objects.get_or_create( + position=op, + list=cl, + auto_checked_in=True, + type=Checkin.TYPE_ENTRY, + ) + if created: + checkin_created.send(sender, checkin=ci) + + +@receiver(order_placed, dispatch_uid="autocheckin_order_placed") +def order_placed_receiver(sender, order, **kwargs): + mode = AutoCheckinRule.MODE_PLACED + payment_methods = set() + perform_auto_checkin(sender, order, mode, payment_methods) + + +@receiver(order_paid, dispatch_uid="autocheckin_order_paid") +def order_paid_receiver(sender, order, **kwargs): + mode = AutoCheckinRule.MODE_PAID + payment_methods = { + p.provider + for p in order.payments.filter( + state__in=[ + OrderPayment.PAYMENT_STATE_CONFIRMED, + OrderPayment.PAYMENT_STATE_REFUNDED, + ] + ) + } + perform_auto_checkin(sender, order, mode, payment_methods) diff --git a/src/pretix/plugins/autocheckin/templates/pretixplugins/autocheckin/add.html b/src/pretix/plugins/autocheckin/templates/pretixplugins/autocheckin/add.html new file mode 100644 index 000000000..9d058c381 --- /dev/null +++ b/src/pretix/plugins/autocheckin/templates/pretixplugins/autocheckin/add.html @@ -0,0 +1,34 @@ +{% extends "pretixcontrol/event/base.html" %} +{% load i18n %} +{% load bootstrap3 %} +{% block title %}{% trans "Create auto check-in rule" %}{% endblock %} +{% block content %} +

{% trans "Create auto check-in rule" %}

+ {% block inner %} +
+ {% csrf_token %} + {% bootstrap_form_errors form %} + +
+ {% trans "Auto check-in" %} + {% bootstrap_field form.mode layout='control' %} + {% bootstrap_field form.list layout='control' %} +
+
+ {% trans "Conditions" %} + {% bootstrap_field form.all_sales_channels layout='control' %} + {% bootstrap_field form.limit_sales_channels layout='control' %} + {% bootstrap_field form.all_products layout='control' %} + {% bootstrap_field form.itemvars layout='control' %} + {% bootstrap_field form.all_payment_methods layout='control' %} + {% bootstrap_field form.limit_payment_methods layout='control' %} +
+ +
+ +
+
+ {% endblock %} +{% endblock %} diff --git a/src/pretix/plugins/autocheckin/templates/pretixplugins/autocheckin/delete.html b/src/pretix/plugins/autocheckin/templates/pretixplugins/autocheckin/delete.html new file mode 100644 index 000000000..b72ac086e --- /dev/null +++ b/src/pretix/plugins/autocheckin/templates/pretixplugins/autocheckin/delete.html @@ -0,0 +1,19 @@ +{% extends "pretixcontrol/event/base.html" %} +{% load i18n %} +{% load static %} +{% block title %}{% trans "Delete auto-checkin rule" %}{% endblock %} +{% block content %} +

{% trans "Delete auto-checkin rule" %}

+
+ {% csrf_token %} +

{% blocktrans %}Are you sure you want to delete the auto check-in rule?{% endblocktrans %}

+
+ + {% trans "Cancel" %} + + +
+
+{% endblock %} \ No newline at end of file diff --git a/src/pretix/plugins/autocheckin/templates/pretixplugins/autocheckin/edit.html b/src/pretix/plugins/autocheckin/templates/pretixplugins/autocheckin/edit.html new file mode 100644 index 000000000..b7c4121e6 --- /dev/null +++ b/src/pretix/plugins/autocheckin/templates/pretixplugins/autocheckin/edit.html @@ -0,0 +1,34 @@ +{% extends "pretixcontrol/event/base.html" %} +{% load i18n %} +{% load bootstrap3 %} +{% block title %}{% trans "Auto check-in rule" %}{% endblock %} +{% block content %} +

{% trans "Auto check-in rule" %}

+ {% block inner %} +
+ {% csrf_token %} + {% bootstrap_form_errors form %} + +
+ {% trans "Auto check-in" %} + {% bootstrap_field form.mode layout='control' %} + {% bootstrap_field form.list layout='control' %} +
+
+ {% trans "Conditions" %} + {% bootstrap_field form.all_sales_channels layout='control' %} + {% bootstrap_field form.limit_sales_channels layout='control' %} + {% bootstrap_field form.all_products layout='control' %} + {% bootstrap_field form.itemvars layout='control' %} + {% bootstrap_field form.all_payment_methods layout='control' %} + {% bootstrap_field form.limit_payment_methods layout='control' %} +
+ +
+ +
+
+ {% endblock %} +{% endblock %} diff --git a/src/pretix/plugins/autocheckin/templates/pretixplugins/autocheckin/index.html b/src/pretix/plugins/autocheckin/templates/pretixplugins/autocheckin/index.html new file mode 100644 index 000000000..aa1b0bfcf --- /dev/null +++ b/src/pretix/plugins/autocheckin/templates/pretixplugins/autocheckin/index.html @@ -0,0 +1,100 @@ +{% extends "pretixcontrol/event/base.html" %} +{% load i18n %} +{% load money %} +{% load static %} +{% block title %}{% trans "Auto check-in rules" %}{% endblock %} +{% block content %} +

{% trans "Auto check-in rules" %}

+ {% if rules|length == 0 %} +
+

+ {% blocktrans trimmed %} + You haven't created any rules yet. + {% endblocktrans %} +

+ + {% trans "Create a new check-in rule" %} + +
+ {% else %} +

+ {% trans "Create a new check-in rule" %} + +

+
+ + + + + + + + + + + + {% for r in rules %} + + + + + + + + {% endfor %} + +
{% trans "Check-in list" %}{% trans "Sales channels" %}{% trans "Products" %}{% trans "Payment methods" %}
+ {% if r.list %} + {{ r.list }} + {% else %} + {% trans "All" %} + {% endif %} + + {% for c in sales_channels %} + {% if r.all_sales_channels or c in r.limit_sales_channels.all %} + {% if "." in c.icon %} + + {% else %} + + {% endif %} + {% else %} + {% endif %} + {% endfor %} + + {% if r.all_products %} + {% trans "All" %} + {% else %} +
    + {% for i in r.limit_products.all %} +
  • {{ i }}
  • + {% endfor %} + {% for v in r.limit_variations.all %} +
  • {{ v.item }} – {{ v.value }}
  • + {% endfor %} +
+ {% endif %} +
+ {% if r.all_payment_methods %} + {% trans "All" %} + {% else %} +
    + {% for p in r.pprovs %} +
  • {{ p.verbose_name }}
  • + {% endfor %} +
+ {% endif %} +
+ {% if "can_change_event_settings" in request.eventpermset %} + + + + {% endif %} +
+
+ {% endif %} + {% include "pretixcontrol/pagination.html" %} +{% endblock %} diff --git a/src/pretix/plugins/autocheckin/urls.py b/src/pretix/plugins/autocheckin/urls.py new file mode 100644 index 000000000..5cfca6f0b --- /dev/null +++ b/src/pretix/plugins/autocheckin/urls.py @@ -0,0 +1,51 @@ +# +# This file is part of pretix (Community Edition). +# +# Copyright (C) 2014-2020 Raphael Michel and contributors +# Copyright (C) 2020-2021 rami.io GmbH and contributors +# +# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General +# Public License as published by the Free Software Foundation in version 3 of the License. +# +# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are +# applicable granting you additional permissions and placing additional restrictions on your usage of this software. +# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive +# this file, see . +# +# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied +# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +# details. +# +# You should have received a copy of the GNU Affero General Public License along with this program. If not, see +# . +# +from django.urls import re_path + +from pretix.api.urls import event_router + +from . import views +from .api import RuleViewSet + +urlpatterns = [ + re_path( + r"^control/event/(?P[^/]+)/(?P[^/]+)/autocheckin/$", + views.IndexView.as_view(), + name="index", + ), + re_path( + r"^control/event/(?P[^/]+)/(?P[^/]+)/autocheckin/add$", + views.RuleAddView.as_view(), + name="add", + ), + re_path( + r"^control/event/(?P[^/]+)/(?P[^/]+)/autocheckin/(?P\d+)/delete$", + views.RuleDeleteView.as_view(), + name="delete", + ), + re_path( + r"^control/event/(?P[^/]+)/(?P[^/]+)/autocheckin/(?P\d+)/$", + views.RuleEditView.as_view(), + name="edit", + ), +] +event_router.register(r"auto_checkin_rules", RuleViewSet, basename="autocheckinrules") diff --git a/src/pretix/plugins/autocheckin/views.py b/src/pretix/plugins/autocheckin/views.py new file mode 100644 index 000000000..626225d49 --- /dev/null +++ b/src/pretix/plugins/autocheckin/views.py @@ -0,0 +1,210 @@ +# +# This file is part of pretix (Community Edition). +# +# Copyright (C) 2014-2020 Raphael Michel and contributors +# Copyright (C) 2020-2021 rami.io GmbH and contributors +# +# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General +# Public License as published by the Free Software Foundation in version 3 of the License. +# +# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are +# applicable granting you additional permissions and placing additional restrictions on your usage of this software. +# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive +# this file, see . +# +# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied +# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +# details. +# +# You should have received a copy of the GNU Affero General Public License along with this program. If not, see +# . +# +from django.contrib import messages +from django.db import transaction +from django.shortcuts import get_object_or_404, redirect +from django.urls import reverse +from django.utils.functional import cached_property +from django.utils.translation import gettext_lazy as _ +from django.views.generic import DeleteView, ListView + +from pretix.control.permissions import EventPermissionRequiredMixin +from pretix.control.views import CreateView, UpdateView +from pretix.helpers.models import modelcopy +from pretix.plugins.autocheckin.forms import AutoCheckinRuleForm +from pretix.plugins.autocheckin.models import AutoCheckinRule + + +class IndexView(EventPermissionRequiredMixin, ListView): + permission = "can_change_event_settings" + template_name = "pretixplugins/autocheckin/index.html" + paginate_by = 50 + context_object_name = "rules" + + def get_queryset(self): + return ( + self.request.event.autocheckinrule_set.select_related( + "list", + ) + .prefetch_related( + "limit_sales_channels", + "limit_products", + "limit_variations", + "limit_variations__item", + ) + .order_by( + "list__name", + "list_id", + "pk", + ) + ) + + def get_context_data(self, **kwargs): + ctx = super().get_context_data(**kwargs) + ctx["sales_channels"] = self.request.organizer.sales_channels.all() + + pprovs = self.request.event.get_payment_providers() + for r in ctx["rules"]: + r.pprovs = [pprovs[p] for p in r.limit_payment_methods if p in pprovs] + return ctx + + +class RuleAddView(EventPermissionRequiredMixin, CreateView): + template_name = "pretixplugins/autocheckin/add.html" + permission = "can_change_event_settings" + form_class = AutoCheckinRuleForm + model = AutoCheckinRule + + @cached_property + def copy_from(self): + if self.request.GET.get("copy_from") and not getattr(self, "object", None): + try: + return AutoCheckinRule.objects.get( + pk=self.request.GET.get("copy_from"), event=self.request.event + ) + except AutoCheckinRule.DoesNotExist: + pass + + def get_form_kwargs(self): + kwargs = super().get_form_kwargs() + kwargs["event"] = self.request.event + + if self.copy_from: + i = modelcopy(self.copy_from) + i.pk = None + kwargs["instance"] = i + kwargs.setdefault("initial", {}) + kwargs["initial"]["itemvars"] = [ + str(i.pk) for i in self.copy_from.limit_products.all() + ] + [ + "{}-{}".format(v.item_id, v.pk) + for v in self.copy_from.limit_variations.all() + ] + kwargs["initial"][ + "limit_payment_methods" + ] = self.copy_from.limit_payment_methods + kwargs["initial"][ + "limit_sales_channels" + ] = self.copy_from.limit_sales_channels.all() + return kwargs + + def form_invalid(self, form): + messages.error( + self.request, _("We could not save your changes. See below for details.") + ) + return super().form_invalid(form) + + def form_valid(self, form): + self.output = {} + + messages.success(self.request, _("Your rule has been created.")) + + form.instance.event = self.request.event + + with transaction.atomic(): + self.object = form.save() + form.instance.log_action( + "pretix.plugins.autocheckin.rule.added", + user=self.request.user, + data=dict(form.cleaned_data), + ) + + return redirect( + "plugins:autocheckin:edit", + event=self.request.event.slug, + organizer=self.request.event.organizer.slug, + rule=self.object.pk, + ) + + +class RuleEditView(EventPermissionRequiredMixin, UpdateView): + model = AutoCheckinRule + form_class = AutoCheckinRuleForm + template_name = "pretixplugins/autocheckin/edit.html" + permission = "can_change_event_settings" + + def get_object(self, queryset=None) -> AutoCheckinRule: + return get_object_or_404( + AutoCheckinRule.objects.all(), + event=self.request.event, + id=self.kwargs["rule"], + ) + + def get_success_url(self): + return reverse( + "plugins:autocheckin:edit", + kwargs={ + "organizer": self.request.event.organizer.slug, + "event": self.request.event.slug, + "rule": self.object.pk, + }, + ) + + @transaction.atomic() + def form_valid(self, form): + messages.success(self.request, _("Your changes have been saved.")) + form.instance.log_action( + "pretix.plugins.autocheckin.rule.changed", + user=self.request.user, + data=dict(form.cleaned_data), + ) + return super().form_valid(form) + + def form_invalid(self, form): + messages.error( + self.request, _("We could not save your changes. See below for details.") + ) + return super().form_invalid(form) + + +class RuleDeleteView(EventPermissionRequiredMixin, DeleteView): + model = AutoCheckinRule + permission = "can_change_event_settings" + template_name = "pretixplugins/autocheckin/delete.html" + context_object_name = "rule" + + def get_success_url(self): + return reverse( + "plugins:autocheckin:index", + kwargs={ + "organizer": self.request.event.organizer.slug, + "event": self.request.event.slug, + }, + ) + + def get_object(self, queryset=None) -> AutoCheckinRule: + return get_object_or_404( + AutoCheckinRule, event=self.request.event, id=self.kwargs["rule"] + ) + + @transaction.atomic + def form_valid(self, request, *args, **kwargs): + self.object = self.get_object() + success_url = self.get_success_url() + + self.request.event.log_action( + "pretix.plugins.autocheckin.rule.deleted", user=self.request.user, data={} + ) + + self.object.delete() + messages.success(self.request, _("The selected rule has been deleted.")) + return redirect(success_url) diff --git a/src/pretix/settings.py b/src/pretix/settings.py index ae13e9655..81ff644be 100644 --- a/src/pretix/settings.py +++ b/src/pretix/settings.py @@ -212,7 +212,7 @@ if config.getboolean('pretix', 'trust_x_forwarded_proto', fallback=False): SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https') PRETIX_PLUGINS_DEFAULT = config.get('pretix', 'plugins_default', - fallback='pretix.plugins.sendmail,pretix.plugins.statistics,pretix.plugins.checkinlists,pretix.plugins.autocheckin') + fallback='pretix.plugins.sendmail,pretix.plugins.statistics,pretix.plugins.checkinlists') PRETIX_PLUGINS_EXCLUDE = config.get('pretix', 'plugins_exclude', fallback='').split(',') PRETIX_PLUGINS_SHOW_META = config.getboolean('pretix', 'plugins_show_meta', fallback=True) diff --git a/src/tests/plugins/autocheckin/__init__.py b/src/tests/plugins/autocheckin/__init__.py new file mode 100644 index 000000000..9fd5bdc50 --- /dev/null +++ b/src/tests/plugins/autocheckin/__init__.py @@ -0,0 +1,21 @@ +# +# This file is part of pretix (Community Edition). +# +# Copyright (C) 2014-2020 Raphael Michel and contributors +# Copyright (C) 2020-2021 rami.io GmbH and contributors +# +# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General +# Public License as published by the Free Software Foundation in version 3 of the License. +# +# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are +# applicable granting you additional permissions and placing additional restrictions on your usage of this software. +# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive +# this file, see . +# +# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied +# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +# details. +# +# You should have received a copy of the GNU Affero General Public License along with this program. If not, see +# . +# diff --git a/src/tests/plugins/autocheckin/conftest.py b/src/tests/plugins/autocheckin/conftest.py new file mode 100644 index 000000000..a382f1213 --- /dev/null +++ b/src/tests/plugins/autocheckin/conftest.py @@ -0,0 +1,96 @@ +# +# This file is part of pretix (Community Edition). +# +# Copyright (C) 2014-2020 Raphael Michel and contributors +# Copyright (C) 2020-2021 rami.io GmbH and contributors +# +# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General +# Public License as published by the Free Software Foundation in version 3 of the License. +# +# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are +# applicable granting you additional permissions and placing additional restrictions on your usage of this software. +# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive +# this file, see . +# +# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied +# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +# details. +# +# You should have received a copy of the GNU Affero General Public License along with this program. If not, see +# . +# +from datetime import datetime, timezone + +import pytest +from django_scopes import scopes_disabled +from rest_framework.test import APIClient + +from pretix.base.models import Event, Organizer, Team + + +@pytest.fixture +@scopes_disabled() +def organizer(): + return Organizer.objects.create(name="Dummy", slug="dummy") + + +@pytest.fixture +@scopes_disabled() +def event(organizer): + e = Event.objects.create( + organizer=organizer, + name="Dummy", + slug="dummy", + date_from=datetime(2017, 12, 27, 10, 0, 0, tzinfo=timezone.utc), + plugins="pretix.plugins.banktransfer,pretix.plugins.ticketoutputpdf,pretix.plugins.autocheckin", + is_public=True, + ) + e.settings.timezone = "Europe/Berlin" + return e + + +@pytest.fixture +@scopes_disabled() +def item(event): + return event.items.create(name="foo", default_price=3) + + +@pytest.fixture +@scopes_disabled() +def checkin_list(event): + return event.checkin_lists.create(name="foo") + + +@pytest.fixture +@scopes_disabled() +def team(organizer): + return Team.objects.create( + organizer=organizer, + name="Test-Team", + all_events=True, + can_change_teams=True, + can_manage_gift_cards=True, + can_change_items=True, + can_create_events=True, + can_change_event_settings=True, + can_change_vouchers=True, + can_view_vouchers=True, + can_view_orders=True, + can_change_orders=True, + can_manage_customers=True, + can_manage_reusable_media=True, + can_change_organizer_settings=True, + ) + + +@pytest.fixture +def client(): + return APIClient() + + +@pytest.fixture +@scopes_disabled() +def token_client(client, team): + t = team.tokens.create(name="Foo") + client.credentials(HTTP_AUTHORIZATION="Token " + t.token) + return client diff --git a/src/tests/plugins/autocheckin/test_api.py b/src/tests/plugins/autocheckin/test_api.py new file mode 100644 index 000000000..8ec9bb96e --- /dev/null +++ b/src/tests/plugins/autocheckin/test_api.py @@ -0,0 +1,155 @@ +# +# This file is part of pretix (Community Edition). +# +# Copyright (C) 2014-2020 Raphael Michel and contributors +# Copyright (C) 2020-2021 rami.io GmbH and contributors +# +# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General +# Public License as published by the Free Software Foundation in version 3 of the License. +# +# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are +# applicable granting you additional permissions and placing additional restrictions on your usage of this software. +# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive +# this file, see . +# +# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied +# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +# details. +# +# You should have received a copy of the GNU Affero General Public License along with this program. If not, see +# . +# +import copy + +import pytest +from django_scopes import scopes_disabled + + +@pytest.fixture +def acr(event, item): + acr = event.autocheckinrule_set.create( + all_products=False, + ) + acr.limit_products.add(item) + return acr + + +RES_RULE = { + "list": None, + "mode": "placed", + "all_sales_channels": True, + "limit_sales_channels": [], + "all_products": False, + "limit_products": [], + "limit_variations": [], + "all_payment_methods": True, + "limit_payment_methods": set(), +} + + +@pytest.mark.django_db +def test_api_list(event, acr, item, token_client): + res = copy.copy(RES_RULE) + res["id"] = acr.pk + res["limit_products"] = [item.pk] + r = token_client.get( + "/api/v1/organizers/{}/events/{}/auto_checkin_rules/".format( + event.organizer.slug, event.slug + ) + ).data + + assert r["results"] == [res] + + +@pytest.mark.django_db +def test_api_detail(event, acr, item, token_client): + res = copy.copy(RES_RULE) + res["id"] = acr.pk + res["limit_products"] = [item.pk] + r = token_client.get( + "/api/v1/organizers/{}/events/{}/auto_checkin_rules/{}/".format( + event.organizer.slug, event.slug, acr.pk + ) + ).data + assert r == res + + +@pytest.mark.django_db +def test_api_create(event, acr, item, token_client): + resp = token_client.post( + "/api/v1/organizers/{}/events/{}/auto_checkin_rules/".format( + event.slug, event.slug + ), + { + "all_products": False, + "limit_products": [item.pk], + }, + format="json", + ) + assert resp.status_code == 201 + with scopes_disabled(): + acr = event.autocheckinrule_set.get(pk=resp.data["id"]) + assert list(acr.limit_products.all()) == [item] + + +@pytest.mark.django_db +def test_api_create_validate_pprov(event, acr, item, token_client): + resp = token_client.post( + "/api/v1/organizers/{}/events/{}/auto_checkin_rules/".format( + event.slug, event.slug + ), + { + "mode": "placed", + "all_payment_methods": False, + "limit_payment_methods": ["manual"], + }, + format="json", + ) + assert resp.status_code == 400 + assert resp.data == { + "non_field_errors": ["all_payment_methods should be used for mode=placed"] + } + + resp = token_client.post( + "/api/v1/organizers/{}/events/{}/auto_checkin_rules/".format( + event.slug, event.slug + ), + { + "mode": "paid", + "all_payment_methods": False, + "limit_payment_methods": ["unknown"], + }, + format="json", + ) + assert resp.status_code == 400 + assert resp.data == {"limit_payment_methods": ['"unknown" is not a valid choice.']} + + +@pytest.mark.django_db +def test_api_update(event, acr, item, token_client): + resp = token_client.patch( + "/api/v1/organizers/{}/events/{}/auto_checkin_rules/{}/".format( + event.slug, event.slug, acr.pk + ), + { + "mode": "paid", + "all_payment_methods": False, + "limit_payment_methods": ["manual"], + }, + format="json", + ) + assert resp.status_code == 200 + acr.refresh_from_db() + assert acr.all_payment_methods is False + assert acr.limit_payment_methods == ["manual"] + + +@pytest.mark.django_db +def test_api_delete(event, acr, item, token_client): + resp = token_client.delete( + "/api/v1/organizers/{}/events/{}/auto_checkin_rules/{}/".format( + event.slug, event.slug, acr.pk + ), + ) + assert resp.status_code == 204 + assert not event.autocheckinrule_set.exists() diff --git a/src/tests/plugins/autocheckin/test_checkin.py b/src/tests/plugins/autocheckin/test_checkin.py new file mode 100644 index 000000000..032380703 --- /dev/null +++ b/src/tests/plugins/autocheckin/test_checkin.py @@ -0,0 +1,310 @@ +# +# This file is part of pretix (Community Edition). +# +# Copyright (C) 2014-2020 Raphael Michel and contributors +# Copyright (C) 2020-2021 rami.io GmbH and contributors +# +# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General +# Public License as published by the Free Software Foundation in version 3 of the License. +# +# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are +# applicable granting you additional permissions and placing additional restrictions on your usage of this software. +# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive +# this file, see . +# +# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied +# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +# details. +# +# You should have received a copy of the GNU Affero General Public License along with this program. If not, see +# . +# +import copy +from datetime import timedelta + +import pytest +from django.utils.timezone import now +from django_scopes import scopes_disabled +from tests.api.test_order_create import ORDER_CREATE_PAYLOAD + +from pretix.base.models import Order, OrderPayment +from pretix.base.signals import order_paid, order_placed +from pretix.plugins.autocheckin.models import AutoCheckinRule + + +@pytest.fixture +@scopes_disabled() +def order(organizer, event, item): + order = Order.objects.create( + event=event, + status=Order.STATUS_PENDING, + expires=now() + timedelta(days=3), + sales_channel=organizer.sales_channels.get(identifier="web"), + total=4, + ) + order.positions.create(order=order, item=item, price=2) + return order + + +@pytest.mark.django_db +@scopes_disabled() +def test_sales_channel_all(event, item, order, checkin_list): + event.autocheckinrule_set.create( + list=checkin_list, + mode=AutoCheckinRule.MODE_PLACED, + all_sales_channels=True, + ) + order_placed.send(event, order=order) + assert order.positions.first().checkins.exists() + + +@pytest.mark.django_db +@scopes_disabled() +def test_sales_channel_limit(event, item, order, checkin_list): + acr = event.autocheckinrule_set.create( + list=checkin_list, + mode=AutoCheckinRule.MODE_PLACED, + all_sales_channels=False, + ) + + order_placed.send(event, order=order) + assert not order.positions.first().checkins.exists() + + acr.limit_sales_channels.add(order.sales_channel) + + order_placed.send(event, order=order) + assert order.positions.first().checkins.exists() + + +@pytest.mark.django_db +@scopes_disabled() +def test_items_all(event, item, order, checkin_list): + event.autocheckinrule_set.create( + list=checkin_list, + mode=AutoCheckinRule.MODE_PLACED, + all_products=True, + ) + order_placed.send(event, order=order) + assert order.positions.first().checkins.exists() + + +@pytest.mark.django_db +@scopes_disabled() +def test_items_limit(event, item, order, checkin_list): + acr = event.autocheckinrule_set.create( + list=checkin_list, + mode=AutoCheckinRule.MODE_PLACED, + all_products=False, + ) + + order_placed.send(event, order=order) + assert not order.positions.first().checkins.exists() + + acr.limit_products.add(item) + + order_placed.send(event, order=order) + assert order.positions.first().checkins.exists() + + +@pytest.mark.django_db +@scopes_disabled() +def test_variations_limit(event, item, order, checkin_list): + var = item.variations.create(value="V1") + op = order.positions.first() + op.variation = var + op.save() + + acr = event.autocheckinrule_set.create( + list=checkin_list, + mode=AutoCheckinRule.MODE_PLACED, + all_products=False, + ) + + order_placed.send(event, order=order) + assert not order.positions.first().checkins.exists() + + acr.limit_variations.add(var) + + order_placed.send(event, order=order) + assert order.positions.first().checkins.exists() + + order.positions.first().checkins.all().delete() + acr.limit_products.add(item) + acr.limit_variations.clear() + + order_placed.send(event, order=order) + assert order.positions.first().checkins.exists() + + +@pytest.mark.django_db +@scopes_disabled() +def test_mode_placed(event, item, order, checkin_list): + event.autocheckinrule_set.create( + list=checkin_list, + mode=AutoCheckinRule.MODE_PLACED, + ) + + order_paid.send(event, order=order) + assert not order.positions.first().checkins.exists() + + order_placed.send(event, order=order) + assert order.positions.first().checkins.exists() + + +@pytest.mark.django_db +@scopes_disabled() +def test_mode_paid(event, item, order, checkin_list): + event.autocheckinrule_set.create( + list=checkin_list, + mode=AutoCheckinRule.MODE_PAID, + ) + + order_placed.send(event, order=order) + assert not order.positions.first().checkins.exists() + + order_paid.send(event, order=order) + assert order.positions.first().checkins.exists() + + +@pytest.mark.django_db +@scopes_disabled() +def test_payment_provider_limit(event, item, order, checkin_list): + event.autocheckinrule_set.create( + list=checkin_list, + mode=AutoCheckinRule.MODE_PAID, + all_payment_methods=False, + limit_payment_methods=["manual"], + ) + + p = order.payments.create( + amount=order.total, + state=OrderPayment.PAYMENT_STATE_CONFIRMED, + provider="banktransfer", + ) + + order_paid.send(event, order=order) + assert not order.positions.first().checkins.exists() + + p.provider = "manual" + p.save() + order_paid.send(event, order=order) + assert order.positions.first().checkins.exists() + + +@pytest.mark.django_db +@scopes_disabled() +def test_idempodency(event, item, order, checkin_list): + event.autocheckinrule_set.create( + list=checkin_list, + mode=AutoCheckinRule.MODE_PAID, + ) + order_paid.send(event, order=order) + assert order.positions.first().checkins.count() == 1 + order_paid.send(event, order=order) + assert order.positions.first().checkins.count() == 1 + + +@pytest.mark.django_db +@scopes_disabled() +def test_multiple_rules_same_list(event, item, order, checkin_list): + event.autocheckinrule_set.create( + list=checkin_list, + mode=AutoCheckinRule.MODE_PAID, + ) + event.autocheckinrule_set.create( + list=checkin_list, + mode=AutoCheckinRule.MODE_PAID, + ) + order_paid.send(event, order=order) + assert order.positions.first().checkins.count() == 1 + + +@pytest.mark.django_db +@scopes_disabled() +def test_multiple_rules_different_lists(event, item, order, checkin_list): + cl2 = event.checkin_lists.create(name="bar") + event.autocheckinrule_set.create( + list=checkin_list, + mode=AutoCheckinRule.MODE_PAID, + ) + event.autocheckinrule_set.create( + list=cl2, + mode=AutoCheckinRule.MODE_PAID, + ) + order_paid.send(event, order=order) + assert order.positions.first().checkins.count() == 2 + + +@pytest.mark.django_db +@scopes_disabled() +def test_autodetect_lists(event, item, order, checkin_list): + cl2 = event.checkin_lists.create(name="bar", all_products=False) + cl2.limit_products.add(item) + event.checkin_lists.create(name="baz", all_products=False) + + event.autocheckinrule_set.create( + mode=AutoCheckinRule.MODE_PAID, + ) + order_paid.send(event, order=order) + + assert {c.list_id for c in order.positions.first().checkins.all()} == { + checkin_list.pk, + cl2.pk, + } + + +@pytest.mark.django_db +@scopes_disabled() +def test_order_create_via_api_placed( + token_client, organizer, event, item, checkin_list +): + acr = event.autocheckinrule_set.create( + list=checkin_list, + mode=AutoCheckinRule.MODE_PLACED, + all_sales_channels=False, + ) + acr.limit_sales_channels.add(organizer.sales_channels.get(identifier="web")) + + q = event.quotas.create(name="Foo", size=None) + q.items.add(item) + + res = copy.deepcopy(ORDER_CREATE_PAYLOAD) + res["positions"][0]["item"] = item.pk + res["positions"][0]["answers"] = [] + resp = token_client.post( + "/api/v1/organizers/{}/events/{}/orders/".format(organizer.slug, event.slug), + format="json", + data=res, + ) + assert resp.status_code == 201 + + o = Order.objects.get(code=resp.data["code"]) + assert o.positions.first().checkins.first().auto_checked_in + + +@pytest.mark.django_db +@scopes_disabled() +def test_order_create_via_api_paid(token_client, organizer, event, item, checkin_list): + event.autocheckinrule_set.create( + list=checkin_list, + mode=AutoCheckinRule.MODE_PAID, + all_payment_methods=False, + limit_payment_methods=["manual"], + ) + q = event.quotas.create(name="Foo", size=None) + q.items.add(item) + + res = copy.deepcopy(ORDER_CREATE_PAYLOAD) + res["positions"][0]["item"] = item.pk + res["positions"][0]["answers"] = [] + res["status"] = "p" + res["payment_provider"] = "manual" + resp = token_client.post( + "/api/v1/organizers/{}/events/{}/orders/".format(organizer.slug, event.slug), + format="json", + data=res, + ) + assert resp.status_code == 201 + + o = Order.objects.get(code=resp.data["code"]) + assert o.positions.first().checkins.first().auto_checked_in diff --git a/src/tests/plugins/autocheckin/test_control.py b/src/tests/plugins/autocheckin/test_control.py new file mode 100644 index 000000000..04b36e12c --- /dev/null +++ b/src/tests/plugins/autocheckin/test_control.py @@ -0,0 +1,185 @@ +# +# This file is part of pretix (Community Edition). +# +# Copyright (C) 2014-2020 Raphael Michel and contributors +# Copyright (C) 2020-2021 rami.io GmbH and contributors +# +# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General +# Public License as published by the Free Software Foundation in version 3 of the License. +# +# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are +# applicable granting you additional permissions and placing additional restrictions on your usage of this software. +# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive +# this file, see . +# +# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied +# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +# details. +# +# You should have received a copy of the GNU Affero General Public License along with this program. If not, see +# . +# +import datetime + +from django_scopes import scopes_disabled +from tests.base import SoupTest, extract_form_fields + +from pretix.base.models import Event, Item, Organizer, Team, User +from pretix.plugins.autocheckin.models import AutoCheckinRule + + +class AutoCheckinFormTest(SoupTest): + def setUp(self): + super().setUp() + self.user = User.objects.create_user("dummy@dummy.dummy", "dummy") + self.orga1 = Organizer.objects.create(name="CCC", slug="ccc") + self.orga2 = Organizer.objects.create(name="MRM", slug="mrm") + self.event1 = Event.objects.create( + organizer=self.orga1, + name="30C3", + slug="30c3", + plugins="pretix.plugins.autocheckin", + date_from=datetime.datetime(2013, 12, 26, tzinfo=datetime.timezone.utc), + ) + self.item1 = Item.objects.create( + event=self.event1, name="Standard", default_price=0, position=1 + ) + t = Team.objects.create( + organizer=self.orga1, + can_change_event_settings=True, + can_view_orders=True, + can_change_items=True, + all_events=True, + can_create_events=True, + can_change_orders=True, + can_change_vouchers=True, + ) + t.members.add(self.user) + t = Team.objects.create( + organizer=self.orga2, + can_change_event_settings=True, + can_view_orders=True, + can_change_items=True, + all_events=True, + can_create_events=True, + can_change_orders=True, + can_change_vouchers=True, + ) + t.members.add(self.user) + self.client.login(email="dummy@dummy.dummy", password="dummy") + + def test_create(self): + doc = self.get_doc( + "/control/event/%s/%s/autocheckin/add" % (self.orga1.slug, self.event1.slug) + ) + form_data = extract_form_fields(doc.select(".container-fluid form")[0]) + doc = self.post_doc( + "/control/event/%s/%s/autocheckin/add" + % (self.orga1.slug, self.event1.slug), + form_data, + ) + assert doc.select(".alert-success") + assert self.event1.autocheckinrule_set.exists() + + def test_delete(self): + acr = self.event1.autocheckinrule_set.create( + mode=AutoCheckinRule.MODE_PAID, + all_payment_methods=False, + limit_payment_methods=["manual"], + ) + doc = self.get_doc( + "/control/event/%s/%s/autocheckin/%s/delete" + % (self.orga1.slug, self.event1.slug, acr.id) + ) + form_data = extract_form_fields(doc.select(".container-fluid form")[0]) + doc = self.post_doc( + "/control/event/%s/%s/autocheckin/%s/delete" + % (self.orga1.slug, self.event1.slug, acr.id), + form_data, + ) + assert doc.select(".alert-success") + with scopes_disabled(): + assert self.event1.autocheckinrule_set.count() == 0 + + def test_item_copy(self): + with scopes_disabled(): + acr = self.event1.autocheckinrule_set.create( + mode=AutoCheckinRule.MODE_PAID, all_products=False + ) + acr.limit_products.add(self.item1) + + self.client.post( + "/control/event/%s/%s/items/add" % (self.orga1.slug, self.event1.slug), + { + "name_0": "Intermediate", + "default_price": "23.00", + "tax_rate": "19.00", + "copy_from": str(self.item1.pk), + "has_variations": "1", + }, + ) + with scopes_disabled(): + acr.refresh_from_db() + i_new = Item.objects.get(name__icontains="Intermediate") + assert i_new in acr.limit_products.all() + + def test_copy_event(self): + with scopes_disabled(): + acr = self.event1.autocheckinrule_set.create( + list=self.event1.checkin_lists.create(name="Test"), + mode=AutoCheckinRule.MODE_PAID, + all_products=False, + all_sales_channels=False, + ) + acr.limit_products.add(self.item1) + acr.limit_sales_channels.add( + self.orga1.sales_channels.get(identifier="web") + ) + + self.post_doc( + "/control/events/add", + { + "event_wizard-current_step": "foundation", + "event_wizard-prefix": "event_wizard", + "foundation-organizer": self.orga2.pk, + "foundation-locales": ("en",), + }, + ) + self.post_doc( + "/control/events/add", + { + "event_wizard-current_step": "basics", + "event_wizard-prefix": "event_wizard", + "basics-name_0": "33C3", + "basics-slug": "33c3", + "basics-date_from_0": "2016-12-27", + "basics-date_from_1": "10:00:00", + "basics-date_to_0": "2016-12-30", + "basics-date_to_1": "19:00:00", + "basics-location_0": "Hamburg", + "basics-currency": "EUR", + "basics-tax_rate": "19.00", + "basics-locale": "en", + "basics-timezone": "Europe/Berlin", + }, + ) + self.post_doc( + "/control/events/add", + { + "event_wizard-current_step": "copy", + "event_wizard-prefix": "event_wizard", + "copy-copy_from_event": self.event1.pk, + }, + ) + + with scopes_disabled(): + ev = Event.objects.get(slug="33c3") + i_new = ev.items.first() + acr_new = ev.autocheckinrule_set.get() + + assert i_new in acr_new.limit_products.all() + assert list(acr_new.limit_sales_channels.all()) == [ + self.orga2.sales_channels.get(identifier="web") + ] + + assert acr_new.list.event == ev