diff --git a/doc/api/resources/orders.rst b/doc/api/resources/orders.rst index c4ee9713eb..ce672f9814 100644 --- a/doc/api/resources/orders.rst +++ b/doc/api/resources/orders.rst @@ -28,10 +28,6 @@ datetime datetime Time of order c expires datetime The order will expire, if it is still pending by this time payment_date date Date of payment receipt payment_provider string Payment provider used for this order -payment_fee money (string) Payment fee included in this order's total -payment_fee_tax_rate decimal (string) Tax rate applied to the payment fee -payment_fee_tax_value money (string) Tax value included in the payment fee -payment_fee_tax_rule integer The ID of the used tax rule (or ``null``) total money (string) Total value of this order comment string Internal comment on this order checkin_attention boolean If ``True``, the check-in app should show a warning @@ -95,6 +91,11 @@ downloads list of objects List of ticket The field ``checkin_attention`` has been added. +.. versionchanged:: 1.15 + + The attributes ``order.payment_fee``, ``order.payment_fee_tax_rate``, ``order.payment_fee_tax_value`` and + ``order.payment_fee_tax_rule`` have finally been removed. + .. _order-position-resource: Order position resource diff --git a/src/pretix/base/services/checkin.py b/src/pretix/base/services/checkin.py new file mode 100644 index 0000000000..fed025224b --- /dev/null +++ b/src/pretix/base/services/checkin.py @@ -0,0 +1,147 @@ +from django.db import transaction +from django.db.models import Prefetch +from django.utils.timezone import now +from django.utils.translation import ugettext as _ + +from pretix.base.models import ( + Checkin, CheckinList, Order, OrderPosition, Question, QuestionOption, +) + + +class CheckInError(Exception): + def __init__(self, msg, code): + self.msg = msg + self.code = code + super().__init__(msg) + + +class RequiredQuestionsError(Exception): + def __init__(self, msg, code, questions): + self.msg = msg + self.code = code + self.questions = questions + super().__init__(msg) + + +def _save_answers(op, answers, given_answers): + for q, a in given_answers.items(): + if not a: + if q in answers: + answers[q].delete() + else: + continue + if isinstance(a, QuestionOption): + if q in answers: + qa = answers[q] + qa.answer = str(a.answer) + qa.save() + qa.options.clear() + else: + qa = op.answers.create(question=q, answer=str(a.answer)) + qa.options.add(a) + elif isinstance(a, list): + if q in answers: + qa = answers[q] + qa.answer = ", ".join([str(o) for o in a]) + qa.save() + qa.options.clear() + else: + qa = op.answers.create(question=q, answer=", ".join([str(o) for o in a])) + qa.options.add(*a) + else: + if q in answers: + qa = answers[q] + qa.answer = str(a) + qa.save() + else: + op.answers.create(question=q, answer=str(a)) + + +@transaction.atomic +def perform_checkin(op: OrderPosition, clist: CheckinList, given_answers: dict, force=False, + ignore_unpaid=False, nonce=None, datetime=None, questions_supported=True): + """ + Create a checkin for this particular order position and check-in list. Fails with CheckInError if the check in is + not valid at this time. + + :param op: The order position to check in + :param clist: The order position to check in + :param given_answers: A dictionary of questions mapped to validated, given answers + :param force: When set to True, this will succeed even when the position is already checked in or when required + questions are not filled out. + :param ignore_unpaid: When set to True, this will succeed even when the order is unpaid. + :param questions_supported: When set to False, questions are ignored + :param nonce: A random nonce to prevent race conditions. + :param datetime: The datetime of the checkin, defaults to now. + """ + dt = datetime or now() + + # Fetch order position with related objects + op = OrderPosition.objects.select_related( + 'item', 'variation', 'order', 'addon_to' + ).prefetch_related( + 'item__questions', + Prefetch( + 'item__questions', + queryset=Question.objects.filter(ask_during_checkin=True), + to_attr='checkin_questions' + ), + 'answers' + ).get(pk=op.pk) + + answers = {a.question: a for a in op.answers.all()} + require_answers = [] + for q in op.item.checkin_questions: + if q not in given_answers: + require_answers.append(q) + + _save_answers(op, answers, given_answers) + + if not clist.all_products and op.item_id not in [i.pk for i in clist.limit_products.all()]: + raise CheckInError( + _('This order position has an invalid product for this check-in list.'), + 'product' + ) + elif op.order.status != Order.STATUS_PAID and not force and not ( + ignore_unpaid and clist.include_pending and op.order.status == Order.STATUS_PENDING + ): + raise CheckInError( + _('This order is not marked as paid.'), + 'unpaid' + ) + elif require_answers and not force and questions_supported: + raise RequiredQuestionsError( + _('You need to answer questions to complete this check-in.'), + 'incomplete', + require_answers + ) + else: + ci, created = Checkin.objects.get_or_create(position=op, list=clist, defaults={ + 'datetime': dt, + 'nonce': nonce, + }) + + if created or (nonce and nonce == ci.nonce): + if created: + op.order.log_action('pretix.event.checkin', data={ + 'position': op.id, + 'positionid': op.positionid, + 'first': True, + 'forced': op.order.status != Order.STATUS_PAID, + 'datetime': dt, + 'list': clist.pk + }) + else: + if not force: + raise CheckInError( + _('This ticket has already been redeemed.'), + 'already_redeemed', + ) + op.order.log_action('pretix.event.checkin', data={ + 'position': op.id, + 'positionid': op.positionid, + 'first': False, + 'forced': force, + 'datetime': dt, + 'list': clist.pk + }) diff --git a/src/pretix/control/logdisplay.py b/src/pretix/control/logdisplay.py index b5ac30ce86..0f90c4b3a4 100644 --- a/src/pretix/control/logdisplay.py +++ b/src/pretix/control/logdisplay.py @@ -97,6 +97,55 @@ def _display_order_changed(event: Event, logentry: LogEntry): ) +def _display_checkin(event, logentry): + data = logentry.parsed_data + + show_dt = False + if 'datetime' in data: + dt = dateutil.parser.parse(data.get('datetime')) + show_dt = abs((logentry.datetime - dt).total_seconds()) > 60 or 'forced' in data + tz = pytz.timezone(event.settings.timezone) + dt_formatted = date_format(dt.astimezone(tz), "SHORT_DATETIME_FORMAT") + + if 'list' in data: + try: + checkin_list = event.checkin_lists.get(pk=data.get('list')).name + except CheckinList.DoesNotExist: + checkin_list = _("(unknown)") + else: + checkin_list = _("(unknown)") + + if data.get('first'): + if show_dt: + return _('Position #{posid} has been scanned at {datetime} for list "{list}".').format( + posid=data.get('positionid'), + datetime=dt_formatted, + list=checkin_list + ) + else: + return _('Position #{posid} has been scanned for list "{list}".').format( + posid=data.get('positionid'), + list=checkin_list + ) + else: + if data.get('forced'): + return _( + 'A scan for position #{posid} at {datetime} for list "{list}" has been uploaded even though it has ' + 'been scanned already.'.format( + posid=data.get('positionid'), + datetime=dt_formatted, + list=checkin_list + ) + ) + return _( + 'Position #{posid} has been scanned and rejected because it has already been scanned before ' + 'on list "{list}".'.format( + posid=data.get('positionid'), + list=checkin_list + ) + ) + + @receiver(signal=logentry_display, dispatch_uid="pretixcontrol_logentry_display") def pretixcontrol_logentry_display(sender: Event, logentry: LogEntry, **kwargs): plains = { @@ -225,6 +274,9 @@ def pretixcontrol_logentry_display(sender: Event, logentry: LogEntry, **kwargs): if logentry.action_type.startswith('pretix.event.tickets.provider.'): return _('The settings of a ticket output provider have been changed.') + if logentry.action_type == 'pretix.event.checkin': + return _display_checkin(sender, logentry) + if logentry.action_type == 'pretix.control.views.checkin': dt = dateutil.parser.parse(data.get('datetime')) tz = pytz.timezone(sender.settings.timezone) diff --git a/src/pretix/plugins/pretixdroid/signals.py b/src/pretix/plugins/pretixdroid/signals.py index b24a000e6e..3a0f4db6a0 100644 --- a/src/pretix/plugins/pretixdroid/signals.py +++ b/src/pretix/plugins/pretixdroid/signals.py @@ -1,14 +1,10 @@ -import json -import dateutil.parser -import pytz from django.core.urlresolvers import resolve, reverse from django.dispatch import receiver -from django.utils.formats import date_format from django.utils.translation import ugettext_lazy as _ -from pretix.base.models import CheckinList from pretix.base.signals import logentry_display +from pretix.control.logdisplay import _display_checkin from pretix.control.signals import nav_event @@ -35,49 +31,4 @@ def pretixcontrol_logentry_display(sender, logentry, **kwargs): if logentry.action_type != 'pretix.plugins.pretixdroid.scan': return - data = json.loads(logentry.data) - - show_dt = False - if 'datetime' in data: - dt = dateutil.parser.parse(data.get('datetime')) - show_dt = abs((logentry.datetime - dt).total_seconds()) > 60 or 'forced' in data - tz = pytz.timezone(sender.settings.timezone) - dt_formatted = date_format(dt.astimezone(tz), "SHORT_DATETIME_FORMAT") - - if 'list' in data: - try: - checkin_list = sender.checkin_lists.get(pk=data.get('list')).name - except CheckinList.DoesNotExist: - checkin_list = _("(unknown)") - else: - checkin_list = _("(unknown)") - - if data.get('first'): - if show_dt: - return _('Position #{posid} has been scanned at {datetime} for list "{list}".').format( - posid=data.get('positionid'), - datetime=dt_formatted, - list=checkin_list - ) - else: - return _('Position #{posid} has been scanned for list "{list}".').format( - posid=data.get('positionid'), - list=checkin_list - ) - else: - if data.get('forced'): - return _( - 'A scan for position #{posid} at {datetime} for list "{list}" has been uploaded even though it has ' - 'been scanned already.'.format( - posid=data.get('positionid'), - datetime=dt_formatted, - list=checkin_list - ) - ) - return _( - 'Position #{posid} has been scanned and rejected because it has already been scanned before ' - 'on list "{list}".'.format( - posid=data.get('positionid'), - list=checkin_list - ) - ) + return _display_checkin(sender, logentry) diff --git a/src/pretix/plugins/pretixdroid/views.py b/src/pretix/plugins/pretixdroid/views.py index c18ce5916b..e0bbc5da89 100644 --- a/src/pretix/plugins/pretixdroid/views.py +++ b/src/pretix/plugins/pretixdroid/views.py @@ -5,8 +5,7 @@ import urllib.parse import dateutil.parser from django.contrib import messages from django.core.exceptions import ValidationError -from django.db import transaction -from django.db.models import Count, Max, OuterRef, Prefetch, Q, Subquery +from django.db.models import Count, Max, OuterRef, Q, Subquery from django.http import ( HttpResponseForbidden, HttpResponseNotFound, JsonResponse, ) @@ -19,10 +18,11 @@ from django.utils.translation import ugettext_lazy as _ from django.views.decorators.csrf import csrf_exempt from django.views.generic import TemplateView, View -from pretix.base.models import ( - Checkin, Event, Order, OrderPosition, Question, QuestionOption, -) +from pretix.base.models import Checkin, Event, Order, OrderPosition from pretix.base.models.event import SubEvent +from pretix.base.services.checkin import ( + CheckInError, RequiredQuestionsError, perform_checkin, +) from pretix.control.permissions import EventPermissionRequiredMixin from pretix.helpers.urls import build_absolute_uri from pretix.multidomain.urlreverse import ( @@ -155,39 +155,6 @@ class ApiView(View): class ApiRedeemView(ApiView): - def _save_answers(self, op, answers, given_answers): - for q, a in given_answers.items(): - if not a: - if q in answers: - answers[q].delete() - else: - continue - if isinstance(a, QuestionOption): - if q in answers: - qa = answers[q] - qa.answer = str(a.answer) - qa.save() - qa.options.clear() - else: - qa = op.answers.create(question=q, answer=str(a.answer)) - qa.options.add(a) - elif isinstance(a, list): - if q in answers: - qa = answers[q] - qa.answer = ", ".join([str(o) for o in a]) - qa.save() - qa.options.clear() - else: - qa = op.answers.create(question=q, answer=", ".join([str(o) for o in a])) - qa.options.add(*a) - else: - if q in answers: - qa = answers[q] - qa.answer = str(a) - qa.save() - else: - op.answers.create(question=q, answer=str(a)) - def post(self, request, **kwargs): secret = request.POST.get('secret', '!INVALID!') force = request.POST.get('force', 'false') in ('true', 'True') @@ -203,93 +170,43 @@ class ApiRedeemView(ApiView): dt = now() try: - with transaction.atomic(): - created = False - op = OrderPosition.objects.select_related( - 'item', 'variation', 'order', 'addon_to' - ).prefetch_related( - 'item__questions', - Prefetch( - 'item__questions', - queryset=Question.objects.filter(ask_during_checkin=True), - to_attr='checkin_questions' - ), - 'answers' - ).get( - order__event=self.event, secret=secret, subevent=self.subevent - ) - answers = {a.question: a for a in op.answers.all()} - require_answers = [] - given_answers = {} - for q in op.item.checkin_questions: - if 'answer_{}'.format(q.pk) in request.POST: - try: - given_answers[q] = q.clean_answer(request.POST.get('answer_{}'.format(q.pk))) - continue - except ValidationError: - pass - - if q in answers: - continue - - require_answers.append(serialize_question(q)) - - self._save_answers(op, answers, given_answers) - - if not self.config.list.all_products and op.item_id not in [i.pk for i in - self.config.list.limit_products.all()]: - response['status'] = 'error' - response['reason'] = 'product' - elif not self.config.all_items and op.item_id not in [i.pk for i in self.config.items.all()]: - response['status'] = 'error' - response['reason'] = 'product' - elif op.order.status != Order.STATUS_PAID and not force and not ( - ignore_unpaid and self.config.list.include_pending and op.order.status == Order.STATUS_PENDING - ): - response['status'] = 'error' - response['reason'] = 'unpaid' - elif require_answers and not force and request.POST.get('questions_supported'): - response['status'] = 'incomplete' - response['questions'] = require_answers - else: - ci, created = Checkin.objects.get_or_create(position=op, list=self.config.list, defaults={ - 'datetime': dt, - 'nonce': nonce, - }) - - if 'status' not in response: - if created or (nonce and nonce == ci.nonce): - response['status'] = 'ok' - if created: - op.order.log_action('pretix.plugins.pretixdroid.scan', data={ - 'position': op.id, - 'positionid': op.positionid, - 'first': True, - 'forced': op.order.status != Order.STATUS_PAID, - 'datetime': dt, - 'list': self.config.list.pk - }) - else: - if force: - response['status'] = 'ok' - else: - response['status'] = 'error' - response['reason'] = 'already_redeemed' - op.order.log_action('pretix.plugins.pretixdroid.scan', data={ - 'position': op.id, - 'positionid': op.positionid, - 'first': False, - 'forced': force, - 'datetime': dt, - 'list': self.config.list.pk - }) - - response['data'] = serialize_op(op, redeemed=op.order.status == Order.STATUS_PAID or force, - clist=self.config.list) - + op = OrderPosition.objects.get(order__event=self.event, secret=secret, subevent=self.subevent) except OrderPosition.DoesNotExist: response['status'] = 'error' response['reason'] = 'unknown_ticket' + else: + given_answers = {} + for q in op.item.questions.filter(ask_during_checkin=True): + if 'answer_{}'.format(q.pk) in request.POST: + try: + given_answers[q] = q.clean_answer(request.POST.get('answer_{}'.format(q.pk))) + except ValidationError: + pass + + try: + if not self.config.all_items and op.item_id not in [i.pk for i in self.config.items.all()]: + raise CheckInError('', 'product') + perform_checkin( + op=op, + clist=self.config.list, + given_answers=given_answers, + force=force, + ignore_unpaid=ignore_unpaid, + nonce=nonce, + datetime=dt, + questions_supported=bool(request.POST.get('questions_supported')) + ) + except RequiredQuestionsError as e: + response['status'] = 'incomplete' + response['questions'] = [serialize_question(q) for q in e.questions] + except CheckInError as e: + response['status'] = 'error' + response['reason'] = e.code + else: + response['status'] = 'ok' + + response['data'] = serialize_op(op, redeemed=op.order.status == Order.STATUS_PAID or force, + clist=self.config.list) return JsonResponse(response)