mirror of
https://github.com/pretix/pretix.git
synced 2026-05-05 15:14:04 +00:00
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:
@@ -7,6 +7,7 @@ from django.utils.crypto import get_random_string
|
||||
from hierarkey.forms import HierarkeyForm
|
||||
|
||||
from pretix.base.models import Event
|
||||
from pretix.base.reldate import RelativeDateField, RelativeDateTimeField
|
||||
|
||||
from .validators import PlaceholderValidator # NOQA
|
||||
|
||||
@@ -56,6 +57,9 @@ class SettingsForm(i18nfield.forms.I18nFormMixin, HierarkeyForm):
|
||||
kwargs['locales'] = self.locales
|
||||
kwargs['initial'] = self.obj.settings.freeze()
|
||||
super().__init__(*args, **kwargs)
|
||||
for f in self.fields.values():
|
||||
if isinstance(f, (RelativeDateTimeField, RelativeDateField)):
|
||||
f.set_event(self.obj)
|
||||
|
||||
def get_new_filename(self, name: str) -> str:
|
||||
nonce = get_random_string(length=8)
|
||||
|
||||
103
src/pretix/base/migrations/0066_auto_20170708_2102.py
Normal file
103
src/pretix/base/migrations/0066_auto_20170708_2102.py
Normal file
@@ -0,0 +1,103 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.11.2 on 2017-07-08 21:02
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import django.db.models.deletion
|
||||
import i18nfield.fields
|
||||
from django.db import migrations, models
|
||||
|
||||
import pretix.base.models.base
|
||||
import pretix.base.models.event
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pretixbase', '0065_auto_20170707_0920'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='SubEvent',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('active', models.BooleanField(default=False, help_text='Only with this checkbox enabled, this sub-event is visible in the frontend to users.', verbose_name='Active')),
|
||||
('name', i18nfield.fields.I18nCharField(max_length=200, verbose_name='Name')),
|
||||
('date_from', models.DateTimeField(verbose_name='Event start time')),
|
||||
('date_to', models.DateTimeField(blank=True, null=True, verbose_name='Event end time')),
|
||||
('date_admission', models.DateTimeField(blank=True, null=True, verbose_name='Admission time')),
|
||||
('presale_end', models.DateTimeField(blank=True, help_text='No products will be sold after this date.', null=True, verbose_name='End of presale')),
|
||||
('presale_start', models.DateTimeField(blank=True, help_text='No products will be sold before this date.', null=True, verbose_name='Start of presale')),
|
||||
('location', i18nfield.fields.I18nTextField(blank=True, max_length=200, null=True, verbose_name='Location')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Sub-Event',
|
||||
'verbose_name_plural': 'Sub-Events',
|
||||
'ordering': ('date_from', 'name'),
|
||||
},
|
||||
bases=(pretix.base.models.event.EventMixin, models.Model, pretix.base.models.base.LoggingMixin),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='SubEventItem',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('price', models.DecimalField(blank=True, decimal_places=2, max_digits=7, null=True)),
|
||||
('item', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='pretixbase.Item')),
|
||||
('subevent', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='pretixbase.SubEvent')),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='SubEventItemVariation',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('price', models.DecimalField(blank=True, decimal_places=2, max_digits=7, null=True)),
|
||||
('subevent', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='pretixbase.SubEvent')),
|
||||
('variation', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='pretixbase.ItemVariation')),
|
||||
],
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='event',
|
||||
name='has_subevents',
|
||||
field=models.BooleanField(default=False, help_text='Only recommended for advanced users. If this feature is enabled, this will not only be a single event but a series of very similar events that are handled within a single shop. The single events inside the series can only differ in prices and quotas, not in other settings, and buying tickets across multiple of these events at the same time is possible.', verbose_name='Event series'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='subevent',
|
||||
name='event',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='subevents', to='pretixbase.Event'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='subevent',
|
||||
name='items',
|
||||
field=models.ManyToManyField(through='pretixbase.SubEventItem', to='pretixbase.Item'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='subevent',
|
||||
name='variations',
|
||||
field=models.ManyToManyField(through='pretixbase.SubEventItemVariation', to='pretixbase.ItemVariation'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='cartposition',
|
||||
name='subevent',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='pretixbase.SubEvent', verbose_name='Sub-event'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='orderposition',
|
||||
name='subevent',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='pretixbase.SubEvent', verbose_name='Sub-event'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='quota',
|
||||
name='subevent',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='quotas', to='pretixbase.SubEvent', verbose_name='Sub-event'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='voucher',
|
||||
name='subevent',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='pretixbase.SubEvent', verbose_name='Sub-event'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='waitinglistentry',
|
||||
name='subevent',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='pretixbase.SubEvent', verbose_name='Sub-event'),
|
||||
),
|
||||
]
|
||||
@@ -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 (
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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):
|
||||
"""
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import logging
|
||||
from collections import OrderedDict
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
from typing import Any, Dict, Union
|
||||
|
||||
@@ -16,11 +16,14 @@ from i18nfield.forms import I18nFormField, I18nTextarea
|
||||
from i18nfield.strings import LazyI18nString
|
||||
|
||||
from pretix.base.decimal import round_decimal
|
||||
from pretix.base.models import Event, Order, Quota
|
||||
from pretix.base.models import CartPosition, Event, Order, Quota
|
||||
from pretix.base.reldate import RelativeDateField, RelativeDateWrapper
|
||||
from pretix.base.settings import SettingsSandbox
|
||||
from pretix.base.signals import register_payment_providers
|
||||
from pretix.presale.views import get_cart_total
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PaymentProviderForm(Form):
|
||||
def clean(self):
|
||||
@@ -150,11 +153,10 @@ class BasePaymentProvider:
|
||||
required=False
|
||||
)),
|
||||
('_availability_date',
|
||||
forms.DateField(
|
||||
RelativeDateField(
|
||||
label=_('Available until'),
|
||||
help_text=_('Users will not be able to choose this payment provider after the given date.'),
|
||||
required=False,
|
||||
widget=forms.DateInput(attrs={'class': 'datepickerfield'})
|
||||
)),
|
||||
('_fee_reverse_calc',
|
||||
forms.BooleanField(
|
||||
@@ -230,12 +232,36 @@ class BasePaymentProvider:
|
||||
|
||||
return form
|
||||
|
||||
def _is_still_available(self, now_dt=None):
|
||||
def _is_still_available(self, now_dt=None, cart_id=None, order=None):
|
||||
now_dt = now_dt or now()
|
||||
tz = pytz.timezone(self.event.settings.timezone)
|
||||
availability_date = self.settings.get('_availability_date', as_type=date)
|
||||
|
||||
availability_date = self.settings.get('_availability_date', as_type=RelativeDateWrapper)
|
||||
if availability_date:
|
||||
if self.event.has_subevents and cart_id:
|
||||
availability_date = min([
|
||||
availability_date.datetime(se).date()
|
||||
for se in self.event.subevents.filter(
|
||||
id__in=CartPosition.objects.filter(
|
||||
cart_id=cart_id, event=self.event
|
||||
).values_list('subevent', flat=True)
|
||||
)
|
||||
])
|
||||
elif self.event.has_subevents and order:
|
||||
availability_date = min([
|
||||
availability_date.datetime(se).date()
|
||||
for se in self.event.subevents.filter(
|
||||
id__in=order.positions.values_list('subevent', flat=True)
|
||||
)
|
||||
])
|
||||
elif self.event.has_subevents:
|
||||
logger.error('Payment provider is not subevent-ready.')
|
||||
return False
|
||||
else:
|
||||
availability_date = availability_date.datetime(self.event).date()
|
||||
|
||||
return availability_date >= now_dt.astimezone(tz).date()
|
||||
|
||||
return True
|
||||
|
||||
def is_allowed(self, request: HttpRequest) -> bool:
|
||||
@@ -247,7 +273,7 @@ class BasePaymentProvider:
|
||||
|
||||
The default implementation checks for the _availability_date setting to be either unset or in the future.
|
||||
"""
|
||||
return self._is_still_available()
|
||||
return self._is_still_available(cart_id=request.session.session_key)
|
||||
|
||||
def payment_form_render(self, request: HttpRequest) -> str:
|
||||
"""
|
||||
@@ -385,7 +411,7 @@ class BasePaymentProvider:
|
||||
|
||||
:param order: The order object
|
||||
"""
|
||||
return self._is_still_available()
|
||||
return self._is_still_available(order=order)
|
||||
|
||||
def order_can_retry(self, order: Order) -> bool:
|
||||
"""
|
||||
|
||||
254
src/pretix/base/reldate.py
Normal file
254
src/pretix/base/reldate.py
Normal file
@@ -0,0 +1,254 @@
|
||||
import datetime
|
||||
from collections import namedtuple
|
||||
from typing import Union
|
||||
|
||||
import pytz
|
||||
from dateutil import parser
|
||||
from django import forms
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
|
||||
BASE_CHOICES = (
|
||||
('date_from', _('Event start')),
|
||||
('date_to', _('Event end')),
|
||||
('date_admission', _('Event admission')),
|
||||
('presale_start', _('Presale start')),
|
||||
('presale_end', _('Presale end')),
|
||||
)
|
||||
|
||||
RelativeDate = namedtuple('RelativeDate', ['days_before', 'time', 'base_date_name'])
|
||||
|
||||
|
||||
class RelativeDateWrapper:
|
||||
"""
|
||||
This contains information on a date that might be relative to an event. This means
|
||||
that the underlying data is either a fixed date or a number of days and a wall clock
|
||||
time to calculate the date based on a base point.
|
||||
|
||||
The base point can be the date_from, date_to, date_admission, presale_start or presale_end
|
||||
attribute of an event or subevent. If the respective attribute is not set, ``date_from``
|
||||
will be used.
|
||||
"""
|
||||
|
||||
def __init__(self, data: Union[datetime.datetime, RelativeDate]):
|
||||
self.data = data
|
||||
|
||||
def datetime(self, event) -> datetime.datetime:
|
||||
from .models import SubEvent
|
||||
|
||||
if isinstance(self.data, (datetime.datetime, datetime.date)):
|
||||
return self.data
|
||||
else:
|
||||
tz = pytz.timezone(event.settings.timezone)
|
||||
if isinstance(event, SubEvent):
|
||||
base_date = (
|
||||
getattr(event, self.data.base_date_name)
|
||||
or getattr(event.event, self.data.base_date_name)
|
||||
or event.date_from
|
||||
)
|
||||
else:
|
||||
base_date = getattr(event, self.data.base_date_name) or event.date_from
|
||||
|
||||
new_date = base_date.astimezone(tz) - datetime.timedelta(days=self.data.days_before)
|
||||
if self.data.time:
|
||||
new_date = new_date.replace(
|
||||
hour=self.data.time.hour,
|
||||
minute=self.data.time.minute,
|
||||
second=self.data.time.second
|
||||
)
|
||||
return new_date
|
||||
|
||||
def to_string(self) -> str:
|
||||
if isinstance(self.data, (datetime.datetime, datetime.date)):
|
||||
return self.data.isoformat()
|
||||
else:
|
||||
return 'RELDATE/{}/{}/{}/'.format( #
|
||||
self.data.days_before,
|
||||
self.data.time.strftime('%H:%M:%S') if self.data.time else '-',
|
||||
self.data.base_date_name
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_string(cls, input: str):
|
||||
if input.startswith('RELDATE/'):
|
||||
parts = input.split('/')
|
||||
if parts[2] == '-':
|
||||
time = None
|
||||
else:
|
||||
timeparts = parts[2].split(':')
|
||||
time = datetime.time(hour=int(timeparts[0]), minute=int(timeparts[1]), second=int(timeparts[2]))
|
||||
data = RelativeDate(
|
||||
days_before=int(parts[1]),
|
||||
base_date_name=parts[3],
|
||||
time=time
|
||||
)
|
||||
else:
|
||||
data = parser.parse(input)
|
||||
return RelativeDateWrapper(data)
|
||||
|
||||
|
||||
class RelativeDateTimeWidget(forms.MultiWidget):
|
||||
template_name = 'pretixbase/forms/widgets/reldatetime.html'
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.status_choices = kwargs.pop('status_choices')
|
||||
widgets = (
|
||||
forms.RadioSelect(choices=self.status_choices),
|
||||
forms.DateTimeInput(
|
||||
attrs={'class': 'datetimepicker'}
|
||||
),
|
||||
forms.NumberInput(),
|
||||
forms.Select(choices=kwargs.pop('base_choices')),
|
||||
forms.TimeInput(attrs={'placeholder': _('Time'), 'class': 'timepickerfield'})
|
||||
)
|
||||
super().__init__(widgets=widgets, *args, **kwargs)
|
||||
|
||||
def decompress(self, value):
|
||||
if not value:
|
||||
return ['unset', None, 1, 'date_from', None]
|
||||
elif isinstance(value.data, (datetime.datetime, datetime.date)):
|
||||
return ['absolute', value.data, 1, 'date_from', None]
|
||||
return ['relative', None, value.data.days_before, value.data.base_date_name, value.data.time]
|
||||
|
||||
def get_context(self, name, value, attrs):
|
||||
ctx = super().get_context(name, value, attrs)
|
||||
ctx['required'] = self.status_choices[0][0] == 'unset'
|
||||
return ctx
|
||||
|
||||
|
||||
class RelativeDateTimeField(forms.MultiValueField):
|
||||
def __init__(self, *args, **kwargs):
|
||||
status_choices = [
|
||||
('absolute', _('Fixed date:')),
|
||||
('relative', _('Relative date:')),
|
||||
]
|
||||
if not kwargs.get('required', True):
|
||||
status_choices.insert(0, ('unset', _('Not set')))
|
||||
fields = (
|
||||
forms.ChoiceField(
|
||||
choices=status_choices,
|
||||
required=True
|
||||
),
|
||||
forms.DateTimeField(
|
||||
required=False
|
||||
),
|
||||
forms.IntegerField(
|
||||
required=False
|
||||
),
|
||||
forms.ChoiceField(
|
||||
choices=BASE_CHOICES,
|
||||
required=False
|
||||
),
|
||||
forms.TimeField(
|
||||
required=False,
|
||||
),
|
||||
)
|
||||
if 'widget' not in kwargs:
|
||||
kwargs['widget'] = RelativeDateTimeWidget(status_choices=status_choices, base_choices=BASE_CHOICES)
|
||||
super().__init__(
|
||||
fields=fields, require_all_fields=False, *args, **kwargs
|
||||
)
|
||||
|
||||
def set_event(self, event):
|
||||
self.widget.widgets[3].choices = [
|
||||
(k, v) for k, v in BASE_CHOICES if getattr(event, k, None)
|
||||
]
|
||||
|
||||
def compress(self, data_list):
|
||||
if not data_list:
|
||||
return None
|
||||
if data_list[0] == 'absolute':
|
||||
return RelativeDateWrapper(data_list[1])
|
||||
elif data_list[0] == 'unset':
|
||||
return None
|
||||
else:
|
||||
return RelativeDateWrapper(RelativeDate(
|
||||
days_before=data_list[2],
|
||||
base_date_name=data_list[3],
|
||||
time=data_list[4]
|
||||
))
|
||||
|
||||
def clean(self, value):
|
||||
if value[0] == 'absolute' and not value[1]:
|
||||
raise ValidationError(self.error_messages['incomplete'])
|
||||
elif value[0] == 'relative' and (value[2] is None or not value[3]):
|
||||
raise ValidationError(self.error_messages['incomplete'])
|
||||
|
||||
return super().clean(value)
|
||||
|
||||
|
||||
class RelativeDateWidget(RelativeDateTimeWidget):
|
||||
template_name = 'pretixbase/forms/widgets/reldate.html'
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.status_choices = kwargs.pop('status_choices')
|
||||
widgets = (
|
||||
forms.RadioSelect(choices=self.status_choices),
|
||||
forms.DateInput(
|
||||
attrs={'class': 'datepickerfield'}
|
||||
),
|
||||
forms.NumberInput(),
|
||||
forms.Select(choices=kwargs.pop('base_choices')),
|
||||
)
|
||||
forms.MultiWidget.__init__(self, widgets=widgets, *args, **kwargs)
|
||||
|
||||
def decompress(self, value):
|
||||
if not value:
|
||||
return ['unset', None, 1, 'date_from']
|
||||
elif isinstance(value.data, (datetime.datetime, datetime.date)):
|
||||
return ['absolute', value.data, 1, 'date_from']
|
||||
return ['relative', None, value.data.days_before, value.data.base_date_name]
|
||||
|
||||
|
||||
class RelativeDateField(RelativeDateTimeField):
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
status_choices = [
|
||||
('absolute', _('Fixed date:')),
|
||||
('relative', _('Relative date:')),
|
||||
]
|
||||
if not kwargs.get('required', True):
|
||||
status_choices.insert(0, ('unset', _('Not set')))
|
||||
fields = (
|
||||
forms.ChoiceField(
|
||||
choices=status_choices,
|
||||
required=True
|
||||
),
|
||||
forms.DateField(
|
||||
required=False
|
||||
),
|
||||
forms.IntegerField(
|
||||
required=False
|
||||
),
|
||||
forms.ChoiceField(
|
||||
choices=BASE_CHOICES,
|
||||
required=False
|
||||
),
|
||||
)
|
||||
if 'widget' not in kwargs:
|
||||
kwargs['widget'] = RelativeDateWidget(status_choices=status_choices, base_choices=BASE_CHOICES)
|
||||
forms.MultiValueField.__init__(
|
||||
self, fields=fields, require_all_fields=False, *args, **kwargs
|
||||
)
|
||||
|
||||
def compress(self, data_list):
|
||||
if not data_list:
|
||||
return None
|
||||
if data_list[0] == 'absolute':
|
||||
return RelativeDateWrapper(data_list[1])
|
||||
elif data_list[0] == 'unset':
|
||||
return None
|
||||
else:
|
||||
return RelativeDateWrapper(RelativeDate(
|
||||
days_before=data_list[2],
|
||||
base_date_name=data_list[3],
|
||||
time=None
|
||||
))
|
||||
|
||||
def clean(self, value):
|
||||
if value[0] == 'absolute' and not value[1]:
|
||||
raise ValidationError(self.error_messages['incomplete'])
|
||||
elif value[0] == 'relative' and (value[2] is None or not value[3]):
|
||||
raise ValidationError(self.error_messages['incomplete'])
|
||||
|
||||
return super().clean(value)
|
||||
@@ -7,15 +7,16 @@ from celery.exceptions import MaxRetriesExceededError
|
||||
from django.db import transaction
|
||||
from django.db.models import Q
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import ugettext as _
|
||||
from django.utils.translation import pgettext_lazy, ugettext as _
|
||||
|
||||
from pretix.base.decimal import round_decimal
|
||||
from pretix.base.i18n import LazyLocaleException, language
|
||||
from pretix.base.models import (
|
||||
CartPosition, Event, Item, ItemVariation, Voucher,
|
||||
)
|
||||
from pretix.base.models.event import SubEvent
|
||||
from pretix.base.services.async import ProfiledTask
|
||||
from pretix.base.services.locking import LockTimeoutException
|
||||
from pretix.base.services.pricing import get_price
|
||||
from pretix.celery_app import app
|
||||
|
||||
|
||||
@@ -28,6 +29,7 @@ error_messages = {
|
||||
'server was too busy. Please try again.'),
|
||||
'empty': _('You did not select any products.'),
|
||||
'unknown_position': _('Unknown cart position.'),
|
||||
'subevent_required': pgettext_lazy('subevent', 'No date was specified.'),
|
||||
'not_for_sale': _('You selected a product which is not available for sale.'),
|
||||
'unavailable': _('Some of the products you selected are no longer available. '
|
||||
'Please see below for details.'),
|
||||
@@ -39,7 +41,11 @@ error_messages = {
|
||||
'min_items_per_product_removed': _("We removed %(product)s from your cart as you can not buy less than "
|
||||
"%(min)s items of it."),
|
||||
'not_started': _('The presale period for this event has not yet started.'),
|
||||
'ended': _('The presale period has ended.'),
|
||||
'ended': _('The presale period for this event has ended.'),
|
||||
'some_subevent_not_started': _('The presale period for this event has not yet started. The affected positions '
|
||||
'have been removed from your cart.'),
|
||||
'some_subevent_ended': _('The presale period for one of the events in your cart has ended. The affected '
|
||||
'positions have been removed from your cart.'),
|
||||
'price_too_high': _('The entered price is to high.'),
|
||||
'voucher_invalid': _('This voucher code is not known in our database.'),
|
||||
'voucher_redeemed': _('This voucher code has already been used the maximum number of times allowed.'),
|
||||
@@ -48,7 +54,9 @@ error_messages = {
|
||||
'cart if you want to use it for a different product.'),
|
||||
'voucher_expired': _('This voucher is expired.'),
|
||||
'voucher_invalid_item': _('This voucher is not valid for this product.'),
|
||||
'voucher_invalid_subevent': pgettext_lazy('subevent', 'This voucher is not valid for this event date.'),
|
||||
'voucher_required': _('You need a valid voucher code to order this product.'),
|
||||
'inactive_subevent': pgettext_lazy('subevent', 'The selected event date is not active.'),
|
||||
'addon_invalid_base': _('You can not select an add-on for the selected product.'),
|
||||
'addon_duplicate_item': _('You can not select two variations of the same add-on product.'),
|
||||
'addon_max_count': _('You can select at most %(max)s add-ons from the category %(cat)s for the product %(base)s.'),
|
||||
@@ -60,10 +68,10 @@ error_messages = {
|
||||
|
||||
class CartManager:
|
||||
AddOperation = namedtuple('AddOperation', ('count', 'item', 'variation', 'price', 'voucher', 'quotas',
|
||||
'addon_to'))
|
||||
'addon_to', 'subevent'))
|
||||
RemoveOperation = namedtuple('RemoveOperation', ('position',))
|
||||
ExtendOperation = namedtuple('ExtendOperation', ('position', 'count', 'item', 'variation', 'price', 'voucher',
|
||||
'quotas'))
|
||||
'quotas', 'subevent'))
|
||||
order = {
|
||||
RemoveOperation: 10,
|
||||
ExtendOperation: 20,
|
||||
@@ -78,6 +86,7 @@ class CartManager:
|
||||
self._quota_diff = Counter()
|
||||
self._voucher_use_diff = Counter()
|
||||
self._items_cache = {}
|
||||
self._subevents_cache = {}
|
||||
self._variations_cache = {}
|
||||
self._expiry = None
|
||||
|
||||
@@ -85,7 +94,7 @@ class CartManager:
|
||||
def positions(self):
|
||||
return CartPosition.objects.filter(
|
||||
Q(cart_id=self.cart_id) & Q(event=self.event)
|
||||
).select_related('item')
|
||||
).select_related('item', 'subevent')
|
||||
|
||||
def _calculate_expiry(self):
|
||||
self._expiry = self.now_dt + timedelta(minutes=self.event.settings.get('reservation_time', as_type=int))
|
||||
@@ -101,31 +110,41 @@ class CartManager:
|
||||
# We can extend the reservation of items which are not yet expired without risk
|
||||
self.positions.filter(expires__gt=self.now_dt).update(expires=self._expiry)
|
||||
|
||||
def _delete_expired(self, expired: List[CartPosition]):
|
||||
for cp in expired:
|
||||
if cp.expires <= self.now_dt:
|
||||
def _delete_out_of_timeframe(self):
|
||||
err = None
|
||||
for cp in self.positions:
|
||||
if cp.subevent and cp.subevent.presale_start and self.now_dt < cp.subevent.presale_start:
|
||||
err = error_messages['some_subevent_not_started']
|
||||
cp.delete()
|
||||
|
||||
if cp.subevent and cp.subevent.presale_end and self.now_dt > cp.subevent.presale_end:
|
||||
err = error_messages['some_subevent_ended']
|
||||
cp.delete()
|
||||
return err
|
||||
|
||||
def _update_subevents_cache(self, se_ids: List[int]):
|
||||
self._subevents_cache.update({
|
||||
i.pk: i
|
||||
for i in self.event.subevents.filter(id__in=[i for i in se_ids if i and i not in self._items_cache])
|
||||
})
|
||||
|
||||
def _update_items_cache(self, item_ids: List[int], variation_ids: List[int]):
|
||||
self._items_cache.update(
|
||||
{
|
||||
i.pk: i
|
||||
for i
|
||||
in self.event.items.select_related('category').prefetch_related(
|
||||
'addons', 'addons__addon_category', 'quotas'
|
||||
).filter(
|
||||
id__in=[i for i in item_ids if i and i not in self._items_cache]
|
||||
)
|
||||
}
|
||||
)
|
||||
self._variations_cache.update(
|
||||
{v.pk: v for v in
|
||||
ItemVariation.objects.filter(item__event=self.event).prefetch_related(
|
||||
'quotas'
|
||||
).select_related('item', 'item__event').filter(
|
||||
id__in=[i for i in variation_ids if i and i not in self._variations_cache]
|
||||
)}
|
||||
)
|
||||
self._items_cache.update({
|
||||
i.pk: i
|
||||
for i in self.event.items.select_related('category').prefetch_related(
|
||||
'addons', 'addons__addon_category', 'quotas'
|
||||
).filter(
|
||||
id__in=[i for i in item_ids if i and i not in self._items_cache]
|
||||
)
|
||||
})
|
||||
self._variations_cache.update({
|
||||
v.pk: v
|
||||
for v in ItemVariation.objects.filter(item__event=self.event).prefetch_related(
|
||||
'quotas'
|
||||
).select_related('item', 'item__event').filter(
|
||||
id__in=[i for i in variation_ids if i and i not in self._variations_cache]
|
||||
)
|
||||
})
|
||||
|
||||
def _check_max_cart_size(self):
|
||||
cartsize = self.positions.filter(addon_to__isnull=True).count()
|
||||
@@ -150,6 +169,18 @@ class CartManager:
|
||||
if op.voucher and not op.voucher.applies_to(op.item, op.variation):
|
||||
raise CartError(error_messages['voucher_invalid_item'])
|
||||
|
||||
if op.voucher and op.voucher.subevent_id and op.voucher.subevent_id != op.subevent.pk:
|
||||
raise CartError(error_messages['voucher_invalid_subevent'])
|
||||
|
||||
if op.subevent and not op.subevent.active:
|
||||
raise CartError(error_messages['inactive_subevent'])
|
||||
|
||||
if op.subevent and op.subevent.presale_start and self.now_dt < op.subevent.presale_start:
|
||||
raise CartError(error_messages['not_started'])
|
||||
|
||||
if op.subevent and op.subevent.presale_end and self.now_dt > op.subevent.presale_end:
|
||||
raise CartError(error_messages['ended'])
|
||||
|
||||
if isinstance(op, self.AddOperation):
|
||||
if op.item.category and op.item.category.is_addon and not op.addon_to:
|
||||
raise CartError(error_messages['addon_only'])
|
||||
@@ -181,34 +212,24 @@ class CartManager:
|
||||
)
|
||||
|
||||
def _get_price(self, item: Item, variation: Optional[ItemVariation],
|
||||
voucher: Optional[Voucher], custom_price: Optional[Decimal]):
|
||||
price = item.default_price if variation is None else (
|
||||
variation.default_price if variation.default_price is not None else item.default_price
|
||||
)
|
||||
if voucher:
|
||||
price = voucher.calculate_price(price)
|
||||
|
||||
if item.free_price and custom_price is not None and custom_price != "":
|
||||
if not isinstance(custom_price, Decimal):
|
||||
custom_price = Decimal(custom_price.replace(",", "."))
|
||||
if custom_price > 100000000:
|
||||
raise CartError(error_messages['price_too_high'])
|
||||
if self.event.settings.display_net_prices:
|
||||
custom_price = round_decimal(custom_price * (100 + item.tax_rate) / 100)
|
||||
price = max(custom_price, price)
|
||||
|
||||
return price
|
||||
voucher: Optional[Voucher], custom_price: Optional[Decimal],
|
||||
subevent: Optional[SubEvent]):
|
||||
return get_price(item, variation, voucher, custom_price, subevent, self.event.settings.display_net_prices)
|
||||
|
||||
def extend_expired_positions(self):
|
||||
expired = self.positions.filter(expires__lte=self.now_dt).select_related(
|
||||
'item', 'variation', 'voucher'
|
||||
).prefetch_related('item__quotas', 'variation__quotas')
|
||||
err = None
|
||||
for cp in expired:
|
||||
price = self._get_price(cp.item, cp.variation, cp.voucher, cp.price)
|
||||
price = self._get_price(cp.item, cp.variation, cp.voucher, cp.price, cp.subevent)
|
||||
|
||||
quotas = list(cp.item.quotas.all()) if cp.variation is None else list(cp.variation.quotas.all())
|
||||
quotas = list(cp.quotas)
|
||||
if not quotas:
|
||||
raise CartError(error_messages['unavailable'])
|
||||
self._operations.append(self.RemoveOperation(position=cp))
|
||||
continue
|
||||
err = error_messages['unavailable']
|
||||
|
||||
if not cp.voucher or (not cp.voucher.allow_ignore_quota and not cp.voucher.block_quota):
|
||||
for quota in quotas:
|
||||
self._quota_diff[quota] += 1
|
||||
@@ -217,7 +238,7 @@ class CartManager:
|
||||
|
||||
op = self.ExtendOperation(
|
||||
position=cp, item=cp.item, variation=cp.variation, voucher=cp.voucher, count=1,
|
||||
price=price, quotas=quotas
|
||||
price=price, quotas=quotas, subevent=cp.subevent
|
||||
)
|
||||
self._check_item_constraints(op)
|
||||
|
||||
@@ -225,10 +246,12 @@ class CartManager:
|
||||
self._voucher_use_diff[cp.voucher] += 1
|
||||
|
||||
self._operations.append(op)
|
||||
return err
|
||||
|
||||
def add_new_items(self, items: List[dict]):
|
||||
# Fetch items from the database
|
||||
self._update_items_cache([i['item'] for i in items], [i['variation'] for i in items])
|
||||
self._update_subevents_cache([i['subevent'] for i in items if i.get('subevent')])
|
||||
quota_diff = Counter()
|
||||
voucher_use_diff = Counter()
|
||||
operations = []
|
||||
@@ -240,6 +263,13 @@ class CartManager:
|
||||
if i['item'] not in self._items_cache or (i['variation'] and i['variation'] not in self._variations_cache):
|
||||
raise CartError(error_messages['not_for_sale'])
|
||||
|
||||
if self.event.has_subevents:
|
||||
if not i.get('subevent'):
|
||||
raise CartError(error_messages['subevent_required'])
|
||||
subevent = self._subevents_cache[int(i.get('subevent'))]
|
||||
else:
|
||||
subevent = None
|
||||
|
||||
item = self._items_cache[i['item']]
|
||||
variation = self._variations_cache[i['variation']] if i['variation'] is not None else None
|
||||
voucher = None
|
||||
@@ -253,8 +283,8 @@ class CartManager:
|
||||
voucher_use_diff[voucher] += i['count']
|
||||
|
||||
# Fetch all quotas. If there are no quotas, this item is not allowed to be sold.
|
||||
|
||||
quotas = list(item.quotas.all()) if variation is None else list(variation.quotas.all())
|
||||
quotas = list(item.quotas.filter(subevent=subevent)
|
||||
if variation is None else variation.quotas.filter(subevent=subevent))
|
||||
if not quotas:
|
||||
raise CartError(error_messages['unavailable'])
|
||||
if not voucher or (not voucher.allow_ignore_quota and not voucher.block_quota):
|
||||
@@ -263,10 +293,10 @@ class CartManager:
|
||||
else:
|
||||
quotas = []
|
||||
|
||||
price = self._get_price(item, variation, voucher, i.get('price'))
|
||||
price = self._get_price(item, variation, voucher, i.get('price'), subevent)
|
||||
op = self.AddOperation(
|
||||
count=i['count'], item=item, variation=variation, price=price, voucher=voucher, quotas=quotas,
|
||||
addon_to=False
|
||||
addon_to=False, subevent=subevent
|
||||
)
|
||||
self._check_item_constraints(op)
|
||||
operations.append(op)
|
||||
@@ -345,7 +375,8 @@ class CartManager:
|
||||
raise CartError(error_messages['addon_invalid_base'])
|
||||
|
||||
# Fetch all quotas. If there are no quotas, this item is not allowed to be sold.
|
||||
quotas = list(item.quotas.all()) if variation is None else list(variation.quotas.all())
|
||||
quotas = list(item.quotas.filter(subevent=cp.subevent)
|
||||
if variation is None else variation.quotas.filter(subevent=cp.subevent))
|
||||
if not quotas:
|
||||
raise CartError(error_messages['unavailable'])
|
||||
|
||||
@@ -361,11 +392,11 @@ class CartManager:
|
||||
for quota in quotas:
|
||||
quota_diff[quota] += 1
|
||||
|
||||
price = self._get_price(item, variation, None, None)
|
||||
price = self._get_price(item, variation, None, None, cp.subevent)
|
||||
|
||||
op = self.AddOperation(
|
||||
count=1, item=item, variation=variation, price=price, voucher=None, quotas=quotas,
|
||||
addon_to=cp
|
||||
addon_to=cp, subevent=cp.subevent
|
||||
)
|
||||
self._check_item_constraints(op)
|
||||
operations.append(op)
|
||||
@@ -403,7 +434,7 @@ class CartManager:
|
||||
for k, v in al.items():
|
||||
if k not in input_addons[cp.id]:
|
||||
if v.expires > self.now_dt:
|
||||
quotas = list(cp.item.quotas.all()) if cp.variation is None else list(cp.variation.quotas.all())
|
||||
quotas = list(cp.quotas)
|
||||
|
||||
for quota in quotas:
|
||||
quota_diff[quota] -= 1
|
||||
@@ -523,7 +554,8 @@ class CartManager:
|
||||
event=self.event, item=op.item, variation=op.variation,
|
||||
price=op.price, expires=self._expiry,
|
||||
cart_id=self.cart_id, voucher=op.voucher,
|
||||
addon_to=op.addon_to if op.addon_to else None
|
||||
addon_to=op.addon_to if op.addon_to else None,
|
||||
subevent=op.subevent
|
||||
))
|
||||
elif isinstance(op, self.ExtendOperation):
|
||||
if available_count == 1:
|
||||
@@ -547,8 +579,9 @@ class CartManager:
|
||||
with transaction.atomic():
|
||||
self.now_dt = now_dt
|
||||
self._extend_expiry_of_valid_existing_positions()
|
||||
self.extend_expired_positions()
|
||||
err = self._perform_operations()
|
||||
err = self._delete_out_of_timeframe()
|
||||
err = self.extend_expired_positions() or err
|
||||
err = self._perform_operations() or err
|
||||
if err:
|
||||
raise CartError(err)
|
||||
|
||||
@@ -559,8 +592,7 @@ def add_items_to_cart(self, event: int, items: List[dict], cart_id: str=None, lo
|
||||
Adds a list of items to a user's cart.
|
||||
:param event: The event ID in question
|
||||
:param items: A list of dicts with the keys item, variation, number, custom_price, voucher
|
||||
:param session: Session ID of a guest
|
||||
:param coupon: A coupon that should also be reeemed
|
||||
:param cart_id: Session ID of a guest
|
||||
:raises CartError: On any error that occured
|
||||
"""
|
||||
with language(locale):
|
||||
|
||||
@@ -22,14 +22,17 @@ from pretix.base.models import (
|
||||
CartPosition, Event, Item, ItemVariation, Order, OrderPosition, Quota,
|
||||
User, Voucher,
|
||||
)
|
||||
from pretix.base.models.event import SubEvent
|
||||
from pretix.base.models.orders import CachedTicket, InvoiceAddress
|
||||
from pretix.base.payment import BasePaymentProvider
|
||||
from pretix.base.reldate import RelativeDateWrapper
|
||||
from pretix.base.services.async import ProfiledTask
|
||||
from pretix.base.services.invoices import (
|
||||
generate_cancellation, generate_invoice, invoice_qualified,
|
||||
)
|
||||
from pretix.base.services.locking import LockTimeoutException
|
||||
from pretix.base.services.mail import SendMailException, mail
|
||||
from pretix.base.services.pricing import get_price
|
||||
from pretix.base.signals import order_paid, order_placed, periodic_task
|
||||
from pretix.celery_app import app
|
||||
from pretix.multidomain.urlreverse import build_absolute_uri
|
||||
@@ -58,6 +61,10 @@ error_messages = {
|
||||
'removed this item from your cart.'),
|
||||
'voucher_required': _('You need a valid voucher code to order one of the products in your cart. We removed this '
|
||||
'item from your cart.'),
|
||||
'some_subevent_not_started': _('The presale period for one of the events in your cart has not yet started. The '
|
||||
'affected positions have been removed from your cart.'),
|
||||
'some_subevent_ended': _('The presale period for one of the events in your cart has ended. The affected '
|
||||
'positions have been removed from your cart.'),
|
||||
}
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -230,7 +237,7 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio
|
||||
err = err or error_messages['unavailable']
|
||||
cp.delete()
|
||||
continue
|
||||
quotas = list(cp.item.quotas.all()) if cp.variation is None else list(cp.variation.quotas.all())
|
||||
quotas = list(cp.quotas)
|
||||
|
||||
products_seen[cp.item] += 1
|
||||
if cp.item.max_per_order and products_seen[cp.item] > cp.item.max_per_order:
|
||||
@@ -250,9 +257,19 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio
|
||||
cp.delete() # Sorry!
|
||||
continue
|
||||
|
||||
if cp.subevent and cp.subevent.presale_start and now_dt < cp.subevent.presale_start:
|
||||
err = err or error_messages['some_subevent_not_started']
|
||||
cp.delete()
|
||||
break
|
||||
|
||||
if cp.subevent and cp.subevent.presale_end and now_dt > cp.subevent.presale_end:
|
||||
err = err or error_messages['some_subevent_ended']
|
||||
cp.delete()
|
||||
break
|
||||
|
||||
if cp.item.require_voucher and cp.voucher is None:
|
||||
cp.delete()
|
||||
err = error_messages['voucher_required']
|
||||
err = err or error_messages['voucher_required']
|
||||
break
|
||||
|
||||
if cp.item.hide_without_voucher and (cp.voucher is None or cp.voucher.item is None
|
||||
@@ -265,8 +282,7 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio
|
||||
# Other checks are not necessary
|
||||
continue
|
||||
|
||||
price = cp.item.default_price if cp.variation is None else (
|
||||
cp.variation.default_price if cp.variation.default_price is not None else cp.item.default_price)
|
||||
price = get_price(cp.item, cp.variation, cp.voucher, cp.price, cp.subevent, custom_price_is_net=False)
|
||||
|
||||
if price is False or len(quotas) == 0:
|
||||
err = err or error_messages['unavailable']
|
||||
@@ -278,7 +294,6 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio
|
||||
err = err or error_messages['voucher_expired']
|
||||
cp.delete()
|
||||
continue
|
||||
price = cp.voucher.calculate_price(price)
|
||||
|
||||
if price != cp.price and not (cp.item.free_price and cp.price > price):
|
||||
positions[i] = cp
|
||||
@@ -317,7 +332,7 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio
|
||||
def _create_order(event: Event, email: str, positions: List[CartPosition], now_dt: datetime,
|
||||
payment_provider: BasePaymentProvider, locale: str=None, address: int=None,
|
||||
meta_info: dict=None):
|
||||
from datetime import date, time
|
||||
from datetime import time
|
||||
|
||||
total = sum([c.price for c in positions])
|
||||
payment_fee = payment_provider.calculate_fee(total)
|
||||
@@ -334,13 +349,21 @@ def _create_order(event: Event, email: str, positions: List[CartPosition], now_d
|
||||
|
||||
expires = exp_by_date
|
||||
|
||||
if event.settings.get('payment_term_last'):
|
||||
last_date = make_aware(datetime.combine(
|
||||
event.settings.get('payment_term_last', as_type=date),
|
||||
term_last = event.settings.get('payment_term_last', as_type=RelativeDateWrapper)
|
||||
if term_last:
|
||||
if event.has_subevents:
|
||||
term_last = min([
|
||||
term_last.datetime(se).date()
|
||||
for se in event.subevents.filter(id__in=[p.subevent_id for p in positions])
|
||||
])
|
||||
else:
|
||||
term_last = term_last.datetime(event).date()
|
||||
term_last = make_aware(datetime.combine(
|
||||
term_last,
|
||||
time(hour=23, minute=59, second=59)
|
||||
), tz)
|
||||
if last_date < expires:
|
||||
expires = last_date
|
||||
if term_last < expires:
|
||||
expires = term_last
|
||||
|
||||
with transaction.atomic():
|
||||
order = Order.objects.create(
|
||||
@@ -385,7 +408,7 @@ def _perform_order(event: str, payment_provider: str, position_ids: List[str],
|
||||
|
||||
with event.lock() as now_dt:
|
||||
positions = list(CartPosition.objects.filter(
|
||||
id__in=position_ids).select_related('item', 'variation'))
|
||||
id__in=position_ids).select_related('item', 'variation', 'subevent'))
|
||||
if len(positions) == 0:
|
||||
raise OrderError(error_messages['empty'])
|
||||
if len(position_ids) != len(positions):
|
||||
@@ -497,6 +520,7 @@ class OrderChangeManager:
|
||||
'free_to_paid': _('You cannot change a free order to a paid order.'),
|
||||
'product_without_variation': _('You need to select a variation of the product.'),
|
||||
'quota': _('The quota {name} does not have enough capacity left to perform the operation.'),
|
||||
'quota_missing': _('There is no quota defined that allows this operation.'),
|
||||
'product_invalid': _('The selected product is not active or has no price set.'),
|
||||
'complete_cancel': _('This operation would leave the order empty. Please cancel the order itself instead.'),
|
||||
'not_pending_or_paid': _('Only pending or paid orders can be changed.'),
|
||||
@@ -506,11 +530,13 @@ class OrderChangeManager:
|
||||
'price of the order as partial payments or refunds are not yet supported.'),
|
||||
'addon_to_required': _('This is an addon product, please select the base position it should be added to.'),
|
||||
'addon_invalid': _('The selected base position does not allow you to add this product as an add-on.'),
|
||||
'subevent_required': _('You need to choose a subevent for the new position.'),
|
||||
}
|
||||
ItemOperation = namedtuple('ItemOperation', ('position', 'item', 'variation', 'price'))
|
||||
SubeventOperation = namedtuple('SubeventOperation', ('position', 'subevent', 'price'))
|
||||
PriceOperation = namedtuple('PriceOperation', ('position', 'price'))
|
||||
CancelOperation = namedtuple('CancelOperation', ('position',))
|
||||
AddOperation = namedtuple('AddOperation', ('item', 'variation', 'price', 'addon_to'))
|
||||
AddOperation = namedtuple('AddOperation', ('item', 'variation', 'price', 'addon_to', 'subevent'))
|
||||
|
||||
def __init__(self, order: Order, user):
|
||||
self.order = order
|
||||
@@ -522,26 +548,51 @@ class OrderChangeManager:
|
||||
def change_item(self, position: OrderPosition, item: Item, variation: Optional[ItemVariation]):
|
||||
if (not variation and item.has_variations) or (variation and variation.item_id != item.pk):
|
||||
raise OrderError(self.error_messages['product_without_variation'])
|
||||
price = item.default_price if variation is None else variation.price
|
||||
if price is None:
|
||||
|
||||
price = get_price(item, variation, voucher=position.voucher, subevent=position.subevent)
|
||||
|
||||
if price is None: # NOQA
|
||||
raise OrderError(self.error_messages['product_invalid'])
|
||||
|
||||
new_quotas = (variation.quotas.filter(subevent=position.subevent)
|
||||
if variation else item.quotas.filter(subevent=position.subevent))
|
||||
if not new_quotas:
|
||||
raise OrderError(self.error_messages['quota_missing'])
|
||||
|
||||
self._totaldiff = price - position.price
|
||||
self._quotadiff.update(variation.quotas.all() if variation else item.quotas.all())
|
||||
self._quotadiff.subtract(position.variation.quotas.all() if position.variation else position.item.quotas.all())
|
||||
self._quotadiff.update(new_quotas)
|
||||
self._quotadiff.subtract(position.quotas)
|
||||
self._operations.append(self.ItemOperation(position, item, variation, price))
|
||||
|
||||
def change_subevent(self, position: OrderPosition, subevent: SubEvent):
|
||||
price = get_price(position.item, position.variation, voucher=position.voucher, subevent=subevent)
|
||||
|
||||
if price is None: # NOQA
|
||||
raise OrderError(self.error_messages['product_invalid'])
|
||||
|
||||
new_quotas = (position.variation.quotas.filter(subevent=subevent)
|
||||
if position.variation else position.item.quotas.filter(subevent=subevent))
|
||||
if not new_quotas:
|
||||
raise OrderError(self.error_messages['quota_missing'])
|
||||
|
||||
self._totaldiff = price - position.price
|
||||
self._quotadiff.update(new_quotas)
|
||||
self._quotadiff.subtract(position.quotas)
|
||||
self._operations.append(self.SubeventOperation(position, subevent, price))
|
||||
|
||||
def change_price(self, position: OrderPosition, price: Decimal):
|
||||
self._totaldiff = price - position.price
|
||||
self._operations.append(self.PriceOperation(position, price))
|
||||
|
||||
def cancel(self, position: OrderPosition):
|
||||
self._totaldiff = -position.price
|
||||
self._quotadiff.subtract(position.variation.quotas.all() if position.variation else position.item.quotas.all())
|
||||
self._quotadiff.subtract(position.quotas)
|
||||
self._operations.append(self.CancelOperation(position))
|
||||
|
||||
def add_position(self, item: Item, variation: ItemVariation, price: Decimal, addon_to: Order):
|
||||
def add_position(self, item: Item, variation: ItemVariation, price: Decimal, addon_to: Order = None,
|
||||
subevent: SubEvent = None):
|
||||
if price is None:
|
||||
price = item.default_price if variation is None else variation.price
|
||||
price = get_price(item, variation, subevent=subevent)
|
||||
if price is None:
|
||||
raise OrderError(self.error_messages['product_invalid'])
|
||||
if not addon_to and item.category and item.category.is_addon:
|
||||
@@ -549,10 +600,17 @@ class OrderChangeManager:
|
||||
if addon_to:
|
||||
if not item.category or item.category_id not in addon_to.item.addons.values_list('addon_category', flat=True):
|
||||
raise OrderError(self.error_messages['addon_invalid'])
|
||||
if self.order.event.has_subevents and not subevent:
|
||||
raise OrderError(self.error_messages['subevent_required'])
|
||||
|
||||
new_quotas = (variation.quotas.filter(subevent=subevent)
|
||||
if variation else item.quotas.filter(subevent=subevent))
|
||||
if not new_quotas:
|
||||
raise OrderError(self.error_messages['quota_missing'])
|
||||
|
||||
self._totaldiff = price
|
||||
self._quotadiff.update(variation.quotas.all() if variation else item.quotas.all())
|
||||
self._operations.append(self.AddOperation(item, variation, price, addon_to))
|
||||
self._quotadiff.update(new_quotas)
|
||||
self._operations.append(self.AddOperation(item, variation, price, addon_to, subevent))
|
||||
|
||||
def _check_quotas(self):
|
||||
for quota, diff in self._quotadiff.items():
|
||||
@@ -597,6 +655,19 @@ class OrderChangeManager:
|
||||
op.position.price = op.price
|
||||
op.position._calculate_tax()
|
||||
op.position.save()
|
||||
elif isinstance(op, self.SubeventOperation):
|
||||
self.order.log_action('pretix.event.order.changed.subevent', user=self.user, data={
|
||||
'position': op.position.pk,
|
||||
'positionid': op.position.positionid,
|
||||
'old_subevent': op.position.subevent.pk,
|
||||
'new_subevent': op.subevent.pk,
|
||||
'old_price': op.position.price,
|
||||
'new_price': op.price
|
||||
})
|
||||
op.position.subevent = op.subevent
|
||||
op.position.price = op.price
|
||||
op.position._calculate_tax()
|
||||
op.position.save()
|
||||
elif isinstance(op, self.PriceOperation):
|
||||
self.order.log_action('pretix.event.order.changed.price', user=self.user, data={
|
||||
'position': op.position.pk,
|
||||
@@ -631,7 +702,7 @@ class OrderChangeManager:
|
||||
pos = OrderPosition.objects.create(
|
||||
item=op.item, variation=op.variation, addon_to=op.addon_to,
|
||||
price=op.price, order=self.order,
|
||||
positionid=nextposid
|
||||
positionid=nextposid, subevent=op.subevent
|
||||
)
|
||||
nextposid += 1
|
||||
self.order.log_action('pretix.event.order.changed.add', user=self.user, data={
|
||||
@@ -640,7 +711,8 @@ class OrderChangeManager:
|
||||
'variation': op.variation.pk if op.variation else None,
|
||||
'addon_to': op.addon_to.pk if op.addon_to else None,
|
||||
'price': op.price,
|
||||
'positionid': pos.positionid
|
||||
'positionid': pos.positionid,
|
||||
'subevent': op.subevent.pk if op.subevent else None,
|
||||
})
|
||||
|
||||
def _recalculate_total_and_payment_fee(self):
|
||||
|
||||
33
src/pretix/base/services/pricing.py
Normal file
33
src/pretix/base/services/pricing.py
Normal file
@@ -0,0 +1,33 @@
|
||||
from decimal import Decimal
|
||||
|
||||
from pretix.base.decimal import round_decimal
|
||||
from pretix.base.models import Item, ItemVariation, Voucher
|
||||
from pretix.base.models.event import SubEvent
|
||||
|
||||
|
||||
def get_price(item: Item, variation: ItemVariation = None,
|
||||
voucher: Voucher = None, custom_price: Decimal = None,
|
||||
subevent: SubEvent = None, custom_price_is_net: bool = False):
|
||||
price = item.default_price
|
||||
if subevent and item.pk in subevent.item_price_overrides:
|
||||
price = subevent.item_price_overrides[item.pk]
|
||||
|
||||
if variation is not None:
|
||||
if variation.default_price is not None:
|
||||
price = variation.default_price
|
||||
if subevent and variation.pk in subevent.var_price_overrides:
|
||||
price = subevent.var_price_overrides[variation.pk]
|
||||
|
||||
if voucher:
|
||||
price = voucher.calculate_price(price)
|
||||
|
||||
if item.free_price and custom_price is not None and custom_price != "":
|
||||
if not isinstance(custom_price, Decimal):
|
||||
custom_price = Decimal(str(custom_price).replace(",", "."))
|
||||
if custom_price > 100000000:
|
||||
raise ValueError('price_too_high')
|
||||
if custom_price_is_net:
|
||||
custom_price = round_decimal(custom_price * (100 + item.tax_rate) / 100)
|
||||
price = max(custom_price, price)
|
||||
|
||||
return price
|
||||
@@ -5,6 +5,7 @@ from django.db.models import Count, Sum
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
|
||||
from pretix.base.models import Event, Item, ItemCategory, Order, OrderPosition
|
||||
from pretix.base.models.event import SubEvent
|
||||
|
||||
|
||||
class DummyObject:
|
||||
@@ -67,14 +68,18 @@ def dictsum(*dicts) -> dict:
|
||||
return res
|
||||
|
||||
|
||||
def order_overview(event: Event) -> Tuple[List[Tuple[ItemCategory, List[Item]]], Dict[str, Tuple[Decimal, Decimal]]]:
|
||||
def order_overview(event: Event, subevent: SubEvent=None) -> Tuple[List[Tuple[ItemCategory, List[Item]]],
|
||||
Dict[str, Tuple[Decimal, Decimal]]]:
|
||||
items = event.items.all().select_related(
|
||||
'category', # for re-grouping
|
||||
).prefetch_related(
|
||||
'variations'
|
||||
).order_by('category__position', 'category_id', 'name')
|
||||
|
||||
counters = OrderPosition.objects.filter(
|
||||
qs = OrderPosition.objects
|
||||
if subevent:
|
||||
qs = qs.filter(subevent=subevent)
|
||||
counters = qs.filter(
|
||||
order__event=event
|
||||
).values(
|
||||
'item', 'variation', 'order__status'
|
||||
@@ -155,71 +160,72 @@ def order_overview(event: Event) -> Tuple[List[Tuple[ItemCategory, List[Item]]],
|
||||
payment_cat_obj.name = _('Payment method fees')
|
||||
payment_items = []
|
||||
|
||||
counters = event.orders.values('payment_provider', 'status').annotate(
|
||||
cnt=Count('id'), payment_fee=Sum('payment_fee'), tax_value=Sum('payment_fee_tax_value')
|
||||
).order_by()
|
||||
if not subevent:
|
||||
counters = event.orders.values('payment_provider', 'status').annotate(
|
||||
cnt=Count('id'), payment_fee=Sum('payment_fee'), tax_value=Sum('payment_fee_tax_value')
|
||||
).order_by()
|
||||
|
||||
num_canceled = {
|
||||
o['payment_provider']: (o['cnt'], o['payment_fee'], o['payment_fee'] - o['tax_value'])
|
||||
for o in counters if o['status'] == Order.STATUS_CANCELED
|
||||
}
|
||||
num_refunded = {
|
||||
o['payment_provider']: (o['cnt'], o['payment_fee'], o['payment_fee'] - o['tax_value'])
|
||||
for o in counters if o['status'] == Order.STATUS_REFUNDED
|
||||
}
|
||||
num_pending = {
|
||||
o['payment_provider']: (o['cnt'], o['payment_fee'], o['payment_fee'] - o['tax_value'])
|
||||
for o in counters if o['status'] == Order.STATUS_PENDING
|
||||
}
|
||||
num_expired = {
|
||||
o['payment_provider']: (o['cnt'], o['payment_fee'], o['payment_fee'] - o['tax_value'])
|
||||
for o in counters if o['status'] == Order.STATUS_EXPIRED
|
||||
}
|
||||
num_paid = {
|
||||
o['payment_provider']: (o['cnt'], o['payment_fee'], o['payment_fee'] - o['tax_value'])
|
||||
for o in counters if o['status'] == Order.STATUS_PAID
|
||||
}
|
||||
num_total = dictsum(num_pending, num_paid)
|
||||
num_canceled = {
|
||||
o['payment_provider']: (o['cnt'], o['payment_fee'], o['payment_fee'] - o['tax_value'])
|
||||
for o in counters if o['status'] == Order.STATUS_CANCELED
|
||||
}
|
||||
num_refunded = {
|
||||
o['payment_provider']: (o['cnt'], o['payment_fee'], o['payment_fee'] - o['tax_value'])
|
||||
for o in counters if o['status'] == Order.STATUS_REFUNDED
|
||||
}
|
||||
num_pending = {
|
||||
o['payment_provider']: (o['cnt'], o['payment_fee'], o['payment_fee'] - o['tax_value'])
|
||||
for o in counters if o['status'] == Order.STATUS_PENDING
|
||||
}
|
||||
num_expired = {
|
||||
o['payment_provider']: (o['cnt'], o['payment_fee'], o['payment_fee'] - o['tax_value'])
|
||||
for o in counters if o['status'] == Order.STATUS_EXPIRED
|
||||
}
|
||||
num_paid = {
|
||||
o['payment_provider']: (o['cnt'], o['payment_fee'], o['payment_fee'] - o['tax_value'])
|
||||
for o in counters if o['status'] == Order.STATUS_PAID
|
||||
}
|
||||
num_total = dictsum(num_pending, num_paid)
|
||||
|
||||
provider_names = {
|
||||
k: v.verbose_name
|
||||
for k, v in event.get_payment_providers().items()
|
||||
}
|
||||
provider_names = {
|
||||
k: v.verbose_name
|
||||
for k, v in event.get_payment_providers().items()
|
||||
}
|
||||
|
||||
for pprov, total in num_total.items():
|
||||
ppobj = DummyObject()
|
||||
ppobj.name = provider_names.get(pprov, pprov)
|
||||
ppobj.provider = pprov
|
||||
ppobj.has_variations = False
|
||||
ppobj.num_total = total
|
||||
ppobj.num_canceled = num_canceled.get(pprov, (0, 0, 0))
|
||||
ppobj.num_refunded = num_refunded.get(pprov, (0, 0, 0))
|
||||
ppobj.num_expired = num_expired.get(pprov, (0, 0, 0))
|
||||
ppobj.num_pending = num_pending.get(pprov, (0, 0, 0))
|
||||
ppobj.num_paid = num_paid.get(pprov, (0, 0, 0))
|
||||
payment_items.append(ppobj)
|
||||
for pprov, total in num_total.items():
|
||||
ppobj = DummyObject()
|
||||
ppobj.name = provider_names.get(pprov, pprov)
|
||||
ppobj.provider = pprov
|
||||
ppobj.has_variations = False
|
||||
ppobj.num_total = total
|
||||
ppobj.num_canceled = num_canceled.get(pprov, (0, 0, 0))
|
||||
ppobj.num_refunded = num_refunded.get(pprov, (0, 0, 0))
|
||||
ppobj.num_expired = num_expired.get(pprov, (0, 0, 0))
|
||||
ppobj.num_pending = num_pending.get(pprov, (0, 0, 0))
|
||||
ppobj.num_paid = num_paid.get(pprov, (0, 0, 0))
|
||||
payment_items.append(ppobj)
|
||||
|
||||
payment_cat_obj.num_total = (
|
||||
Dontsum(''), sum(i.num_total[1] for i in payment_items), sum(i.num_total[2] for i in payment_items)
|
||||
)
|
||||
payment_cat_obj.num_canceled = (
|
||||
Dontsum(''), sum(i.num_canceled[1] for i in payment_items), sum(i.num_canceled[2] for i in payment_items)
|
||||
)
|
||||
payment_cat_obj.num_refunded = (
|
||||
Dontsum(''), sum(i.num_refunded[1] for i in payment_items), sum(i.num_refunded[2] for i in payment_items)
|
||||
)
|
||||
payment_cat_obj.num_expired = (
|
||||
Dontsum(''), sum(i.num_expired[1] for i in payment_items), sum(i.num_expired[2] for i in payment_items)
|
||||
)
|
||||
payment_cat_obj.num_pending = (
|
||||
Dontsum(''), sum(i.num_pending[1] for i in payment_items), sum(i.num_pending[2] for i in payment_items)
|
||||
)
|
||||
payment_cat_obj.num_paid = (
|
||||
Dontsum(''), sum(i.num_paid[1] for i in payment_items), sum(i.num_paid[2] for i in payment_items)
|
||||
)
|
||||
payment_cat = (payment_cat_obj, payment_items)
|
||||
payment_cat_obj.num_total = (
|
||||
Dontsum(''), sum(i.num_total[1] for i in payment_items), sum(i.num_total[2] for i in payment_items)
|
||||
)
|
||||
payment_cat_obj.num_canceled = (
|
||||
Dontsum(''), sum(i.num_canceled[1] for i in payment_items), sum(i.num_canceled[2] for i in payment_items)
|
||||
)
|
||||
payment_cat_obj.num_refunded = (
|
||||
Dontsum(''), sum(i.num_refunded[1] for i in payment_items), sum(i.num_refunded[2] for i in payment_items)
|
||||
)
|
||||
payment_cat_obj.num_expired = (
|
||||
Dontsum(''), sum(i.num_expired[1] for i in payment_items), sum(i.num_expired[2] for i in payment_items)
|
||||
)
|
||||
payment_cat_obj.num_pending = (
|
||||
Dontsum(''), sum(i.num_pending[1] for i in payment_items), sum(i.num_pending[2] for i in payment_items)
|
||||
)
|
||||
payment_cat_obj.num_paid = (
|
||||
Dontsum(''), sum(i.num_paid[1] for i in payment_items), sum(i.num_paid[2] for i in payment_items)
|
||||
)
|
||||
payment_cat = (payment_cat_obj, payment_items)
|
||||
|
||||
items_by_category.append(payment_cat)
|
||||
items_by_category.append(payment_cat)
|
||||
|
||||
total = {
|
||||
'num_total': tuplesum(c.num_total for c, i in items_by_category),
|
||||
|
||||
@@ -8,7 +8,7 @@ from pretix.celery_app import app
|
||||
|
||||
|
||||
@app.task(base=ProfiledTask)
|
||||
def assign_automatically(event_id: int, user_id: int=None):
|
||||
def assign_automatically(event_id: int, user_id: int=None, subevent_id: int=None):
|
||||
event = Event.objects.get(id=event_id)
|
||||
if user_id:
|
||||
user = User.objects.get(id=user_id)
|
||||
@@ -21,17 +21,24 @@ def assign_automatically(event_id: int, user_id: int=None):
|
||||
qs = WaitingListEntry.objects.filter(
|
||||
event=event, voucher__isnull=True
|
||||
).select_related('item', 'variation').prefetch_related('item__quotas', 'variation__quotas').order_by('created')
|
||||
|
||||
if subevent_id and event.has_subevents:
|
||||
subevent = event.subevents.get(id=subevent_id)
|
||||
qs = qs.filter(subevent=subevent)
|
||||
|
||||
sent = 0
|
||||
|
||||
for wle in qs:
|
||||
if (wle.item, wle.variation) in gone:
|
||||
continue
|
||||
|
||||
quotas = wle.variation.quotas.all() if wle.variation else wle.item.quotas.all()
|
||||
quotas = (wle.variation.quotas.filter(subevent=wle.subevent)
|
||||
if wle.variation
|
||||
else wle.item.quotas.filter(subevent=wle.subevent))
|
||||
availability = (
|
||||
wle.variation.check_quotas(count_waitinglist=False, _cache=quota_cache)
|
||||
wle.variation.check_quotas(count_waitinglist=False, _cache=quota_cache, subevent=wle.subevent)
|
||||
if wle.variation
|
||||
else wle.item.check_quotas(count_waitinglist=False, _cache=quota_cache)
|
||||
else wle.item.check_quotas(count_waitinglist=False, _cache=quota_cache, subevent=wle.subevent)
|
||||
)
|
||||
if availability[1] > 0:
|
||||
try:
|
||||
|
||||
@@ -10,6 +10,8 @@ from hierarkey.models import GlobalSettingsBase, Hierarkey
|
||||
from i18nfield.strings import LazyI18nString
|
||||
from typing import Any
|
||||
|
||||
from pretix.base.reldate import RelativeDateWrapper
|
||||
|
||||
DEFAULTS = {
|
||||
'max_items_per_order': {
|
||||
'default': '10',
|
||||
@@ -69,7 +71,7 @@ DEFAULTS = {
|
||||
},
|
||||
'payment_term_last': {
|
||||
'default': None,
|
||||
'type': datetime,
|
||||
'type': RelativeDateWrapper,
|
||||
},
|
||||
'payment_term_weekdays': {
|
||||
'default': 'True',
|
||||
@@ -165,7 +167,7 @@ DEFAULTS = {
|
||||
},
|
||||
'ticket_download_date': {
|
||||
'default': None,
|
||||
'type': datetime
|
||||
'type': RelativeDateWrapper
|
||||
},
|
||||
'ticket_download_addons': {
|
||||
'default': 'False',
|
||||
@@ -181,7 +183,7 @@ DEFAULTS = {
|
||||
},
|
||||
'last_order_modification_date': {
|
||||
'default': None,
|
||||
'type': datetime
|
||||
'type': RelativeDateWrapper
|
||||
},
|
||||
'cancel_allow_user': {
|
||||
'default': 'True',
|
||||
@@ -437,6 +439,9 @@ def i18n_uns(v):
|
||||
settings_hierarkey.add_type(LazyI18nString,
|
||||
serialize=lambda s: json.dumps(s.data),
|
||||
unserialize=i18n_uns)
|
||||
settings_hierarkey.add_type(RelativeDateWrapper,
|
||||
serialize=lambda rdw: rdw.to_string(),
|
||||
unserialize=lambda s: RelativeDateWrapper.from_string(s))
|
||||
|
||||
|
||||
@settings_hierarkey.set_global(cache_namespace='global')
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
{% load i18n %}
|
||||
<div class="reldatetime">
|
||||
{% for group_name, group_choices, group-index in widget.subwidgets.0.optgroups %}
|
||||
{% for selopt in group_choices %}
|
||||
<div class="radio">
|
||||
<label>
|
||||
<input type="radio" name="{{ widget.subwidgets.0.name }}" value="{{ selopt.value }}"
|
||||
{% include "django/forms/widgets/attrs.html" with widget=selopt %} />
|
||||
{{ selopt.label }}
|
||||
</label>
|
||||
{% if selopt.value == "absolute" %}
|
||||
{% include widget.subwidgets.1.template_name with widget=widget.subwidgets.1 %}
|
||||
{% elif selopt.value == "relative" %}
|
||||
{% include widget.subwidgets.2.template_name with widget=widget.subwidgets.2 %}
|
||||
{% trans "days before" %}
|
||||
{% include widget.subwidgets.3.template_name with widget=widget.subwidgets.3 %}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
@@ -0,0 +1,23 @@
|
||||
{% load i18n %}
|
||||
<div class="reldatetime">
|
||||
{% for group_name, group_choices, group-index in widget.subwidgets.0.optgroups %}
|
||||
{% for selopt in group_choices %}
|
||||
<div class="radio">
|
||||
<label>
|
||||
<input type="radio" name="{{ widget.subwidgets.0.name }}" value="{{ selopt.value }}"
|
||||
{% include "django/forms/widgets/attrs.html" with widget=selopt %} />
|
||||
{{ selopt.label }}
|
||||
</label>
|
||||
{% if selopt.value == "absolute" %}
|
||||
{% include widget.subwidgets.1.template_name with widget=widget.subwidgets.1 %}
|
||||
{% elif selopt.value == "relative" %}
|
||||
{% include widget.subwidgets.2.template_name with widget=widget.subwidgets.2 %}
|
||||
{% trans "days before" %}
|
||||
{% include widget.subwidgets.3.template_name with widget=widget.subwidgets.3 %}
|
||||
{% trans "at" %}
|
||||
{% include widget.subwidgets.4.template_name with widget=widget.subwidgets.4 %}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
Reference in New Issue
Block a user