Add sub-events and relative date settings (#503)

* Data model

* little crud

* SubEventItemForm etc

* Drop SubEventItem.active, quota editor

* Fix failing tests

* First frontend stuff

* Addons form stuff

* Quota calculation

* net price display on EventIndex

* Add tests, solve some bugs

* Correct quota selection in more places, consolidate pricing logic

* Fix failing quota tests

* Fix TypeError

* Add tests for checkout

* Fixed a bug in QuotaForm

* Prevent immutable cart if a quota was removed from an item

* Add tests for pricing

* Handle waiting list

* Filter in check-in list

* Fixed import lost in rebase

* Fix waiting list widget

* Voucher management

* Voucher redemption

* Fix broken tests

* Add subevents to OrderChangeManager

* Create a subevent during event creation

* Fix bulk voucher creation

* Introduce subevent.active

* Copy from for subevents

* Show active in list

* ICal download for subevents

* Check start and end of presale

* Failing tests / show cart logic

* Test

* Rebase migrations

* REST API integration of sub-events

* Integrate quota calculation into the traditional quota form

* Make subevent argument to add_position optional

* Log-display foo

* pretixdroid and subevents

* Filter by subevent

* Add more tests

* Some mor tests

* Rebase fixes

* More tests

* Relative dates

* Restrict selection in relative datetime widgets

* Filter subevent list

* Re-label has_subevents

* Rebase fixes, subevents in calendar view

* Performance and caching issues

* Refactor calendar templates

* Permission tests

* Calendar fixes and month selection

* subevent selection

* Rename subevents to dates

* Add tests for calendar views
This commit is contained in:
Raphael Michel
2017-07-11 13:56:00 +02:00
committed by GitHub
parent 554800c06f
commit 8123effa65
141 changed files with 5920 additions and 1012 deletions

View File

@@ -3,13 +3,13 @@ from .auth import U2FDevice, User
from .base import CachedFile, LoggedModel, cachedfile_name
from .checkin import Checkin
from .event import (
Event, Event_SettingsStore, EventLock, RequiredAction,
Event, Event_SettingsStore, EventLock, RequiredAction, SubEvent,
generate_invite_token,
)
from .invoices import Invoice, InvoiceLine, invoice_filename
from .items import (
Item, ItemAddOn, ItemCategory, ItemVariation, Question, QuestionOption,
Quota, itempicture_upload_to,
Quota, SubEventItem, SubEventItemVariation, itempicture_upload_to,
)
from .log import LogEntry
from .orders import (

View File

@@ -6,7 +6,8 @@ from django.db import models
from django.db.models.signals import post_delete
from django.dispatch import receiver
from django.utils.crypto import get_random_string
from i18nfield.utils import I18nJSONEncoder
from pretix.helpers.json import CustomJSONEncoder
def cachedfile_name(instance, filename: str) -> str:
@@ -54,7 +55,7 @@ class LoggingMixin:
event = self.event
l = LogEntry(content_object=self, user=user, action_type=action, event=event)
if data:
l.data = json.dumps(data, cls=I18nJSONEncoder)
l.data = json.dumps(data, cls=CustomJSONEncoder)
l.save()

View File

@@ -1,6 +1,6 @@
import string
import uuid
from datetime import date, datetime, time
from datetime import datetime, time
import pytz
from django.conf import settings
@@ -9,14 +9,17 @@ from django.core.files.storage import default_storage
from django.core.mail import get_connection
from django.core.validators import RegexValidator
from django.db import models
from django.db.models import Q
from django.template.defaultfilters import date as _date
from django.utils.crypto import get_random_string
from django.utils.functional import cached_property
from django.utils.timezone import make_aware, now
from django.utils.translation import ugettext_lazy as _
from i18nfield.fields import I18nCharField, I18nTextField
from pretix.base.email import CustomSMTPBackend
from pretix.base.models.base import LoggedModel
from pretix.base.reldate import RelativeDateWrapper
from pretix.base.validators import EventSlugBlacklistValidator
from pretix.helpers.daterange import daterange
@@ -24,8 +27,85 @@ from ..settings import settings_hierarkey
from .organizer import Organizer
class EventMixin:
def clean(self):
if self.presale_start and self.presale_end and self.presale_start > self.presale_end:
raise ValidationError({'presale_end': _('The end of the presale period has to be later than its start.')})
if self.date_from and self.date_to and self.date_from > self.date_to:
raise ValidationError({'date_to': _('The end of the event has to be later than its start.')})
super().clean()
def get_date_from_display(self, tz=None, show_times=True) -> str:
"""
Returns a formatted string containing the start date of the event with respect
to the current locale and to the ``show_times`` setting.
"""
tz = tz or pytz.timezone(self.settings.timezone)
return _date(
self.date_from.astimezone(tz),
"DATETIME_FORMAT" if self.settings.show_times and show_times else "DATE_FORMAT"
)
def get_time_from_display(self, tz=None) -> str:
"""
Returns a formatted string containing the start time of the event, ignoring
the ``show_times`` setting.
"""
tz = tz or pytz.timezone(self.settings.timezone)
return _date(
self.date_from.astimezone(tz), "TIME_FORMAT"
)
def get_date_to_display(self, tz=None) -> 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``.
"""
tz = tz or pytz.timezone(self.settings.timezone)
if not self.settings.show_date_to or not self.date_to:
return ""
return _date(
self.date_to.astimezone(tz),
"DATETIME_FORMAT" if self.settings.show_times else "DATE_FORMAT"
)
def get_date_range_display(self, tz=None) -> str:
"""
Returns a formatted string containing the start date and the event date
of the event with respect to the current locale and to the ``show_times`` and
``show_date_to`` settings.
"""
tz = tz or pytz.timezone(self.settings.timezone)
if not self.settings.show_date_to or not self.date_to:
return _date(self.date_from.astimezone(tz), "DATE_FORMAT")
return daterange(self.date_from.astimezone(tz), self.date_to.astimezone(tz))
@property
def presale_has_ended(self):
"""
Is true, when ``presale_end`` is set and in the past.
"""
if self.presale_end and now() > self.presale_end:
return True
return False
@property
def presale_is_running(self):
"""
Is true, when ``presale_end`` is not set or in the future and ``presale_start`` is not
set or in the past.
"""
if self.presale_start and now() < self.presale_start:
return False
if self.presale_end and now() > self.presale_end:
return False
return True
@settings_hierarkey.add(parent_field='organizer', cache_namespace='event')
class Event(LoggedModel):
class Event(EventMixin, LoggedModel):
"""
This model represents an event. An event is anything you can buy
tickets for.
@@ -54,6 +134,8 @@ class Event(LoggedModel):
:param plugins: A comma-separated list of plugin names that are active for this
event.
:type plugins: str
:param has_subevents: Enable event series functionality
:type has_subevents: bool
"""
settings_namespace = 'event'
@@ -116,6 +198,10 @@ class Event(LoggedModel):
verbose_name=_("Internal comment"),
null=True, blank=True
)
has_subevents = models.BooleanField(
verbose_name=_('Event series'),
default=False
)
class Meta:
verbose_name = _("Event")
@@ -130,13 +216,6 @@ class Event(LoggedModel):
self.get_cache().clear()
return obj
def clean(self):
if self.presale_start and self.presale_end and self.presale_start > self.presale_end:
raise ValidationError({'presale_end': _('The end of the presale period has to be later than its start.')})
if self.date_from and self.date_to and self.date_from > self.date_to:
raise ValidationError({'date_to': _('The end of the event has to be later than its start.')})
super().clean()
def get_plugins(self) -> "list[str]":
"""
Returns the names of the plugins activated for this event as a list.
@@ -145,47 +224,6 @@ class Event(LoggedModel):
return []
return self.plugins.split(",")
def get_date_from_display(self, tz=None, show_times=True) -> str:
"""
Returns a formatted string containing the start date of the event with respect
to the current locale and to the ``show_times`` setting.
"""
tz = tz or pytz.timezone(self.settings.timezone)
return _date(
self.date_from.astimezone(tz),
"DATETIME_FORMAT" if self.settings.show_times and show_times else "DATE_FORMAT"
)
def get_time_from_display(self, tz=None) -> str:
"""
Returns a formatted string containing the start time of the event, ignoring
the ``show_times`` setting.
"""
tz = tz or pytz.timezone(self.settings.timezone)
return _date(
self.date_from.astimezone(tz), "TIME_FORMAT"
)
def get_date_to_display(self, tz=None) -> 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``.
"""
tz = tz or pytz.timezone(self.settings.timezone)
if not self.settings.show_date_to or not self.date_to:
return ""
return _date(
self.date_to.astimezone(tz),
"DATETIME_FORMAT" if self.settings.show_times else "DATE_FORMAT"
)
def get_date_range_display(self, tz=None) -> str:
tz = tz or pytz.timezone(self.settings.timezone)
if not self.settings.show_date_to or not self.date_to:
return _date(self.date_from.astimezone(tz), "DATE_FORMAT")
return daterange(self.date_from.astimezone(tz), self.date_to.astimezone(tz))
def get_cache(self) -> "pretix.base.cache.ObjectRelatedCache":
"""
Returns an :py:class:`ObjectRelatedCache` object. This behaves equivalent to
@@ -197,20 +235,6 @@ class Event(LoggedModel):
return ObjectRelatedCache(self)
@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.
@@ -220,6 +244,10 @@ class Event(LoggedModel):
return locking.LockManager(self)
def get_mail_backend(self, force_custom=False):
"""
Returns an email server connection, either by using the system-wide connection
or by returning a custom one based on the event's settings.
"""
if self.settings.smtp_use_custom or force_custom:
return CustomSMTPBackend(host=self.settings.smtp_host,
port=self.settings.smtp_port,
@@ -233,9 +261,12 @@ class Event(LoggedModel):
@property
def payment_term_last(self):
"""
The last datetime of payments for this event.
"""
tz = pytz.timezone(self.settings.timezone)
return make_aware(datetime.combine(
self.settings.get('payment_term_last', as_type=date),
self.settings.get('payment_term_last', as_type=RelativeDateWrapper).datetime(self).date(),
time(hour=23, minute=59, second=59)
), tz)
@@ -277,7 +308,7 @@ class Event(LoggedModel):
ia.addon_category = category_map[ia.addon_category.pk]
ia.save()
for q in Quota.objects.filter(event=other).prefetch_related('items', 'variations'):
for q in Quota.objects.filter(event=other, subevent__isnull=True).prefetch_related('items', 'variations'):
items = list(q.items.all())
vars = list(q.variations.all())
q.pk = None
@@ -318,6 +349,9 @@ class Event(LoggedModel):
event_copy_data.send(sender=self, other=other)
def get_payment_providers(self) -> dict:
"""
Returns a dictionary of initialized payment providers mapped by their identifiers.
"""
from ..signals import register_payment_providers
responses = register_payment_providers.send(self)
@@ -331,6 +365,9 @@ class Event(LoggedModel):
return providers
def get_invoice_renderers(self) -> dict:
"""
Returns a dictionary of initialized invoice renderers mapped by their identifiers.
"""
from ..signals import register_invoice_renderers
responses = register_invoice_renderers.send(self)
@@ -345,9 +382,113 @@ class Event(LoggedModel):
@property
def invoice_renderer(self):
"""
Returns the currently configured invoice renderer.
"""
irs = self.get_invoice_renderers()
return irs[self.settings.invoice_renderer]
@property
def active_subevents(self):
"""
Returns a queryset of active subevents.
"""
return self.subevents.filter(active=True).order_by('-date_from', 'name')
@property
def active_future_subevents(self):
return self.subevents.filter(
Q(active=True) & (
Q(Q(date_to__isnull=True) & Q(date_from__gte=now()))
| Q(date_to__gte=now())
)
).order_by('date_from', 'name')
class SubEvent(EventMixin, LoggedModel):
"""
This model represents a date within an event series.
:param event: The event this belongs to
:type event: Event
:param active: Whether to show the subevent
:type active: bool
:param name: This event's full title
:type name: 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 after this date.
:type presale_end: datetime
:param location: venue
:type location: str
"""
event = models.ForeignKey(Event, related_name="subevents", on_delete=models.PROTECT)
active = models.BooleanField(default=False, verbose_name=_("Active"),
help_text=_("Only with this checkbox enabled, this date is visible in the "
"frontend to users."))
name = I18nCharField(
max_length=200,
verbose_name=_("Name"),
)
date_from = models.DateTimeField(verbose_name=_("Event start time"))
date_to = models.DateTimeField(null=True, blank=True,
verbose_name=_("Event end time"))
date_admission = models.DateTimeField(null=True, blank=True,
verbose_name=_("Admission 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."),
)
location = I18nTextField(
null=True, blank=True,
max_length=200,
verbose_name=_("Location"),
)
items = models.ManyToManyField('Item', through='SubEventItem')
variations = models.ManyToManyField('ItemVariation', through='SubEventItemVariation')
class Meta:
verbose_name = _("Date in event series")
verbose_name_plural = _("Dates in event series")
ordering = ("date_from", "name")
def __str__(self):
return '{} - {}'.format(self.name, self.get_date_range_display())
@cached_property
def settings(self):
return self.event.settings
@cached_property
def item_price_overrides(self):
from .items import SubEventItem
return {
si.item_id: si.price
for si in SubEventItem.objects.filter(subevent=self, price__isnull=False)
}
@cached_property
def var_price_overrides(self):
from .items import SubEventItemVariation
return {
si.variation_id: si.price
for si in SubEventItemVariation.objects.filter(subevent=self, price__isnull=False)
}
def generate_invite_token():
return get_random_string(length=32, allowed_chars=string.ascii_lowercase + string.digits)

View File

@@ -10,13 +10,13 @@ from django.db import models
from django.db.models import F, Func, Q, Sum
from django.utils.functional import cached_property
from django.utils.timezone import now
from django.utils.translation import ugettext_lazy as _
from django.utils.translation import pgettext_lazy, ugettext_lazy as _
from i18nfield.fields import I18nCharField, I18nTextField
from pretix.base.decimal import round_decimal
from pretix.base.models.base import LoggedModel
from .event import Event
from .event import Event, SubEvent
class ItemCategory(LoggedModel):
@@ -88,6 +88,40 @@ def itempicture_upload_to(instance, filename: str) -> str:
)
class SubEventItem(models.Model):
"""
This model can be used to change the price of a product for a single subevent (i.e. a
date in an event series).
:param subevent: The date this belongs to
:type subevent: SubEvent
:param item: The item to modify the price for
:type item: Item
:param price: The modified price (or ``None`` for the original price)
:type price: Decimal
"""
subevent = models.ForeignKey('SubEvent', on_delete=models.CASCADE)
item = models.ForeignKey('Item', on_delete=models.CASCADE)
price = models.DecimalField(max_digits=7, decimal_places=2, null=True, blank=True)
class SubEventItemVariation(models.Model):
"""
This model can be used to change the price of a product variation for a single
subevent (i.e. a date in an event series).
:param subevent: The date this belongs to
:type subevent: SubEvent
:param variation: The variation to modify the price for
:type variation: ItemVariation
:param price: The modified price (or ``None`` for the original price)
:type price: Decimal
"""
subevent = models.ForeignKey('SubEvent', on_delete=models.CASCADE)
variation = models.ForeignKey('ItemVariation', on_delete=models.CASCADE)
price = models.DecimalField(max_digits=7, decimal_places=2, null=True, blank=True)
class Item(LoggedModel):
"""
An item is a thing which can be sold. It belongs to an event and may or may not belong to a category.
@@ -271,7 +305,7 @@ class Item(LoggedModel):
return False
return True
def check_quotas(self, ignored_quotas=None, count_waitinglist=True, _cache=None):
def check_quotas(self, ignored_quotas=None, count_waitinglist=True, subevent=None, _cache=None):
"""
This method is used to determine whether this Item is currently available
for sale.
@@ -285,12 +319,18 @@ class Item(LoggedModel):
:raises ValueError: if you call this on an item which has variations associated with it.
Please use the method on the ItemVariation object you are interested in.
"""
check_quotas = set(self.quotas.all())
check_quotas = set(getattr(
self, '_subevent_quotas', # Utilize cache in product list
self.quotas.select_related('subevent').filter(subevent=subevent)
if subevent else self.quotas.all()
))
if not subevent and self.event.has_subevents:
raise TypeError('You need to supply a subevent.')
if ignored_quotas:
check_quotas -= set(ignored_quotas)
if not check_quotas:
return Quota.AVAILABILITY_OK, sys.maxsize
if self.variations.count() > 0: # NOQA
if self.has_variations: # NOQA
raise ValueError('Do not call this directly on items which have variations '
'but call this on their ItemVariation objects')
return min([q.availability(count_waitinglist=count_waitinglist, _cache=_cache) for q in check_quotas],
@@ -371,7 +411,7 @@ class ItemVariation(models.Model):
if self.item:
self.item.event.get_cache().clear()
def check_quotas(self, ignored_quotas=None, count_waitinglist=True, _cache=None) -> Tuple[int, int]:
def check_quotas(self, ignored_quotas=None, count_waitinglist=True, subevent=None, _cache=None) -> Tuple[int, int]:
"""
This method is used to determine whether this ItemVariation is currently
available for sale in terms of quotas.
@@ -383,9 +423,15 @@ class ItemVariation(models.Model):
:param count_waitinglist: If ``False``, waiting list entries will be ignored for quota calculation.
:returns: any of the return codes of :py:meth:`Quota.availability()`.
"""
check_quotas = set(self.quotas.all())
check_quotas = set(getattr(
self, '_subevent_quotas', # Utilize cache in product list
self.quotas.filter(subevent=subevent).select_related('subevent')
if subevent else self.quotas.all()
))
if ignored_quotas:
check_quotas -= set(ignored_quotas)
if not subevent and self.item.event.has_subevents: # NOQA
raise TypeError('You need to supply a subevent.')
if not check_quotas:
return Quota.AVAILABILITY_OK, sys.maxsize
return min([q.availability(count_waitinglist=count_waitinglist, _cache=_cache) for q in check_quotas],
@@ -402,6 +448,17 @@ class ItemAddOn(models.Model):
An instance of this model indicates that buying a ticket of the time ``base_item``
allows you to add up to ``max_count`` items from the category ``addon_category``
to your order that will be associated with the base item.
:param base_item: The base item the add-ons are attached to
:type base_item: Item
:param addon_category: The category the add-on can be chosen from
:type addon_category: ItemCategory
:param min_count: The minimal number of add-ons to be chosen
:type min_count: int
:param max_count: The maximal number of add-ons to be chosen
:type max_count: int
:param position: An integer used for sorting
:type position: int
"""
base_item = models.ForeignKey(
Item,
@@ -574,6 +631,8 @@ class Quota(LoggedModel):
:param event: The event this belongs to
:type event: Event
:param subevent: The event series date this belongs to, if event series are enabled
:type subevent: SubEvent
:param name: This quota's name
:type name: str
:param size: The number of items in this quota
@@ -593,6 +652,13 @@ class Quota(LoggedModel):
related_name="quotas",
verbose_name=_("Event"),
)
subevent = models.ForeignKey(
SubEvent,
null=True, blank=True,
on_delete=models.CASCADE,
related_name="quotas",
verbose_name=pgettext_lazy('subevent', "Date"),
)
name = models.CharField(
max_length=200,
verbose_name=_("Name")
@@ -687,11 +753,11 @@ class Quota(LoggedModel):
now_dt = now_dt or now()
if 'sqlite3' in settings.DATABASES['default']['ENGINE']:
func = 'MAX'
else:
else: # NOQA
func = 'GREATEST'
return Voucher.objects.filter(
Q(event=self.event) &
Q(event=self.event) & Q(subevent=self.subevent) &
Q(block_quota=True) &
Q(Q(valid_until__isnull=True) | Q(valid_until__gte=now_dt)) &
Q(Q(self._position_lookup) | Q(quota=self))
@@ -702,7 +768,7 @@ class Quota(LoggedModel):
def count_waiting_list_pending(self) -> int:
from pretix.base.models import WaitingListEntry
return WaitingListEntry.objects.filter(
Q(voucher__isnull=True) &
Q(voucher__isnull=True) & Q(subevent=self.subevent) &
self._position_lookup
).distinct().count()
@@ -711,7 +777,7 @@ class Quota(LoggedModel):
now_dt = now_dt or now()
return CartPosition.objects.filter(
Q(event=self.event) &
Q(event=self.event) & Q(subevent=self.subevent) &
Q(expires__gte=now_dt) &
~Q(
Q(voucher__isnull=False) & Q(voucher__block_quota=True)
@@ -725,14 +791,14 @@ class Quota(LoggedModel):
# This query has beeen benchmarked against a Count('id', distinct=True) aggregate and won by a small margin.
return OrderPosition.objects.filter(
self._position_lookup, order__status=Order.STATUS_PENDING, order__event=self.event
self._position_lookup, order__status=Order.STATUS_PENDING, order__event=self.event, subevent=self.subevent
).values('id').distinct().count()
def count_paid_orders(self):
from pretix.base.models import Order, OrderPosition
return OrderPosition.objects.filter(
self._position_lookup, order__status=Order.STATUS_PAID, order__event=self.event
self._position_lookup, order__status=Order.STATUS_PAID, order__event=self.event, subevent=self.subevent
).values('id').distinct().count()
@cached_property

View File

@@ -5,7 +5,9 @@ from django.contrib.contenttypes.models import ContentType
from django.db import models
from django.urls import reverse
from django.utils.functional import cached_property
from django.utils.translation import ugettext_lazy as _
from django.utils.translation import pgettext_lazy, ugettext_lazy as _
from pretix.base.models.event import SubEvent
class LogEntry(models.Model):
@@ -88,6 +90,16 @@ class LogEntry(models.Model):
}),
'val': co.name,
}
elif isinstance(co, SubEvent):
a_text = pgettext_lazy('subevent', 'Date {val}')
a_map = {
'href': reverse('control:event.subevent', kwargs={
'event': self.event.slug,
'organizer': self.event.organizer.slug,
'subevent': co.id
}),
'val': str(co)
}
elif isinstance(co, Quota):
a_text = _('Quota {val}')
a_map = {

View File

@@ -2,10 +2,11 @@ import copy
import json
import os
import string
from datetime import datetime
from datetime import datetime, time
from decimal import Decimal
from typing import List, Union
import pytz
from django.conf import settings
from django.db import models
from django.db.models import F, Sum
@@ -16,12 +17,14 @@ from django.utils.encoding import escape_uri_path
from django.utils.functional import cached_property
from django.utils.html import escape
from django.utils.safestring import mark_safe
from django.utils.timezone import now
from django.utils.translation import ugettext_lazy as _
from django.utils.timezone import make_aware, now
from django.utils.translation import pgettext_lazy, ugettext_lazy as _
from pretix.base.reldate import RelativeDateWrapper
from ..decimal import round_decimal
from .base import LoggedModel
from .event import Event
from .event import Event, SubEvent
from .items import Item, ItemVariation, Question, QuestionOption, Quota
@@ -267,7 +270,16 @@ class Order(LoggedModel):
"""
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)
modify_deadline = self.event.settings.get('last_order_modification_date', as_type=RelativeDateWrapper)
if self.event.has_subevents and modify_deadline:
modify_deadline = min([
modify_deadline.datetime(se)
for se in self.event.subevents.filter(id__in=self.positions.values_list('subevent', flat=True))
])
elif modify_deadline:
modify_deadline = modify_deadline.datetime(self.event)
if modify_deadline is not None and now() > modify_deadline:
return False
if self.event.settings.get('invoice_address_asked', as_type=bool):
@@ -292,6 +304,37 @@ class Order(LoggedModel):
and not self.event.settings.get('payment_term_expire_automatically')
)
@property
def ticket_download_date(self):
dl_date = self.event.settings.get('ticket_download_date', as_type=RelativeDateWrapper)
if dl_date:
if self.event.has_subevents:
dl_date = min([
dl_date.datetime(se)
for se in self.event.subevents.filter(id__in=self.positions.values_list('subevent', flat=True))
])
else:
dl_date = dl_date.datetime(self.event)
return dl_date
@property
def payment_term_last(self):
tz = pytz.timezone(self.event.settings.timezone)
term_last = self.event.settings.get('payment_term_last', as_type=RelativeDateWrapper)
if term_last:
if self.event.has_subevents:
term_last = min([
term_last.datetime(se).date()
for se in self.event.subevents.filter(id__in=self.positions.values_list('subevent', flat=True))
])
else:
term_last = term_last.datetime(self.event).date()
term_last = make_aware(datetime.combine(
term_last,
time(hour=23, minute=59, second=59)
), tz)
return term_last
def _can_be_paid(self) -> Union[bool, str]:
error_messages = {
'late_lastdate': _("The payment can not be accepted as the last date of payments configured in the "
@@ -299,9 +342,9 @@ class Order(LoggedModel):
'late': _("The payment can not be accepted as it the order is expired and you configured that no late "
"payments should be accepted in the payment settings."),
}
if self.event.settings.get('payment_term_last'):
if now() > self.event.payment_term_last:
term_last = self.payment_term_last
if term_last:
if now() > term_last:
return error_messages['late_lastdate']
if self.status == self.STATUS_PENDING:
@@ -320,9 +363,11 @@ class Order(LoggedModel):
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())
quotas = list(op.quotas)
if len(quotas) == 0:
raise Quota.QuotaExceededException(error_messages['unavailable'])
raise Quota.QuotaExceededException(error_messages['unavailable'].format(
item=str(op.item) + (' - ' + str(op.variation) if op.variation else '')
))
for quota in quotas:
if quota.id not in quota_cache:
@@ -430,6 +475,8 @@ class AbstractPosition(models.Model):
"""
A position can either be one line of an order or an item placed in a cart.
:param subevent: The date in the event series, if event series are enabled
:type subevent: SubEvent
:param item: The selected item
:type item: Item
:param variation: The selected ItemVariation or null, if the item has no variations
@@ -449,6 +496,12 @@ class AbstractPosition(models.Model):
:param meta_info: Additional meta information on the position, JSON-encoded.
:type meta_info: str
"""
subevent = models.ForeignKey(
SubEvent,
null=True, blank=True,
on_delete=models.CASCADE,
verbose_name=pgettext_lazy("subevent", "Date"),
)
item = models.ForeignKey(
Item,
verbose_name=_("Item"),
@@ -520,6 +573,12 @@ class AbstractPosition(models.Model):
def net_price(self):
return self.price - self.tax_value
@property
def quotas(self):
return (self.item.quotas.filter(subevent=self.subevent)
if self.variation is None
else self.variation.quotas.filter(subevent=self.subevent))
class OrderPosition(AbstractPosition):
"""

View File

@@ -5,11 +5,11 @@ from django.core.exceptions import ValidationError
from django.db import models
from django.utils.crypto import get_random_string
from django.utils.timezone import now
from django.utils.translation import ugettext_lazy as _
from django.utils.translation import pgettext_lazy, ugettext_lazy as _
from ..decimal import round_decimal
from .base import LoggedModel
from .event import Event
from .event import Event, SubEvent
from .items import Item, ItemVariation, Quota
@@ -33,6 +33,8 @@ class Voucher(LoggedModel):
:param event: The event this voucher is valid for
:type event: Event
:param subevent: The date in the event series, if event series are enabled
:type subevent: SubEvent
:param code: The secret voucher code
:type code: str
:param max_usages: The number of times this voucher can be redeemed
@@ -80,6 +82,12 @@ class Voucher(LoggedModel):
related_name="vouchers",
verbose_name=_("Event"),
)
subevent = models.ForeignKey(
SubEvent,
null=True, blank=True,
on_delete=models.CASCADE,
verbose_name=pgettext_lazy("subevent", "Date"),
)
code = models.CharField(
verbose_name=_("Voucher code"),
max_length=255, default=generate_code,
@@ -186,6 +194,8 @@ class Voucher(LoggedModel):
'Otherwise it might be unclear which quotas to block.'))
else:
raise ValidationError(_('You need to specify either a quota or a product.'))
if self.event.has_subevents and self.block_quota and not self.subevent:
raise ValidationError(_('If you want this voucher to block quota, you need to select a specific date.'))
def save(self, *args, **kwargs):
self.code = self.code.upper()

View File

@@ -3,7 +3,7 @@ from datetime import timedelta
from django.core.exceptions import ValidationError
from django.db import models, transaction
from django.utils.timezone import now
from django.utils.translation import ugettext_lazy as _
from django.utils.translation import pgettext_lazy, ugettext_lazy as _
from pretix.base.i18n import language
from pretix.base.models import Voucher
@@ -11,7 +11,7 @@ from pretix.base.services.mail import mail
from pretix.multidomain.urlreverse import build_absolute_uri
from .base import LoggedModel
from .event import Event
from .event import Event, SubEvent
from .items import Item, ItemVariation
@@ -26,6 +26,12 @@ class WaitingListEntry(LoggedModel):
related_name="waitinglistentries",
verbose_name=_("Event"),
)
subevent = models.ForeignKey(
SubEvent,
null=True, blank=True,
on_delete=models.CASCADE,
verbose_name=pgettext_lazy("subevent", "Date"),
)
created = models.DateTimeField(
verbose_name=_("On waiting list since"),
auto_now_add=True
@@ -77,9 +83,9 @@ class WaitingListEntry(LoggedModel):
def send_voucher(self, quota_cache=None, user=None):
availability = (
self.variation.check_quotas(count_waitinglist=False, _cache=quota_cache)
self.variation.check_quotas(count_waitinglist=False, subevent=self.subevent, _cache=quota_cache)
if self.variation
else self.item.check_quotas(count_waitinglist=False, _cache=quota_cache)
else self.item.check_quotas(count_waitinglist=False, subevent=self.subevent, _cache=quota_cache)
)
if availability[1] < 1:
raise WaitingListException(_('This product is currently not available.'))
@@ -98,6 +104,7 @@ class WaitingListEntry(LoggedModel):
email=self.email
),
block_quota=True,
subevent=self.subevent,
)
v.log_action('pretix.voucher.added.waitinglist', {
'item': self.item.pk,
@@ -107,7 +114,8 @@ class WaitingListEntry(LoggedModel):
'valid_until': v.valid_until.isoformat(),
'max_usages': 1,
'email': self.email,
'waitinglistentry': self.pk
'waitinglistentry': self.pk,
'subevent': self.subevent.pk if self.subevent else None,
}, user=user)
self.log_action('pretix.waitinglist.voucher', user=user)
self.voucher = v