Compare commits

..
Author SHA1 Message Date
Mira Weller e4abfb0bdf start implementing time machine mode 2024-03-06 13:18:23 +01:00
Raphael Michel d32bd717b7 Fix meta filter being selectable for export 2024-03-04 15:52:45 +01:00
Mira 6e6b75d55e Don't double file extension in clean_filename (#3942)
* Don't double file extension in clean_filename

* Don't use display_name as ClearableBasenameFileInput.FakeFile.name

Reason: it's used as the thumbnail source and therefore needs to be a valid file name and not some display name
2024-03-01 09:58:17 +01:00
Richard Schreiber 50b5f760bb Presale: prefer event’s microdata from settings over generated microdata (#3943) 2024-03-01 09:56:55 +01:00
Richard Schreiber 9ab2e61c31 Widget: fix quantity cut-off on very narrow screens (Z#23141650) (#3944)
* Widget: fix quantity cut-off on very narrow screens

* align price to left

* fix columns on mobile screens (missing clearfix)

* use min-width instead
2024-03-01 09:56:48 +01:00
Mira 4876a0b61f Allow multiple returnurl prefixes (Z#23145768) (#3941)
* Allow multiple returnurl prefixes, improve validation and docs.

* Fix typo

* Allow URL prefixes starting with http://localhost

* Add more explanation
2024-03-01 09:56:22 +01:00
Dean Wyns 56bbcb65c3 Translations: Update Dutch
Currently translated at 85.1% (4741 of 5565 strings)

Translation: pretix/pretix
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix/nl/

powered by weblate
2024-03-01 09:56:00 +01:00
Dean Wyns 5bb1cb498f Translations: Update Dutch
Currently translated at 83.9% (4672 of 5565 strings)

Translation: pretix/pretix
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix/nl/

powered by weblate
2024-03-01 09:56:00 +01:00
Raphael Michel 6bf23b0fdd Allow to create blocking vouchers for items with unspecified variation (#3932) 2024-02-29 16:45:41 +01:00
Raphael Michel 5deb1a8c69 Fix organizer being integer in mail_send_task 2024-02-29 08:59:15 +01:00
Raphael Michel 1523137300 Docs: Add extension of exhibitor API 2024-02-28 16:11:50 +01:00
Raphael Michel 04ef097eb1 Fix #65 -- Disallow None value for product default prices (#3847)
* Fix #65 -- Disallow None value for product default prices

* Fix #65 -- Disallow None value for product default prices

* Rebase migration
2024-02-28 16:10:53 +01:00
Raphael Michel a5d4434a64 Bump to 2024.3.0.dev0 2024-02-28 14:40:30 +01:00
30 changed files with 472 additions and 147 deletions
+3 -1
View File
@@ -94,7 +94,9 @@ If you want the user to return to your application after the payment is complete
"Plugins". Enable the plugin "Redirection from order page". Then, go to the new page "Settings", then "Redirection". "Plugins". Enable the plugin "Redirection from order page". Then, go to the new page "Settings", then "Redirection".
Enter the base URL of your web application. This will allow you to redirect to pages under this base URL later on. Enter the base URL of your web application. This will allow you to redirect to pages under this base URL later on.
For example, if you want users to be redirected to ``https://example.org/order/return?tx_id=1234``, you could now For example, if you want users to be redirected to ``https://example.org/order/return?tx_id=1234``, you could now
either enter ``https://example.org`` or ``https://example.org/order/``. either enter ``https://example.org/order/`` or ``https://example.org/``.
Please note that in the latter case the trailing slash is required, ``https://example.org`` is not allowed to prevent.
Only base URLs with a secure (``https://``) or local (``http://localhost``) origin are permitted.
The user will be redirected back to your page instead of pretix' order confirmation page after the payment, The user will be redirected back to your page instead of pretix' order confirmation page after the payment,
**regardless of whether it was successful or not**. We will append an ``error=…`` query parameter with an error **regardless of whether it was successful or not**. We will append an ``error=…`` query parameter with an error
+6
View File
@@ -34,6 +34,7 @@ internal_id string Can be used for
contact_name string Contact person (or ``null``) contact_name string Contact person (or ``null``)
contact_name_parts object of strings Decomposition of contact name (i.e. given name, family name) contact_name_parts object of strings Decomposition of contact name (i.e. given name, family name)
contact_email string Contact person email address (or ``null``) contact_email string Contact person email address (or ``null``)
contact_cc_email string Copy email addresses, can be multiple separated by comma (or ``null``)
booth string Booth number (or ``null``). Maximum 100 characters. booth string Booth number (or ``null``). Maximum 100 characters.
locale string Locale for communication with the exhibitor. locale string Locale for communication with the exhibitor.
access_code string Access code for the exhibitor to access their data or use the lead scanning app (read-only). access_code string Access code for the exhibitor to access their data or use the lead scanning app (read-only).
@@ -109,6 +110,7 @@ Endpoints
"title": "Dr" "title": "Dr"
}, },
"contact_email": "johnson@as.example.org", "contact_email": "johnson@as.example.org",
"contact_cc_email": "miller@as.example.org,smith@as.example.org",
"booth": "A2", "booth": "A2",
"locale": "de", "locale": "de",
"access_code": "VKHZ2FU84", "access_code": "VKHZ2FU84",
@@ -162,6 +164,7 @@ Endpoints
"title": "Dr" "title": "Dr"
}, },
"contact_email": "johnson@as.example.org", "contact_email": "johnson@as.example.org",
"contact_cc_email": "miller@as.example.org,smith@as.example.org",
"booth": "A2", "booth": "A2",
"locale": "de", "locale": "de",
"access_code": "VKHZ2FU84", "access_code": "VKHZ2FU84",
@@ -365,6 +368,7 @@ Endpoints
"title": "Dr" "title": "Dr"
}, },
"contact_email": "johnson@as.example.org", "contact_email": "johnson@as.example.org",
"contact_cc_email": "miller@as.example.org,smith@as.example.org",
"booth": "A2", "booth": "A2",
"locale": "de", "locale": "de",
"allow_lead_scanning": true, "allow_lead_scanning": true,
@@ -394,6 +398,7 @@ Endpoints
"title": "Dr" "title": "Dr"
}, },
"contact_email": "johnson@as.example.org", "contact_email": "johnson@as.example.org",
"contact_cc_email": "miller@as.example.org,smith@as.example.org",
"booth": "A2", "booth": "A2",
"locale": "de", "locale": "de",
"access_code": "VKHZ2FU84", "access_code": "VKHZ2FU84",
@@ -454,6 +459,7 @@ Endpoints
"title": "Dr" "title": "Dr"
}, },
"contact_email": "johnson@as.example.org", "contact_email": "johnson@as.example.org",
"contact_cc_email": "miller@as.example.org,smith@as.example.org",
"booth": "A2", "booth": "A2",
"locale": "de", "locale": "de",
"access_code": "VKHZ2FU84", "access_code": "VKHZ2FU84",
+1 -1
View File
@@ -19,4 +19,4 @@
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see # You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>. # <https://www.gnu.org/licenses/>.
# #
__version__ = "2024.2.0" __version__ = "2024.3.0.dev0"
+1
View File
@@ -86,6 +86,7 @@ class InvoiceExporterMixin:
('', _('All payment providers')), ('', _('All payment providers')),
] + [ ] + [
(k, v.verbose_name) for k, v in self.event.get_payment_providers().items() (k, v.verbose_name) for k, v in self.event.get_payment_providers().items()
if not v.is_meta
], ],
required=False, required=False,
help_text=_('Only include invoices for orders that have at least one payment attempt ' help_text=_('Only include invoices for orders that have at least one payment attempt '
@@ -0,0 +1,19 @@
# Generated by Django 4.2.9 on 2024-01-30 11:11
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("pretixbase", "0256_itemvariation_unavail_modes"),
]
operations = [
migrations.AlterField(
model_name="item",
name="default_price",
field=models.DecimalField(decimal_places=2, default=0, max_digits=13),
preserve_default=False,
),
]
+25 -13
View File
@@ -80,6 +80,15 @@ from .organizer import Organizer, Team
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def annotate_with_time_based_properties(events_or_subevents, now_dt):
print("annotate_with_time_based_properties", now_dt)
for e_s in events_or_subevents:
if e_s:
e_s.presale_is_running = e_s.presale_is_running_by_time(now_dt)
e_s.presale_has_ended = e_s.presale_has_ended_by_time(now_dt)
return events_or_subevents
class EventMixin: class EventMixin:
def clean(self): def clean(self):
if self.presale_start and self.presale_end and self.presale_start > self.presale_end: if self.presale_start and self.presale_end and self.presale_start > self.presale_end:
@@ -229,17 +238,17 @@ class EventMixin:
else: else:
return self.presale_end return self.presale_end
@property def presale_has_ended_by_time(self, now_dt: datetime=None):
def presale_has_ended(self):
""" """
Is true, when ``presale_end`` is set and in the past. Is true, when ``presale_end`` is set and in the past.
""" """
now_dt = now_dt or now()
if self.effective_presale_end: if self.effective_presale_end:
return now() > self.effective_presale_end return now_dt > self.effective_presale_end
elif self.date_to: elif self.date_to:
return now() > self.date_to return now_dt > self.date_to
else: else:
return now().astimezone(self.timezone).date() > self.date_from.astimezone(self.timezone).date() return now_dt.astimezone(self.timezone).date() > self.date_from.astimezone(self.timezone).date()
@property @property
def effective_presale_start(self): def effective_presale_start(self):
@@ -253,18 +262,21 @@ class EventMixin:
else: else:
return self.presale_start return self.presale_start
@property def presale_is_running_by_time(self, now_dt: datetime=None):
def presale_is_running(self):
""" """
Is true, when ``presale_end`` is not set or in the future and ``presale_start`` is not Is true, when ``presale_end`` is not set or in the future and ``presale_start`` is not
set or in the past. set or in the past.
""" """
if self.effective_presale_start and now() < self.effective_presale_start: now_dt = now_dt or now()
if self.effective_presale_start and now_dt < self.effective_presale_start:
return False return False
return not self.presale_has_ended return not self.presale_has_ended_by_time(now_dt)
@property @property
def event_microdata(self): def event_microdata(self):
if self.settings.event_microdata:
return self.settings.event_microdata
import json import json
eventdict = { eventdict = {
@@ -680,12 +692,12 @@ class Event(EventMixin, LoggedModel):
return qs_annotated return qs_annotated
@property def presale_has_ended_by_time(self, now_dt: datetime = None):
def presale_has_ended(self): now_dt = now_dt or now()
if self.has_subevents: if self.has_subevents:
return self.presale_end and now() > self.presale_end return self.presale_end and now_dt > self.presale_end
else: else:
return super().presale_has_ended return super().presale_has_ended_by_time(now_dt)
def delete_all_orders(self, really=False): def delete_all_orders(self, really=False):
from .checkin import Checkin from .checkin import Checkin
+1 -1
View File
@@ -430,7 +430,7 @@ class Item(LoggedModel):
help_text=_("If this product has multiple variations, you can set different prices for each of the " help_text=_("If this product has multiple variations, you can set different prices for each of the "
"variations. If a variation does not have a special price or if you do not have variations, " "variations. If a variation does not have a special price or if you do not have variations, "
"this price will be used."), "this price will be used."),
max_digits=13, decimal_places=2, null=True max_digits=13, decimal_places=2,
) )
free_price = models.BooleanField( free_price = models.BooleanField(
default=False, default=False,
+18 -7
View File
@@ -351,9 +351,6 @@ class Voucher(LoggedModel):
'variations.')) 'variations.'))
if variation and not item.variations.filter(pk=variation.pk).exists(): if variation and not item.variations.filter(pk=variation.pk).exists():
raise ValidationError(_('This variation does not belong to this product.')) raise ValidationError(_('This variation does not belong to this product.'))
if item.has_variations and not variation and data.get('block_quota'):
raise ValidationError(_('You can only block quota if you specify a specific product variation. '
'Otherwise it might be unclear which quotas to block.'))
if item.category and item.category.is_addon: if item.category and item.category.is_addon:
raise ValidationError(_('It is currently not possible to create vouchers for add-on products.')) raise ValidationError(_('It is currently not possible to create vouchers for add-on products.'))
elif block_quota: elif block_quota:
@@ -431,7 +428,15 @@ class Voucher(LoggedModel):
elif old_instance.variation: elif old_instance.variation:
quotas |= set(old_instance.variation.quotas.filter(subevent=old_instance.subevent)) quotas |= set(old_instance.variation.quotas.filter(subevent=old_instance.subevent))
elif old_instance.item: elif old_instance.item:
quotas |= set(old_instance.item.quotas.filter(subevent=old_instance.subevent)) if old_instance.item.has_variations:
quotas |= set(
Quota.objects.filter(pk__in=Quota.variations.through.objects.filter(
itemvariation__item=old_instance.item,
quota__subevent=old_instance.subevent,
).values('quota_id'))
)
else:
quotas |= set(old_instance.item.quotas.filter(subevent=old_instance.subevent))
return quotas return quotas
@staticmethod @staticmethod
@@ -446,13 +451,19 @@ class Voucher(LoggedModel):
if quota: if quota:
new_quotas = {quota} new_quotas = {quota}
elif item and item.has_variations and not variation:
raise ValidationError(_('You can only block quota if you specify a specific product variation. '
'Otherwise it might be unclear which quotas to block.'))
elif item and variation: elif item and variation:
new_quotas = set(variation.quotas.filter(subevent=data.get('subevent'))) new_quotas = set(variation.quotas.filter(subevent=data.get('subevent')))
elif item and not item.has_variations: elif item and not item.has_variations:
new_quotas = set(item.quotas.filter(subevent=data.get('subevent'))) new_quotas = set(item.quotas.filter(subevent=data.get('subevent')))
elif item and item.has_variations:
new_quotas = set(
Quota.objects.filter(
pk__in=Quota.variations.through.objects.filter(
itemvariation__item=old_instance.item,
quota__subevent=data.get('subevent'),
).values('quota_id')
)
)
else: else:
raise ValidationError(_('You need to select a specific product or quota if this voucher should reserve ' raise ValidationError(_('You need to select a specific product or quota if this voucher should reserve '
'tickets.')) 'tickets.'))
+1
View File
@@ -383,6 +383,7 @@ def mail_send_task(self, *args, to: List[str], subject: str, body: str, html: st
if event: if event:
with scopes_disabled(): with scopes_disabled():
event = Event.objects.get(id=event) event = Event.objects.get(id=event)
organizer = event.organizer
backend = event.get_mail_backend() backend = event.get_mail_backend()
cm = lambda: scope(organizer=event.organizer) # noqa cm = lambda: scope(organizer=event.organizer) # noqa
elif organizer: elif organizer:
+12 -6
View File
@@ -90,8 +90,8 @@ class QuotaAvailability:
self._count_waitinglist = count_waitinglist self._count_waitinglist = count_waitinglist
self._ignore_closed = ignore_closed self._ignore_closed = ignore_closed
self._full_results = full_results self._full_results = full_results
self._item_to_quotas = defaultdict(list) self._item_to_quotas = defaultdict(set)
self._var_to_quotas = defaultdict(list) self._var_to_quotas = defaultdict(set)
self._early_out = early_out self._early_out = early_out
self._quota_objects = {} self._quota_objects = {}
self.results = {} self.results = {}
@@ -243,13 +243,16 @@ class QuotaAvailability:
quota_id__in=[q.pk for q in quotas] quota_id__in=[q.pk for q in quotas]
).values('quota_id', 'item_id') ).values('quota_id', 'item_id')
for m in q_items: for m in q_items:
self._item_to_quotas[m['item_id']].append(self._quota_objects[m['quota_id']]) self._item_to_quotas[m['item_id']].add(self._quota_objects[m['quota_id']])
q_vars = Quota.variations.through.objects.filter( q_vars = Quota.variations.through.objects.filter(
quota_id__in=[q.pk for q in quotas] quota_id__in=[q.pk for q in quotas]
).values('quota_id', 'itemvariation_id') ).values('quota_id', 'itemvariation_id', 'itemvariation__item_id')
for m in q_vars: for m in q_vars:
self._var_to_quotas[m['itemvariation_id']].append(self._quota_objects[m['quota_id']]) self._var_to_quotas[m['itemvariation_id']].add(self._quota_objects[m['quota_id']])
# We can't be 100% certain that a quota, when it is connected to a variation, is also always connected to
# the parent item, so we double-check here just to be sure.
self._item_to_quotas[m['itemvariation__item_id']].add(self._quota_objects[m['quota_id']])
self._compute_orders(quotas, q_items, q_vars, size_left) self._compute_orders(quotas, q_items, q_vars, size_left)
@@ -378,7 +381,10 @@ class QuotaAvailability:
Q( Q(
Q( Q(
Q(variation_id__isnull=True) & Q(variation_id__isnull=True) &
Q(item_id__in={i['item_id'] for i in q_items if i['quota_id'] in quota_ids}) Q(item_id__in=(
{i['item_id'] for i in q_items if i['quota_id'] in quota_ids} |
{i['itemvariation__item_id'] for i in q_vars if i['quota_id'] in quota_ids}
))
) | Q( ) | Q(
variation_id__in={i['itemvariation_id'] for i in q_vars if i['quota_id'] in quota_ids} variation_id__in={i['itemvariation_id'] for i in q_vars if i['quota_id'] in quota_ids}
) | Q( ) | Q(
-2
View File
@@ -123,8 +123,6 @@ class ClearableBasenameFileInput(forms.ClearableFileInput):
@property @property
def name(self): def name(self):
if hasattr(self.file, 'display_name'):
return self.file.display_name
return self.file.name return self.file.name
@property @property
+7 -2
View File
@@ -32,9 +32,14 @@ def clean_filename(fname):
"Terms.pdf""Terms.pdf.OybgvyAH.22c0583727d5bc.pdf" "Terms.pdf""Terms.pdf.OybgvyAH.22c0583727d5bc.pdf"
This function reverses this operation: This function reverses this operation (leaving names without doubled extension as-is):
"Terms.pdf.OybgvyAH.22c0583727d5bc.pdf""Terms.pdf" "Terms.pdf.OybgvyAH.22c0583727d5bc.pdf""Terms.pdf"
"Terms.pdf""Terms.pdf"
""" """
ext = '.' + fname.split('.')[-1] ext = '.' + fname.split('.')[-1]
return fname.rsplit(ext + ".", 1)[0] + ext parts = fname.rsplit(ext + ".", 1)
if len(parts) == 1:
return parts[0]
else:
return parts[0] + ext
File diff suppressed because it is too large Load Diff
+5 -1
View File
@@ -71,11 +71,15 @@ def returnurl_process_request(sender, request, **kwargs):
u = request.GET.get('return_url') u = request.GET.get('return_url')
if not sender.settings.returnurl_prefix: if not sender.settings.returnurl_prefix:
raise PermissionDenied('No return URL prefix set.') raise PermissionDenied('No return URL prefix set.')
elif not u.startswith(sender.settings.returnurl_prefix): elif not check_against_prefix_list(u, sender.settings.returnurl_prefix):
raise PermissionDenied('Invalid return URL.') raise PermissionDenied('Invalid return URL.')
request.session[key] = u request.session[key] = u
def check_against_prefix_list(u, allowlist):
return any(u.startswith(allow.strip()) for allow in allowlist.split("\n") if allow.strip() != "")
@receiver(nav_event_settings, dispatch_uid='returnurl_nav') @receiver(nav_event_settings, dispatch_uid='returnurl_nav')
def navbar_info(sender, request, **kwargs): def navbar_info(sender, request, **kwargs):
url = resolve(request.path_info) url = resolve(request.path_info)
+9 -3
View File
@@ -19,6 +19,8 @@
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see # You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>. # <https://www.gnu.org/licenses/>.
# #
import re
from django import forms from django import forms
from django.urls import reverse from django.urls import reverse
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
@@ -31,10 +33,14 @@ from pretix.control.views.event import (
class ReturnSettingsForm(SettingsForm): class ReturnSettingsForm(SettingsForm):
returnurl_prefix = forms.URLField( returnurl_prefix = forms.RegexField(
label=_("Base redirection URL"), label=_("Base redirection URLs"),
help_text=_("Redirection will only be allowed to URLs that start with this prefix."), help_text=_("Redirection will only be allowed to URLs that start with one of these prefixes. "
"Enter one or more allowed URL prefix per line. "
"URL prefixes must include a slash after the hostname."),
required=False, required=False,
widget=forms.Textarea,
regex=re.compile(r'^((https://.*/.*|http://localhost[:/].*)\n*)*$')
) )
+1
View File
@@ -545,6 +545,7 @@ class AddOnsStep(CartMixin, AsyncAction, TemplateFlowStep):
) )
if getattr(self.request, 'customer', None) else None if getattr(self.request, 'customer', None) else None
), ),
now_dt=self.request.now_dt,
) )
item_cache[ckey] = items item_cache[ckey] = items
else: else:
+1
View File
@@ -59,6 +59,7 @@ class WaitingListForm(forms.ModelForm):
) )
if customer else None if customer else None
), ),
now_dt=request.now_dt,
) )
for i in items: for i in items:
if not i.allow_waitinglist: if not i.allow_waitinglist:
+27
View File
@@ -32,8 +32,11 @@
# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the # distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations under the License. # License for the specific language governing permissions and limitations under the License.
from dateutil.parser import parse
from django.template.response import TemplateResponse from django.template.response import TemplateResponse
from django.urls import resolve from django.urls import resolve
from django.utils.timezone import now
from django_scopes import scope from django_scopes import scope
from pretix.base.channels import WebshopSalesChannel from pretix.base.channels import WebshopSalesChannel
@@ -79,3 +82,27 @@ class EventMiddleware:
response = response.render() response = response.render()
return response return response
class TimeMachineMiddleware:
def __init__(self, get_response=None):
self.get_response = get_response
super().__init__()
def __call__(self, request):
if hasattr(request, 'event') and hasattr(request, '_namespace') and request._namespace == 'presale' and \
'time_machine' in request.COOKIES and \
request.user.has_event_permission(request.organizer, request.event, 'can_change_event_settings', request):
print("setting now_dt from cookie")
request.now_dt = parse(request.COOKIES['time_machine'])
request.now_dt_is_fake = True
else:
print("setting now_dt to now",
"hasevent?",hasattr(request, 'event') ,
"namespace?",hasattr(request, '_namespace') and request._namespace,
"cookies?",request.COOKIES)
request.now_dt = now()
return self.get_response(request)
@@ -33,6 +33,17 @@
</div> </div>
</div> </div>
{% endif %} {% endif %}
{% if request.now_dt_is_fake %}
<div class="offline-banner">
<div class="container">
<span class="fa fa-user-secret" aria-hidden="true"></span>
{% trans "You are currently using the time machine. The ticket shop is rendered as if it were" %} {{ request.now_dt }}
<a href="#">
{% trans "Go back to current time" %}
</a>
</div>
</div>
{% endif %}
<div class="container page-header-links {% if event.settings.theme_color_background|upper != "#FFFFFF" or event_logo_image_large %}page-header-links-outside{% endif %}"> <div class="container page-header-links {% if event.settings.theme_color_background|upper != "#FFFFFF" or event_logo_image_large %}page-header-links-outside{% endif %}">
{% if event.settings.locales|length > 1 or request.organizer.settings.customer_accounts %} {% if event.settings.locales|length > 1 or request.organizer.settings.customer_accounts %}
{% if event.settings.theme_color_background|upper != "#FFFFFF" or event_logo_image_large %} {% if event.settings.theme_color_background|upper != "#FFFFFF" or event_logo_image_large %}
+1
View File
@@ -569,6 +569,7 @@ class RedeemView(NoSearchIndexViewMixin, EventViewMixin, CartMixin, TemplateView
testmode=self.request.event.testmode testmode=self.request.event.testmode
) if getattr(self.request, 'customer', None) else None ) if getattr(self.request, 'customer', None) else None
), ),
now_dt=self.request.now_dt,
) )
# Calculate how many options the user still has. If there is only one option, we can # Calculate how many options the user still has. If there is only one option, we can
+1 -1
View File
@@ -55,7 +55,7 @@ class CheckoutView(View):
messages.error(request, _("Your cart is empty")) messages.error(request, _("Your cart is empty"))
return self.redirect(self.get_index_url(self.request)) return self.redirect(self.get_index_url(self.request))
if not request.event.presale_is_running: if not request.event.presale_is_running_by_time(request.now_dt):
messages.error(request, _("The booking period for this event is over or has not yet started.")) messages.error(request, _("The booking period for this event is over or has not yet started."))
return self.redirect(self.get_index_url(self.request)) return self.redirect(self.get_index_url(self.request))
+21 -15
View File
@@ -64,7 +64,7 @@ from pretix.base.channels import get_all_sales_channels
from pretix.base.models import ( from pretix.base.models import (
ItemVariation, Quota, SeatCategoryMapping, Voucher, ItemVariation, Quota, SeatCategoryMapping, Voucher,
) )
from pretix.base.models.event import Event, SubEvent from pretix.base.models.event import Event, SubEvent, annotate_with_time_based_properties
from pretix.base.models.items import ( from pretix.base.models.items import (
ItemAddOn, ItemBundle, SubEventItem, SubEventItemVariation, ItemAddOn, ItemBundle, SubEventItem, SubEventItemVariation,
) )
@@ -105,7 +105,8 @@ def item_group_by_category(items):
def get_grouped_items(event, subevent=None, voucher=None, channel='web', require_seat=0, base_qs=None, allow_addons=False, def get_grouped_items(event, subevent=None, voucher=None, channel='web', require_seat=0, base_qs=None, allow_addons=False,
quota_cache=None, filter_items=None, filter_categories=None, memberships=None, quota_cache=None, filter_items=None, filter_categories=None, memberships=None,
ignore_hide_sold_out_for_item_ids=None): ignore_hide_sold_out_for_item_ids=None, now_dt: datetime=None):
now_dt = now_dt or now()
base_qs_set = base_qs is not None base_qs_set = base_qs is not None
base_qs = base_qs if base_qs is not None else event.items base_qs = base_qs if base_qs is not None else event.items
@@ -119,8 +120,8 @@ def get_grouped_items(event, subevent=None, voucher=None, channel='web', require
requires_seat = Value(0, output_field=IntegerField()) requires_seat = Value(0, output_field=IntegerField())
variation_q = ( variation_q = (
Q(Q(available_from__isnull=True) | Q(available_from__lte=now()) | Q(available_from_mode='info')) & Q(Q(available_from__isnull=True) | Q(available_from__lte=now_dt) | Q(available_from_mode='info')) &
Q(Q(available_until__isnull=True) | Q(available_until__gte=now()) | Q(available_until_mode='info')) Q(Q(available_until__isnull=True) | Q(available_until__gte=now_dt) | Q(available_until_mode='info'))
) )
if not voucher or not voucher.show_hidden_items: if not voucher or not voucher.show_hidden_items:
variation_q &= Q(hide_without_voucher=False) variation_q &= Q(hide_without_voucher=False)
@@ -137,8 +138,8 @@ def get_grouped_items(event, subevent=None, voucher=None, channel='web', require
subevent_disabled=Exists( subevent_disabled=Exists(
SubEventItemVariation.objects.filter( SubEventItemVariation.objects.filter(
Q(disabled=True) Q(disabled=True)
| (Exact(OuterRef('available_from_mode'), 'hide') & Q(available_from__gt=now())) | (Exact(OuterRef('available_from_mode'), 'hide') & Q(available_from__gt=now_dt))
| (Exact(OuterRef('available_until_mode'), 'hide') & Q(available_until__lt=now())), | (Exact(OuterRef('available_until_mode'), 'hide') & Q(available_until__lt=now_dt)),
variation_id=OuterRef('pk'), variation_id=OuterRef('pk'),
subevent=subevent, subevent=subevent,
) )
@@ -209,8 +210,8 @@ def get_grouped_items(event, subevent=None, voucher=None, channel='web', require
subevent_disabled=Exists( subevent_disabled=Exists(
SubEventItem.objects.filter( SubEventItem.objects.filter(
Q(disabled=True) Q(disabled=True)
| (Exact(OuterRef('available_from_mode'), 'hide') & Q(available_from__gt=now())) | (Exact(OuterRef('available_from_mode'), 'hide') & Q(available_from__gt=now_dt))
| (Exact(OuterRef('available_until_mode'), 'hide') & Q(available_until__lt=now())), | (Exact(OuterRef('available_until_mode'), 'hide') & Q(available_until__lt=now_dt)),
item_id=OuterRef('pk'), item_id=OuterRef('pk'),
subevent=subevent, subevent=subevent,
) )
@@ -306,7 +307,7 @@ def get_grouped_items(event, subevent=None, voucher=None, channel='web', require
item._remove = True item._remove = True
continue continue
item.current_unavailability_reason = item.unavailability_reason(has_voucher=voucher, subevent=subevent) item.current_unavailability_reason = item.unavailability_reason(now_dt=now_dt, has_voucher=voucher, subevent=subevent)
item.description = str(item.description) item.description = str(item.description)
for recv, resp in item_description.send(sender=event, item=item, variation=None, subevent=subevent): for recv, resp in item_description.send(sender=event, item=item, variation=None, subevent=subevent):
@@ -422,7 +423,7 @@ def get_grouped_items(event, subevent=None, voucher=None, channel='web', require
if not display_add_to_cart: if not display_add_to_cart:
display_add_to_cart = not item.requires_seat and var.order_max > 0 display_add_to_cart = not item.requires_seat and var.order_max > 0
var.current_unavailability_reason = var.unavailability_reason(has_voucher=voucher, subevent=subevent) var.current_unavailability_reason = var.unavailability_reason(now_dt=now_dt, has_voucher=voucher, subevent=subevent)
item.original_price = ( item.original_price = (
item.tax(item.original_price, currency=event.currency, include_bundled=True, item.tax(item.original_price, currency=event.currency, include_bundled=True,
@@ -535,6 +536,7 @@ class EventIndex(EventViewMixin, EventListMixin, CartMixin, TemplateView):
context['ev'] = self.subevent or self.request.event context['ev'] = self.subevent or self.request.event
context['subevent'] = self.subevent context['subevent'] = self.subevent
annotate_with_time_based_properties([self.request.event, self.subevent], self.request.now_dt)
# Show voucher option if an event is selected and vouchers exist # Show voucher option if an event is selected and vouchers exist
vouchers_exist = self.request.event.cache.get('vouchers_exist') vouchers_exist = self.request.event.cache.get('vouchers_exist')
@@ -543,10 +545,10 @@ class EventIndex(EventViewMixin, EventListMixin, CartMixin, TemplateView):
self.request.event.cache.set('vouchers_exist', vouchers_exist) self.request.event.cache.set('vouchers_exist', vouchers_exist)
context['show_vouchers'] = context['vouchers_exist'] = vouchers_exist and ( context['show_vouchers'] = context['vouchers_exist'] = vouchers_exist and (
(self.request.event.has_subevents and not self.subevent) or (self.request.event.has_subevents and not self.subevent) or
context['ev'].presale_is_running context['ev'].presale_is_running_by_time(self.request.now_dt)
) )
context['allow_waitinglist'] = self.request.event.settings.waiting_list_enabled and context['ev'].presale_is_running context['allow_waitinglist'] = self.request.event.settings.waiting_list_enabled and context['ev'].presale_is_running_by_time(self.request.now_dt)
if not self.request.event.has_subevents or self.subevent: if not self.request.event.has_subevents or self.subevent:
# Fetch all items # Fetch all items
@@ -562,6 +564,7 @@ class EventIndex(EventViewMixin, EventListMixin, CartMixin, TemplateView):
testmode=self.request.event.testmode testmode=self.request.event.testmode
) if getattr(self.request, 'customer', None) else None ) if getattr(self.request, 'customer', None) else None
), ),
now_dt=self.request.now_dt
) )
context['waitinglist_seated'] = False context['waitinglist_seated'] = False
@@ -612,7 +615,7 @@ class EventIndex(EventViewMixin, EventListMixin, CartMixin, TemplateView):
context['show_cart'] = ( context['show_cart'] = (
context['cart']['positions'] and ( context['cart']['positions'] and (
self.request.event.has_subevents or self.request.event.presale_is_running self.request.event.has_subevents or self.request.event.presale_is_running_by_time(self.request.now_dt)
) )
) )
if self.request.event.settings.redirect_to_checkout_directly: if self.request.event.settings.redirect_to_checkout_directly:
@@ -679,6 +682,7 @@ class EventIndex(EventViewMixin, EventListMixin, CartMixin, TemplateView):
limit_before, after, ebd, set(), self.request.event, limit_before, after, ebd, set(), self.request.event,
self.kwargs.get('cart_namespace'), self.kwargs.get('cart_namespace'),
voucher, voucher,
now_dt=self.request.now_dt,
) )
# Hide names of subevents in event series where it is always the same. No need to show the name of the museum thousands of times # Hide names of subevents in event series where it is always the same. No need to show the name of the museum thousands of times
@@ -738,6 +742,7 @@ class EventIndex(EventViewMixin, EventListMixin, CartMixin, TemplateView):
limit_before, after, ebd, set(), self.request.event, limit_before, after, ebd, set(), self.request.event,
self.kwargs.get('cart_namespace'), self.kwargs.get('cart_namespace'),
voucher, voucher,
now_dt=self.request.now_dt,
) )
# Hide names of subevents in event series where it is always the same. No need to show the name of the museum thousands of times # Hide names of subevents in event series where it is always the same. No need to show the name of the museum thousands of times
@@ -776,7 +781,7 @@ class EventIndex(EventViewMixin, EventListMixin, CartMixin, TemplateView):
future_only=self.request.event.settings.event_calendar_future_only future_only=self.request.event.settings.event_calendar_future_only
) )
else: else:
context['subevent_list'] = self.request.event.subevents_sorted( context['subevent_list'] = annotate_with_time_based_properties(self.request.event.subevents_sorted(
filter_qs_by_attr( filter_qs_by_attr(
self.request.event.subevents_annotated( self.request.event.subevents_annotated(
self.request.sales_channel.identifier, self.request.sales_channel.identifier,
@@ -784,7 +789,8 @@ class EventIndex(EventViewMixin, EventListMixin, CartMixin, TemplateView):
).using(settings.DATABASE_REPLICA), ).using(settings.DATABASE_REPLICA),
self.request self.request
) )
) ), self.request.now_dt)
if self.request.event.settings.event_list_available_only and not voucher: if self.request.event.settings.event_list_available_only and not voucher:
context['subevent_list'] = [ context['subevent_list'] = [
se for se in context['subevent_list'] se for se in context['subevent_list']
+2 -1
View File
@@ -1311,7 +1311,8 @@ class OrderChangeMixin:
) )
if self.order.customer else None if self.order.customer else None
), ),
ignore_hide_sold_out_for_item_ids={k[0] for k in current_addon_products.keys()} ignore_hide_sold_out_for_item_ids={k[0] for k in current_addon_products.keys()},
now_dt=self.request.now_dt,
) )
item_cache[ckey] = items item_cache[ckey] = items
else: else:
+5 -2
View File
@@ -63,6 +63,7 @@ from pretix.base.i18n import language
from pretix.base.models import ( from pretix.base.models import (
Event, EventMetaValue, Organizer, Quota, SubEvent, SubEventMetaValue, Event, EventMetaValue, Organizer, Quota, SubEvent, SubEventMetaValue,
) )
from pretix.base.models.event import annotate_with_time_based_properties
from pretix.base.services.quotas import QuotaAvailability from pretix.base.services.quotas import QuotaAvailability
from pretix.helpers.compat import date_fromisocalendar from pretix.helpers.compat import date_fromisocalendar
from pretix.helpers.daterange import daterange from pretix.helpers.daterange import daterange
@@ -549,14 +550,16 @@ def add_events_for_days(request, baseqs, before, after, ebd, timezones):
}) })
def add_subevents_for_days(qs, before, after, ebd, timezones, event=None, cart_namespace=None, voucher=None): def add_subevents_for_days(qs, before, after, ebd, timezones, event=None, cart_namespace=None, voucher=None, now_dt=None):
print("add_subevents_for_days", now_dt)
now_dt = now_dt or now()
qs = qs.filter(active=True, is_public=True).filter( qs = qs.filter(active=True, is_public=True).filter(
Q(Q(date_to__gte=before) & Q(date_from__lte=after)) | Q(Q(date_to__gte=before) & Q(date_from__lte=after)) |
Q(Q(date_to__isnull=True) & Q(date_from__gte=before) & Q(date_from__lte=after)) Q(Q(date_to__isnull=True) & Q(date_from__gte=before) & Q(date_from__lte=after))
).order_by( ).order_by(
'date_from' 'date_from'
) )
qs = annotate_with_time_based_properties(qs, now_dt)
quotas_to_compute = [] quotas_to_compute = []
for se in qs: for se in qs:
if se.presale_is_running: if se.presale_is_running:
+3 -2
View File
@@ -256,6 +256,7 @@ class WidgetAPIProductList(EventListMixin, View):
testmode=self.request.event.testmode testmode=self.request.event.testmode
) if getattr(self.request, 'customer', None) else None ) if getattr(self.request, 'customer', None) else None
), ),
now_dt=self.request.now_dt,
) )
grps = [] grps = []
@@ -409,11 +410,11 @@ class WidgetAPIProductList(EventListMixin, View):
availability['color'] = 'none' availability['color'] = 'none'
availability['text'] = gettext('More info') availability['text'] = gettext('More info')
availability['reason'] = 'unknown' availability['reason'] = 'unknown'
elif ev.presale_is_running: elif ev.presale_is_running_by_time(self.request.now_dt):
availability['color'] = 'green' availability['color'] = 'green'
availability['text'] = gettext('Book now') availability['text'] = gettext('Book now')
availability['reason'] = 'ok' availability['reason'] = 'ok'
elif ev.presale_has_ended: elif ev.presale_has_ended_by_time(self.request.now_dt):
availability['color'] = 'red' availability['color'] = 'red'
availability['text'] = gettext('Sale over') availability['text'] = gettext('Sale over')
availability['reason'] = 'over' availability['reason'] = 'over'
+1
View File
@@ -443,6 +443,7 @@ MIDDLEWARE = [
'pretix.base.middleware.LocaleMiddleware', 'pretix.base.middleware.LocaleMiddleware',
'pretix.base.middleware.SecurityMiddleware', 'pretix.base.middleware.SecurityMiddleware',
'pretix.presale.middleware.EventMiddleware', 'pretix.presale.middleware.EventMiddleware',
'pretix.presale.middleware.TimeMachineMiddleware',
'pretix.api.middleware.ApiScopeMiddleware', 'pretix.api.middleware.ApiScopeMiddleware',
] ]
@@ -967,8 +967,14 @@
float: none; float: none;
margin-bottom: 5px; margin-bottom: 5px;
} }
.pretix-widget-item-info-col:after {
display: block;
content: "";
clear: both;
}
.pretix-widget-item-price-col, .pretix-widget-item-availability-col { .pretix-widget-item-price-col, .pretix-widget-item-availability-col {
width: 50%; width: 50%;
min-width: 140px;
} }
.pretix-widget-action { .pretix-widget-action {
width: 100%; width: 100%;
+1 -1
View File
@@ -934,7 +934,7 @@ def test_cartpos_create_with_voucher_unknown(token_client, organizer, event, ite
@pytest.mark.django_db @pytest.mark.django_db
def test_cartpos_create_with_voucher_invalid_item(token_client, organizer, event, item, quota): def test_cartpos_create_with_voucher_invalid_item(token_client, organizer, event, item, quota):
with scopes_disabled(): with scopes_disabled():
item2 = event.items.create(name="item2") item2 = event.items.create(name="item2", default_price=0)
voucher = event.vouchers.create(code="FOOBAR", item=item2) voucher = event.vouchers.create(code="FOOBAR", item=item2)
res = copy.deepcopy(CARTPOS_CREATE_PAYLOAD) res = copy.deepcopy(CARTPOS_CREATE_PAYLOAD)
res['item'] = item.pk res['item'] = item.pk
+25 -3
View File
@@ -283,6 +283,29 @@ class QuotaTestCase(BaseQuotaTestCase):
v.save() v.save()
self.assertEqual(self.var1.check_quotas(), (Quota.AVAILABILITY_ORDERED, 0)) self.assertEqual(self.var1.check_quotas(), (Quota.AVAILABILITY_ORDERED, 0))
@classscope(attr='o')
def test_voucher_all_variations(self):
self.quota.variations.add(self.var1)
self.quota.size = 1
self.quota.save()
self.quota2 = Quota.objects.create(name="Test", size=2, event=self.event)
self.quota2.variations.add(self.var2)
self.quota3 = Quota.objects.create(name="Test", size=2, event=self.event)
self.quota3.variations.add(self.var3)
v = Voucher.objects.create(item=self.item2, event=self.event)
self.assertEqual(self.var1.check_quotas(), (Quota.AVAILABILITY_OK, 1))
self.assertEqual(self.var2.check_quotas(), (Quota.AVAILABILITY_OK, 2))
self.assertEqual(self.var3.check_quotas(), (Quota.AVAILABILITY_OK, 2))
v.block_quota = True
v.save()
self.assertEqual(self.var1.check_quotas(), (Quota.AVAILABILITY_ORDERED, 0))
self.assertEqual(self.var2.check_quotas(), (Quota.AVAILABILITY_OK, 1))
self.assertEqual(self.var3.check_quotas(), (Quota.AVAILABILITY_OK, 2))
@classscope(attr='o') @classscope(attr='o')
def test_voucher_quota(self): def test_voucher_quota(self):
self.quota.variations.add(self.var1) self.quota.variations.add(self.var1)
@@ -979,9 +1002,8 @@ class VoucherTestCase(BaseQuotaTestCase):
@classscope(attr='o') @classscope(attr='o')
def test_voucher_specify_variation_for_block_quota(self): def test_voucher_specify_variation_for_block_quota(self):
with self.assertRaises(ValidationError): v = Voucher(item=self.item2, block_quota=True, event=self.event)
v = Voucher(item=self.item2, block_quota=True, event=self.event) v.clean()
v.clean()
@classscope(attr='o') @classscope(attr='o')
def test_voucher_no_item_but_variation(self): def test_voucher_no_item_but_variation(self):
+1 -1
View File
@@ -254,7 +254,7 @@ class VoucherFormTest(SoupTestMixin, TransactionTestCase):
self._create_voucher({ self._create_voucher({
'itemvar': '%d' % self.shirt.pk, 'itemvar': '%d' % self.shirt.pk,
'block_quota': 'on' 'block_quota': 'on'
}, expected_failure=True) })
def test_create_blocking_item_voucher_quota_full_invalid(self): def test_create_blocking_item_voucher_quota_full_invalid(self):
self.quota_shirts.size = 0 self.quota_shirts.size = 0