diff --git a/src/pretix/base/forms/__init__.py b/src/pretix/base/forms/__init__.py index 339b3fe379..48cf00365e 100644 --- a/src/pretix/base/forms/__init__.py +++ b/src/pretix/base/forms/__init__.py @@ -50,7 +50,7 @@ class SettingsForm(forms.Form): def __init__(self, *args, **kwargs): self.obj = kwargs.pop('obj') - kwargs['initial'] = self.obj.settings + kwargs['initial'] = self.obj.settings.freeze() super().__init__(*args, **kwargs) def save(self): diff --git a/src/pretix/base/i18n.py b/src/pretix/base/i18n.py index f4d82e71f9..69df674e5a 100644 --- a/src/pretix/base/i18n.py +++ b/src/pretix/base/i18n.py @@ -3,7 +3,8 @@ import json from django import forms from django.conf import settings -from django.db.models import SubfieldBase, TextField +from django.core.serializers.json import DjangoJSONEncoder +from django.db.models import Model, QuerySet, SubfieldBase, TextField from django.utils import translation from django.utils.safestring import mark_safe from typing import Dict, List @@ -233,3 +234,15 @@ class I18nTextField(I18nFieldMixin, TextField, metaclass=SubfieldBase): Like I18nCharField, but for TextFields. """ widget = I18nTextarea + + +class I18nJSONEncoder(DjangoJSONEncoder): + def default(self, obj): + if isinstance(obj, LazyI18nString): + return obj.data + elif isinstance(obj, QuerySet): + return list(obj) + elif isinstance(obj, Model): + return {'type': obj.__class__.__name__, 'id': obj.id} + else: + return super().default(obj) diff --git a/src/pretix/base/models/auth.py b/src/pretix/base/models/auth.py index aef87c5d66..f58260a565 100644 --- a/src/pretix/base/models/auth.py +++ b/src/pretix/base/models/auth.py @@ -5,6 +5,8 @@ from django.contrib.auth.models import ( from django.db import models from django.utils.translation import ugettext_lazy as _ +from .base import LoggingMixin + class UserManager(BaseUserManager): """ @@ -30,7 +32,7 @@ class UserManager(BaseUserManager): return user -class User(AbstractBaseUser, PermissionsMixin): +class User(AbstractBaseUser, PermissionsMixin, LoggingMixin): """ This is the user model used by pretix for authentication. diff --git a/src/pretix/base/models/base.py b/src/pretix/base/models/base.py index cea57f989a..658ca12b6d 100644 --- a/src/pretix/base/models/base.py +++ b/src/pretix/base/models/base.py @@ -1,3 +1,4 @@ +import json import uuid from django.contrib.contenttypes.fields import GenericRelation @@ -5,6 +6,8 @@ from django.db import models from django.db.models.signals import post_delete from django.dispatch import receiver +from pretix.base.i18n import I18nJSONEncoder + def cachedfile_name(instance, filename: str) -> str: return 'cachedfiles/%012d.%s' % (instance.id, filename.split('.')[-1]) @@ -29,10 +32,18 @@ def cached_file_delete(sender, instance, **kwargs): instance.file.delete(False) -class LoggedModel(models.Model): +class LoggingMixin: logentries = GenericRelation('LogEntry') - def log_action(self, user, action, data): + def log_action(self, action, data=None, user=None): + """ + Create a LogEntry object that is related to this object. + See the LogEntry documentation for details. + + :param action: The namespaced action code + :param data: Any JSON-serializable object + :param user: The user performing the action (optional) + """ from .log import LogEntry from .event import Event @@ -41,7 +52,13 @@ class LoggedModel(models.Model): event = self elif hasattr(self, 'event'): event = self.event - LogEntry.objects.create(content_object=self, user=user, action=action, data=data, event=event) + l = LogEntry(content_object=self, user=user, action_type=action, event=event) + if data: + l.data = json.dumps(data, cls=I18nJSONEncoder) + l.save() + + +class LoggedModel(models.Model, LoggingMixin): class Meta: abstract = True diff --git a/src/pretix/base/models/orders.py b/src/pretix/base/models/orders.py index 18f58bc62b..b42dcd1862 100644 --- a/src/pretix/base/models/orders.py +++ b/src/pretix/base/models/orders.py @@ -2,7 +2,7 @@ import random import string from datetime import datetime -from django.db import models +from django.db import models, transaction from django.utils.timezone import now from django.utils.translation import ugettext_lazy as _ from typing import List, Union @@ -177,14 +177,6 @@ class Order(LoggedModel): return True return False # nothing there to modify - def mark_refunded(self): - """ - Mark this order as refunded. This sets the payment status and returns the order object. - """ - self.status = Order.STATUS_REFUNDED - self.save() - return self - def _can_be_paid(self) -> Union[bool, str]: error_messages = { 'late': _("The payment is too late to be accepted."), diff --git a/src/pretix/base/payment.py b/src/pretix/base/payment.py index d94d6eee71..6bc2e00561 100644 --- a/src/pretix/base/payment.py +++ b/src/pretix/base/payment.py @@ -359,6 +359,7 @@ class BasePaymentProvider: :param order: The order object """ + self.order.log_action('pretix.base.order.refunded') return '
%s
' % _('The money can not be automatically refunded, ' 'please transfer the money back manually.') @@ -379,7 +380,9 @@ class BasePaymentProvider: :param request: The HTTP request :param order: The order object """ - order.mark_refunded() + from pretix.base.services.orders import mark_order_refunded + + mark_order_refunded(order, user=request.user) messages.success(request, _('The order has been marked as refunded. Please transfer the money ' 'back to the buyer manually.')) @@ -440,7 +443,9 @@ class FreeOrderProvider(BasePaymentProvider): :param request: The HTTP request :param order: The order object """ - order.mark_refunded() + from pretix.base.services.orders import mark_order_refunded + + mark_order_refunded(order, user=request.user) messages.success(request, _('The order has been marked as refunded.')) def is_allowed(self, request: HttpRequest) -> bool: diff --git a/src/pretix/base/services/orders.py b/src/pretix/base/services/orders.py index 12abd79cde..33b6ebba9b 100644 --- a/src/pretix/base/services/orders.py +++ b/src/pretix/base/services/orders.py @@ -7,7 +7,7 @@ from django.utils.translation import ugettext_lazy as _ from typing import List from pretix.base.models import ( - CartPosition, Event, EventLock, Order, OrderPosition, Quota, + CartPosition, Event, EventLock, Order, OrderPosition, Quota, User, ) from pretix.base.payment import BasePaymentProvider from pretix.base.services.mail import mail @@ -31,7 +31,7 @@ error_messages = { def mark_order_paid(order: Order, provider: str=None, info: str=None, date: datetime=None, manual: bool=None, - force: bool=False) -> Order: + force: bool=False, user: User=None) -> Order: """ Marks an order as paid. This sets the payment provider, info and date and returns the order object. @@ -46,6 +46,7 @@ def mark_order_paid(order: Order, provider: str=None, info: str=None, date: date :param force: Whether this payment should be marked as paid even if no remaining quota is available (default: ``False``). :type force: boolean + :param user: The user that performed the change :raises Quota.QuotaExceededException: if the quota is exceeded and ``force`` is ``False`` """ with order.event.lock(): @@ -59,6 +60,13 @@ def mark_order_paid(order: Order, provider: str=None, info: str=None, date: date order.payment_manual = manual order.status = Order.STATUS_PAID order.save() + order.log_action('pretix.event.order.paid', { + 'provider': provider, + 'info': info, + 'date': date, + 'manual': manual, + 'force': force + }, user=user) order_paid.send(order.event, order=order) mail( @@ -78,6 +86,32 @@ def mark_order_paid(order: Order, provider: str=None, info: str=None, date: date return order +@transaction.atomic +def mark_order_refunded(order: Order, user: User=None): + """ + Mark this order as refunded. This sets the payment status and returns the order object. + :param order: The order to change + :param user: The user that performed the change + """ + order.status = Order.STATUS_REFUNDED + order.save() + order.log_action('pretix.event.order.refunded', user=user) + return order + + +@transaction.atomic +def cancel_order(order: Order, user: User=None): + """ + Mark this order as canceled + :param order: The order to change + :param user: The user that performed the change + """ + order.status = Order.STATUS_CANCELLED + order.save() + order.log_action('pretix.event.order.cancelled', user=user) + return order + + class OrderError(Exception): pass @@ -154,6 +188,7 @@ def _create_order(event: Event, email: str, positions: List[CartPosition], dt: d payment_provider=payment_provider.identifier ) OrderPosition.transform_cart_positions(positions, order) + order.log_action('pretix.event.order.placed') order_placed.send(event, order=order) return order diff --git a/src/pretix/base/settings.py b/src/pretix/base/settings.py index 83f4299166..63d7722ae5 100644 --- a/src/pretix/base/settings.py +++ b/src/pretix/base/settings.py @@ -121,7 +121,20 @@ class SettingsProxy: def _flush(self) -> None: self._cached_obj = None + def freeze(self): + settings = {} + for key, v in DEFAULTS.items(): + settings[key] = self._unserialize(v['default'], v['type']) + if self._parent: + settings.update(self._parent.settings.freeze()) + for key, value in self._cache().items(): + settings[key] = self.get(key) + return settings + def _unserialize(self, value: str, as_type: type) -> Any: + if as_type is None and value is not None and value.startswith('file://'): + as_type = File + if as_type is not None and isinstance(value, as_type): return value elif value is None: @@ -186,15 +199,14 @@ class SettingsProxy: if value is None and default is not None: value = default - if as_type is None and value is not None and value.startswith('file://'): - as_type = File - return self._unserialize(value, as_type) def __getitem__(self, key: str) -> Any: return self.get(key) def __getattr__(self, key: str) -> Any: + if key.startswith('_'): + return super().__getattr__(key) return self.get(key) def __setattr__(self, key: str, value: Any) -> None: diff --git a/src/pretix/control/views/auth.py b/src/pretix/control/views/auth.py index be74cc989a..27b67e3ccc 100644 --- a/src/pretix/control/views/auth.py +++ b/src/pretix/control/views/auth.py @@ -62,6 +62,7 @@ def register(request): timezone=request.timezone if hasattr(request, 'timezone') else settings.TIME_ZONE ) user = authenticate(email=user.email, password=form.cleaned_data['password']) + user.log_action('pretix.control.auth.user.created', user=user) auth_login(request, user) return redirect('control:index') else: @@ -90,6 +91,7 @@ class Forgot(TemplateView): }, None, locale=user.locale ) + user.log_action('pretix.control.auth.user.forgot_password.mail_sent') messages.success(request, _('We sent you an e-mail containing further instructions.')) return redirect('control:auth.forgot') else: @@ -141,6 +143,7 @@ class Recover(TemplateView): user.set_password(self.form.cleaned_data['password']) user.save() messages.success(request, _('You can now login using your new password.')) + user.log_action('pretix.control.auth.user.forgot_password.recovered') return redirect('control:auth.login') else: return self.get(request, *args, **kwargs) diff --git a/src/pretix/control/views/event.py b/src/pretix/control/views/event.py index bce61f3fe0..c5f82039f8 100644 --- a/src/pretix/control/views/event.py +++ b/src/pretix/control/views/event.py @@ -3,6 +3,7 @@ from collections import OrderedDict from django import forms from django.contrib import messages from django.core.urlresolvers import reverse +from django.db import transaction from django.db.models import Sum from django.forms import modelformset_factory from django.shortcuts import redirect, render @@ -53,8 +54,17 @@ class EventUpdate(EventPermissionRequiredMixin, UpdateView): context['sform'] = self.sform return context + @transaction.atomic() def form_valid(self, form): self.sform.save() + if self.sform.has_changed(): + self.request.event.log_action('pretix.event.settings', user=self.request.user, data={ + k: self.request.event.settings.get(k) for k in self.sform.changed_data + }) + if form.has_changed(): + self.request.event.log_action('pretix.event.changed', user=self.request.user, data={ + k: getattr(self.request.event, k) for k in form.changed_data + }) messages.success(self.request, _('Your changes have been saved.')) return super().form_valid(form) @@ -97,15 +107,20 @@ class EventPlugins(EventPermissionRequiredMixin, TemplateView, SingleObjectMixin def post(self, request, *args, **kwargs): self.object = self.get_object() plugins_active = self.object.get_plugins() - for key, value in request.POST.items(): - if key.startswith("plugin:"): - module = key.split(":")[1] - if value == "enable": - plugins_active.append(module) - else: - plugins_active.remove(module) - self.object.plugins = ",".join(plugins_active) - self.object.save() + with transaction.atomic(): + for key, value in request.POST.items(): + if key.startswith("plugin:"): + module = key.split(":")[1] + if value == "enable": + self.request.event.log_action('pretix.event.plugins.enabled', user=self.request.user, + data={'plugin': module}) + plugins_active.append(module) + else: + self.request.event.log_action('pretix.event.plugins.disabled', user=self.request.user, + data={'plugin': module}) + plugins_active.remove(module) + self.object.plugins = ",".join(plugins_active) + self.object.save() messages.success(self.request, _('Your changes have been saved.')) return redirect(self.get_success_url()) @@ -159,11 +174,18 @@ class PaymentSettings(EventPermissionRequiredMixin, TemplateView, SingleObjectMi context['providers'] = self.provider_forms return self.render_to_response(context) + @transaction.atomic() def post(self, request, *args, **kwargs): self.object = self.get_object() success = True for provider in self.provider_forms: if provider.form.is_valid(): + if provider.form.has_changed(): + self.request.event.log_action( + 'pretix.event.payment.provider.' + provider.identifier, user=self.request.user, data={ + k: provider.form.cleaned_data.get(k) for k in provider.form.changed_data + } + ) provider.form.save() else: success = False @@ -207,16 +229,29 @@ class TicketSettings(EventPermissionRequiredMixin, FormView): form.prepare_fields() return form + @transaction.atomic() def post(self, request, *args, **kwargs): success = True for provider in self.provider_forms: if provider.form.is_valid(): provider.form.save() + if provider.form.has_changed(): + self.request.event.log_action( + 'pretix.event.tickets.provider.' + provider.identifier, user=self.request.user, data={ + k: provider.form.cleaned_data.get(k) for k in provider.form.changed_data + } + ) else: success = False form = self.get_form(self.get_form_class()) if success and form.is_valid(): form.save() + if form.has_changed(): + self.request.event.log_action( + 'pretix.event.tickets.settings', user=self.request.user, data={ + k: form.cleaned_data.get(k) for k in form.changed_data + } + ) messages.success(self.request, _('Your changes have been saved.')) return redirect(self.get_success_url()) else: @@ -310,6 +345,7 @@ class EventPermissions(EventPermissionRequiredMixin, TemplateView): ctx['add_form'] = self.add_form return ctx + @transaction.atomic() def post(self, *args, **kwargs): if self.formset.is_valid() and self.add_form.is_valid(): if self.add_form.has_changed(): @@ -327,7 +363,22 @@ class EventPermissions(EventPermissionRequiredMixin, TemplateView): messages.error(self.request, _('This user already has permissions for this event.')) return self.get(*args, **kwargs) self.add_form.save() + logdata = { + k: v for k, v in self.add_form.cleaned_data.items() + } + logdata['user'] = self.add_form.instance.user_id + self.request.event.log_action( + 'pretix.event.permissions.added', user=self.request.user, data=logdata + ) for form in self.formset.forms: + if form.has_changed(): + changedata = { + k: form.cleaned_data.get(k) for k in form.changed_data + } + changedata['user'] = form.instance.user_id + self.request.event.log_action( + 'pretix.event.permissions.changed', user=self.request.user, data=changedata + ) if form.instance.user_id == self.request.user.pk: if not form.cleaned_data['can_change_permissions'] or form in self.formset.deleted_forms: messages.error(self.request, _('You cannot remove your own permission to view this page.')) diff --git a/src/pretix/control/views/item.py b/src/pretix/control/views/item.py index a83f8dbb52..b2eb09c144 100644 --- a/src/pretix/control/views/item.py +++ b/src/pretix/control/views/item.py @@ -103,12 +103,14 @@ class CategoryDelete(EventPermissionRequiredMixin, DeleteView): except ItemCategory.DoesNotExist: raise Http404(_("The requested product category does not exist.")) + @transaction.atomic() def delete(self, request, *args, **kwargs): self.object = self.get_object() for item in self.object.items.all(): item.category = None item.save() success_url = self.get_success_url() + self.object.log_action('pretix.event.category.deleted', user=self.request.user) self.object.delete() messages.success(request, _('The selected category has been deleted.')) return HttpResponseRedirect(success_url) @@ -136,8 +138,15 @@ class CategoryUpdate(EventPermissionRequiredMixin, UpdateView): except ItemCategory.DoesNotExist: raise Http404(_("The requested product category does not exist.")) + @transaction.atomic() def form_valid(self, form): messages.success(self.request, _('Your changes have been saved.')) + if form.has_changed(): + self.object.log_action( + 'pretix.event.category.changed', user=self.request.user, data={ + k: form.cleaned_data.get(k) for k in form.changed_data + } + ) return super().form_valid(form) def get_success_url(self) -> str: @@ -160,10 +169,13 @@ class CategoryCreate(EventPermissionRequiredMixin, CreateView): 'event': self.request.event.slug, }) + @transaction.atomic() def form_valid(self, form): form.instance.event = self.request.event messages.success(self.request, _('The new category has been created.')) - return super().form_valid(form) + ret = super().form_valid(form) + form.instance.log_action('pretix.event.category.added', data=dict(form.cleaned_data), user=self.request.user) + return ret class CategoryList(ListView): @@ -248,9 +260,11 @@ class QuestionDelete(EventPermissionRequiredMixin, DeleteView): context['dependent'] = list(self.get_object().items.all()) return context + @transaction.atomic() def delete(self, request, *args, **kwargs): self.object = self.get_object() success_url = self.get_success_url() + self.object.log_action(action='pretix.event.question.deleted', user=request.user) self.object.delete() messages.success(request, _('The selected question has been deleted.')) return HttpResponseRedirect(success_url) @@ -277,7 +291,14 @@ class QuestionUpdate(EventPermissionRequiredMixin, UpdateView): except Question.DoesNotExist: raise Http404(_("The requested question does not exist.")) + @transaction.atomic() def form_valid(self, form): + if form.has_changed(): + self.object.log_action( + 'pretix.event.question.changed', user=self.request.user, data={ + k: form.cleaned_data.get(k) for k in form.changed_data + } + ) messages.success(self.request, _('Your changes have been saved.')) return super().form_valid(form) @@ -306,9 +327,12 @@ class QuestionCreate(EventPermissionRequiredMixin, CreateView): 'event': self.request.event.slug, }) + @transaction.atomic() def form_valid(self, form): messages.success(self.request, _('The new question has been created.')) - return super().form_valid(form) + ret = super().form_valid(form) + form.instance.log_action('pretix.event.question.added', user=self.request.user, data=dict(form.cleaned_data)) + return ret class QuotaList(ListView): @@ -378,10 +402,13 @@ class QuotaCreate(EventPermissionRequiredMixin, QuotaEditorMixin, CreateView): 'event': self.request.event.slug, }) + @transaction.atomic() def form_valid(self, form): form.instance.event = self.request.event messages.success(self.request, _('The new quota has been created.')) - return super().form_valid(form) + ret = super().form_valid(form) + form.instance.log_action('pretix.event.quota.added', user=self.request.user, data=dict(form.cleaned_data)) + return ret class QuotaUpdate(EventPermissionRequiredMixin, QuotaEditorMixin, UpdateView): @@ -399,8 +426,15 @@ class QuotaUpdate(EventPermissionRequiredMixin, QuotaEditorMixin, UpdateView): except Quota.DoesNotExist: raise Http404(_("The requested quota does not exist.")) + @transaction.atomic() def form_valid(self, form): messages.success(self.request, _('Your changes have been saved.')) + if form.has_changed(): + self.object.log_action( + 'pretix.event.quota.changed', user=self.request.user, data={ + k: form.cleaned_data.get(k) for k in form.changed_data + } + ) return super().form_valid(form) def get_success_url(self) -> str: @@ -429,9 +463,11 @@ class QuotaDelete(EventPermissionRequiredMixin, DeleteView): context['dependent'] = list(self.get_object().items.all()) return context + @transaction.atomic() def delete(self, request, *args, **kwargs): self.object = self.get_object() success_url = self.get_success_url() + self.object.log_action(action='pretix.event.quota.deleted', user=request.user) self.object.delete() messages.success(self.request, _('The selected quota has been deleted.')) return HttpResponseRedirect(success_url) @@ -471,9 +507,12 @@ class ItemCreate(EventPermissionRequiredMixin, CreateView): 'item': self.object.id, }) + @transaction.atomic() def form_valid(self, form): messages.success(self.request, _('Your changes have been saved.')) - return super().form_valid(form) + ret = super().form_valid(form) + form.instance.log_action('pretix.event.item.added', user=self.request.user, data=dict(form.cleaned_data)) + return ret def get_form_kwargs(self): """ @@ -497,8 +536,15 @@ class ItemUpdateGeneral(ItemDetailMixin, EventPermissionRequiredMixin, UpdateVie 'item': self.get_object().id, }) + @transaction.atomic() def form_valid(self, form): messages.success(self.request, _('Your changes have been saved.')) + if form.has_changed(): + self.object.log_action( + 'pretix.event.item.changed', user=self.request.user, data={ + k: form.cleaned_data.get(k) for k in form.changed_data + } + ) return super().form_valid(form) @@ -543,13 +589,31 @@ class ItemProperties(ItemDetailMixin, EventPermissionRequiredMixin, TemplateView for f in formset: f.instance.event = self.request.event f.instance.item = self.get_object() + is_created = not f.instance.pk f.instance.save() - print(f.instance) + if f.has_changed() and not is_created: + change_data = { + k: f.cleaned_data.get(k) for k in f.changed_data + } + change_data['id'] = f.instance.pk + f.instance.item.log_action( + 'pretix.event.item.property.changed', user=self.request.user, data=change_data + ) + elif is_created: + change_data = dict(f.cleaned_data) + change_data['id'] = f.instance.pk + f.instance.item.log_action( + 'pretix.event.item.property.added', user=self.request.user, data=change_data + ) for n in f.nested: - print(n.deleted_forms, n.ordered_forms, n.extra_forms) for fn in n.deleted_forms: + f.instance.item.log_action( + 'pretix.event.item.property.value.deleted', user=self.request.user, data={ + 'id': fn.instance.pk + } + ) fn.instance.delete() fn.instance.pk = None @@ -557,8 +621,24 @@ class ItemProperties(ItemDetailMixin, EventPermissionRequiredMixin, TemplateView fn.instance.position = i fn.instance.prop = f.instance fn.save() + if f.has_changed(): + change_data = {k: f.cleaned_data.get(k) for k in f.changed_data} + change_data['id'] = f.instance.pk + f.instance.item.log_action( + 'pretix.event.item.property.value.changed', user=self.request.user, data=change_data + ) - n.save_new_objects() + for form in n.extra_forms: + if not form.has_changed(): + continue + if n.can_delete and n._should_delete_form(form): + continue + change_data = dict(f.cleaned_data) + n.save_new(form) + change_data['id'] = form.instance.pk + f.instance.item.log_action( + 'pretix.event.item.property.value.added', user=self.request.user, data=change_data + ) messages.success(self.request, _('Your changes have been saved.')) return redirect(self.get_success_url()) @@ -700,6 +780,13 @@ class ItemVariations(ItemDetailMixin, EventPermissionRequiredMixin, TemplateView for form in self.forms_flat: if form.is_valid() and form.has_changed(): form.save() + change_data = { + k: form.cleaned_data.get(k) for k in form.changed_data + } + change_data['id'] = form.instance.pk + self.object.log_action( + 'pretix.event.item.variation.changed', user=self.request.user, data=change_data + ) if hasattr(form.instance, 'creation') and form.instance.creation: # We need this special 'creation' field set to true in get_form # for newly created items as cleanerversion does already set the @@ -758,9 +845,11 @@ class ItemDelete(EventPermissionRequiredMixin, DeleteView): raise Http404(_("The requested product does not exist.")) return self.object + @transaction.atomic def delete(self, request, *args, **kwargs): success_url = self.get_success_url() if self.is_allowed(): + self.get_object().log_action('pretix.event.item.deleted', user=self.request.user) self.get_object().delete() messages.success(request, _('The selected product has been deleted.')) return HttpResponseRedirect(success_url) @@ -768,6 +857,9 @@ class ItemDelete(EventPermissionRequiredMixin, DeleteView): o = self.get_object() o.active = False o.save() + o.log_action('pretix.event.item.changed', user=self.request.user, data={ + 'active': False + }) messages.success(request, _('The selected product has been deactivated.')) return HttpResponseRedirect(success_url) diff --git a/src/pretix/control/views/orders.py b/src/pretix/control/views/orders.py index d108422906..9d782cc146 100644 --- a/src/pretix/control/views/orders.py +++ b/src/pretix/control/views/orders.py @@ -17,7 +17,7 @@ from pretix.base.models import ( ) from pretix.base.services import tickets from pretix.base.services.export import export -from pretix.base.services.orders import mark_order_paid +from pretix.base.services.orders import cancel_order, mark_order_paid from pretix.base.services.stats import order_overview from pretix.base.signals import ( register_data_exporters, register_payment_providers, @@ -136,8 +136,8 @@ class OrderDetail(OrderView): def keyfunc(pos): if (pos.item.admission and self.request.event.settings.attendee_names_asked) \ or pos.item.questions.all(): - return pos.id, "", "", "" - return "", pos.item_id, pos.variation_id, pos.price + return pos.id, 0, 0, 0 + return 0, pos.item_id, pos.variation_id, pos.price positions = [] for k, g in groupby(sorted(list(cartpos), key=keyfunc), key=keyfunc): @@ -164,19 +164,19 @@ class OrderTransition(OrderView): to = self.request.POST.get('status', '') if self.order.status == 'n' and to == 'p': try: - mark_order_paid(self.order, manual=True) + mark_order_paid(self.order, manual=True, user=self.request.user) except Quota.QuotaExceededException as e: messages.error(self.request, str(e)) else: messages.success(self.request, _('The order has been marked as paid.')) elif self.order.status == 'n' and to == 'c': - self.order.status = Order.STATUS_CANCELLED - self.order.save() + cancel_order(self.order, user=self.request.user) messages.success(self.request, _('The order has been cancelled.')) elif self.order.status == 'p' and to == 'n': self.order.status = Order.STATUS_PENDING self.order.payment_manual = True self.order.save() + self.order.log_action('pretix.base.order.unpaid', user=self.request.user) messages.success(self.request, _('The order has been marked as not paid.')) elif self.order.status == 'p' and to == 'r': ret = self.payment_provider.order_control_refund_perform(self.request, self.order) @@ -247,6 +247,9 @@ class OrderExtend(OrderView): if self.form.is_valid(): if oldvalue > now(): messages.success(self.request, _('The payment term has been changed.')) + self.order.log_action('pretix.order.changed', user=self.request.user, data={ + 'expires': self.order.expires + }) self.form.save() else: try: @@ -254,6 +257,9 @@ class OrderExtend(OrderView): is_available = self.order._is_still_available() if is_available is True: self.form.save() + self.order.log_action('pretix.order.changed', user=self.request.user, data={ + 'expires': self.order.expires + }) messages.success(self.request, _('The payment term has been changed.')) else: messages.error(self.request, is_available) diff --git a/src/pretix/plugins/paypal/payment.py b/src/pretix/plugins/paypal/payment.py index e466188125..805e965e91 100644 --- a/src/pretix/plugins/paypal/payment.py +++ b/src/pretix/plugins/paypal/payment.py @@ -10,7 +10,7 @@ from django.utils.translation import ugettext as __, ugettext_lazy as _ from pretix.base.models import Quota from pretix.base.payment import BasePaymentProvider -from pretix.base.services.orders import mark_order_paid +from pretix.base.services.orders import mark_order_paid, mark_order_refunded from pretix.multidomain.urlreverse import build_absolute_uri logger = logging.getLogger('pretix.plugins.paypal') @@ -218,7 +218,7 @@ class Paypal(BasePaymentProvider): payment_info = None if not payment_info: - order.mark_refunded() + mark_order_refunded(order) messages.warning(request, _('We were unable to transfer the money back automatically. ' 'Please get in touch with the customer and transfer it back manually.')) return @@ -231,11 +231,11 @@ class Paypal(BasePaymentProvider): refund = sale.refund({}) if not refund.success(): - order.mark_refunded() + mark_order_refunded(order, user=request.user) messages.warning(request, _('We were unable to transfer the money back automatically. ' 'Please get in touch with the customer and transfer it back manually.')) else: sale = paypalrestsdk.Payment.find(payment_info['id']) - order = order.mark_refunded() + order = mark_order_refunded(order) order.payment_info = json.dumps(sale.to_dict()) order.save() diff --git a/src/pretix/plugins/sendmail/views.py b/src/pretix/plugins/sendmail/views.py index a33a94ad08..72e5ec282f 100644 --- a/src/pretix/plugins/sendmail/views.py +++ b/src/pretix/plugins/sendmail/views.py @@ -27,12 +27,15 @@ class SenderView(EventPermissionRequiredMixin, FormView): def form_valid(self, form): orders = Order.objects.filter( event=self.request.event, status__in=form.cleaned_data['sendto'] - ).select_related("user") - users = set([o.user for o in orders]) + ) + mails = set([o.email for o in orders]) - for u in users: - mail(u.email, form.cleaned_data['subject'], form.cleaned_data['message'], - None, self.request.event, locale=u.locale) + self.request.event.log_action('pretix.plugins.sendmail.sent', user=self.request.user, data=dict( + form.cleaned_data)) + + for m in mails: + mail(m, form.cleaned_data['subject'], form.cleaned_data['message'], + None, self.request.event, locale=m.locale) messages.success(self.request, _('Your message will be sent to the selected users.')) diff --git a/src/pretix/plugins/stripe/payment.py b/src/pretix/plugins/stripe/payment.py index 6fbe062c46..e16869f0d8 100644 --- a/src/pretix/plugins/stripe/payment.py +++ b/src/pretix/plugins/stripe/payment.py @@ -10,7 +10,7 @@ from django.utils.translation import ugettext_lazy as _ from pretix.base.models import Quota from pretix.base.payment import BasePaymentProvider -from pretix.base.services.orders import mark_order_paid +from pretix.base.services.orders import mark_order_paid, mark_order_refunded from pretix.helpers.urls import build_absolute_uri logger = logging.getLogger('pretix.plugins.stripe') @@ -157,7 +157,7 @@ class Stripe(BasePaymentProvider): payment_info = None if not payment_info: - order.mark_refunded() + mark_order_refunded(order, user=request.user) messages.warning(request, _('We were unable to transfer the money back automatically. ' 'Please get in touch with the customer and transfer it back manually.')) return @@ -173,10 +173,10 @@ class Stripe(BasePaymentProvider): 'support if the problem persists.')) logger.error('Stripe error: %s' % str(err)) except stripe.error.StripeError: - order.mark_refunded() + mark_order_refunded(order) messages.warning(request, _('We were unable to transfer the money back automatically. ' 'Please get in touch with the customer and transfer it back manually.')) else: - order = order.mark_refunded() + order = mark_order_refunded(order) order.payment_info = str(ch) order.save() diff --git a/src/pretix/plugins/stripe/views.py b/src/pretix/plugins/stripe/views.py index 42bfc200c9..bcaf6a9e06 100644 --- a/src/pretix/plugins/stripe/views.py +++ b/src/pretix/plugins/stripe/views.py @@ -7,6 +7,7 @@ from django.views.decorators.csrf import csrf_exempt from django.views.decorators.http import require_POST from pretix.base.models import Event, Order +from pretix.base.services.orders import mark_order_refunded from pretix.plugins.stripe.payment import Stripe logger = logging.getLogger('pretix.plugins.stripe') @@ -42,6 +43,8 @@ def webhook(request): prov = Stripe(event) prov._init_api() + order.log_action('pretix.plugins.stripe.event', data=event_json) + try: charge = stripe.Charge.retrieve(charge['id']) except stripe.error.StripeError as err: @@ -49,6 +52,6 @@ def webhook(request): return HttpResponse('StripeError', status=500) if charge['refunds']['total_count'] > 0 and order.status == Order.STATUS_PAID: - order.mark_refunded() + mark_order_refunded(order) return HttpResponse(status=200) diff --git a/src/pretix/presale/views/order.py b/src/pretix/presale/views/order.py index ed8b796ea3..d0aad2b795 100644 --- a/src/pretix/presale/views/order.py +++ b/src/pretix/presale/views/order.py @@ -10,6 +10,7 @@ from django.utils.translation import ugettext_lazy as _ from django.views.generic import TemplateView, View from pretix.base.models import CachedFile, CachedTicket, Order, OrderPosition +from pretix.base.services.orders import cancel_order from pretix.base.services.tickets import generate from pretix.base.signals import ( register_payment_providers, register_ticket_outputs, @@ -215,6 +216,7 @@ class OrderModify(EventViewMixin, OrderDetailMixin, QuestionsViewMixin, Template messages.error(self.request, _("We had difficulties processing your input. Please review the errors below.")) return self.get(request, *args, **kwargs) + self.order.log_action('pretix.event.order.modified') return redirect(self.get_order_url()) def get(self, request, *args, **kwargs): @@ -251,8 +253,7 @@ class OrderCancel(EventViewMixin, OrderDetailMixin, TemplateView): return super().dispatch(request, *args, **kwargs) def post(self, request, *args, **kwargs): - self.order.status = Order.STATUS_CANCELLED - self.order.save() + cancel_order(self.order) return redirect(self.get_order_url()) def get(self, request, *args, **kwargs): diff --git a/src/tests/base/test_settings.py b/src/tests/base/test_settings.py index 8249559b2e..6f7defeeda 100644 --- a/src/tests/base/test_settings.py +++ b/src/tests/base/test_settings.py @@ -12,7 +12,6 @@ from pretix.base.settings import SettingsSandbox class SettingsTestCase(TestCase): - def setUp(self): settings.DEFAULTS['test_default'] = { 'default': 'def', @@ -149,6 +148,7 @@ class SettingsTestCase(TestCase): def test_serialize_unknown(self): class Type: pass + try: self._test_serialization(Type(), Type) self.assertTrue(False, 'No exception thrown!') @@ -195,3 +195,23 @@ class SettingsTestCase(TestCase): self.assertIsNone(sandbox.bar) self.assertIsNone(sandbox['baz']) + + def test_freeze(self): + olddef = settings.DEFAULTS + settings.DEFAULTS = { + 'test_default': { + 'default': 'def', + 'type': str + } + } + self.event.organizer.settings.set('bar', 'baz') + self.event.organizer.settings.set('foo', 'baz') + self.event.settings.set('foo', 'bar') + try: + self.assertEqual(self.event.settings.freeze(), { + 'test_default': 'def', + 'bar': 'baz', + 'foo': 'bar' + }) + finally: + settings.DEFAULTS = olddef