diff --git a/src/pretix/base/models.py b/src/pretix/base/models.py deleted file mode 100644 index fcc63e71d2..0000000000 --- a/src/pretix/base/models.py +++ /dev/null @@ -1,1811 +0,0 @@ -import copy -import random -import string -import uuid -from datetime import datetime -from itertools import product - -import six -from django.conf import settings -from django.contrib.auth.models import ( - AbstractBaseUser, BaseUserManager, PermissionsMixin, -) -from django.core.validators import RegexValidator -from django.db import models -from django.db.models import Q, Count -from django.db.models.signals import post_delete -from django.dispatch import receiver -from django.template.defaultfilters import date as _date -from django.utils.functional import cached_property -from django.utils.timezone import now -from django.utils.translation import ugettext_lazy as _ -from versions.models import ( - Versionable as BaseVersionable, VersionedForeignKey, - VersionedManyToManyField, get_utc_now, -) - -from pretix.base.i18n import I18nCharField, I18nTextField -from pretix.base.settings import SettingsProxy - -from .types import VariationDict - - -class Versionable(BaseVersionable): - class Meta: - abstract = True - - def clone_shallow(self, forced_version_date=None): - """ - This behaves like clone(), but misses all the Many2Many-relation-handling. This is - a performance optimization for cases in which we have to handle the Many2Many relations - by hand anyways. - """ - if not self.pk: # NOQA - raise ValueError('Instance must be saved before it can be cloned') - - if self.version_end_date: # NOQA - raise ValueError('This is a historical item and can not be cloned.') - - if forced_version_date: # NOQA - if not self.version_start_date <= forced_version_date <= get_utc_now(): - raise ValueError('The clone date must be between the version start date and now.') - else: - forced_version_date = get_utc_now() - - earlier_version = self - - later_version = copy.copy(earlier_version) - later_version.version_end_date = None - later_version.version_start_date = forced_version_date - - # set earlier_version's ID to a new UUID so the clone (later_version) can - # get the old one -- this allows 'head' to always have the original - # id allowing us to get at all historic foreign key relationships - earlier_version.id = six.u(str(uuid.uuid4())) - earlier_version.version_end_date = forced_version_date - earlier_version.save() - - for field in earlier_version._meta.many_to_many: - earlier_version.clone_relations_shallow(later_version, field.attname, forced_version_date) - - if hasattr(earlier_version._meta, 'many_to_many_related'): - for rel in earlier_version._meta.many_to_many_related: - earlier_version.clone_relations_shallow(later_version, rel.via_field_name, forced_version_date) - - later_version.save() - - return later_version - - def clone_relations_shallow(self, clone, manager_field_name, forced_version_date): - # Source: the original object, where relations are currently pointing to - source = getattr(self, manager_field_name) # returns a VersionedRelatedManager instance - # Destination: the clone, where the cloned relations should point to - source.through.objects.filter(**{source.source_field.attname: clone.id}).update(**{ - source.source_field.attname: self.id, 'version_end_date': forced_version_date - }) - - -class UserManager(BaseUserManager): - """ - This is the user manager for our custom user model. See the User - model documentation to see what's so special about our user model. - """ - - def create_user(self, email, password=None, **kwargs): - user = self.model(email=email, **kwargs) - user.set_password(password) - user.save() - return user - - def create_superuser(self, email, password=None): # NOQA - # Not used in the software but required by Django - if password is None: - raise Exception("You must provide a password") - user = self.model(email=email) - user.is_staff = True - user.is_superuser = True - user.set_password(password) - user.save() - return user - - -class User(AbstractBaseUser, PermissionsMixin): - """ - This is the user model used by pretix for authentication. - - :param email: The user's e-mail address, used for identification. - :type email: str - :param givenname: The user's given name. May be empty or null. - :type givenname: str - :param familyname: The user's given name. May be empty or null. - :type familyname: str - :param givenname: The user's given name. May be empty or null. - :type givenname: str - :param is_active: Whether this user account is activated. - :type is_active: bool - :param is_staff: ``True`` for system operators. - :type is_staff: bool - :param date_joined: The datetime of the user's registration. - :type date_joined: datetime - :param locale: The user's preferred locale code. - :type locale: str - :param timezone: The user's preferred timezone. - :type timezone: str - """ - - USERNAME_FIELD = 'email' - REQUIRED_FIELDS = [] - - email = models.EmailField(unique=True, db_index=True, null=True, blank=True, - verbose_name=_('E-mail')) - givenname = models.CharField(max_length=255, blank=True, null=True, - verbose_name=_('Given name')) - familyname = models.CharField(max_length=255, blank=True, null=True, - verbose_name=_('Family name')) - is_active = models.BooleanField(default=True, - verbose_name=_('Is active')) - is_staff = models.BooleanField(default=False, - verbose_name=_('Is site admin')) - date_joined = models.DateTimeField(auto_now_add=True, - verbose_name=_('Date joined')) - locale = models.CharField(max_length=50, - choices=settings.LANGUAGES, - default=settings.LANGUAGE_CODE, - verbose_name=_('Language')) - timezone = models.CharField(max_length=100, - default=settings.TIME_ZONE, - verbose_name=_('Timezone')) - - objects = UserManager() - - class Meta: - verbose_name = _("User") - verbose_name_plural = _("Users") - - def save(self, *args, **kwargs): - self.email = self.email.lower() - super().save(*args, **kwargs) - - def __str__(self): - return self.email - - def get_short_name(self) -> str: - """ - Returns the first of the following user properties that is found to exist: - - * Given name - * Family name - * E-mail address - """ - if self.givenname: - return self.givenname - elif self.familyname: - return self.familyname - else: - return self.email - - def get_full_name(self) -> str: - """ - Returns the first of the following user properties that is found to exist: - - * A combination of given name and family name, depending on the locale - * Given name - * Family name - * User name - """ - if self.givenname and not self.familyname: - return self.givenname - elif not self.givenname and self.familyname: - return self.familyname - elif self.familyname and self.givenname: - return _('%(family)s, %(given)s') % { - 'family': self.familyname, - 'given': self.givenname - } - else: - return self.email - - -def cachedfile_name(instance, filename): - return 'cachedfiles/%s.%s' % (instance.id, filename.split('.')[-1]) - - -class CachedFile(models.Model): - """ - A cached file (e.g. pre-generated ticket PDF) - """ - id = models.UUIDField(primary_key=True, default=uuid.uuid4) - expires = models.DateTimeField(null=True, blank=True) - date = models.DateTimeField(null=True, blank=True) - filename = models.CharField(max_length=255) - type = models.CharField(max_length=255) - file = models.FileField(null=True, blank=True, upload_to=cachedfile_name) - - -class Organizer(Versionable): - """ - This model represents an entity organizing events, e.g. a company, institution, - charity, person, … - - :param name: The organizer's name - :type name: str - :param slug: A globally unique, short name for this organizer, to be used - in URLs and similar places. - :type slug: str - """ - - name = models.CharField(max_length=200, - verbose_name=_("Name")) - slug = models.SlugField( - max_length=50, db_index=True, - help_text=_( - "Should be short, only contain lowercase letters and numbers, and must be unique among your events. " - "This is being used in addresses and bank transfer references."), - validators=[ - RegexValidator( - regex="^[a-zA-Z0-9.-]+$", - message=_("The slug may only contain letters, numbers, dots and dashes.") - ) - ], - verbose_name=_("Slug"), - ) - permitted = models.ManyToManyField(User, through='OrganizerPermission', - related_name="organizers") - - class Meta: - verbose_name = _("Organizer") - verbose_name_plural = _("Organizers") - ordering = ("name",) - - def __str__(self): - return self.name - - def save(self, *args, **kwargs): - obj = super().save(*args, **kwargs) - self.get_cache().clear() - return obj - - @cached_property - def settings(self) -> SettingsProxy: - """ - Returns an object representing this organizer's settings - """ - return SettingsProxy(self, type=OrganizerSetting) - - def get_cache(self) -> "pretix.base.cache.ObjectRelatedCache": - """ - Returns an :py:class:`ObjectRelatedCache` object. This behaves equivalent to - Django's built-in cache backends, but puts you into an isolated environment for - this organizer, so you don't have to prefix your cache keys. In addition, the cache - is being cleared every time the organizer changes. - """ - from pretix.base.cache import ObjectRelatedCache - - return ObjectRelatedCache(self) - - -class OrganizerPermission(Versionable): - """ - The relation between an Organizer and an User who has permissions to - access an organizer profile. - - :param organizer: The organizer this relation refers to - :type organizer: Organizer - :param user: The user this set of permissions is valid for - :type user: User - :param can_create_events: Whether or not this user can create new events with this - organizer account. - :type can_create_events: bool - """ - - organizer = VersionedForeignKey(Organizer) - user = models.ForeignKey(User, related_name="organizer_perms") - can_create_events = models.BooleanField( - default=True, - verbose_name=_("Can create events"), - ) - - class Meta: - verbose_name = _("Organizer permission") - verbose_name_plural = _("Organizer permissions") - - def __str__(self): - return _("%(name)s on %(object)s") % { - 'name': str(self.user), - 'object': str(self.organizer), - } - - -class Event(Versionable): - """ - This model represents an event. An event is anything you can buy - tickets for. - - :param organizer: The organizer this event belongs to - :type organizer: Organizer - :param name: This events full title - :type name: str - :param slug: A short, alphanumeric, all-lowercase name for use in URLs. The slug has to - be unique among the events of the same organizer. - :type slug: str - :param currency: The currency of all prices and payments of this event - :type currency: str - :param date_from: The datetime this event starts - :type date_from: datetime - :param date_to: The datetime this event ends - :type date_to: datetime - :param presale_start: No tickets will be sold before this date. - :type presale_start: datetime - :param presale_end: No tickets will be sold before this date. - :type presale_end: datetime - :param plugins: A comma-separated list of plugin names that are active for this - event. - :type plugins: str - """ - - organizer = VersionedForeignKey(Organizer, related_name="events", - on_delete=models.PROTECT) - name = I18nCharField( - max_length=200, - verbose_name=_("Name"), - ) - slug = models.SlugField( - max_length=50, db_index=True, - help_text=_( - "Should be short, only contain lowercase letters and numbers, and must be unique among your events. " - "This is being used in addresses and bank transfer references."), - validators=[ - RegexValidator( - regex="^[a-zA-Z0-9.-]+$", - message=_("The slug may only contain letters, numbers, dots and dashes."), - ) - ], - verbose_name=_("Slug"), - ) - permitted = models.ManyToManyField(User, through='EventPermission', - related_name="events", ) - currency = models.CharField(max_length=10, - verbose_name=_("Default currency"), - default=settings.DEFAULT_CURRENCY) - date_from = models.DateTimeField(verbose_name=_("Event start time")) - date_to = models.DateTimeField(null=True, blank=True, - verbose_name=_("Event end time")) - presale_end = models.DateTimeField( - null=True, blank=True, - verbose_name=_("End of presale"), - help_text=_("No products will be sold after this date."), - ) - presale_start = models.DateTimeField( - null=True, blank=True, - verbose_name=_("Start of presale"), - help_text=_("No products will be sold before this date."), - ) - plugins = models.TextField( - null=True, blank=True, - verbose_name=_("Plugins"), - ) - - class Meta: - verbose_name = _("Event") - verbose_name_plural = _("Events") - # unique_together = (("organizer", "slug"),) # TODO: Enforce manually - ordering = ("date_from", "name") - - def __str__(self): - return str(self.name) - - def save(self, *args, **kwargs): - obj = super().save(*args, **kwargs) - self.get_cache().clear() - return obj - - def get_plugins(self) -> "list[str]": - """ - Get the names of the plugins activated for this event as a list. - """ - if self.plugins is None: - return [] - return self.plugins.split(",") - - def get_date_from_display(self) -> str: - """ - Returns a formatted string containing the start date of the event with respect - to the current locale and to the ``show_times`` setting. - """ - return _date( - self.date_from, - "DATETIME_FORMAT" if self.settings.show_times else "DATE_FORMAT" - ) - - def get_date_to_display(self) -> str: - """ - Returns a formatted string containing the start date of the event with respect - to the current locale and to the ``show_times`` setting. Returns an empty string - if ``show_date_to`` is ``False``. - """ - if not self.settings.show_date_to: - return "" - return _date( - self.date_to, - "DATETIME_FORMAT" if self.settings.show_times else "DATE_FORMAT" - ) - - def get_cache(self) -> "pretix.base.cache.ObjectRelatedCache": - """ - Returns an :py:class:`ObjectRelatedCache` object. This behaves equivalent to - Django's built-in cache backends, but puts you into an isolated environment for - this event, so you don't have to prefix your cache keys. In addition, the cache - is being cleared every time the event or one of its related objects change. - """ - from pretix.base.cache import ObjectRelatedCache - - return ObjectRelatedCache(self) - - @cached_property - def settings(self) -> SettingsProxy: - """ - Returns an object representing this event's settings - """ - return SettingsProxy(self, type=EventSetting, parent=self.organizer) - - @property - def presale_has_ended(self): - if self.presale_end and now() > self.presale_end: - return True - return False - - @property - def presale_is_running(self): - if self.presale_start and now() < self.presale_start: - return False - if self.presale_end and now() > self.presale_end: - return False - return True - - def lock(self): - """ - Returns a contextmanager that can be used to lock an event for bookings - """ - from .services import locking - return locking.LockManager(self) - - -class EventPermission(Versionable): - """ - The relation between an Event and an User who has permissions to - access an event. - - :param event: The event this refers to - :type event: Event - :param user: The user these permission set applies to - :type user: User - :param can_change_settings: If ``True``, the user can change all basic settings for this event. - :type can_change_settings: bool - :param can_change_items: If ``True``, the user can change and add items and related objects for this event. - :type can_change_items: bool - :param can_view_orders: If ``True``, the user can inspect details of all orders. - :type can_view_orders: bool - :param can_change_orders: If ``True``, the user can change details of orders - :type can_change_orders: bool - """ - - event = VersionedForeignKey(Event) - user = models.ForeignKey(User, related_name="event_perms") - can_change_settings = models.BooleanField( - default=True, - verbose_name=_("Can change event settings") - ) - can_change_items = models.BooleanField( - default=True, - verbose_name=_("Can change product settings") - ) - can_view_orders = models.BooleanField( - default=True, - verbose_name=_("Can view orders") - ) - can_change_permissions = models.BooleanField( - default=True, - verbose_name=_("Can change permissions") - ) - can_change_orders = models.BooleanField( - default=True, - verbose_name=_("Can change orders") - ) - - class Meta: - verbose_name = _("Event permission") - verbose_name_plural = _("Event permissions") - - def __str__(self): - return _("%(name)s on %(object)s") % { - 'name': str(self.user), - 'object': str(self.event), - } - - -class ItemCategory(Versionable): - """ - Items can be sorted into these categories. - - :param event: The event this belongs to - :type event: Event - :param name: The name of this category - :type name: str - :param position: An integer, used for sorting - :type position: int - """ - event = VersionedForeignKey( - Event, - on_delete=models.CASCADE, - related_name='categories', - ) - name = I18nCharField( - max_length=255, - verbose_name=_("Category name"), - ) - position = models.IntegerField( - default=0 - ) - - class Meta: - verbose_name = _("Product category") - verbose_name_plural = _("Product categories") - ordering = ('position', 'version_birth_date') - - def __str__(self): - return str(self.name) - - def delete(self, *args, **kwargs): - super().delete(*args, **kwargs) - if self.event: - self.event.get_cache().clear() - - def save(self, *args, **kwargs): - super().save(*args, **kwargs) - if self.event: - self.event.get_cache().clear() - - @property - def sortkey(self): - return self.position, self.version_birth_date - - def __lt__(self, other): - return self.sortkey < other.sortkey - - -def itempicture_upload_to(instance, filename): - return '%s/%s/item-%s.%s' % ( - instance.event.organizer.slug, instance.event.slug, instance.identity, - filename.split('.')[-1] - ) - - -class Item(Versionable): - """ - An item is a thing which can be sold. It belongs to an event and may or may not belong to a category. - Items are often also called 'products' but are named 'items' internally due to historic reasons. - - It has a default price which might by overriden by restrictions. - - :param event: The event this belongs to. - :type event: Event - :param category: The category this belongs to. May be null. - :type category: ItemCategory - :param name: The name of this item: - :type name: str - :param active: Whether this item is being sold - :type active: bool - :param description: A short description - :type description: str - :param default_price: The item's default price - :type default_price: decimal.Decimal - :param tax_rate: The VAT tax that is included in this item's price (in %) - :type tax_rate: decimal.Decimal - :param admission: ``True``, if this item allows persons to enter the event (as opposed to e.g. merchandise) - :type admission: bool - :param picture: A product picture to be shown next to the product description. - :type picture: File - - """ - - event = VersionedForeignKey( - Event, - on_delete=models.PROTECT, - related_name="items", - verbose_name=_("Event"), - ) - category = VersionedForeignKey( - ItemCategory, - on_delete=models.PROTECT, - related_name="items", - blank=True, null=True, - verbose_name=_("Category"), - ) - name = I18nCharField( - max_length=255, - verbose_name=_("Item name"), - ) - active = models.BooleanField( - default=True, - verbose_name=_("Active"), - ) - description = I18nTextField( - verbose_name=_("Description"), - help_text=_("This is shown below the product name in lists."), - null=True, blank=True, - ) - default_price = models.DecimalField( - verbose_name=_("Default price"), - max_digits=7, decimal_places=2, null=True - ) - tax_rate = models.DecimalField( - null=True, blank=True, - verbose_name=_("Taxes included in percent"), - max_digits=7, decimal_places=2 - ) - admission = models.BooleanField( - verbose_name=_("Is an admission ticket"), - help_text=_( - 'Whether or not buying this product allows a person to enter ' - 'your event' - ), - default=False - ) - position = models.IntegerField( - default=0 - ) - picture = models.ImageField( - verbose_name=_("Product picture"), - null=True, blank=True, - upload_to=itempicture_upload_to - ) - - class Meta: - verbose_name = _("Product") - verbose_name_plural = _("Products") - ordering = ("category__position", "category", "position") - - def __str__(self): - return str(self.name) - - def save(self, *args, **kwargs): - super().save(*args, **kwargs) - if self.event: - self.event.get_cache().clear() - - def delete(self, *args, **kwargs): - super().delete(*args, **kwargs) - if self.event: - self.event.get_cache().clear() - - def get_all_variations(self, use_cache: bool=False) -> "list[VariationDict]": - """ - This method returns a list containing all variations of this - item. The list contains one VariationDict per variation, where - the Proprty IDs are keys and the PropertyValue objects are - values. If an ItemVariation object exists, it is available in - the dictionary via the special key 'variation'. - - VariationDicts differ from dicts only by specifying some extra - methods. - - :param use_cache: If this parameter is set to ``True``, a second call to this method - on the same model instance won't query the database again but return - the previous result again. - :type use_cache: bool - """ - if use_cache and hasattr(self, '_get_all_variations_cache'): - return self._get_all_variations_cache - - all_variations = self.variations.all().prefetch_related("values") - all_properties = self.properties.all().prefetch_related("values") - variations_cache = {} - for var in all_variations: - key = [] - for v in var.values.all(): - key.append((v.prop_id, v.identity)) - key = tuple(sorted(key)) - variations_cache[key] = var - - result = [] - for comb in product(*[prop.values.all() for prop in all_properties]): - if len(comb) == 0: - result.append(VariationDict()) - continue - key = [] - var = VariationDict() - for v in comb: - key.append((v.prop.identity, v.identity)) - var[v.prop.identity] = v - key = tuple(sorted(key)) - if key in variations_cache: - var['variation'] = variations_cache[key] - result.append(var) - - self._get_all_variations_cache = result - return result - - def _get_all_generated_variations(self): - propids = set([p.identity for p in self.properties.all()]) - if len(propids) == 0: - variations = [VariationDict()] - else: - all_variations = list( - self.variations.annotate( - qc=Count('quotas') - ).filter(qc__gt=0).prefetch_related( - "values", "values__prop", "quotas__event" - ) - ) - variations = [] - for var in all_variations: - values = list(var.values.all()) - # Make sure we don't expose stale ItemVariation objects which are - # still around altough they have an old set of properties - if set([v.prop.identity for v in values]) != propids: - continue - vardict = VariationDict() - for v in values: - vardict[v.prop.identity] = v - vardict['variation'] = var - variations.append(vardict) - return variations - - def get_all_available_variations(self, use_cache: bool=False): - """ - This method returns a list of all variations which are theoretically - possible for sale. It DOES call all activated restriction plugins, and it - DOES only return variations which DO have an ItemVariation object, as all - variations without one CAN NOT be part of a Quota and therefore can never - be available for sale. The only exception is the empty variation - for items without properties, which never has an ItemVariation object. - - This DOES NOT take into account quotas itself. Use ``is_available`` on the - ItemVariation objects (or the Item it self, if it does not have variations) to - determine availability by the terms of quotas. - - It is recommended to call:: - - .prefetch_related('properties', 'variations__values__prop') - - when retrieving Item objects you are going to use this method on. - """ - if use_cache and hasattr(self, '_get_all_available_variations_cache'): - return self._get_all_available_variations_cache - - from .signals import determine_availability - - variations = self._get_all_generated_variations() - responses = determine_availability.send( - self.event, item=self, - variations=variations, context=None, - cache=self.event.get_cache() - ) - - for i, var in enumerate(variations): - var['available'] = var['variation'].active if 'variation' in var else True - if 'variation' in var: - if var['variation'].default_price: - var['price'] = var['variation'].default_price - else: - var['price'] = self.default_price - else: - var['price'] = self.default_price - - # It is possible, that *multiple* restriction plugins change the default price. - # In this case, the cheapest one wins. As soon as there is a restriction - # that changes the price, the default price has no effect. - - newprice = None - for rec, response in responses: - if 'available' in response[i] and not response[i]['available']: - var['available'] = False - break - if 'price' in response[i] and response[i]['price'] is not None \ - and (newprice is None or response[i]['price'] < newprice): - newprice = response[i]['price'] - var['price'] = newprice or var['price'] - - variations = [var for var in variations if var['available']] - - self._get_all_available_variations_cache = variations - return variations - - def check_quotas(self): - """ - This method is used to determine whether this Item is currently available - for sale. - - :returns: any of the return codes of :py:meth:`Quota.availability()`. - - :raises ValueError: if you call this on an item which has properties associated with it. - Please use the method on the ItemVariation object you are interested in. - """ - if self.properties.count() > 0: # NOQA - raise ValueError('Do not call this directly on items which have properties ' - 'but call this on their ItemVariation objects') - return min([q.availability() for q in self.quotas.all()]) - - def check_restrictions(self): - """ - This method is used to determine whether this ItemVariation is restricted - in sale by any restriction plugins. - - :returns: - - * ``False``, if the item is unavailable - * the item's price, otherwise - - :raises ValueError: if you call this on an item which has properties associated with it. - Please use the method on the ItemVariation object you are interested in. - """ - if self.properties.count() > 0: # NOQA - raise ValueError('Do not call this directly on items which have properties ' - 'but call this on their ItemVariation objects') - from .signals import determine_availability - - vd = VariationDict() - responses = determine_availability.send( - self.event, item=self, - variations=[vd], context=None, - cache=self.event.get_cache() - ) - price = self.default_price - for rec, response in responses: - if 'available' in response[0] and not response[0]['available']: - return False - elif 'price' in response[0] and response[0]['price'] is not None and response[0]['price'] < price: - price = response[0]['price'] - return price - - -class Property(Versionable): - """ - A property is a modifier which can be applied to an Item. For example - 'Size' would be a property associated with the item 'T-Shirt'. - - :param event: The event this belongs to - :type event: Event - :param name: The name of this property. - :type name: str - """ - - event = VersionedForeignKey( - Event, - related_name="properties" - ) - item = VersionedForeignKey( - Item, related_name='properties', null=True, blank=True - ) - name = I18nCharField( - max_length=250, - verbose_name=_("Property name") - ) - - class Meta: - verbose_name = _("Product property") - verbose_name_plural = _("Product properties") - - def __str__(self): - return str(self.name) - - def delete(self, *args, **kwargs): - super().delete(*args, **kwargs) - if self.event: - self.event.get_cache().clear() - - def save(self, *args, **kwargs): - super().save(*args, **kwargs) - if self.event: - self.event.get_cache().clear() - - -class PropertyValue(Versionable): - """ - A value of a property. If the property would be 'T-Shirt size', - this could be 'M' or 'L'. - - :param prop: The property this value is a valid option for. - :type prop: Property - :param value: The value, as a human-readable string - :type value: str - :param position: An integer, used for sorting - :type position: int - """ - - prop = VersionedForeignKey( - Property, - on_delete=models.CASCADE, - related_name="values" - ) - value = I18nCharField( - max_length=250, - verbose_name=_("Value"), - ) - position = models.IntegerField( - default=0 - ) - - class Meta: - verbose_name = _("Property value") - verbose_name_plural = _("Property values") - ordering = ("position", "version_birth_date") - - def __str__(self): - return "%s: %s" % (self.prop.name, self.value) - - def delete(self, *args, **kwargs): - super().delete(*args, **kwargs) - if self.prop: - self.prop.event.get_cache().clear() - - def save(self, *args, **kwargs): - super().save(*args, **kwargs) - if self.prop: - self.prop.event.get_cache().clear() - - @property - def sortkey(self): - return self.position, self.version_birth_date - - def __lt__(self, other): - return self.sortkey < other.sortkey - - -class ItemVariation(Versionable): - """ - A variation is an item combined with values for all properties - associated with the item. For example, if your item is 'T-Shirt' - and your properties are 'Size' and 'Color', then an example for an - variation would be 'T-Shirt XL read'. - - Attention: _ALL_ combinations of PropertyValues _ALWAYS_ exist, - even if there is no ItemVariation object for them! ItemVariation objects - do NOT prove existance, they are only available to make it possible - to override default values (like the price) for certain combinations - of property values. However, appropriate ItemVariation objects will be - created as soon as you add your variations to a quota. - - They also allow to explicitly EXCLUDE certain combinations of property - values by creating an ItemVariation object for them with active set to - False. - - Restrictions can be not only set to items but also directly to variations. - - :param item: The item this variation belongs to - :type item: Item - :param values: A set of ``PropertyValue`` objects defining this variation - :param active: Whether this value is to be sold. - :type active: bool - :param default_price: This variation's default price - :type default_price: decimal.Decimal - """ - item = VersionedForeignKey( - Item, - related_name='variations' - ) - values = VersionedManyToManyField( - PropertyValue, - related_name='variations', - ) - active = models.BooleanField( - default=True, - verbose_name=_("Active"), - ) - default_price = models.DecimalField( - decimal_places=2, max_digits=7, - null=True, blank=True, - verbose_name=_("Default price"), - ) - - class Meta: - verbose_name = _("Product variation") - verbose_name_plural = _("Product variations") - - def __str__(self): - return str(self.to_variation_dict()) - - def delete(self, *args, **kwargs): - super().delete(*args, **kwargs) - if self.item: - self.item.event.get_cache().clear() - - def save(self, *args, **kwargs): - super().save(*args, **kwargs) - if self.item: - self.item.event.get_cache().clear() - - def check_quotas(self): - """ - This method is used to determine whether this ItemVariation is currently - available for sale in terms of quotas. - - :returns: any of the return codes of :py:meth:`Quota.availability()`. - """ - return min([q.availability() for q in self.quotas.all()]) - - def to_variation_dict(self): - """ - :return: a :py:class:`VariationDict` representing this variation. - """ - vd = VariationDict() - for v in self.values.all(): - vd[v.prop.identity] = v - vd['variation'] = self - return vd - - def check_restrictions(self): - """ - This method is used to determine whether this ItemVariation is restricted - in sale by any restriction plugins. - - :returns: - - * ``False``, if the item is unavailable - * the item's price, otherwise - """ - from .signals import determine_availability - - responses = determine_availability.send( - self.item.event, item=self.item, - variations=[self.to_variation_dict()], context=None, - cache=self.item.event.get_cache() - ) - price = self.default_price if self.default_price is not None else self.item.default_price - for rec, response in responses: - if 'available' in response[0] and not response[0]['available']: - return False - elif 'price' in response[0] and response[0]['price'] is not None and response[0]['price'] < price: - price = response[0]['price'] - return price - - def add_values_from_string(self, pk): - """ - Add values to this ItemVariation using a serialized string of the form - ``property-id:value-id,ṗroperty-id:value-id`` - """ - for pair in pk.split(","): - prop, value = pair.split(":") - self.values.add( - PropertyValue.objects.current.get( - identity=value, - prop_id=prop - ) - ) - - -class VariationsField(VersionedManyToManyField): - """ - This is a ManyToManyField using the pretixcontrol.views.forms.VariationsField - form field by default. - """ - - def formfield(self, **kwargs): - from pretix.control.forms import VariationsField as FVariationsField - from django.db.models.fields.related import RelatedField - - defaults = { - 'form_class': FVariationsField, - # We don't need a queryset - 'queryset': ItemVariation.objects.none(), - } - defaults.update(kwargs) - # If initial is passed in, it's a list of related objects, but the - # MultipleChoiceField takes a list of IDs. - if defaults.get('initial') is not None: - initial = defaults['initial'] - if callable(initial): - initial = initial() - defaults['initial'] = [i.identity for i in initial] - # Skip ManyToManyField in dependency chain - return super(RelatedField, self).formfield(**defaults) - - -class Question(Versionable): - """ - A question is an input field that can be used to extend a ticket - by custom information, e.g. "Attendee age". A question can allow one o several - input types, currently: - - * a number (``TYPE_NUMBER``) - * a one-line string (``TYPE_STRING``) - * a multi-line string (``TYPE_TEXT``) - * a boolean (``TYPE_BOOLEAN``) - - :param event: The event this question belongs to - :type event: Event - :param question: The question text. This will be displayed next to the input field. - :type question: str - :param type: One of the above types - :param required: Whether answering this question is required for submiting an order including - items associated with this question. - :type required: bool - :param items: A set of ``Items`` objects that this question should be applied to - """ - TYPE_NUMBER = "N" - TYPE_STRING = "S" - TYPE_TEXT = "T" - TYPE_BOOLEAN = "B" - TYPE_CHOICES = ( - (TYPE_NUMBER, _("Number")), - (TYPE_STRING, _("Text (one line)")), - (TYPE_TEXT, _("Multiline text")), - (TYPE_BOOLEAN, _("Yes/No")), - ) - - event = VersionedForeignKey( - Event, - related_name="questions" - ) - question = I18nTextField( - verbose_name=_("Question") - ) - type = models.CharField( - max_length=5, - choices=TYPE_CHOICES, - verbose_name=_("Question type") - ) - required = models.BooleanField( - default=False, - verbose_name=_("Required question") - ) - items = VersionedManyToManyField( - Item, - related_name='questions', - verbose_name=_("Products"), - blank=True, - help_text=_('This question will be asked to buyers of the selected products') - ) - - class Meta: - verbose_name = _("Question") - verbose_name_plural = _("Questions") - - def __str__(self): - return str(self.question) - - def delete(self, *args, **kwargs): - super().delete(*args, **kwargs) - if self.event: - self.event.get_cache().clear() - - def save(self, *args, **kwargs): - super().save(*args, **kwargs) - if self.event: - self.event.get_cache().clear() - - -class BaseRestriction(Versionable): - """ - A restriction is the abstract concept of a rule that limits the availability - of Items or ItemVariations. This model is just an abstract base class to be - extended by restriction plugins. - """ - event = VersionedForeignKey( - Event, - on_delete=models.CASCADE, - related_name="restrictions_%(app_label)s_%(class)s", - verbose_name=_("Event"), - ) - item = VersionedForeignKey( - Item, - blank=True, null=True, - verbose_name=_("Item"), - related_name="restrictions_%(app_label)s_%(class)s", - ) - variations = VariationsField( - 'pretixbase.ItemVariation', - blank=True, - verbose_name=_("Variations"), - related_name="restrictions_%(app_label)s_%(class)s", - ) - - class Meta: - abstract = True - verbose_name = _("Restriction") - verbose_name_plural = _("Restrictions") - - def delete(self, *args, **kwargs): - super().delete(*args, **kwargs) - if self.event: - self.event.get_cache().clear() - - def save(self, *args, **kwargs): - super().save(*args, **kwargs) - if self.event: - self.event.get_cache().clear() - - -class Quota(Versionable): - """ - A quota is a "pool of tickets". It is there to limit the number of items - of a certain type to be sold. For example, you could have a quota of 500 - applied to all your items (because you only have that much space in your - building), and also a quota of 100 applied to the VIP tickets for - exclusivity. In this case, no more than 500 tickets will be sold in total - and no more than 100 of them will be VIP tickets (but 450 normal and 50 - VIP tickets will be fine). - - As always, a quota can not only be tied to an item, but also to specific - variations. - - Please read the documentation section on quotas carefully before doing - anything with quotas. This might confuse you otherwise. - http://docs.pretix.eu/en/latest/development/concepts.html#restriction-by-number - - The AVAILABILITY_* constants represent various states of an quota allowing - its items/variations being for sale. - - AVAILABILITY_OK - This item is available for sale. - - AVAILABILITY_RESERVED - This item is currently not available for sale, because all available - items are in people's shopping carts. It might become available - again if those people do not proceed with checkout. - - AVAILABILITY_ORDERED - This item is currently not availalbe for sale, because all available - items are ordered. It might become available again if those people - do not pay. - - AVAILABILITY_GONE - This item is completely sold out. - - :param event: The event this belongs to - :type event: Event - :param name: This quota's name - :type str: - :param size: The number of items in this quota - :type size: int - :param items: The set of :py:class:`Item` objects this quota applies to - :param variations: The set of :py:class:`ItemVariation` objects this quota applies to - """ - - AVAILABILITY_GONE = 0 - AVAILABILITY_ORDERED = 10 - AVAILABILITY_RESERVED = 20 - AVAILABILITY_OK = 100 - - event = VersionedForeignKey( - Event, - on_delete=models.CASCADE, - related_name="quotas", - verbose_name=_("Event"), - ) - name = models.CharField( - max_length=200, - verbose_name=_("Name") - ) - size = models.PositiveIntegerField( - verbose_name=_("Total capacity") - ) - items = VersionedManyToManyField( - Item, - verbose_name=_("Item"), - related_name="quotas", - blank=True - ) - variations = VariationsField( - ItemVariation, - related_name="quotas", - blank=True, - verbose_name=_("Variations") - ) - - class Meta: - verbose_name = _("Quota") - verbose_name_plural = _("Quotas") - - def __str__(self): - return self.name - - def delete(self, *args, **kwargs): - super().delete(*args, **kwargs) - if self.event: - self.event.get_cache().clear() - - def save(self, *args, **kwargs): - super().save(*args, **kwargs) - if self.event: - self.event.get_cache().clear() - - def availability(self): - """ - This method is used to determine whether Items or ItemVariations belonging - to this quota should currently be available for sale. - - :returns: a tuple where the first entry is one of the ``Quota.AVAILABILITY_`` constants - and the second is the number of available tickets. - """ - # TODO: These lookups are highly inefficient. However, we'll wait with optimizing - # until Django 1.8 is released, as the following feature might make it a - # lot easier: - # https://docs.djangoproject.com/en/1.8/ref/models/conditional-expressions/ - # TODO: Test for interference with old versions of Item-Quota-relations, etc. - # TODO: Prevent corner-cases like people having ordered an item before it got - # its first variationsadded - quotalookup = ( - ( # Orders for items which do not have any variations - Q(variation__isnull=True) - & Q(item__quotas__in=[self]) - ) | ( # Orders for items which do have any variations - Q(variation__quotas__in=[self]) - ) - ) - - paid_orders = OrderPosition.objects.current.filter( - Q(order__status=Order.STATUS_PAID) - & quotalookup - ).count() - - if paid_orders >= self.size: - return Quota.AVAILABILITY_GONE, 0 - - pending_valid_orders = OrderPosition.objects.current.filter( - Q(order__status=Order.STATUS_PENDING) - & Q(order__expires__gte=now()) - & quotalookup - ).count() - if (paid_orders + pending_valid_orders) >= self.size: - return Quota.AVAILABILITY_ORDERED, 0 - - valid_cart_positions = CartPosition.objects.current.filter( - Q(expires__gte=now()) - & quotalookup - ).count() - if (paid_orders + pending_valid_orders + valid_cart_positions) >= self.size: - return Quota.AVAILABILITY_RESERVED, 0 - - return Quota.AVAILABILITY_OK, self.size - paid_orders - pending_valid_orders - valid_cart_positions - - class QuotaExceededException(Exception): - pass - - -def generate_secret(): - return ''.join(random.choice(string.ascii_letters + string.digits) for _ in range(16)) - - -class Order(Versionable): - """ - An order is created when a user clicks 'buy' on his cart. It holds - several OrderPositions and is connected to an user. It has an - expiration date: If items run out of capacity, orders which are over - their expiration date might be cancelled. - - An order -- like all objects -- has an ID, which is globally unique, - but also a code, which is shorter and easier to memorize, but only - unique among a single conference. - - :param code: In addition to the ID, which is globally unique, every - order has an order code, which is shorter and easier to - memorize, but is only unique among a single conference. - :param status: The status of this order. One of: - - * ``STATUS_PENDING`` - * ``STATUS_PAID`` - * ``STATUS_EXPIRED`` - * ``STATUS_CANCELLED`` - * ``STATUS_REFUNDED`` - - :param event: The event this belongs to - :type event: Event - :param email: The email of the person who ordered this - :type email: str - :param locale: The locale of this order - :type locale: str - :param datetime: The datetime of the order placement - :type datetime: datetime - :param expires: The date until this order has to be paid to guarantee the - :type expires: datetime - :param payment_date: The date of the payment completion (null, if not yet paid). - :type payment_date: datetime - :param payment_provider: The payment provider selected by the user - :type payment_provider: str - :param payment_fee: The payment fee calculated at checkout time - :type payment_fee: decimal.Decimal - :param payment_info: Arbitrary information stored by the payment provider - :type payment_info: str - :param total: The total amount of the order, including the payment fee - :type total: decimal.Decimal - """ - - STATUS_PENDING = "n" - STATUS_PAID = "p" - STATUS_EXPIRED = "e" - STATUS_CANCELLED = "c" - STATUS_REFUNDED = "r" - STATUS_CHOICE = ( - (STATUS_PENDING, _("pending")), - (STATUS_PAID, _("paid")), - (STATUS_EXPIRED, _("expired")), - (STATUS_CANCELLED, _("cancelled")), - (STATUS_REFUNDED, _("refunded")) - ) - - code = models.CharField( - max_length=16, - verbose_name=_("Order code") - ) - status = models.CharField( - max_length=3, - choices=STATUS_CHOICE, - verbose_name=_("Status") - ) - event = VersionedForeignKey( - Event, - verbose_name=_("Event"), - related_name="orders" - ) - email = models.EmailField( - null=True, blank=True, - verbose_name=_('E-mail') - ) - locale = models.CharField( - null=True, blank=True, max_length=32, - verbose_name=_('Locale') - ) - secret = models.CharField(max_length=32, default=generate_secret) - datetime = models.DateTimeField( - verbose_name=_("Date") - ) - expires = models.DateTimeField( - verbose_name=_("Expiration date") - ) - payment_date = models.DateTimeField( - verbose_name=_("Payment date"), - null=True, blank=True - ) - payment_provider = models.CharField( - null=True, blank=True, - max_length=255, - verbose_name=_("Payment provider") - ) - payment_fee = models.DecimalField( - decimal_places=2, max_digits=10, - default=0, verbose_name=_("Payment method fee") - ) - payment_info = models.TextField( - verbose_name=_("Payment information"), - null=True, blank=True - ) - payment_manual = models.BooleanField( - verbose_name=_("Payment state was manually modified"), - default=False - ) - total = models.DecimalField( - decimal_places=2, max_digits=10, - verbose_name=_("Total amount") - ) - - class Meta: - verbose_name = _("Order") - verbose_name_plural = _("Orders") - ordering = ("-datetime",) - - def str(self): - return self.full_code - - @property - def full_code(self): - """ - A order code which is unique among all events of a single organizer, - built by contatenating the event slug and the order code. - """ - return self.event.slug.upper() + self.code - - def save(self, *args, **kwargs): - if not self.code: - self.assign_code() - if not self.datetime: - self.datetime = now() - super().save(*args, **kwargs) - - def assign_code(self): - charset = list('ABCDEFGHKLMNPQRSTUVWXYZ23456789') - while True: - code = "".join([random.choice(charset) for i in range(5)]) - if not Order.objects.filter(event=self.event, code=code).exists(): - self.code = code - return - - @property - def can_modify_answers(self): - """ - Is ``True`` if the user can change the question answers / attendee names that are - related to the order. This checks order status and modification deadlines. It also - returns ``False``, if there are no questions that can be answered. - """ - if self.status not in (Order.STATUS_PENDING, Order.STATUS_PAID, Order.STATUS_EXPIRED): - return False - modify_deadline = self.event.settings.get('last_order_modification_date', as_type=datetime) - if modify_deadline is not None and now() > modify_deadline: - return False - ask_names = self.event.settings.get('attendee_names_asked', as_type=bool) - for cp in self.positions.all().prefetch_related('item__questions'): - if (cp.item.admission and ask_names) or cp.item.questions.all(): - return True - return False # nothing there to modify - - def mark_refunded(self): - """ - Mark this order as refunded. This clones the order object, sets the payment status and - returns the cloned order object. - """ - order = self.clone() - order.status = Order.STATUS_REFUNDED - order.save() - return order - - def _can_be_paid(self): - error_messages = { - 'late': _("The payment is too late to be accepted."), - } - - if self.event.settings.get('payment_term_last') \ - and now() > self.event.settings.get('payment_term_last'): - return error_messages['late'] - if now() < self.expires: - return True - if not self.event.settings.get('payment_term_accept_late'): - return error_messages['late'] - - return self._is_still_available() - - def _is_still_available(self): - error_messages = { - 'unavailable': _('Some of the ordered products were no longer available.'), - } - positions = list(self.positions.all().select_related( - 'item', 'variation' - ).prefetch_related( - 'variation__values', 'variation__values__prop', - 'item__questions', 'answers' - )) - quota_cache = {} - try: - for i, op in enumerate(positions): - quotas = list(op.item.quotas.all()) if op.variation is None else list(op.variation.quotas.all()) - if len(quotas) == 0: - raise Quota.QuotaExceededException(error_messages['unavailable']) - - for quota in quotas: - # Lock the quota, so no other thread is allowed to perform sales covered by this - # quota while we're doing so. - if quota.identity not in quota_cache: - quota_cache[quota.identity] = quota - quota.cached_availability = quota.availability()[1] - else: - # Use cached version - quota = quota_cache[quota.identity] - quota.cached_availability -= 1 - if quota.cached_availability < 0: - # This quota is sold out/currently unavailable, so do not sell this at all - raise Quota.QuotaExceededException(error_messages['unavailable']) - except Quota.QuotaExceededException as e: - return str(e) - return True - - -class CachedTicket(models.Model): - order = VersionedForeignKey(Order, on_delete=models.CASCADE) - cachedfile = models.ForeignKey(CachedFile, on_delete=models.CASCADE) - provider = models.CharField(max_length=255) - - -class QuestionAnswer(Versionable): - """ - The answer to a Question, connected to an OrderPosition or CartPosition. - - :param orderposition: The order position this is related to, or null if this is - related to a cart position. - :type orderposition: OrderPosition - :param cartposition: The cart position this is related to, or null if this is related - to an order position. - :type cartposition: CartPosition - :param question: The question this is an answer for - :type question: Question - :param answer: The actual answer data - :type answer: str - """ - orderposition = models.ForeignKey( - 'OrderPosition', null=True, blank=True, - related_name='answers' - ) - cartposition = models.ForeignKey( - 'CartPosition', null=True, blank=True, - related_name='answers' - ) - question = VersionedForeignKey( - Question, related_name='answers' - ) - answer = models.TextField() - - -class ObjectWithAnswers: - def cache_answers(self): - """ - Creates two properties on the object. - (1) answ: a dictionary of question.id → answer string - (2) questions: a list of Question objects, extended by an 'answer' property - """ - self.answ = {} - for a in self.answers.all(): - self.answ[a.question_id] = a.answer - self.questions = [] - for q in self.item.questions.all(): - if q.identity in self.answ: - q.answer = self.answ[q.identity] - else: - q.answer = "" - self.questions.append(q) - - -class OrderPosition(ObjectWithAnswers, Versionable): - """ - An OrderPosition is one line of an order, representing one ordered items - of a specified type (or variation). - - :param order: The order this is a part of - :type order: Order - :param item: The ordered item - :type item: Item - :param variation: The ordered ItemVariation or null, if the item has no properties - :type variation: ItemVariation - :param price: The price of this item - :type price: decimal.Decimal - :param attendee_name: The attendee's name, if entered. - :type attendee_name: str - """ - order = VersionedForeignKey( - Order, - verbose_name=_("Order"), - related_name='positions' - ) - item = VersionedForeignKey( - Item, - verbose_name=_("Item"), - related_name='positions' - ) - variation = VersionedForeignKey( - ItemVariation, - null=True, blank=True, - verbose_name=_("Variation") - ) - price = models.DecimalField( - decimal_places=2, max_digits=10, - verbose_name=_("Price") - ) - attendee_name = models.CharField( - max_length=255, - verbose_name=_("Attendee name"), - blank=True, null=True, - help_text=_("Empty, if this product is not an admission ticket") - ) - - class Meta: - verbose_name = _("Order position") - verbose_name_plural = _("Order positions") - - @classmethod - def transform_cart_positions(cls, cp: list, order) -> list: - ops = [] - for cartpos in cp: - op = OrderPosition( - order=order, item=cartpos.item, variation=cartpos.variation, - price=cartpos.price, attendee_name=cartpos.attendee_name - ) - for answ in cartpos.answers.all(): - answ = answ.clone() - answ.orderposition = op - answ.cartposition = None - answ.save() - op.save() - cartpos.delete() - ops.append(op) - - -class CartPosition(ObjectWithAnswers, Versionable): - """ - A cart position is similar to a order line, except that it is not - yet part of a binding order but just placed by some user in his or - her cart. It therefore normally has a much shorter expiration time - than an ordered position, but still blocks an item in the quota pool - as we do not want to throw out users while they're clicking through - the checkout process. - - :param event: The event this belongs to - :type event: Evnt - :param item: The selected item - :type item: Item - :param session: The user session that contains this cart position - :type session: str - :param variation: The selected ItemVariation or null, if the item has no properties - :type variation: ItemVariation - :param datetime: The datetime this item was put into the cart - :type datetime: datetime - :param expires: The date until this item is guarenteed to be reserved - :type expires: datetime - :param price: The price of this item - :type price: decimal.Decimal - :param attendee_name: The attendee's name, if entered. - :type attendee_name: str - """ - event = VersionedForeignKey( - Event, - verbose_name=_("Event") - ) - session = models.CharField( - max_length=255, null=True, blank=True, - verbose_name=_("Session") - ) - item = VersionedForeignKey( - Item, - verbose_name=_("Item") - ) - variation = VersionedForeignKey( - ItemVariation, - null=True, blank=True, - verbose_name=_("Variation") - ) - price = models.DecimalField( - decimal_places=2, max_digits=10, - verbose_name=_("Price") - ) - datetime = models.DateTimeField( - verbose_name=_("Date"), - auto_now_add=True - ) - expires = models.DateTimeField( - verbose_name=_("Expiration date") - ) - attendee_name = models.CharField( - max_length=255, - verbose_name=_("Attendee name"), - blank=True, null=True, - help_text=_("Empty, if this product is not an admission ticket") - ) - - class Meta: - verbose_name = _("Cart position") - verbose_name_plural = _("Cart positions") - - -class EventSetting(Versionable): - """ - An event settings is a key-value setting which can be set for a - specific event - """ - object = VersionedForeignKey(Event, related_name='setting_objects') - key = models.CharField(max_length=255) - value = models.TextField() - - -class OrganizerSetting(Versionable): - """ - An event option is a key-value setting which can be set for an - organizer. It will be inherited by the events of this organizer - """ - object = VersionedForeignKey(Organizer, related_name='setting_objects') - key = models.CharField(max_length=255) - value = models.TextField() - - -class EventLock(models.Model): - event = models.CharField(max_length=36, primary_key=True) - date = models.DateTimeField(auto_now=True) - token = models.UUIDField(default=uuid.uuid4) - - class LockTimeoutException(Exception): - pass - - class LockReleaseException(Exception): - pass - - -@receiver(post_delete, sender=CachedFile) -def cached_file_delete(sender, instance, **kwargs): - if instance.file: - # Pass false so FileField doesn't save the model. - instance.file.delete(False) diff --git a/src/pretix/base/models/__init__.py b/src/pretix/base/models/__init__.py new file mode 100644 index 0000000000..bf884178df --- /dev/null +++ b/src/pretix/base/models/__init__.py @@ -0,0 +1,20 @@ +from .auth import User +from .base import CachedFile, Versionable, cachedfile_name +from .event import Event, EventLock, EventPermission, EventSetting +from .items import ( + BaseRestriction, Item, ItemCategory, ItemVariation, Property, + PropertyValue, Question, Quota, VariationsField, itempicture_upload_to, +) +from .orders import ( + CachedTicket, CartPosition, ObjectWithAnswers, Order, OrderPosition, + QuestionAnswer, generate_secret, +) +from .organizer import Organizer, OrganizerPermission, OrganizerSetting + +__all__ = [ + 'Versionable', 'User', 'CachedFile', 'Organizer', 'OrganizerPermission', 'Event', 'EventPermission', + 'ItemCategory', 'Item', 'Property', 'PropertyValue', 'ItemVariation', 'VariationsField', 'Question', + 'BaseRestriction', 'Quota', 'Order', 'CachedTicket', 'QuestionAnswer', 'ObjectWithAnswers', 'OrderPosition', + 'CartPosition', 'EventSetting', 'OrganizerSetting', 'EventLock', 'cachedfile_name', 'itempicture_upload_to', + 'generate_secret' +] diff --git a/src/pretix/base/models/auth.py b/src/pretix/base/models/auth.py new file mode 100644 index 0000000000..b3e1ef6c8e --- /dev/null +++ b/src/pretix/base/models/auth.py @@ -0,0 +1,127 @@ +from django.conf import settings +from django.contrib.auth.models import ( + AbstractBaseUser, BaseUserManager, PermissionsMixin, +) +from django.db import models +from django.utils.translation import ugettext_lazy as _ + + +class UserManager(BaseUserManager): + """ + This is the user manager for our custom user model. See the User + model documentation to see what's so special about our user model. + """ + + def create_user(self, email, password=None, **kwargs): + user = self.model(email=email, **kwargs) + user.set_password(password) + user.save() + return user + + def create_superuser(self, email, password=None): # NOQA + # Not used in the software but required by Django + if password is None: + raise Exception("You must provide a password") + user = self.model(email=email) + user.is_staff = True + user.is_superuser = True + user.set_password(password) + user.save() + return user + + +class User(AbstractBaseUser, PermissionsMixin): + """ + This is the user model used by pretix for authentication. + + :param email: The user's e-mail address, used for identification. + :type email: str + :param givenname: The user's given name. May be empty or null. + :type givenname: str + :param familyname: The user's given name. May be empty or null. + :type familyname: str + :param givenname: The user's given name. May be empty or null. + :type givenname: str + :param is_active: Whether this user account is activated. + :type is_active: bool + :param is_staff: ``True`` for system operators. + :type is_staff: bool + :param date_joined: The datetime of the user's registration. + :type date_joined: datetime + :param locale: The user's preferred locale code. + :type locale: str + :param timezone: The user's preferred timezone. + :type timezone: str + """ + + USERNAME_FIELD = 'email' + REQUIRED_FIELDS = [] + + email = models.EmailField(unique=True, db_index=True, null=True, blank=True, + verbose_name=_('E-mail')) + givenname = models.CharField(max_length=255, blank=True, null=True, + verbose_name=_('Given name')) + familyname = models.CharField(max_length=255, blank=True, null=True, + verbose_name=_('Family name')) + is_active = models.BooleanField(default=True, + verbose_name=_('Is active')) + is_staff = models.BooleanField(default=False, + verbose_name=_('Is site admin')) + date_joined = models.DateTimeField(auto_now_add=True, + verbose_name=_('Date joined')) + locale = models.CharField(max_length=50, + choices=settings.LANGUAGES, + default=settings.LANGUAGE_CODE, + verbose_name=_('Language')) + timezone = models.CharField(max_length=100, + default=settings.TIME_ZONE, + verbose_name=_('Timezone')) + + objects = UserManager() + + class Meta: + verbose_name = _("User") + verbose_name_plural = _("Users") + + def save(self, *args, **kwargs): + self.email = self.email.lower() + super().save(*args, **kwargs) + + def __str__(self): + return self.email + + def get_short_name(self) -> str: + """ + Returns the first of the following user properties that is found to exist: + + * Given name + * Family name + * E-mail address + """ + if self.givenname: + return self.givenname + elif self.familyname: + return self.familyname + else: + return self.email + + def get_full_name(self) -> str: + """ + Returns the first of the following user properties that is found to exist: + + * A combination of given name and family name, depending on the locale + * Given name + * Family name + * User name + """ + if self.givenname and not self.familyname: + return self.givenname + elif not self.givenname and self.familyname: + return self.familyname + elif self.familyname and self.givenname: + return _('%(family)s, %(given)s') % { + 'family': self.familyname, + 'given': self.givenname + } + else: + return self.email diff --git a/src/pretix/base/models/base.py b/src/pretix/base/models/base.py new file mode 100644 index 0000000000..6302df8d54 --- /dev/null +++ b/src/pretix/base/models/base.py @@ -0,0 +1,86 @@ +import copy +import uuid + +import six +from django.db import models +from django.db.models.signals import post_delete +from django.dispatch import receiver +from versions.models import Versionable as BaseVersionable, get_utc_now + + +class Versionable(BaseVersionable): + class Meta: + abstract = True + + def clone_shallow(self, forced_version_date=None): + """ + This behaves like clone(), but misses all the Many2Many-relation-handling. This is + a performance optimization for cases in which we have to handle the Many2Many relations + by hand anyways. + """ + if not self.pk: # NOQA + raise ValueError('Instance must be saved before it can be cloned') + + if self.version_end_date: # NOQA + raise ValueError('This is a historical item and can not be cloned.') + + if forced_version_date: # NOQA + if not self.version_start_date <= forced_version_date <= get_utc_now(): + raise ValueError('The clone date must be between the version start date and now.') + else: + forced_version_date = get_utc_now() + + earlier_version = self + + later_version = copy.copy(earlier_version) + later_version.version_end_date = None + later_version.version_start_date = forced_version_date + + # set earlier_version's ID to a new UUID so the clone (later_version) can + # get the old one -- this allows 'head' to always have the original + # id allowing us to get at all historic foreign key relationships + earlier_version.id = six.u(str(uuid.uuid4())) + earlier_version.version_end_date = forced_version_date + earlier_version.save() + + for field in earlier_version._meta.many_to_many: + earlier_version.clone_relations_shallow(later_version, field.attname, forced_version_date) + + if hasattr(earlier_version._meta, 'many_to_many_related'): + for rel in earlier_version._meta.many_to_many_related: + earlier_version.clone_relations_shallow(later_version, rel.via_field_name, forced_version_date) + + later_version.save() + + return later_version + + def clone_relations_shallow(self, clone, manager_field_name, forced_version_date): + # Source: the original object, where relations are currently pointing to + source = getattr(self, manager_field_name) # returns a VersionedRelatedManager instance + # Destination: the clone, where the cloned relations should point to + source.through.objects.filter(**{source.source_field.attname: clone.id}).update(**{ + source.source_field.attname: self.id, 'version_end_date': forced_version_date + }) + + +def cachedfile_name(instance, filename): + return 'cachedfiles/%s.%s' % (instance.id, filename.split('.')[-1]) + + +class CachedFile(models.Model): + """ + A cached file (e.g. pre-generated ticket PDF) + """ + id = models.UUIDField(primary_key=True, default=uuid.uuid4) + expires = models.DateTimeField(null=True, blank=True) + date = models.DateTimeField(null=True, blank=True) + filename = models.CharField(max_length=255) + type = models.CharField(max_length=255) + file = models.FileField(null=True, blank=True, upload_to=cachedfile_name) + + +@receiver(post_delete, sender=CachedFile) +def cached_file_delete(sender, instance, **kwargs): + if instance.file: + # Pass false so FileField doesn't save the model. + instance.file.delete(False) diff --git a/src/pretix/base/models/event.py b/src/pretix/base/models/event.py new file mode 100644 index 0000000000..d95330b05e --- /dev/null +++ b/src/pretix/base/models/event.py @@ -0,0 +1,248 @@ +import uuid +from datetime import datetime + +from django.conf import settings +from django.core.validators import RegexValidator +from django.db import models +from django.template.defaultfilters import date as _date +from django.utils.functional import cached_property +from django.utils.timezone import now +from django.utils.translation import ugettext_lazy as _ +from versions.models import VersionedForeignKey + +from pretix.base.i18n import I18nCharField +from pretix.base.settings import SettingsProxy + +from .auth import User +from .base import Versionable +from .organizer import Organizer + + +class Event(Versionable): + """ + This model represents an event. An event is anything you can buy + tickets for. + + :param organizer: The organizer this event belongs to + :type organizer: Organizer + :param name: This events full title + :type name: str + :param slug: A short, alphanumeric, all-lowercase name for use in URLs. The slug has to + be unique among the events of the same organizer. + :type slug: str + :param currency: The currency of all prices and payments of this event + :type currency: str + :param date_from: The datetime this event starts + :type date_from: datetime + :param date_to: The datetime this event ends + :type date_to: datetime + :param presale_start: No tickets will be sold before this date. + :type presale_start: datetime + :param presale_end: No tickets will be sold before this date. + :type presale_end: datetime + :param plugins: A comma-separated list of plugin names that are active for this + event. + :type plugins: str + """ + + organizer = VersionedForeignKey(Organizer, related_name="events", + on_delete=models.PROTECT) + name = I18nCharField( + max_length=200, + verbose_name=_("Name"), + ) + slug = models.SlugField( + max_length=50, db_index=True, + help_text=_( + "Should be short, only contain lowercase letters and numbers, and must be unique among your events. " + "This is being used in addresses and bank transfer references."), + validators=[ + RegexValidator( + regex="^[a-zA-Z0-9.-]+$", + message=_("The slug may only contain letters, numbers, dots and dashes."), + ) + ], + verbose_name=_("Slug"), + ) + permitted = models.ManyToManyField(User, through='EventPermission', + related_name="events", ) + currency = models.CharField(max_length=10, + verbose_name=_("Default currency"), + default=settings.DEFAULT_CURRENCY) + date_from = models.DateTimeField(verbose_name=_("Event start time")) + date_to = models.DateTimeField(null=True, blank=True, + verbose_name=_("Event end time")) + presale_end = models.DateTimeField( + null=True, blank=True, + verbose_name=_("End of presale"), + help_text=_("No products will be sold after this date."), + ) + presale_start = models.DateTimeField( + null=True, blank=True, + verbose_name=_("Start of presale"), + help_text=_("No products will be sold before this date."), + ) + plugins = models.TextField( + null=True, blank=True, + verbose_name=_("Plugins"), + ) + + class Meta: + verbose_name = _("Event") + verbose_name_plural = _("Events") + # unique_together = (("organizer", "slug"),) # TODO: Enforce manually + ordering = ("date_from", "name") + + def __str__(self): + return str(self.name) + + def save(self, *args, **kwargs): + obj = super().save(*args, **kwargs) + self.get_cache().clear() + return obj + + def get_plugins(self) -> "list[str]": + """ + Get the names of the plugins activated for this event as a list. + """ + if self.plugins is None: + return [] + return self.plugins.split(",") + + def get_date_from_display(self) -> str: + """ + Returns a formatted string containing the start date of the event with respect + to the current locale and to the ``show_times`` setting. + """ + return _date( + self.date_from, + "DATETIME_FORMAT" if self.settings.show_times else "DATE_FORMAT" + ) + + def get_date_to_display(self) -> str: + """ + Returns a formatted string containing the start date of the event with respect + to the current locale and to the ``show_times`` setting. Returns an empty string + if ``show_date_to`` is ``False``. + """ + if not self.settings.show_date_to: + return "" + return _date( + self.date_to, + "DATETIME_FORMAT" if self.settings.show_times else "DATE_FORMAT" + ) + + def get_cache(self) -> "pretix.base.cache.ObjectRelatedCache": + """ + Returns an :py:class:`ObjectRelatedCache` object. This behaves equivalent to + Django's built-in cache backends, but puts you into an isolated environment for + this event, so you don't have to prefix your cache keys. In addition, the cache + is being cleared every time the event or one of its related objects change. + """ + from pretix.base.cache import ObjectRelatedCache + + return ObjectRelatedCache(self) + + @cached_property + def settings(self) -> SettingsProxy: + """ + Returns an object representing this event's settings + """ + return SettingsProxy(self, type=EventSetting, parent=self.organizer) + + @property + def presale_has_ended(self): + if self.presale_end and now() > self.presale_end: + return True + return False + + @property + def presale_is_running(self): + if self.presale_start and now() < self.presale_start: + return False + if self.presale_end and now() > self.presale_end: + return False + return True + + def lock(self): + """ + Returns a contextmanager that can be used to lock an event for bookings + """ + from pretix.base.services import locking + + return locking.LockManager(self) + + +class EventPermission(Versionable): + """ + The relation between an Event and an User who has permissions to + access an event. + + :param event: The event this refers to + :type event: Event + :param user: The user these permission set applies to + :type user: User + :param can_change_settings: If ``True``, the user can change all basic settings for this event. + :type can_change_settings: bool + :param can_change_items: If ``True``, the user can change and add items and related objects for this event. + :type can_change_items: bool + :param can_view_orders: If ``True``, the user can inspect details of all orders. + :type can_view_orders: bool + :param can_change_orders: If ``True``, the user can change details of orders + :type can_change_orders: bool + """ + + event = VersionedForeignKey(Event) + user = models.ForeignKey(User, related_name="event_perms") + can_change_settings = models.BooleanField( + default=True, + verbose_name=_("Can change event settings") + ) + can_change_items = models.BooleanField( + default=True, + verbose_name=_("Can change product settings") + ) + can_view_orders = models.BooleanField( + default=True, + verbose_name=_("Can view orders") + ) + can_change_permissions = models.BooleanField( + default=True, + verbose_name=_("Can change permissions") + ) + can_change_orders = models.BooleanField( + default=True, + verbose_name=_("Can change orders") + ) + + class Meta: + verbose_name = _("Event permission") + verbose_name_plural = _("Event permissions") + + def __str__(self): + return _("%(name)s on %(object)s") % { + 'name': str(self.user), + 'object': str(self.event), + } + + +class EventSetting(Versionable): + """ + An event settings is a key-value setting which can be set for a + specific event + """ + object = VersionedForeignKey(Event, related_name='setting_objects') + key = models.CharField(max_length=255) + value = models.TextField() + + +class EventLock(models.Model): + event = models.CharField(max_length=36, primary_key=True) + date = models.DateTimeField(auto_now=True) + token = models.UUIDField(default=uuid.uuid4) + + class LockTimeoutException(Exception): + pass + + class LockReleaseException(Exception): + pass diff --git a/src/pretix/base/models/items.py b/src/pretix/base/models/items.py new file mode 100644 index 0000000000..f183798e50 --- /dev/null +++ b/src/pretix/base/models/items.py @@ -0,0 +1,854 @@ +from itertools import product + +from django.db import models +from django.db.models import Q, Count +from django.utils.timezone import now +from django.utils.translation import ugettext_lazy as _ +from versions.models import VersionedForeignKey, VersionedManyToManyField + +from pretix.base.i18n import I18nCharField, I18nTextField + +from ..types import VariationDict +from .base import Versionable +from .event import Event + + +class ItemCategory(Versionable): + """ + Items can be sorted into these categories. + + :param event: The event this belongs to + :type event: Event + :param name: The name of this category + :type name: str + :param position: An integer, used for sorting + :type position: int + """ + event = VersionedForeignKey( + Event, + on_delete=models.CASCADE, + related_name='categories', + ) + name = I18nCharField( + max_length=255, + verbose_name=_("Category name"), + ) + position = models.IntegerField( + default=0 + ) + + class Meta: + verbose_name = _("Product category") + verbose_name_plural = _("Product categories") + ordering = ('position', 'version_birth_date') + + def __str__(self): + return str(self.name) + + def delete(self, *args, **kwargs): + super().delete(*args, **kwargs) + if self.event: + self.event.get_cache().clear() + + def save(self, *args, **kwargs): + super().save(*args, **kwargs) + if self.event: + self.event.get_cache().clear() + + @property + def sortkey(self): + return self.position, self.version_birth_date + + def __lt__(self, other): + return self.sortkey < other.sortkey + + +def itempicture_upload_to(instance, filename): + return '%s/%s/item-%s.%s' % ( + instance.event.organizer.slug, instance.event.slug, instance.identity, + filename.split('.')[-1] + ) + + +class Item(Versionable): + """ + An item is a thing which can be sold. It belongs to an event and may or may not belong to a category. + Items are often also called 'products' but are named 'items' internally due to historic reasons. + + It has a default price which might by overriden by restrictions. + + :param event: The event this belongs to. + :type event: Event + :param category: The category this belongs to. May be null. + :type category: ItemCategory + :param name: The name of this item: + :type name: str + :param active: Whether this item is being sold + :type active: bool + :param description: A short description + :type description: str + :param default_price: The item's default price + :type default_price: decimal.Decimal + :param tax_rate: The VAT tax that is included in this item's price (in %) + :type tax_rate: decimal.Decimal + :param admission: ``True``, if this item allows persons to enter the event (as opposed to e.g. merchandise) + :type admission: bool + :param picture: A product picture to be shown next to the product description. + :type picture: File + + """ + + event = VersionedForeignKey( + Event, + on_delete=models.PROTECT, + related_name="items", + verbose_name=_("Event"), + ) + category = VersionedForeignKey( + ItemCategory, + on_delete=models.PROTECT, + related_name="items", + blank=True, null=True, + verbose_name=_("Category"), + ) + name = I18nCharField( + max_length=255, + verbose_name=_("Item name"), + ) + active = models.BooleanField( + default=True, + verbose_name=_("Active"), + ) + description = I18nTextField( + verbose_name=_("Description"), + help_text=_("This is shown below the product name in lists."), + null=True, blank=True, + ) + default_price = models.DecimalField( + verbose_name=_("Default price"), + max_digits=7, decimal_places=2, null=True + ) + tax_rate = models.DecimalField( + null=True, blank=True, + verbose_name=_("Taxes included in percent"), + max_digits=7, decimal_places=2 + ) + admission = models.BooleanField( + verbose_name=_("Is an admission ticket"), + help_text=_( + 'Whether or not buying this product allows a person to enter ' + 'your event' + ), + default=False + ) + position = models.IntegerField( + default=0 + ) + picture = models.ImageField( + verbose_name=_("Product picture"), + null=True, blank=True, + upload_to=itempicture_upload_to + ) + + class Meta: + verbose_name = _("Product") + verbose_name_plural = _("Products") + ordering = ("category__position", "category", "position") + + def __str__(self): + return str(self.name) + + def save(self, *args, **kwargs): + super().save(*args, **kwargs) + if self.event: + self.event.get_cache().clear() + + def delete(self, *args, **kwargs): + super().delete(*args, **kwargs) + if self.event: + self.event.get_cache().clear() + + def get_all_variations(self, use_cache: bool=False) -> "list[VariationDict]": + """ + This method returns a list containing all variations of this + item. The list contains one VariationDict per variation, where + the Proprty IDs are keys and the PropertyValue objects are + values. If an ItemVariation object exists, it is available in + the dictionary via the special key 'variation'. + + VariationDicts differ from dicts only by specifying some extra + methods. + + :param use_cache: If this parameter is set to ``True``, a second call to this method + on the same model instance won't query the database again but return + the previous result again. + :type use_cache: bool + """ + if use_cache and hasattr(self, '_get_all_variations_cache'): + return self._get_all_variations_cache + + all_variations = self.variations.all().prefetch_related("values") + all_properties = self.properties.all().prefetch_related("values") + variations_cache = {} + for var in all_variations: + key = [] + for v in var.values.all(): + key.append((v.prop_id, v.identity)) + key = tuple(sorted(key)) + variations_cache[key] = var + + result = [] + for comb in product(*[prop.values.all() for prop in all_properties]): + if len(comb) == 0: + result.append(VariationDict()) + continue + key = [] + var = VariationDict() + for v in comb: + key.append((v.prop.identity, v.identity)) + var[v.prop.identity] = v + key = tuple(sorted(key)) + if key in variations_cache: + var['variation'] = variations_cache[key] + result.append(var) + + self._get_all_variations_cache = result + return result + + def _get_all_generated_variations(self): + propids = set([p.identity for p in self.properties.all()]) + if len(propids) == 0: + variations = [VariationDict()] + else: + all_variations = list( + self.variations.annotate( + qc=Count('quotas') + ).filter(qc__gt=0).prefetch_related( + "values", "values__prop", "quotas__event" + ) + ) + variations = [] + for var in all_variations: + values = list(var.values.all()) + # Make sure we don't expose stale ItemVariation objects which are + # still around altough they have an old set of properties + if set([v.prop.identity for v in values]) != propids: + continue + vardict = VariationDict() + for v in values: + vardict[v.prop.identity] = v + vardict['variation'] = var + variations.append(vardict) + return variations + + def get_all_available_variations(self, use_cache: bool=False): + """ + This method returns a list of all variations which are theoretically + possible for sale. It DOES call all activated restriction plugins, and it + DOES only return variations which DO have an ItemVariation object, as all + variations without one CAN NOT be part of a Quota and therefore can never + be available for sale. The only exception is the empty variation + for items without properties, which never has an ItemVariation object. + + This DOES NOT take into account quotas itself. Use ``is_available`` on the + ItemVariation objects (or the Item it self, if it does not have variations) to + determine availability by the terms of quotas. + + It is recommended to call:: + + .prefetch_related('properties', 'variations__values__prop') + + when retrieving Item objects you are going to use this method on. + """ + if use_cache and hasattr(self, '_get_all_available_variations_cache'): + return self._get_all_available_variations_cache + + from pretix.base.signals import determine_availability + + variations = self._get_all_generated_variations() + responses = determine_availability.send( + self.event, item=self, + variations=variations, context=None, + cache=self.event.get_cache() + ) + + for i, var in enumerate(variations): + var['available'] = var['variation'].active if 'variation' in var else True + if 'variation' in var: + if var['variation'].default_price: + var['price'] = var['variation'].default_price + else: + var['price'] = self.default_price + else: + var['price'] = self.default_price + + # It is possible, that *multiple* restriction plugins change the default price. + # In this case, the cheapest one wins. As soon as there is a restriction + # that changes the price, the default price has no effect. + + newprice = None + for rec, response in responses: + if 'available' in response[i] and not response[i]['available']: + var['available'] = False + break + if 'price' in response[i] and response[i]['price'] is not None \ + and (newprice is None or response[i]['price'] < newprice): + newprice = response[i]['price'] + var['price'] = newprice or var['price'] + + variations = [var for var in variations if var['available']] + + self._get_all_available_variations_cache = variations + return variations + + def check_quotas(self): + """ + This method is used to determine whether this Item is currently available + for sale. + + :returns: any of the return codes of :py:meth:`Quota.availability()`. + + :raises ValueError: if you call this on an item which has properties associated with it. + Please use the method on the ItemVariation object you are interested in. + """ + if self.properties.count() > 0: # NOQA + raise ValueError('Do not call this directly on items which have properties ' + 'but call this on their ItemVariation objects') + return min([q.availability() for q in self.quotas.all()]) + + def check_restrictions(self): + """ + This method is used to determine whether this ItemVariation is restricted + in sale by any restriction plugins. + + :returns: + + * ``False``, if the item is unavailable + * the item's price, otherwise + + :raises ValueError: if you call this on an item which has properties associated with it. + Please use the method on the ItemVariation object you are interested in. + """ + if self.properties.count() > 0: # NOQA + raise ValueError('Do not call this directly on items which have properties ' + 'but call this on their ItemVariation objects') + from pretix.base.signals import determine_availability + + vd = VariationDict() + responses = determine_availability.send( + self.event, item=self, + variations=[vd], context=None, + cache=self.event.get_cache() + ) + price = self.default_price + for rec, response in responses: + if 'available' in response[0] and not response[0]['available']: + return False + elif 'price' in response[0] and response[0]['price'] is not None and response[0]['price'] < price: + price = response[0]['price'] + return price + + +class Property(Versionable): + """ + A property is a modifier which can be applied to an Item. For example + 'Size' would be a property associated with the item 'T-Shirt'. + + :param event: The event this belongs to + :type event: Event + :param name: The name of this property. + :type name: str + """ + + event = VersionedForeignKey( + Event, + related_name="properties" + ) + item = VersionedForeignKey( + Item, related_name='properties', null=True, blank=True + ) + name = I18nCharField( + max_length=250, + verbose_name=_("Property name") + ) + + class Meta: + verbose_name = _("Product property") + verbose_name_plural = _("Product properties") + + def __str__(self): + return str(self.name) + + def delete(self, *args, **kwargs): + super().delete(*args, **kwargs) + if self.event: + self.event.get_cache().clear() + + def save(self, *args, **kwargs): + super().save(*args, **kwargs) + if self.event: + self.event.get_cache().clear() + + +class PropertyValue(Versionable): + """ + A value of a property. If the property would be 'T-Shirt size', + this could be 'M' or 'L'. + + :param prop: The property this value is a valid option for. + :type prop: Property + :param value: The value, as a human-readable string + :type value: str + :param position: An integer, used for sorting + :type position: int + """ + + prop = VersionedForeignKey( + Property, + on_delete=models.CASCADE, + related_name="values" + ) + value = I18nCharField( + max_length=250, + verbose_name=_("Value"), + ) + position = models.IntegerField( + default=0 + ) + + class Meta: + verbose_name = _("Property value") + verbose_name_plural = _("Property values") + ordering = ("position", "version_birth_date") + + def __str__(self): + return "%s: %s" % (self.prop.name, self.value) + + def delete(self, *args, **kwargs): + super().delete(*args, **kwargs) + if self.prop: + self.prop.event.get_cache().clear() + + def save(self, *args, **kwargs): + super().save(*args, **kwargs) + if self.prop: + self.prop.event.get_cache().clear() + + @property + def sortkey(self): + return self.position, self.version_birth_date + + def __lt__(self, other): + return self.sortkey < other.sortkey + + +class ItemVariation(Versionable): + """ + A variation is an item combined with values for all properties + associated with the item. For example, if your item is 'T-Shirt' + and your properties are 'Size' and 'Color', then an example for an + variation would be 'T-Shirt XL read'. + + Attention: _ALL_ combinations of PropertyValues _ALWAYS_ exist, + even if there is no ItemVariation object for them! ItemVariation objects + do NOT prove existance, they are only available to make it possible + to override default values (like the price) for certain combinations + of property values. However, appropriate ItemVariation objects will be + created as soon as you add your variations to a quota. + + They also allow to explicitly EXCLUDE certain combinations of property + values by creating an ItemVariation object for them with active set to + False. + + Restrictions can be not only set to items but also directly to variations. + + :param item: The item this variation belongs to + :type item: Item + :param values: A set of ``PropertyValue`` objects defining this variation + :param active: Whether this value is to be sold. + :type active: bool + :param default_price: This variation's default price + :type default_price: decimal.Decimal + """ + item = VersionedForeignKey( + Item, + related_name='variations' + ) + values = VersionedManyToManyField( + PropertyValue, + related_name='variations', + ) + active = models.BooleanField( + default=True, + verbose_name=_("Active"), + ) + default_price = models.DecimalField( + decimal_places=2, max_digits=7, + null=True, blank=True, + verbose_name=_("Default price"), + ) + + class Meta: + verbose_name = _("Product variation") + verbose_name_plural = _("Product variations") + + def __str__(self): + return str(self.to_variation_dict()) + + def delete(self, *args, **kwargs): + super().delete(*args, **kwargs) + if self.item: + self.item.event.get_cache().clear() + + def save(self, *args, **kwargs): + super().save(*args, **kwargs) + if self.item: + self.item.event.get_cache().clear() + + def check_quotas(self): + """ + This method is used to determine whether this ItemVariation is currently + available for sale in terms of quotas. + + :returns: any of the return codes of :py:meth:`Quota.availability()`. + """ + return min([q.availability() for q in self.quotas.all()]) + + def to_variation_dict(self): + """ + :return: a :py:class:`VariationDict` representing this variation. + """ + vd = VariationDict() + for v in self.values.all(): + vd[v.prop.identity] = v + vd['variation'] = self + return vd + + def check_restrictions(self): + """ + This method is used to determine whether this ItemVariation is restricted + in sale by any restriction plugins. + + :returns: + + * ``False``, if the item is unavailable + * the item's price, otherwise + """ + from pretix.base.signals import determine_availability + + responses = determine_availability.send( + self.item.event, item=self.item, + variations=[self.to_variation_dict()], context=None, + cache=self.item.event.get_cache() + ) + price = self.default_price if self.default_price is not None else self.item.default_price + for rec, response in responses: + if 'available' in response[0] and not response[0]['available']: + return False + elif 'price' in response[0] and response[0]['price'] is not None and response[0]['price'] < price: + price = response[0]['price'] + return price + + def add_values_from_string(self, pk): + """ + Add values to this ItemVariation using a serialized string of the form + ``property-id:value-id,ṗroperty-id:value-id`` + """ + for pair in pk.split(","): + prop, value = pair.split(":") + self.values.add( + PropertyValue.objects.current.get( + identity=value, + prop_id=prop + ) + ) + + +class VariationsField(VersionedManyToManyField): + """ + This is a ManyToManyField using the pretixcontrol.views.forms.VariationsField + form field by default. + """ + + def formfield(self, **kwargs): + from pretix.control.forms import VariationsField as FVariationsField + from django.db.models.fields.related import RelatedField + + defaults = { + 'form_class': FVariationsField, + # We don't need a queryset + 'queryset': ItemVariation.objects.none(), + } + defaults.update(kwargs) + # If initial is passed in, it's a list of related objects, but the + # MultipleChoiceField takes a list of IDs. + if defaults.get('initial') is not None: + initial = defaults['initial'] + if callable(initial): + initial = initial() + defaults['initial'] = [i.identity for i in initial] + # Skip ManyToManyField in dependency chain + return super(RelatedField, self).formfield(**defaults) + + +class Question(Versionable): + """ + A question is an input field that can be used to extend a ticket + by custom information, e.g. "Attendee age". A question can allow one o several + input types, currently: + + * a number (``TYPE_NUMBER``) + * a one-line string (``TYPE_STRING``) + * a multi-line string (``TYPE_TEXT``) + * a boolean (``TYPE_BOOLEAN``) + + :param event: The event this question belongs to + :type event: Event + :param question: The question text. This will be displayed next to the input field. + :type question: str + :param type: One of the above types + :param required: Whether answering this question is required for submiting an order including + items associated with this question. + :type required: bool + :param items: A set of ``Items`` objects that this question should be applied to + """ + TYPE_NUMBER = "N" + TYPE_STRING = "S" + TYPE_TEXT = "T" + TYPE_BOOLEAN = "B" + TYPE_CHOICES = ( + (TYPE_NUMBER, _("Number")), + (TYPE_STRING, _("Text (one line)")), + (TYPE_TEXT, _("Multiline text")), + (TYPE_BOOLEAN, _("Yes/No")), + ) + + event = VersionedForeignKey( + Event, + related_name="questions" + ) + question = I18nTextField( + verbose_name=_("Question") + ) + type = models.CharField( + max_length=5, + choices=TYPE_CHOICES, + verbose_name=_("Question type") + ) + required = models.BooleanField( + default=False, + verbose_name=_("Required question") + ) + items = VersionedManyToManyField( + Item, + related_name='questions', + verbose_name=_("Products"), + blank=True, + help_text=_('This question will be asked to buyers of the selected products') + ) + + class Meta: + verbose_name = _("Question") + verbose_name_plural = _("Questions") + + def __str__(self): + return str(self.question) + + def delete(self, *args, **kwargs): + super().delete(*args, **kwargs) + if self.event: + self.event.get_cache().clear() + + def save(self, *args, **kwargs): + super().save(*args, **kwargs) + if self.event: + self.event.get_cache().clear() + + +class BaseRestriction(Versionable): + """ + A restriction is the abstract concept of a rule that limits the availability + of Items or ItemVariations. This model is just an abstract base class to be + extended by restriction plugins. + """ + event = VersionedForeignKey( + Event, + on_delete=models.CASCADE, + related_name="restrictions_%(app_label)s_%(class)s", + verbose_name=_("Event"), + ) + item = VersionedForeignKey( + Item, + blank=True, null=True, + verbose_name=_("Item"), + related_name="restrictions_%(app_label)s_%(class)s", + ) + variations = VariationsField( + 'pretixbase.ItemVariation', + blank=True, + verbose_name=_("Variations"), + related_name="restrictions_%(app_label)s_%(class)s", + ) + + class Meta: + abstract = True + verbose_name = _("Restriction") + verbose_name_plural = _("Restrictions") + + def delete(self, *args, **kwargs): + super().delete(*args, **kwargs) + if self.event: + self.event.get_cache().clear() + + def save(self, *args, **kwargs): + super().save(*args, **kwargs) + if self.event: + self.event.get_cache().clear() + + +class Quota(Versionable): + """ + A quota is a "pool of tickets". It is there to limit the number of items + of a certain type to be sold. For example, you could have a quota of 500 + applied to all your items (because you only have that much space in your + building), and also a quota of 100 applied to the VIP tickets for + exclusivity. In this case, no more than 500 tickets will be sold in total + and no more than 100 of them will be VIP tickets (but 450 normal and 50 + VIP tickets will be fine). + + As always, a quota can not only be tied to an item, but also to specific + variations. + + Please read the documentation section on quotas carefully before doing + anything with quotas. This might confuse you otherwise. + http://docs.pretix.eu/en/latest/development/concepts.html#restriction-by-number + + The AVAILABILITY_* constants represent various states of an quota allowing + its items/variations being for sale. + + AVAILABILITY_OK + This item is available for sale. + + AVAILABILITY_RESERVED + This item is currently not available for sale, because all available + items are in people's shopping carts. It might become available + again if those people do not proceed with checkout. + + AVAILABILITY_ORDERED + This item is currently not availalbe for sale, because all available + items are ordered. It might become available again if those people + do not pay. + + AVAILABILITY_GONE + This item is completely sold out. + + :param event: The event this belongs to + :type event: Event + :param name: This quota's name + :type str: + :param size: The number of items in this quota + :type size: int + :param items: The set of :py:class:`Item` objects this quota applies to + :param variations: The set of :py:class:`ItemVariation` objects this quota applies to + """ + + AVAILABILITY_GONE = 0 + AVAILABILITY_ORDERED = 10 + AVAILABILITY_RESERVED = 20 + AVAILABILITY_OK = 100 + + event = VersionedForeignKey( + Event, + on_delete=models.CASCADE, + related_name="quotas", + verbose_name=_("Event"), + ) + name = models.CharField( + max_length=200, + verbose_name=_("Name") + ) + size = models.PositiveIntegerField( + verbose_name=_("Total capacity") + ) + items = VersionedManyToManyField( + Item, + verbose_name=_("Item"), + related_name="quotas", + blank=True + ) + variations = VariationsField( + ItemVariation, + related_name="quotas", + blank=True, + verbose_name=_("Variations") + ) + + class Meta: + verbose_name = _("Quota") + verbose_name_plural = _("Quotas") + + def __str__(self): + return self.name + + def delete(self, *args, **kwargs): + super().delete(*args, **kwargs) + if self.event: + self.event.get_cache().clear() + + def save(self, *args, **kwargs): + super().save(*args, **kwargs) + if self.event: + self.event.get_cache().clear() + + def availability(self): + """ + This method is used to determine whether Items or ItemVariations belonging + to this quota should currently be available for sale. + + :returns: a tuple where the first entry is one of the ``Quota.AVAILABILITY_`` constants + and the second is the number of available tickets. + """ + from pretix.base.models import Order, OrderPosition, CartPosition + # TODO: These lookups are highly inefficient. However, we'll wait with optimizing + # until Django 1.8 is released, as the following feature might make it a + # lot easier: + # https://docs.djangoproject.com/en/1.8/ref/models/conditional-expressions/ + # TODO: Test for interference with old versions of Item-Quota-relations, etc. + # TODO: Prevent corner-cases like people having ordered an item before it got + # its first variationsadded + quotalookup = ( + ( # Orders for items which do not have any variations + Q(variation__isnull=True) + & Q(item__quotas__in=[self]) + ) | ( # Orders for items which do have any variations + Q(variation__quotas__in=[self]) + ) + ) + + paid_orders = OrderPosition.objects.current.filter( + Q(order__status=Order.STATUS_PAID) + & quotalookup + ).count() + + if paid_orders >= self.size: + return Quota.AVAILABILITY_GONE, 0 + + pending_valid_orders = OrderPosition.objects.current.filter( + Q(order__status=Order.STATUS_PENDING) + & Q(order__expires__gte=now()) + & quotalookup + ).count() + if (paid_orders + pending_valid_orders) >= self.size: + return Quota.AVAILABILITY_ORDERED, 0 + + valid_cart_positions = CartPosition.objects.current.filter( + Q(expires__gte=now()) + & quotalookup + ).count() + if (paid_orders + pending_valid_orders + valid_cart_positions) >= self.size: + return Quota.AVAILABILITY_RESERVED, 0 + + return Quota.AVAILABILITY_OK, self.size - paid_orders - pending_valid_orders - valid_cart_positions + + class QuotaExceededException(Exception): + pass diff --git a/src/pretix/base/models/orders.py b/src/pretix/base/models/orders.py new file mode 100644 index 0000000000..bace17ed43 --- /dev/null +++ b/src/pretix/base/models/orders.py @@ -0,0 +1,421 @@ +import random +import string +from datetime import datetime + +from django.db import models +from django.utils.timezone import now +from django.utils.translation import ugettext_lazy as _ +from versions.models import VersionedForeignKey + +from .base import CachedFile, Versionable +from .event import Event +from .items import Item, ItemVariation, Question, Quota + + +def generate_secret(): + return ''.join(random.choice(string.ascii_letters + string.digits) for _ in range(16)) + + +class Order(Versionable): + """ + An order is created when a user clicks 'buy' on his cart. It holds + several OrderPositions and is connected to an user. It has an + expiration date: If items run out of capacity, orders which are over + their expiration date might be cancelled. + + An order -- like all objects -- has an ID, which is globally unique, + but also a code, which is shorter and easier to memorize, but only + unique among a single conference. + + :param code: In addition to the ID, which is globally unique, every + order has an order code, which is shorter and easier to + memorize, but is only unique among a single conference. + :param status: The status of this order. One of: + + * ``STATUS_PENDING`` + * ``STATUS_PAID`` + * ``STATUS_EXPIRED`` + * ``STATUS_CANCELLED`` + * ``STATUS_REFUNDED`` + + :param event: The event this belongs to + :type event: Event + :param email: The email of the person who ordered this + :type email: str + :param locale: The locale of this order + :type locale: str + :param datetime: The datetime of the order placement + :type datetime: datetime + :param expires: The date until this order has to be paid to guarantee the + :type expires: datetime + :param payment_date: The date of the payment completion (null, if not yet paid). + :type payment_date: datetime + :param payment_provider: The payment provider selected by the user + :type payment_provider: str + :param payment_fee: The payment fee calculated at checkout time + :type payment_fee: decimal.Decimal + :param payment_info: Arbitrary information stored by the payment provider + :type payment_info: str + :param total: The total amount of the order, including the payment fee + :type total: decimal.Decimal + """ + + STATUS_PENDING = "n" + STATUS_PAID = "p" + STATUS_EXPIRED = "e" + STATUS_CANCELLED = "c" + STATUS_REFUNDED = "r" + STATUS_CHOICE = ( + (STATUS_PENDING, _("pending")), + (STATUS_PAID, _("paid")), + (STATUS_EXPIRED, _("expired")), + (STATUS_CANCELLED, _("cancelled")), + (STATUS_REFUNDED, _("refunded")) + ) + + code = models.CharField( + max_length=16, + verbose_name=_("Order code") + ) + status = models.CharField( + max_length=3, + choices=STATUS_CHOICE, + verbose_name=_("Status") + ) + event = VersionedForeignKey( + Event, + verbose_name=_("Event"), + related_name="orders" + ) + email = models.EmailField( + null=True, blank=True, + verbose_name=_('E-mail') + ) + locale = models.CharField( + null=True, blank=True, max_length=32, + verbose_name=_('Locale') + ) + secret = models.CharField(max_length=32, default=generate_secret) + datetime = models.DateTimeField( + verbose_name=_("Date") + ) + expires = models.DateTimeField( + verbose_name=_("Expiration date") + ) + payment_date = models.DateTimeField( + verbose_name=_("Payment date"), + null=True, blank=True + ) + payment_provider = models.CharField( + null=True, blank=True, + max_length=255, + verbose_name=_("Payment provider") + ) + payment_fee = models.DecimalField( + decimal_places=2, max_digits=10, + default=0, verbose_name=_("Payment method fee") + ) + payment_info = models.TextField( + verbose_name=_("Payment information"), + null=True, blank=True + ) + payment_manual = models.BooleanField( + verbose_name=_("Payment state was manually modified"), + default=False + ) + total = models.DecimalField( + decimal_places=2, max_digits=10, + verbose_name=_("Total amount") + ) + + class Meta: + verbose_name = _("Order") + verbose_name_plural = _("Orders") + ordering = ("-datetime",) + + def str(self): + return self.full_code + + @property + def full_code(self): + """ + A order code which is unique among all events of a single organizer, + built by contatenating the event slug and the order code. + """ + return self.event.slug.upper() + self.code + + def save(self, *args, **kwargs): + if not self.code: + self.assign_code() + if not self.datetime: + self.datetime = now() + super().save(*args, **kwargs) + + def assign_code(self): + charset = list('ABCDEFGHKLMNPQRSTUVWXYZ23456789') + while True: + code = "".join([random.choice(charset) for i in range(5)]) + if not Order.objects.filter(event=self.event, code=code).exists(): + self.code = code + return + + @property + def can_modify_answers(self): + """ + Is ``True`` if the user can change the question answers / attendee names that are + related to the order. This checks order status and modification deadlines. It also + returns ``False``, if there are no questions that can be answered. + """ + if self.status not in (Order.STATUS_PENDING, Order.STATUS_PAID, Order.STATUS_EXPIRED): + return False + modify_deadline = self.event.settings.get('last_order_modification_date', as_type=datetime) + if modify_deadline is not None and now() > modify_deadline: + return False + ask_names = self.event.settings.get('attendee_names_asked', as_type=bool) + for cp in self.positions.all().prefetch_related('item__questions'): + if (cp.item.admission and ask_names) or cp.item.questions.all(): + return True + return False # nothing there to modify + + def mark_refunded(self): + """ + Mark this order as refunded. This clones the order object, sets the payment status and + returns the cloned order object. + """ + order = self.clone() + order.status = Order.STATUS_REFUNDED + order.save() + return order + + def _can_be_paid(self): + error_messages = { + 'late': _("The payment is too late to be accepted."), + } + + if self.event.settings.get('payment_term_last') \ + and now() > self.event.settings.get('payment_term_last'): + return error_messages['late'] + if now() < self.expires: + return True + if not self.event.settings.get('payment_term_accept_late'): + return error_messages['late'] + + return self._is_still_available() + + def _is_still_available(self): + error_messages = { + 'unavailable': _('Some of the ordered products were no longer available.'), + } + positions = list(self.positions.all().select_related( + 'item', 'variation' + ).prefetch_related( + 'variation__values', 'variation__values__prop', + 'item__questions', 'answers' + )) + quota_cache = {} + try: + for i, op in enumerate(positions): + quotas = list(op.item.quotas.all()) if op.variation is None else list(op.variation.quotas.all()) + if len(quotas) == 0: + raise Quota.QuotaExceededException(error_messages['unavailable']) + + for quota in quotas: + # Lock the quota, so no other thread is allowed to perform sales covered by this + # quota while we're doing so. + if quota.identity not in quota_cache: + quota_cache[quota.identity] = quota + quota.cached_availability = quota.availability()[1] + else: + # Use cached version + quota = quota_cache[quota.identity] + quota.cached_availability -= 1 + if quota.cached_availability < 0: + # This quota is sold out/currently unavailable, so do not sell this at all + raise Quota.QuotaExceededException(error_messages['unavailable']) + except Quota.QuotaExceededException as e: + return str(e) + return True + + +class CachedTicket(models.Model): + order = VersionedForeignKey(Order, on_delete=models.CASCADE) + cachedfile = models.ForeignKey(CachedFile, on_delete=models.CASCADE) + provider = models.CharField(max_length=255) + + +class QuestionAnswer(Versionable): + """ + The answer to a Question, connected to an OrderPosition or CartPosition. + + :param orderposition: The order position this is related to, or null if this is + related to a cart position. + :type orderposition: OrderPosition + :param cartposition: The cart position this is related to, or null if this is related + to an order position. + :type cartposition: CartPosition + :param question: The question this is an answer for + :type question: Question + :param answer: The actual answer data + :type answer: str + """ + orderposition = models.ForeignKey( + 'OrderPosition', null=True, blank=True, + related_name='answers' + ) + cartposition = models.ForeignKey( + 'CartPosition', null=True, blank=True, + related_name='answers' + ) + question = VersionedForeignKey( + Question, related_name='answers' + ) + answer = models.TextField() + + +class ObjectWithAnswers: + def cache_answers(self): + """ + Creates two properties on the object. + (1) answ: a dictionary of question.id → answer string + (2) questions: a list of Question objects, extended by an 'answer' property + """ + self.answ = {} + for a in self.answers.all(): + self.answ[a.question_id] = a.answer + self.questions = [] + for q in self.item.questions.all(): + if q.identity in self.answ: + q.answer = self.answ[q.identity] + else: + q.answer = "" + self.questions.append(q) + + +class OrderPosition(ObjectWithAnswers, Versionable): + """ + An OrderPosition is one line of an order, representing one ordered items + of a specified type (or variation). + + :param order: The order this is a part of + :type order: Order + :param item: The ordered item + :type item: Item + :param variation: The ordered ItemVariation or null, if the item has no properties + :type variation: ItemVariation + :param price: The price of this item + :type price: decimal.Decimal + :param attendee_name: The attendee's name, if entered. + :type attendee_name: str + """ + order = VersionedForeignKey( + Order, + verbose_name=_("Order"), + related_name='positions' + ) + item = VersionedForeignKey( + Item, + verbose_name=_("Item"), + related_name='positions' + ) + variation = VersionedForeignKey( + ItemVariation, + null=True, blank=True, + verbose_name=_("Variation") + ) + price = models.DecimalField( + decimal_places=2, max_digits=10, + verbose_name=_("Price") + ) + attendee_name = models.CharField( + max_length=255, + verbose_name=_("Attendee name"), + blank=True, null=True, + help_text=_("Empty, if this product is not an admission ticket") + ) + + class Meta: + verbose_name = _("Order position") + verbose_name_plural = _("Order positions") + + @classmethod + def transform_cart_positions(cls, cp: list, order) -> list: + ops = [] + for cartpos in cp: + op = OrderPosition( + order=order, item=cartpos.item, variation=cartpos.variation, + price=cartpos.price, attendee_name=cartpos.attendee_name + ) + for answ in cartpos.answers.all(): + answ = answ.clone() + answ.orderposition = op + answ.cartposition = None + answ.save() + op.save() + cartpos.delete() + ops.append(op) + + +class CartPosition(ObjectWithAnswers, Versionable): + """ + A cart position is similar to a order line, except that it is not + yet part of a binding order but just placed by some user in his or + her cart. It therefore normally has a much shorter expiration time + than an ordered position, but still blocks an item in the quota pool + as we do not want to throw out users while they're clicking through + the checkout process. + + :param event: The event this belongs to + :type event: Evnt + :param item: The selected item + :type item: Item + :param session: The user session that contains this cart position + :type session: str + :param variation: The selected ItemVariation or null, if the item has no properties + :type variation: ItemVariation + :param datetime: The datetime this item was put into the cart + :type datetime: datetime + :param expires: The date until this item is guarenteed to be reserved + :type expires: datetime + :param price: The price of this item + :type price: decimal.Decimal + :param attendee_name: The attendee's name, if entered. + :type attendee_name: str + """ + event = VersionedForeignKey( + Event, + verbose_name=_("Event") + ) + session = models.CharField( + max_length=255, null=True, blank=True, + verbose_name=_("Session") + ) + item = VersionedForeignKey( + Item, + verbose_name=_("Item") + ) + variation = VersionedForeignKey( + ItemVariation, + null=True, blank=True, + verbose_name=_("Variation") + ) + price = models.DecimalField( + decimal_places=2, max_digits=10, + verbose_name=_("Price") + ) + datetime = models.DateTimeField( + verbose_name=_("Date"), + auto_now_add=True + ) + expires = models.DateTimeField( + verbose_name=_("Expiration date") + ) + attendee_name = models.CharField( + max_length=255, + verbose_name=_("Attendee name"), + blank=True, null=True, + help_text=_("Empty, if this product is not an admission ticket") + ) + + class Meta: + verbose_name = _("Cart position") + verbose_name_plural = _("Cart positions") diff --git a/src/pretix/base/models/organizer.py b/src/pretix/base/models/organizer.py new file mode 100644 index 0000000000..61e86a8e3f --- /dev/null +++ b/src/pretix/base/models/organizer.py @@ -0,0 +1,114 @@ +from django.core.validators import RegexValidator +from django.db import models +from django.utils.functional import cached_property +from django.utils.translation import ugettext_lazy as _ +from versions.models import VersionedForeignKey + +from pretix.base.settings import SettingsProxy + +from .auth import User +from .base import Versionable + + +class Organizer(Versionable): + """ + This model represents an entity organizing events, e.g. a company, institution, + charity, person, … + + :param name: The organizer's name + :type name: str + :param slug: A globally unique, short name for this organizer, to be used + in URLs and similar places. + :type slug: str + """ + + name = models.CharField(max_length=200, + verbose_name=_("Name")) + slug = models.SlugField( + max_length=50, db_index=True, + help_text=_( + "Should be short, only contain lowercase letters and numbers, and must be unique among your events. " + "This is being used in addresses and bank transfer references."), + validators=[ + RegexValidator( + regex="^[a-zA-Z0-9.-]+$", + message=_("The slug may only contain letters, numbers, dots and dashes.") + ) + ], + verbose_name=_("Slug"), + ) + permitted = models.ManyToManyField(User, through='OrganizerPermission', + related_name="organizers") + + class Meta: + verbose_name = _("Organizer") + verbose_name_plural = _("Organizers") + ordering = ("name",) + + def __str__(self): + return self.name + + def save(self, *args, **kwargs): + obj = super().save(*args, **kwargs) + self.get_cache().clear() + return obj + + @cached_property + def settings(self) -> SettingsProxy: + """ + Returns an object representing this organizer's settings + """ + return SettingsProxy(self, type=OrganizerSetting) + + def get_cache(self) -> "pretix.base.cache.ObjectRelatedCache": + """ + Returns an :py:class:`ObjectRelatedCache` object. This behaves equivalent to + Django's built-in cache backends, but puts you into an isolated environment for + this organizer, so you don't have to prefix your cache keys. In addition, the cache + is being cleared every time the organizer changes. + """ + from pretix.base.cache import ObjectRelatedCache + + return ObjectRelatedCache(self) + + +class OrganizerPermission(Versionable): + """ + The relation between an Organizer and an User who has permissions to + access an organizer profile. + + :param organizer: The organizer this relation refers to + :type organizer: Organizer + :param user: The user this set of permissions is valid for + :type user: User + :param can_create_events: Whether or not this user can create new events with this + organizer account. + :type can_create_events: bool + """ + + organizer = VersionedForeignKey(Organizer) + user = models.ForeignKey(User, related_name="organizer_perms") + can_create_events = models.BooleanField( + default=True, + verbose_name=_("Can create events"), + ) + + class Meta: + verbose_name = _("Organizer permission") + verbose_name_plural = _("Organizer permissions") + + def __str__(self): + return _("%(name)s on %(object)s") % { + 'name': str(self.user), + 'object': str(self.organizer), + } + + +class OrganizerSetting(Versionable): + """ + An event option is a key-value setting which can be set for an + organizer. It will be inherited by the events of this organizer + """ + object = VersionedForeignKey(Organizer, related_name='setting_objects') + key = models.CharField(max_length=255) + value = models.TextField()