mirror of
https://github.com/pretix/pretix.git
synced 2026-05-03 14:54:04 +00:00
Re-organized models into multiple files
This commit is contained in:
File diff suppressed because it is too large
Load Diff
20
src/pretix/base/models/__init__.py
Normal file
20
src/pretix/base/models/__init__.py
Normal file
@@ -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'
|
||||
]
|
||||
127
src/pretix/base/models/auth.py
Normal file
127
src/pretix/base/models/auth.py
Normal file
@@ -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
|
||||
86
src/pretix/base/models/base.py
Normal file
86
src/pretix/base/models/base.py
Normal file
@@ -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)
|
||||
248
src/pretix/base/models/event.py
Normal file
248
src/pretix/base/models/event.py
Normal file
@@ -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
|
||||
854
src/pretix/base/models/items.py
Normal file
854
src/pretix/base/models/items.py
Normal file
@@ -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
|
||||
421
src/pretix/base/models/orders.py
Normal file
421
src/pretix/base/models/orders.py
Normal file
@@ -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")
|
||||
114
src/pretix/base/models/organizer.py
Normal file
114
src/pretix/base/models/organizer.py
Normal file
@@ -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()
|
||||
Reference in New Issue
Block a user