Compare commits

..
Author SHA1 Message Date
Raphael Michel 943fe8b185 API: Disable endpoints that should have never existed 2026-07-28 13:59:23 +02:00
Raphael Michel 16040f70bb [SECURITY] Add missing permission check for view (CVE-2026-57532) 2026-07-28 12:24:59 +02:00
Raphael Michel 9b5ed06b2e Improve handling of reauth during async tasks (Z#23238978) (#6381) 2026-07-28 10:59:42 +02:00
Raphael Michel f3ffce4e5b Question form: Allow to unset required boolean questions (Z#23239887) (#6382)
Proper fix for #6366
2026-07-28 10:59:34 +02:00
Albizuri 5c98dc16b7 Translations: Update Basque
Currently translated at 48.3% (3077 of 6362 strings)

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

powered by weblate
2026-07-28 10:11:51 +02:00
Nikolai d814b40fcd Translations: Update Danish
Currently translated at 61.8% (3932 of 6362 strings)

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

powered by weblate
2026-07-28 10:11:51 +02:00
LilaHexeandRaphael Michel a696c493e2 PayPal: Drop maximum length of client ID (#6424)
* Update PayPal max_length to 82 characters

* Apply suggestions from code review

Co-authored-by: Raphael Michel <mail@raphaelmichel.de>

---------

Co-authored-by: Raphael Michel <mail@raphaelmichel.de>
2026-07-28 09:13:45 +02:00
dependabot[bot] e88b539375 Update tlds requirement from >=2026041800 to >=2026072401 (#6428)
Updates the requirements on [tlds](https://github.com/kichik/tlds) to permit the latest version.
- [Commits](https://github.com/kichik/tlds/commits)

---
updated-dependencies:
- dependency-name: tlds
  dependency-version: '2026072401'
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-28 09:08:29 +02:00
Raphael Michel 163cf85c86 Orders: Fix N+1 query issues on order detail and change page (#6390)
* rders: Fix N+1 query issues on order detail and change page

There is one I couldn't fix: Loading the list of tax rules for every
select box on the OrderChange page. Unfortunately, Django has a
cache-breaking .all() in ModelChoiceField and that would need nasty
patching that didn't feel worth it

* Fix isort

* Fix N+1 query in gate call

* Fix leftovers

* Add local cache for objects referenced in log entries

* Update src/pretix/control/views/orders.py
2026-07-28 09:08:14 +02:00
pajowu 55b6887c5e Show new login time in users timezone (Z#23241469) (#6420) 2026-07-28 09:08:00 +02:00
dependabot[bot] 2592d3cb8d Update django-phonenumber-field requirement from ==8.4.* to ==8.5.* (#6415)
Updates the requirements on [django-phonenumber-field](https://github.com/django-phonenumber-field/django-phonenumber-field) to permit the latest version.
- [Release notes](https://github.com/django-phonenumber-field/django-phonenumber-field/releases)
- [Changelog](https://github.com/django-phonenumber-field/django-phonenumber-field/blob/main/CHANGELOG.rst)
- [Commits](https://github.com/django-phonenumber-field/django-phonenumber-field/compare/8.4.0...8.5.0)

---
updated-dependencies:
- dependency-name: django-phonenumber-field
  dependency-version: 8.5.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-28 09:07:55 +02:00
Raphael Michel 47b632eabe RejectInvalidInputMiddleware: Ignore invalid charsets on body (#6314) 2026-07-28 09:07:16 +02:00
Raphael Michel 7f2e957092 Security profiles: Ensure default profile is always listed first (#6266) 2026-07-28 09:06:59 +02:00
Raphael Michel 1ff9742a08 Customer export: Add memberships tab (Z#23228422) (#6253) 2026-07-28 09:06:45 +02:00
Raphael Michel d9f8679647 Waiting list: Redirect back after voucher edit (Z#23230178) (#6252) 2026-07-27 23:19:10 +02:00
52 changed files with 759 additions and 697 deletions
+1 -1
View File
@@ -83,7 +83,7 @@ Dashboards
.. automodule:: pretix.control.signals
:no-index:
:members: event_dashboard_statistics, user_dashboard_widgets, event_dashboard_top
:members: event_dashboard_widgets, user_dashboard_widgets, event_dashboard_top
Ticket designs
""""""""""""""
+2 -2
View File
@@ -52,7 +52,7 @@ dependencies = [
"django-markup",
"django-oauth-toolkit==2.3.*",
"django-otp==1.7.*",
"django-phonenumber-field==8.4.*",
"django-phonenumber-field==8.5.*",
"django-querytagger==0.0.3",
"django-redis==7.0.*",
"django-scopes==2.1.*",
@@ -98,7 +98,7 @@ dependencies = [
"sepaxml==2.7.*",
"stripe==7.9.*",
"text-unidecode==1.*",
"tlds>=2026041800",
"tlds>=2026072401",
"tqdm==4.*",
"ua-parser==1.0.*",
"vobject==0.9.*",
+13 -4
View File
@@ -20,7 +20,6 @@
# <https://www.gnu.org/licenses/>.
#
import logging
from collections import OrderedDict
from django.dispatch import receiver
from django.utils.translation import gettext_lazy as _
@@ -52,10 +51,18 @@ class BaseSecurityProfile:
"""
raise NotImplementedError()
@property
def priority(self) -> int:
"""
Priority for ordering, higher will come first.
"""
return 100
class FullAccessSecurityProfile(BaseSecurityProfile):
identifier = 'full'
verbose_name = _('Full device access (reading and changing orders and gift cards, reading of products and settings)')
priority = 1000
def is_allowed(self, request):
return True
@@ -195,13 +202,15 @@ def get_all_security_profiles():
if _ALL_PROFILES:
return _ALL_PROFILES
types = OrderedDict()
types = []
for recv, ret in register_device_security_profile.send(None):
if isinstance(ret, (list, tuple)):
for r in ret:
types[r.identifier] = r
types.append(r)
else:
types[ret.identifier] = ret
types.append(ret)
types.sort(key=lambda el: el.priority, reverse=True)
types = {r.identifier: r for r in types}
_ALL_PROFILES = types
return types
+2 -1
View File
@@ -45,6 +45,7 @@ from rest_framework.exceptions import (
NotFound, PermissionDenied, ValidationError,
)
from rest_framework.generics import get_object_or_404
from rest_framework.mixins import UpdateModelMixin
from rest_framework.response import Response
from pretix.api.auth.permission import EventCRUDPermission
@@ -711,7 +712,7 @@ class SeatFilter(FilterSet):
fields = ('zone_name', 'row_name', 'row_label', 'seat_number', 'seat_label', 'seat_guid', 'blocked',)
class SeatViewSet(ConditionalListView, viewsets.ModelViewSet):
class SeatViewSet(ConditionalListView, UpdateModelMixin, viewsets.ReadOnlyModelViewSet):
serializer_class = SeatSerializer
queryset = Seat.objects.none()
write_permission = 'event.settings.general:write'
+57 -3
View File
@@ -40,11 +40,12 @@ from django.utils.translation import gettext as _, gettext_lazy, pgettext_lazy
from pretix.base.settings import PERSON_NAME_SCHEMES
from ..exporter import ListExporter, OrganizerLevelExportMixin
from ..exporter import MultiSheetListExporter, OrganizerLevelExportMixin
from ..models import Membership
from ..signals import register_multievent_data_exporters
class CustomerListExporter(OrganizerLevelExportMixin, ListExporter):
class CustomerListExporter(OrganizerLevelExportMixin, MultiSheetListExporter):
identifier = 'customerlist'
verbose_name = gettext_lazy('Customer accounts')
category = pgettext_lazy('export_category', 'Customer accounts')
@@ -54,13 +55,20 @@ class CustomerListExporter(OrganizerLevelExportMixin, ListExporter):
def get_required_organizer_permission(cls) -> str:
return 'organizer.customers:write'
@property
def sheets(self):
return (
('customers', _('Customers')),
('memberships', _('Memberships')),
)
@property
def additional_form_fields(self):
return OrderedDict(
[]
)
def iterate_list(self, form_data):
def iterate_customers(self, form_data):
qs = self.organizer.customers.prefetch_related('provider')
headers = [
@@ -109,6 +117,52 @@ class CustomerListExporter(OrganizerLevelExportMixin, ListExporter):
]
yield row
def iterate_memberships(self, form_data):
qs = Membership.objects.filter(
customer__organizer=self.organizer
).prefetch_related('membership_type').select_related('customer', 'granted_in', 'granted_in__order')
headers = [
_('Customer ID'),
_('External identifier'),
_('Email'),
_('Test mode'),
_('Canceled'),
_('Membership type'),
_('Purchase ticket'),
_('Start date'),
_('Start time'),
_('End date'),
_('End time'),
_('Name'),
]
name_scheme = PERSON_NAME_SCHEMES[self.organizer.settings.name_scheme]
if name_scheme and len(name_scheme['fields']) > 1:
for k, label, w in name_scheme['fields']:
headers.append(_('Name') + ': ' + str(label))
yield headers
tz = get_current_timezone()
for obj in qs:
row = [
obj.customer.identifier,
obj.customer.external_identifier,
obj.customer.email or '',
_('Yes') if obj.testmode else _('No'),
_('Yes') if obj.canceled else _('No'),
str(obj.membership_type.name),
f'{obj.granted_in.order.code}-{obj.granted_in.positionid}' if obj.granted_in else None,
obj.date_start.astimezone(tz).strftime('%Y-%m-%d'),
obj.date_start.astimezone(tz).strftime('%H:%M'),
obj.date_end.astimezone(tz).strftime('%Y-%m-%d'),
obj.date_end.astimezone(tz).strftime('%H:%M'),
obj.attendee_name or '',
]
if name_scheme and len(name_scheme['fields']) > 1:
for k, label, w in name_scheme['fields']:
row.append(obj.attendee_name_parts.get(k, ''))
yield row
def get_filename(self):
return '{}_customers'.format(self.organizer.slug)
+1 -1
View File
@@ -1121,7 +1121,7 @@ class BaseQuestionsForm(forms.Form):
for q in question_cache.values():
if q.required and q.type == Question.TYPE_BOOLEAN:
if 'question_%d' % q.pk in d and d['question_%d' % q.pk] is False:
del d['question_%d' % q.pk]
d['question_%d' % q.pk] = None
return d
+10 -2
View File
@@ -497,8 +497,16 @@ class RejectInvalidInputMiddleware(MiddlewareMixin):
if "\x00" in request.META['QUERY_STRING'] or "%00" in request.META['QUERY_STRING']:
raise BadRequest("Invalid characters in input.")
if request.method in ('POST', 'PUT', 'PATCH') and request.content_type == "application/x-www-form-urlencoded":
if any("\x00" in value for key, value_list in request.POST.lists() for value in value_list):
raise BadRequest("Invalid characters in input.")
try:
post_data = request.POST.lists()
except BadRequest:
# Reading request.POST wasn't possible, probably an invalid charset. Django will crash once we actually
# use request.POST, but if we don't, let's not crash it (required for some weird payment provider
# webhooks, e.g. computop).
pass
else:
if any("\x00" in value for key, value_list in post_data for value in value_list):
raise BadRequest("Invalid characters in input.")
class CustomCommonMiddleware(CommonMiddleware):
+1 -1
View File
@@ -44,7 +44,7 @@ def timeline_for_event(event, subevent=None):
ev = subevent or event
if subevent:
ev_edit_url = reverse(
'control:event.subevent.edit', kwargs={
'control:event.subevent', kwargs={
'event': event.slug,
'organizer': event.organizer.slug,
'subevent': subevent.pk
+1 -3
View File
@@ -562,9 +562,7 @@ class OrderPositionChangeForm(forms.Form):
if instance.addon_to_id:
del self.fields['operation_split']
if not instance.seat and not (
instance.item.seat_category_mappings.filter(subevent=instance.subevent).exists()
):
if not instance.seat and not instance._seat_allowed:
del self.fields['seat']
choices = [
+21 -13
View File
@@ -33,6 +33,7 @@
# 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.
import functools
from collections import defaultdict
from datetime import datetime
from decimal import Decimal
@@ -69,6 +70,15 @@ OVERVIEW_BANLIST = [
]
@functools.lru_cache(maxsize=32)
def object_id_to_string(model_class, **kwargs):
# The cache is thread-local and persists requests, but it's small enough that we can accept that
try:
return str(model_class.objects.get(**kwargs))
except model_class.DoesNotExist:
return "?"
class OrderChangeLogEntryType(OrderLogEntryType):
prefix = _('The order has been changed:')
@@ -93,10 +103,10 @@ class OrderItemChanged(OrderChangeLogEntryType):
def display_prefixed(self, event: Event, logentry: LogEntry, data):
old_item = str(event.items.get(pk=data['old_item']))
if data['old_variation']:
old_item += ' - ' + str(ItemVariation.objects.get(item__event=event, pk=data['old_variation']))
old_item += ' - ' + str(object_id_to_string(ItemVariation, item__event_id=event.pk, pk=data['old_variation']))
new_item = str(event.items.get(pk=data['new_item']))
if data['new_variation']:
new_item += ' - ' + str(ItemVariation.objects.get(item__event=event, pk=data['new_variation']))
new_item += ' - ' + str(object_id_to_string(ItemVariation, item__event_id=event.pk, pk=data['new_variation']))
return _('Position #{posid}: {old_item} ({old_price}) changed to {new_item} ({new_price}).').format(
posid=data.get('positionid', '?'),
old_item=old_item, new_item=new_item,
@@ -153,14 +163,14 @@ class OrderTaxRuleChanged(OrderChangeLogEntryType):
if 'positionid' in data:
return _('Tax rule of position #{posid} changed from {old_rule} to {new_rule}.').format(
posid=data.get('positionid', '?'),
old_rule=TaxRule.objects.get(pk=data['old_taxrule']) if data['old_taxrule'] else '',
new_rule=TaxRule.objects.get(pk=data['new_taxrule']),
old_rule=object_id_to_string(TaxRule, pk=data['old_taxrule']) if data['old_taxrule'] else '',
new_rule=object_id_to_string(TaxRule, pk=data['new_taxrule']),
)
elif 'fee' in data:
return _('Tax rule of fee #{fee} changed from {old_rule} to {new_rule}.').format(
fee=data.get('fee', '?'),
old_rule=TaxRule.objects.get(pk=data['old_taxrule']) if data['old_taxrule'] else '',
new_rule=TaxRule.objects.get(pk=data['new_taxrule']),
old_rule=object_id_to_string(TaxRule, pk=data['old_taxrule']) if data['old_taxrule'] else '',
new_rule=object_id_to_string(TaxRule, pk=data['new_taxrule']),
)
@@ -204,7 +214,7 @@ class OrderCanceled(OrderChangeLogEntryType):
def display_prefixed(self, event: Event, logentry: LogEntry, data):
old_item = str(event.items.get(pk=data['old_item']))
if data['old_variation']:
old_item += ' - ' + str(ItemVariation.objects.get(pk=data['old_variation']))
old_item += ' - ' + object_id_to_string(ItemVariation, pk=data['old_variation'])
return _('Position #{posid} ({old_item}, {old_price}) canceled.').format(
posid=data.get('positionid', '?'),
old_item=old_item,
@@ -219,7 +229,7 @@ class OrderPositionAdded(OrderChangeLogEntryType):
def display_prefixed(self, event: Event, logentry: LogEntry, data):
item = str(event.items.get(pk=data['item']))
if data['variation']:
item += ' - ' + str(ItemVariation.objects.get(item__event=event, pk=data['variation']))
item += ' - ' + object_id_to_string(ItemVariation, item__event_id=event.pk, pk=data['variation'])
if data['addon_to']:
addon_to = OrderPosition.objects.get(order__event=event, pk=data['addon_to'])
return _('Position #{posid} created: {item} ({price}) as an add-on to position #{addon_to}.').format(
@@ -283,7 +293,7 @@ class OrderChangedSplit(OrderChangeLogEntryType):
def display_prefixed(self, event: Event, logentry: LogEntry, data):
old_item = str(event.items.get(pk=data['old_item']))
if data['old_variation']:
old_item += ' - ' + str(ItemVariation.objects.get(pk=data['old_variation']))
old_item += ' - ' + object_id_to_string(ItemVariation, pk=data['old_variation'])
url = reverse('control:event.order', kwargs={
'event': event.slug,
'organizer': event.organizer.slug,
@@ -339,6 +349,7 @@ class OrderChangedSplitFrom(OrderLogEntryType):
'pretix.event.checkin.reverted': _('The check-in of position #{posid} on list "{list}" has been reverted.'),
})
class CheckinErrorLogEntryType(OrderLogEntryType):
def display(self, logentry: LogEntry, data):
return self.display_plain(self.plain, logentry, data)
@@ -353,10 +364,7 @@ class CheckinErrorLogEntryType(OrderLogEntryType):
event = logentry.event
if 'list' in data and event:
try:
data['list'] = event.checkin_lists.get(pk=data.get('list')).name
except CheckinList.DoesNotExist:
data['list'] = _("(unknown)")
data['list'] = object_id_to_string(CheckinList, event_id=event.id, pk=data['list'])
else:
data['list'] = _("(unknown)")
+11 -3
View File
@@ -133,14 +133,22 @@ This signal is sent out to include custom HTML in the top part of the the event
Receivers should return a SafeString containing HTML, or a string that will be HTML-escaped.
As with all event plugin signals, the ``sender`` keyword argument will contain the event.
An additional keyword argument ``subevent`` *can* contain a sub-event.
"""
event_dashboard_statistics = EventPluginSignal()
event_dashboard_widgets = EventPluginSignal()
"""
This signal is sent out to include statistical content on the event dashboard.
Receivers should return a SafeString containing HTML, or a string that will be HTML-escaped.
This signal is sent out to include widgets in the event dashboard. Receivers
should return a list of dictionaries, where each dictionary can have the keys:
* content (SafeString, containing HTML)
* display_size (str, one of "full" (whole row), "big" (half a row) or "small"
(quarter of a row). May be ignored on small displays, default is "small")
* priority (int, used for ordering, higher comes first, default is 1)
* url (str, optional, if the full widget should be a link)
As with all event plugin signals, the ``sender`` keyword argument will contain the event.
An additional keyword argument ``subevent`` *can* contain a sub-event.
"""
user_dashboard_widgets = GlobalSignal()
@@ -23,7 +23,6 @@
{% endif %}
{% compress js %}
<script type="text/javascript" src="{% static "jquery/js/jquery-3.6.4.min.js" %}"></script>
<script type="text/javascript" src="{% static "htmx/htmx-2.0.10.min.js" %}"></script>
<script type="text/javascript" src="{% static "js/jquery.formset.js" %}"></script>
<script type="text/javascript" src="{% static "typeahead/typeahead.bundle.js" %}"></script>
<script type="text/javascript" src="{% static "bootstrap/js/bootstrap.js" %}"></script>
@@ -1,23 +0,0 @@
{% load i18n %}
{% load icon %}
{% load humanize %}
{% for cl in lists %}
<a class="quotabox quotabox-full availability"
href="{% url "control:event.orders.checkinlists.show" organizer=request.event.organizer.slug event=request.event.slug list=cl.id %}">
<strong>{{ cl.name }}</strong>
<div class="progress">
<div class="progress-bar progress-bar-success progress-bar-{{ cl.percent }}">
</div>
</div>
<div class="numbers">
{% icon "sign-in" %}
{{ cl.checkin_count|default_if_none:0|intcomma }} /
{{ cl.position_count|default_if_none:0|intcomma }}
<br>
{% icon "user" %}
{% blocktrans trimmed with n=cl.inside_count|intcomma %}
{{ n }} present
{% endblocktrans %}
</div>
</a>
{% endfor %}
@@ -1,17 +0,0 @@
{% load i18n %}
{% load icon %}
<div id="comment-form">
<strong>{% trans "Comment" %}:</strong>
{% if "event.settings.general:write" in request.eventpermset %}
<button type="button" class="btn btn-default btn-xs"
hx-get="{% url "control:event.index.comment" organizer=request.organizer.slug event=request.event.slug %}"
hx-target="#comment-form">
{% icon "edit" %}
</button>
{% endif %}
{% if request.event.comment %}
<p>
{{ request.event.comment|linebreaksbr }}
</p>
{% endif %}
</div>
@@ -1,16 +0,0 @@
{% load i18n %}
{% load bootstrap3 %}
<form class="form" method="post"
hx-post="{% url "control:event.index.comment" event=request.event.slug organizer=request.event.organizer.slug %}">
{% csrf_token %}
<div>
<p>
{% bootstrap_field form.comment layout="inline" show_help=True show_label=False horizontal_field_class="col-md-12" %}
</p>
<p class="text-right flip">
<button class="btn btn-default">
{% trans "Update comment" %}
</button>
</p>
</div>
</form>
@@ -1,4 +0,0 @@
{% load i18n %}
{% for q in quotas %}
{% include "pretixcontrol/fragment_quota_box.html" with quota=q full=1 %}
{% endfor %}
@@ -1,11 +0,0 @@
{% load i18n %}
{% load humanize %}
{% if count %}
<a href="{% url "control:event.orders.waitinglist" event=request.event.slug organizer=request.organizer.slug %}">
{% blocktrans trimmed with number=count|intcomma count count=count %}
{{ number }} person waiting
{% plural %}
{{ number }} persons waiting
{% endblocktrans %}
</a>
{% endif %}
@@ -1,5 +1,4 @@
{% load i18n %}
{% load eventsignal %}
{% if has_overpaid_orders %}
<div class="alert alert-warning">
{% blocktrans trimmed %}
@@ -57,4 +56,3 @@
class="btn btn-primary">{% trans "Show sync problems" %}</a>
</div>
{% endif %}
{% eventsignal request.event "pretix.control.signals.event_dashboard_top" request=request %}
@@ -1,19 +0,0 @@
{% load i18n %}
<div class="panel panel-primary">
<div class="panel-heading">
<h2 class="panel-title">{% trans "Welcome to pretix!" %}</h2>
</div>
<div class="panel-body">
<div class="attentionline">{% trans "Get started with our setup tool" %}</div>
<p>
{% blocktrans trimmed %}
To start selling tickets, you need to create products or quotas. The fastest way to create
this is to use our setup tool.
{% endblocktrans %}
</p>
<a href="{% url "control:event.quick" organizer=request.organizer.slug event=request.event.slug %}"
class="btn btn-primary btn-lg">
{% trans "Set up event" %}
</a>
</div>
</div>
@@ -0,0 +1,12 @@
<div class="welcome-wizard">
<h3>{{ title }}</h3>
{% if subtitle %}
<div class="attentionline">{{ subtitle }}</div>
{% endif %}
{% if text %}
<p>{{ text }}</p>
{% endif %}
{% if button_text %}
<p><a href="{{ button_url }}" class="btn btn-primary btn-lg">{{ button_text }}</a></p>
{% endif %}
</div>
@@ -1,10 +1,10 @@
{% load i18n %}
<details class="panel panel-default items" open>
<summary class="panel-heading">
<div class="panel panel-default items">
<div class="panel-heading">
<h3 class="panel-title">
{% trans "Timeline" %}
{% trans "Your timeline" %}
</h3>
</summary>
</div>
<div class="panel-body timeline">
{% regroup timeline by date as tl_list %}
{% for day in tl_list %}
@@ -33,4 +33,4 @@
</div>
{% endfor %}
</div>
</details>
</div>
@@ -3,10 +3,10 @@
{% load eventurl %}
{% load bootstrap3 %}
{% load static %}
{% load icon %}
{% load eventsignal %}
{% block title %}{{ request.event.name }}{% endblock %}
{% block content %}
<h1 class="event-dashboard-header">
<h1>
{{ request.event.name }}
<small>
{% if request.event.has_subevents %}
@@ -14,43 +14,11 @@
{% else %}
{{ request.event.get_date_range_display }}
{% endif %}
<span id="warnings_indicator" class="htmx-indicator">{% icon "cog fa-spin" %}</span>
<span id="warnings_loading" class="fa fa-cog fa-spin"></span>
</small>
<div class="pull-right flip">
<a href="{% url "control:event.live" organizer=request.organizer.slug event=request.event.slug %}"
data-toggle="tooltip"
title="{% trans "Click to change shop status" %}">
{% icon "pencil" %}</a>
<a href="{% url "control:event.live" organizer=request.organizer.slug event=request.event.slug %}"
data-toggle="tooltip"
title="{% trans "Click to change shop status" %}">
{% if request.event.live and request.event.testmode %}
<span class="label label-warning">
<span class="fa fa-warning"></span>
{% trans "public test mode" %}
</span>
{% elif request.event.live %}
<span class="label label-success">
<span class="fa fa-check"></span>
{% trans "live" %}
</span>
{% elif request.event.testmode %}
<span class="label label-warning">
<span class="fa fa-power-off"></span>
{% trans "private test mode" %}
</span>
{% else %}
<span class="label label-danger">
<span class="fa fa-power-off"></span>
{% trans "offline" %}
</span>
{% endif %}
</a>
</div>
<div class="clearfix"></div>
</h1>
<div class="helper-space-below">
<strong>{% trans "Shop URL:" %}</strong>
{% trans "Shop URL:" %}
<span id="shop_url" class="text-muted">{% abseventurl request.event "presale:event.index" %}</span>
<button type="button" class="btn btn-default btn-xs btn-clipboard js-only" data-clipboard-target="#shop_url">
<i class="fa fa-clipboard" aria-hidden="true"></i>
@@ -63,76 +31,75 @@
{% include "pretixcontrol/event/fragment_qr_dropdown.html" with url=0 %}
</div>
<div class="clearfix"></div>
{% include "pretixcontrol/event/dashboard_partial_comment.html" with url=0 %}
</div>
<div id="warnings_target"></div>
{% eventsignal request.event "pretix.control.signals.event_dashboard_top" request=request %}
{% if request.event.has_subevents %}
<form class="form-inline helper-display-inline" action="" method="get">
{% include "pretixcontrol/event/fragment_subevent_choice_simple.html" %}
</form>
{% endif %}
{% if not request.event.has_subevents or subevent %}
{% include "pretixcontrol/event/fragment_timeline.html" %}
{% endif %}
<div class="dashboard">
{% for w in widgets %}
<div class="widget-container widget-{{ w.display_size|default:"small" }} {% if w.lazy %}widget-lazy-loading{% endif %}" data-lazy-id="{{ w.lazy }}">
{% if w.url %}{# backwards compatibility #}
<a href="{{ w.url }}" class="widget">
{% if w.lazy %}
<span class="fa fa-cog fa-4x"></span>
{% else %}
{{ w.content }}
{% endif %}
</a>
{% elif w.link %}
<a href="{{ w.link }}" class="widget">
{% if w.lazy %}
<span class="fa fa-cog fa-4x´"></span>
{% else %}
{{ w.content }}
{% endif %}
</a>
{% else %}
<div class="widget">
{% if w.lazy %}
<span class="fa fa-cog fa-4x"></span>
{% else %}
{{ w.content }}
{% endif %}
</div>
{% endif %}
</div>
{% endfor %}
</div>
<div hx-get="{% url "control:event.index.warnings" organizer=request.organizer.slug event=request.event.slug %}"
{# loading indicator is somewhere else because the happy case is "no warnings" and shouldn't make the page jump #}
hx-indicator="#warnings_indicator"
hx-trigger="load"></div>
{% if stats %}
{{ stats }}
{% endif %}
<p>&nbsp;</p>
{% if not request.event.items.exists %}
{% include "pretixcontrol/event/dashboard_partial_welcome.html" %}
{% elif not request.event.has_subevents %}
{% include "pretixcontrol/event/fragment_timeline.html" %}
<div class="row">
<div class="col-md-6 col-sm-12">
<div class="panel panel-default">
<div class="panel-heading">
<div class="pull-right">
<div hx-get="{% url "control:event.index.waiting" organizer=request.organizer.slug event=request.event.slug %}"
hx-trigger="load">
<span class="loading-mock loading-mock-text-short"></span>
</div>
</div>
<h2 class="panel-title">{% trans "Quotas" %}</h2>
</div>
<div class="panel-body">
<div hx-get="{% url "control:event.index.quotas" organizer=request.organizer.slug event=request.event.slug %}"
hx-trigger="load">
{% for i in "12" %}
{% include "pretixcontrol/fragment_quota_box_mock.html" %}
{% endfor %}
</div>
</div>
</div>
</div>
<div class="col-md-6 col-sm-12">
<div class="panel panel-default">
<div class="panel-heading">
<h2 class="panel-title">{% trans "Check-in lists" %}</h2>
</div>
<div class="panel-body">
<div hx-get="{% url "control:event.index.checkin" organizer=request.organizer.slug event=request.event.slug %}"
hx-trigger="load">
{% for i in "12" %}
{% include "pretixcontrol/fragment_quota_box_mock.html" %}
{% endfor %}
</div>
</div>
</div>
</div>
<div class="panel panel-default items">
<div class="panel-heading">
<h3 class="panel-title">
{% trans "Internal comment" %}
</h3>
</div>
{% elif has_checkin_widgets %}
<div class="panel panel-default">
<div class="panel-heading">
<h2 class="panel-title">{% trans "Check-in lists" %}</h2>
</div>
<div class="panel-body">
<div hx-get="{% url "control:event.index.checkin" organizer=request.organizer.slug event=request.event.slug %}"
hx-trigger="load">
{% for i in "12" %}
{% include "pretixcontrol/fragment_quota_box_mock.html" %}
{% endfor %}
<div class="panel-body">
<form class="form" method="post"
action="{% url "control:event.comment" event=request.event.slug organizer=request.event.organizer.slug %}">
{% csrf_token %}
<div class="row">
{% bootstrap_field comment_form.comment layout="horizontal" show_help=True show_label=False horizontal_field_class="col-md-12" %}
</div>
</div>
{% if not comment_form.readonly %}
<p class="text-right flip">
<br>
<button class="btn btn-default">
{% trans "Update comment" %}
</button>
</p>
{% endif %}
</form>
</div>
{% endif %}
</div>
{% if "event.orders:read" in request.eventpermset or "event.orders:write" in request.eventpermset or "event.settings.general:write" in request.eventpermset or "event.items:write" in request.eventpermset %}
<div class="panel panel-default">
<div class="panel-heading">
@@ -140,27 +107,10 @@
{% trans "Event logs" %}
</h3>
</div>
<ul class="list-group"
hx-get="{% url "control:event.index.logs" organizer=request.organizer.slug event=request.event.slug %}"
hx-trigger="load">
{% for i in "12345" %}
<li class="list-group-item logentry">
<div class="row">
<div class="col-lg-2 col-sm-6 col-xs-12">
<span class="loading-mock loading-mock-text-medium"></span>
</div>
<div class="col-lg-2 col-sm-6 col-xs-12">
<span class="loading-mock loading-mock-text-short"></span>
</div>
<div class="col-lg-2 col-sm-12 col-xs-12">
<span class="loading-mock loading-mock-text-medium"></span>
</div>
<div class="col-lg-6 col-sm-12 col-xs-12">
<span class="loading-mock loading-mock-text-long"></span>
</div>
</div>
</li>
{% endfor %}
<ul class="list-group" id="logs_target">
<div class="logs-lazy-loading">
<span class="fa fa-cog fa-4x"></span>
</div>
</ul>
<div class="panel-footer">
<a href="{% url "control:event.log" event=request.event.slug organizer=request.event.organizer.slug %}">
@@ -86,7 +86,7 @@
<a href="?{% url_replace request 'ordering' 'date_to' %}"><i class="fa fa-caret-up"></i></a>
</th>
<th>
{% trans "Quota utilization" %}
{% trans "Paid tickets per quota" %}
</th>
<th>
{% trans "Status" %}
@@ -151,7 +151,7 @@
</td>
<td>
{% for q in e.first_quotas|slice:":3" %}
{% include "pretixcontrol/fragment_quota_box.html" with quota=q %}
{% include "pretixcontrol/fragment_quota_box_paid.html" with quota=q %}
{% endfor %}
{% if e.first_quotas|length > 3 %}
<a href="{% url "control:event.items.quotas" organizer=e.organizer.slug event=e.slug %}"
@@ -1,26 +1,18 @@
{% load i18n %}
{% load humanize %}
<a class="quotabox {% if full %}quotabox-full{% endif %}" data-toggle="tooltip_html" data-placement="top"
title="{% trans "Quota:" %} {{ q.name|force_escape|force_escape }}{% if q.cached_avail.1 is not None %}<br>{% blocktrans with num=q.cached_avail.1 %}Currently available: {{ num }}{% endblocktrans %}{% endif %}"
href="{% url "control:event.items.quotas.show" event=q.event.slug organizer=q.event.organizer.slug quota=q.pk %}">
{% if full %}
<strong>{{ q.name }}</strong>
{% endif %}
<div class="quotabox availability" data-toggle="tooltip_html" data-placement="top"
title="{% trans "Quota:" %} {{ q.name|force_escape|force_escape }}<br>{% blocktrans with date=q.cached_availability_time|date:"SHORT_DATETIME_FORMAT" %}Numbers as of {{ date }}{% endblocktrans %}">
{% if q.size|default_if_none:"NONE" == "NONE" %}
<div class="progress">
<div class="progress-bar progress-bar-success progress-bar-100">
</div>
</div>
{% else %}
<div class="progress">
<div class="progress-bar progress-bar-{% if q.cached_avail.0 < 10 %}danger{% elif q.cached_avail.0 < 100 %}warning{% else %}success{% endif %} progress-bar-{{ q.percent_paid }}">
</div>
<div class="progress-bar progress-bar-{% if q.cached_avail.0 < 10 %}danger{% elif q.cached_avail.0 < 100 %}warning{% else %}success{% endif %} progress-bar-unconfirmed progress-bar-{{ q.percent_other }}">
<div class="progress-bar progress-bar-{% if q.cached_avail.0 <= 10 or q.cached_avail.0 >= 100 %}danger{% else %}warning{% endif %} progress-bar-{{ q.inv_percent }}">
</div>
</div>
{% endif %}
<div class="numbers">
{{ q.used|default_if_none:"∞" }} / {{ q.size|default_if_none:"∞" }}<br>
{% blocktrans trimmed with n=q.cached_availability_paid_orders %}
{{ n }} paid
{% endblocktrans %}
{{ q.cached_avail.1|default_if_none:"∞" }} / {{ q.size|default_if_none:"∞" }}
</div>
</a>
</div>
@@ -1,10 +0,0 @@
<a class="quotabox quotabox-full">
<strong>
<span class="loading-mock loading-mock-text-short"></span>
</strong>
<div class="progress loading-mock"></div>
<div class="numbers">
<span class="loading-mock loading-mock-text-short"></span>
<br><span class="loading-mock loading-mock-text-short"></span>
</div>
</a>
@@ -0,0 +1,17 @@
{% load i18n %}
<a class="quotabox" data-toggle="tooltip_html" data-placement="top"
title="{% trans "Quota:" %} {{ q.name|force_escape|force_escape }}{% if q.cached_avail.1 is not None %}<br>{% blocktrans with num=q.cached_avail.1 %}Currently available: {{ num }}{% endblocktrans %}{% endif %}"
href="{% url "control:event.items.quotas.show" event=q.event.slug organizer=q.event.organizer.slug quota=q.pk %}">
{% if q.size|default_if_none:"NONE" == "NONE" %}
<div class="progress">
</div>
{% else %}
<div class="progress">
<div class="progress-bar progress-bar-{% if q.cached_avail.0 < 10 %}danger{% elif q.cached_avail.0 < 100 %}warning{% else %}success{% endif %} progress-bar-{{ q.percent_paid }}">
</div>
</div>
{% endif %}
<div class="numbers">
{{ q.cached_availability_paid_orders|default_if_none:"?" }} / {{ q.size|default_if_none:"∞" }}
</div>
</a>
@@ -833,7 +833,7 @@
<strong>{% trans "Pending total" %}</strong>
</div>
<div class="col-md-3 col-xs-6 col-md-offset-5 price">
<strong>{{ order.pending_sum|money:event.currency }}</strong>
<strong>{{ pending_sum|money:event.currency }}</strong>
</div>
<div class="clearfix"></div>
</div>
@@ -72,7 +72,6 @@
{% endif %}
</dl>
</fieldset>
{% include "pretixcontrol/event/fragment_timeline.html" %}
<fieldset>
<legend>{% trans "Quotas" %}</legend>
<div class="table-responsive">
@@ -102,7 +102,7 @@
<a href="?{% url_replace request 'filter-ordering' 'date_from' %}"><i class="fa fa-caret-up"></i></a>
</th>
<th>
{% trans "Quota utilization" %}
{% trans "Paid tickets per quota" %}
</th>
<th>
{% trans "Status" %}
@@ -155,7 +155,7 @@
</td>
<td>
{% for q in s.first_quotas|slice:":3" %}
{% include "pretixcontrol/fragment_quota_box.html" with quota=q %}
{% include "pretixcontrol/fragment_quota_box_paid.html" with quota=q %}
{% endfor %}
{% if s.first_quotas|length > 3 %}
<a href="{% url "control:event.items.quotas" organizer=request.event.organizer.slug event=request.event.slug %}?subevent={{ s.id }}"
@@ -251,7 +251,7 @@
</td>
<td>
{% if e.voucher %}
<a href="{% url "control:event.voucher" organizer=request.event.organizer.slug event=request.event.slug voucher=e.voucher.pk %}">
<a href="{% url "control:event.voucher" organizer=request.event.organizer.slug event=request.event.slug voucher=e.voucher.pk %}?next={{ request.get_full_path|urlencode }}">
{{ e.voucher }}
</a>
{% elif not e.voucher and e.availability.0 == 100 and e.availability.1|default_if_none:"none" != "none" %}
+3 -4
View File
@@ -272,16 +272,15 @@ urlpatterns = [
re_path(r'^event/(?P<organizer>[^/]+)/(?P<event>[^/]+)/', include([
re_path(r'^$', dashboards.event_index, name='event.index'),
re_path(r'^qrcode.(?P<filetype>(png|jpeg|gif|svg))$', event.EventQRCode.as_view(), name='event.qrcode'),
re_path(r'^widgets.json$', dashboards.event_index_widgets_lazy, name='event.index.widgets'),
re_path(r'^dashboard/partials/logs$', dashboards.event_index_log_lazy, name='event.index.logs'),
re_path(r'^dashboard/partials/warnings$', dashboards.event_index_warnings_lazy, name='event.index.warnings'),
re_path(r'^dashboard/partials/quotas$', dashboards.event_index_quotas_lazy, name='event.index.quotas'),
re_path(r'^dashboard/partials/waiting$', dashboards.event_index_waiting_lazy, name='event.index.waiting'),
re_path(r'^dashboard/partials/checkin$', dashboards.event_index_checkin_lazy, name='event.index.checkin'),
re_path(r'^dashboard/partials/comment$', event.EventComment.as_view(), name='event.index.comment'),
re_path(r'^live/$', event.EventLive.as_view(), name='event.live'),
re_path(r'^transfer_session/$', event.EventTransferSession.as_view(), name='event.transfer_session'),
re_path(r'^logs/$', event.EventLog.as_view(), name='event.log'),
re_path(r'^delete/$', event.EventDelete.as_view(), name='event.delete'),
re_path(r'^comment/$', event.EventComment.as_view(),
name='event.comment'),
re_path(r'^quickstart/$', event.QuickSetupView.as_view(), name='event.quick'),
re_path(r'^settings/$', event.EventUpdate.as_view(), name='event.settings'),
re_path(r'^settings/plugins$', event.EventPlugins.as_view(), name='event.settings.plugins'),
+330 -63
View File
@@ -33,48 +33,317 @@
# License for the specific language governing permissions and limitations under the License.
from datetime import timedelta
from decimal import Decimal
from zoneinfo import ZoneInfo
from django.conf import settings
from django.contrib.contenttypes.models import ContentType
from django.contrib.humanize.templatetags.humanize import intcomma
from django.db.models import (
Count, IntegerField, Max, Min, OuterRef, Q, Subquery,
Count, IntegerField, Max, Min, OuterRef, Prefetch, Q, Subquery, Sum,
)
from django.db.models.functions import Coalesce, Greatest
from django.http import Http404, JsonResponse
from django.dispatch import receiver
from django.http import JsonResponse
from django.shortcuts import render
from django.template.loader import get_template
from django.urls import reverse
from django.utils.formats import date_format
from django.utils.html import (
conditional_escape, escape, format_html, format_html_join,
)
from django.utils.html import conditional_escape, escape, format_html
from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _, ngettext, pgettext
from pretix.base.decimal import round_decimal
from pretix.base.models import (
Item, ItemCategory, Order, OrderRefund, Question, Quota, Voucher,
WaitingListEntry,
Item, ItemCategory, ItemVariation, Order, OrderPosition, OrderRefund,
Question, Quota, SubEvent, Voucher, WaitingListEntry,
)
from pretix.base.services.quotas import QuotaAvailability
from pretix.base.timeline import timeline_for_event
from pretix.control.signals import (
event_dashboard_statistics, user_dashboard_widgets,
event_dashboard_widgets, user_dashboard_widgets,
)
from pretix.helpers.daterange import daterange
from ...base.models.orders import CancellationRequest
from ...base.models.organizer import TeamQuerySet
from ...base.templatetags.money import money_filter
from ..logdisplay import OVERVIEW_BANLIST
from .utils import prepare_quotas_for_boxes
NUM_WIDGET = '<div class="numwidget"><span class="num">{num}</span><span class="text">{text}</span></div>'
def event_index_waiting_lazy(request, organizer, event):
wles = WaitingListEntry.objects.filter(event=request.event, voucher__isnull=True)
return render(
request,
'pretixcontrol/event/dashboard_partial_waiting.html',
@receiver(signal=event_dashboard_widgets)
def base_widgets(sender, subevent=None, lazy=False, **kwargs):
if not lazy:
prodc = Item.objects.filter(
event=sender, active=True,
).filter(
(Q(available_until__isnull=True) | Q(available_until__gte=now())) &
(Q(available_from__isnull=True) | Q(available_from__lte=now()))
).count()
if subevent:
opqs = OrderPosition.objects.filter(subevent=subevent)
else:
opqs = OrderPosition.objects
tickc = opqs.filter(
order__event=sender, item__admission=True,
order__status__in=(Order.STATUS_PAID, Order.STATUS_PENDING),
).count()
paidc = opqs.filter(
order__event=sender, item__admission=True,
order__status=Order.STATUS_PAID,
).count()
if subevent:
rev = opqs.filter(
order__event=sender, order__status=Order.STATUS_PAID
).aggregate(
sum=Sum('price')
)['sum'] or Decimal('0.00')
else:
rev = Order.objects.filter(
event=sender,
status=Order.STATUS_PAID
).aggregate(sum=Sum('total'))['sum'] or Decimal('0.00')
return [
{
'count': wles.count,
}
)
'content': None if lazy else format_html(NUM_WIDGET, num=intcomma(tickc), text=_('Attendees (ordered)')),
'lazy': 'attendees-ordered',
'display_size': 'small',
'priority': 100,
'url': reverse('control:event.orders', kwargs={
'event': sender.slug,
'organizer': sender.organizer.slug
}) + ('?subevent={}'.format(subevent.pk) if subevent else '')
},
{
'content': None if lazy else format_html(NUM_WIDGET, num=intcomma(paidc), text=_('Attendees (paid)')),
'lazy': 'attendees-paid',
'display_size': 'small',
'priority': 100,
'url': reverse('control:event.orders.overview', kwargs={
'event': sender.slug,
'organizer': sender.organizer.slug
}) + ('?subevent={}'.format(subevent.pk) if subevent else '')
},
{
'content': None if lazy else format_html(
NUM_WIDGET,
num=money_filter(round_decimal(rev, sender.currency), sender.currency, hide_currency=True),
text=_('Total revenue ({currency})').format(currency=sender.currency)
),
'lazy': 'total-revenue',
'display_size': 'small',
'priority': 100,
'url': reverse('control:event.orders.overview', kwargs={
'event': sender.slug,
'organizer': sender.organizer.slug
}) + ('?subevent={}'.format(subevent.pk) if subevent else '')
},
{
'content': None if lazy else format_html(NUM_WIDGET, num=prodc, text=_('Active products')),
'lazy': 'active-products',
'display_size': 'small',
'priority': 100,
'url': reverse('control:event.items', kwargs={
'event': sender.slug,
'organizer': sender.organizer.slug
})
},
]
@receiver(signal=event_dashboard_widgets)
def waitinglist_widgets(sender, subevent=None, lazy=False, **kwargs):
widgets = []
wles = WaitingListEntry.objects.filter(event=sender, subevent=subevent, voucher__isnull=True)
if wles.exists():
if not lazy:
quota_cache = {}
happy = 0
tuples = wles.values('item', 'variation').order_by().annotate(cnt=Count('id'))
items = {
i.pk: i for i in sender.items.filter(id__in=[t['item'] for t in tuples]).prefetch_related(
Prefetch('quotas',
to_attr='_subevent_quotas',
queryset=sender.quotas.using(settings.DATABASE_REPLICA).filter(subevent=subevent)),
)
}
vars = {
i.pk: i for i in ItemVariation.objects.filter(
item__event=sender, id__in=[t['variation'] for t in tuples if t['variation']]
).prefetch_related(
Prefetch('quotas',
to_attr='_subevent_quotas',
queryset=sender.quotas.using(settings.DATABASE_REPLICA).filter(subevent=subevent)),
)
}
for wlt in tuples:
item = items.get(wlt['item'])
variation = vars.get(wlt['variation'])
if not item:
continue
quotas = (
variation._get_quotas(subevent=subevent)
if variation
else item._get_quotas(subevent=subevent)
)
row = (
variation.check_quotas(subevent=subevent, count_waitinglist=False, _cache=quota_cache)
if variation
else item.check_quotas(subevent=subevent, count_waitinglist=False, _cache=quota_cache)
)
if row[1] is None:
happy += wlt['cnt']
elif row[1] > 0:
happy += min(wlt['cnt'], row[1])
for q in quotas:
if q.size is not None:
quota_cache[q.pk] = (quota_cache[q.pk][0], quota_cache[q.pk][1] - min(wlt['cnt'], row[1]))
widgets.append({
'content': None if lazy else format_html(
NUM_WIDGET, num=intcomma(happy), text=_('available to give to people on waiting list')
),
'lazy': 'waitinglist-avail',
'priority': 50,
'url': reverse('control:event.orders.waitinglist', kwargs={
'event': sender.slug,
'organizer': sender.organizer.slug,
})
})
widgets.append({
'content': None if lazy else format_html(
NUM_WIDGET, num=intcomma(wles.count()), text=_('total waiting list length')
),
'lazy': 'waitinglist-length',
'display_size': 'small',
'priority': 50,
'url': reverse('control:event.orders.waitinglist', kwargs={
'event': sender.slug,
'organizer': sender.organizer.slug,
})
})
return widgets
@receiver(signal=event_dashboard_widgets)
def quota_widgets(sender, subevent=None, lazy=False, **kwargs):
widgets = []
quotas = sender.quotas.filter(subevent=subevent)
qa = QuotaAvailability()
if quotas:
qa.queue(*quotas)
qa.compute(allow_cache=True)
for q in quotas:
if not lazy:
status, left = qa.results[q] if q in qa.results else q.availability(allow_cache=True)
widgets.append({
'content': None if lazy else format_html(
NUM_WIDGET,
num='{}/{}'.format(intcomma(left), intcomma(q.size)) if q.size is not None else '\u221e',
text=format_html(_('{quota} left'), quota=q.name)
),
'lazy': 'quota-{}'.format(q.pk),
'display_size': 'small',
'priority': 50,
'url': reverse('control:event.items.quotas.show', kwargs={
'event': sender.slug,
'organizer': sender.organizer.slug,
'quota': q.id
})
})
return widgets
@receiver(signal=event_dashboard_widgets)
def shop_state_widget(sender, **kwargs):
return [{
'display_size': 'small',
'priority': 1000,
'content': format_html(
'<div class="shopstate">{t1}<br><span class="{cls}"><span class="fa {icon}"></span> {state}</span>{t2}</div>',
t1=_('Your ticket shop is'), t2=_('Click here to change'),
state=_('live') if sender.live and not sender.testmode else (
_('live and in test mode') if sender.live else (
_('not yet public') if not sender.testmode else (
_('in private test mode')
)
)
),
icon='fa-check-circle' if sender.live and not sender.testmode else (
'fa-warning' if sender.live else (
'fa-times-circle' if not sender.testmode else (
'fa-times-circle'
)
)
),
cls='live' if sender.live else 'off'
),
'url': reverse('control:event.live', kwargs={
'event': sender.slug,
'organizer': sender.organizer.slug
})
}]
@receiver(signal=event_dashboard_widgets)
def checkin_widget(sender, subevent=None, lazy=False, **kwargs):
widgets = []
qs = sender.checkin_lists.filter(subevent=subevent)
for cl in qs:
widgets.append({
'content': None if lazy else format_html(
NUM_WIDGET,
num='{}/{}'.format(intcomma(cl.inside_count), intcomma(cl.position_count)),
text=format_html(_('Present {list}'), list=cl.name)
),
'lazy': 'checkin-{}'.format(cl.pk),
'display_size': 'small',
'priority': 50,
'url': reverse('control:event.orders.checkinlists.show', kwargs={
'event': sender.slug,
'organizer': sender.organizer.slug,
'list': cl.pk
})
})
return widgets
@receiver(signal=event_dashboard_widgets)
def welcome_wizard_widget(sender, **kwargs):
template = get_template('pretixcontrol/event/dashboard_widget_welcome.html')
ctx = {
'title': _('Welcome to pretix!')
}
kwargs = {'event': sender.slug, 'organizer': sender.organizer.slug}
if not sender.items.exists():
ctx.update({
'subtitle': _('Get started with our setup tool'),
'text': _('To start selling tickets, you need to create products or quotas. The fastest way to create '
'this is to use our setup tool.'),
'button_text': _('Set up event'),
'button_url': reverse('control:event.quick', kwargs=kwargs)
})
else:
return []
return [{
'display_size': 'full',
'priority': 2000,
'content': template.render(ctx)
}]
def build_json_response(widgets):
@@ -84,38 +353,62 @@ def build_json_response(widgets):
def event_index(request, organizer, event):
can_view_orders = request.user.has_event_permission(
request.organizer,
request.event,
'event.orders:read',
request=request
)
from pretix.control.forms.event import CommentForm
stats = []
subevent = None
if request.GET.get("subevent", "") != "" and request.event.has_subevents:
i = request.GET.get("subevent", "")
try:
subevent = request.event.subevents.get(pk=i)
except SubEvent.DoesNotExist:
pass
can_view_orders = request.user.has_event_permission(request.organizer, request.event, 'event.orders:read',
request=request)
can_change_event_settings = request.user.has_event_permission(request.organizer, request.event,
'event.settings.general:write', request=request)
widgets = []
if can_view_orders:
for r, result in event_dashboard_statistics.send(sender=request.event, request=request):
stats.append(result)
for r, result in event_dashboard_widgets.send(sender=request.event, subevent=subevent, lazy=True):
widgets.extend(result)
ctx = {
'stats': format_html_join("", "{}", [(s,) for s in stats]),
'widgets': rearrange(widgets),
'subevent': subevent,
'comment_form': CommentForm(initial={'comment': request.event.comment}, readonly=not can_change_event_settings),
}
if not request.event.has_subevents:
ctx['timeline'] = [
{
'date': t.datetime.astimezone(request.event.timezone).date(),
'entry': t,
'time': t.datetime.astimezone(request.event.timezone)
}
for t in timeline_for_event(request.event, None)
]
ctx['timeline'] = [
{
'date': t.datetime.astimezone(request.event.timezone).date(),
'entry': t,
'time': t.datetime.astimezone(request.event.timezone)
}
for t in timeline_for_event(request.event, subevent)
]
ctx['today'] = now().astimezone(request.event.timezone).date()
ctx['nearly_now'] = now().astimezone(request.event.timezone) - timedelta(seconds=20)
ctx['has_checkin_widgets'] = not request.event.has_subevents or request.event.checkin_lists.filter(subevent=None).exists()
resp = render(request, 'pretixcontrol/event/index.html', ctx)
return resp
def event_index_widgets_lazy(request, organizer, event):
subevent = None
if request.GET.get("subevent", "") != "" and request.event.has_subevents:
i = request.GET.get("subevent", "")
try:
subevent = request.event.subevents.get(pk=i)
except SubEvent.DoesNotExist:
pass
widgets = []
for r, result in event_dashboard_widgets.send(sender=request.event, subevent=subevent, lazy=False):
widgets.extend(result)
return build_json_response(widgets)
def event_index_warnings_lazy(request, organizer, event):
can_view_orders = request.user.has_event_permission(request.organizer, request.event, 'event.orders:read',
request=request)
@@ -152,32 +445,6 @@ def event_index_warnings_lazy(request, organizer, event):
)
def event_index_quotas_lazy(request, organizer, event):
if request.event.has_subevents:
raise Http404()
quotas = request.event.quotas.filter(subevent=None)[:10]
prepare_quotas_for_boxes(quotas)
return render(
request,
'pretixcontrol/event/dashboard_partial_quotas.html',
{
'quotas': quotas,
}
)
def event_index_checkin_lazy(request, organizer, event):
lists = request.event.checkin_lists.filter(subevent=None)[:10]
return render(
request,
'pretixcontrol/event/dashboard_partial_checkin.html',
{
'lists': lists,
}
)
def event_index_log_lazy(request, organizer, event):
qs = request.event.logentry_set.all().select_related('user', 'content_type', 'api_token', 'oauth_application',
'device').order_by('-datetime')
+19 -17
View File
@@ -57,9 +57,10 @@ from django.db import transaction
from django.db.models import Count, ProtectedError
from django.forms import inlineformset_factory
from django.http import (
Http404, HttpResponse, HttpResponseBadRequest, JsonResponse,
Http404, HttpResponse, HttpResponseBadRequest, HttpResponseNotAllowed,
JsonResponse,
)
from django.shortcuts import redirect, render
from django.shortcuts import redirect
from django.urls import NoReverseMatch, reverse
from django.utils.functional import cached_property
from django.utils.html import conditional_escape, format_html
@@ -1298,23 +1299,24 @@ class EventLog(EventPermissionRequiredMixin, PaginationMixin, ListView):
return LogFilterForm(data=self.request.GET, organizer=self.request.organizer)
class EventComment(EventPermissionRequiredMixin, UpdateView):
class EventComment(EventPermissionRequiredMixin, View):
permission = 'event.settings.general:write'
form_class = CommentForm
template_name = 'pretixcontrol/event/dashboard_partial_comment_form.html'
def get_object(self, queryset=None):
return self.request.event
def post(self, *args, **kwargs):
form = CommentForm(self.request.POST)
if form.is_valid():
self.request.event.comment = form.cleaned_data.get('comment')
self.request.event.save()
self.request.event.log_action('pretix.event.comment', user=self.request.user, data={
'new_comment': form.cleaned_data.get('comment')
})
messages.success(self.request, _('The comment has been updated.'))
else:
messages.error(self.request, _('Could not update the comment.'))
return redirect(self.get_success_url())
def form_valid(self, form):
form.save()
self.request.event.log_action('pretix.event.comment', user=self.request.user, data={
'new_comment': form.cleaned_data.get('comment')
})
return render(
self.request,
'pretixcontrol/event/dashboard_partial_comment.html',
)
def get(self, *args, **kwargs):
return HttpResponseNotAllowed(['POST'])
def get_success_url(self) -> str:
return reverse('control:event.index', kwargs={
@@ -1566,7 +1568,7 @@ class WidgetSettings(EventSettingsViewMixin, EventPermissionRequiredMixin, FormV
return ctx
class QuickSetupView(FormView):
class QuickSetupView(EventPermissionRequiredMixin, FormView):
template_name = 'pretixcontrol/event/quick_setup.html'
permission = 'event.settings.general:write'
form_class = QuickSetupForm
+14 -2
View File
@@ -52,13 +52,13 @@ from pretix.base.forms import SafeSessionWizardView
from pretix.base.i18n import language
from pretix.base.models import Event, EventMetaValue, Organizer, Quota, Team
from pretix.base.models.organizer import TeamQuerySet
from pretix.base.services.quotas import QuotaAvailability
from pretix.control.forms.event import (
EventWizardBasicsForm, EventWizardCopyForm, EventWizardFoundationForm,
)
from pretix.control.forms.filter import EventFilterForm
from pretix.control.permissions import OrganizerPermissionRequiredMixin
from pretix.control.views import PaginationMixin
from pretix.control.views.utils import prepare_quotas_for_boxes
class EventList(PaginationMixin, ListView):
@@ -117,7 +117,19 @@ class EventList(PaginationMixin, ListView):
s.first_quotas = s.first_quotas[:4]
quotas += list(s.first_quotas)
prepare_quotas_for_boxes(quotas)
qa = QuotaAvailability(early_out=False)
for q in quotas:
qa.queue(q)
qa.compute()
for q in quotas:
q.cached_avail = qa.results[q]
q.cached_availability_paid_orders = qa.count_paid_orders.get(q, 0)
if q.size is not None:
q.percent_paid = min(
100,
round(q.cached_availability_paid_orders / q.size * 100) if q.size > 0 else 100
)
return ctx
@cached_property
+30 -11
View File
@@ -81,7 +81,7 @@ from pretix.base.i18n import language
from pretix.base.models import (
CachedFile, CachedTicket, Checkin, GiftCard, Invoice, InvoiceAddress, Item,
ItemVariation, LogEntry, Order, QuestionAnswer, Quota,
ScheduledEventExport, generate_secret,
ScheduledEventExport, SeatCategoryMapping, generate_secret,
)
from pretix.base.models.orders import (
CancellationRequest, OrderFee, OrderPayment, OrderPosition, OrderRefund,
@@ -564,10 +564,11 @@ class OrderDetail(OrderView):
})
ctx['display_locale'] = dict(settings.LANGUAGES)[self.object.locale or self.request.event.settings.locale]
ctx['overpaid'] = self.order.pending_sum * -1
pending_sum = self.order.pending_sum
ctx['overpaid'] = pending_sum * -1
ctx['download_buttons'] = self.download_buttons
ctx['payment_refund_sum'] = self.order.payment_refund_sum
ctx['pending_sum'] = self.order.pending_sum
ctx['pending_sum'] = pending_sum
ctx['uncancelled_invoice'] = self.order.invoices.exclude(
Exists(self.order.invoices.filter(refers=OuterRef('pk'), is_cancellation=True))
).exclude(is_cancellation=True).first()
@@ -601,8 +602,9 @@ class OrderDetail(OrderView):
).prefetch_related(
'item__questions', 'issued_gift_cards', 'owned_gift_cards', 'linked_media',
Prefetch('answers', queryset=QuestionAnswer.objects.prefetch_related('options').select_related('question')),
Prefetch('all_checkins', queryset=Checkin.all.select_related('list').order_by('datetime')),
Prefetch('all_checkins', queryset=Checkin.all.select_related('list', 'gate').order_by('datetime')),
Prefetch('print_logs', queryset=PrintLog.objects.select_related('device').order_by('datetime')),
Prefetch('subevent', queryset=self.request.event.subevents.all()),
).order_by('positionid')
positions = []
@@ -1985,20 +1987,37 @@ class OrderChange(OrderView):
def fees(self):
fees = list(self.order.fees.all())
for f in fees:
f.form = OrderFeeChangeForm(prefix='of-{}'.format(f.pk), instance=f,
data=self.request.POST if self.request.method == "POST" else None)
f.form = OrderFeeChangeForm(
prefix='of-{}'.format(f.pk),
instance=f,
data=self.request.POST if self.request.method == "POST" else None
)
return fees
@cached_property
def positions(self):
positions = list(self.order.positions.select_related(
'item', 'item__tax_rule', 'used_membership', 'used_membership__membership_type', 'tax_rule',
'seat', 'subevent',
).prefetch_related('granted_memberships', 'issued_gift_cards'))
'seat',
).prefetch_related(
Prefetch(
'subevent',
queryset=self.request.event.subevents.all(),
),
'granted_memberships',
'issued_gift_cards',
'addons',
).annotate(
_seat_allowed=Exists(SeatCategoryMapping.objects.filter(subevent=OuterRef("subevent"), product=OuterRef("item")))
))
for p in positions:
p.form = OrderPositionChangeForm(prefix='op-{}'.format(p.pk), instance=p, items=self.items,
initial={'seat': p.seat.seat_guid if p.seat else None},
data=self.request.POST if self.request.method == "POST" else None)
p.form = OrderPositionChangeForm(
prefix='op-{}'.format(p.pk),
instance=p,
items=self.items,
initial={'seat': p.seat.seat_guid if p.seat else None},
data=self.request.POST if self.request.method == "POST" else None
)
return positions
def get_context_data(self, **kwargs):
+13 -14
View File
@@ -69,7 +69,6 @@ from pretix.base.models.orders import CancellationRequest
from pretix.base.reldate import RelativeDate, RelativeDateWrapper
from pretix.base.services import tickets
from pretix.base.services.quotas import QuotaAvailability
from pretix.base.timeline import timeline_for_event
from pretix.base.views.tasks import AsyncFormView
from pretix.control.forms.checkin import SimpleCheckinListForm
from pretix.control.forms.filter import SubEventFilterForm
@@ -84,7 +83,6 @@ from pretix.control.permissions import EventPermissionRequiredMixin
from pretix.control.signals import subevent_forms
from pretix.control.views import PaginationMixin
from pretix.control.views.event import MetaDataEditorMixin
from pretix.control.views.utils import prepare_quotas_for_boxes
from pretix.helpers import GroupConcat
from pretix.helpers.compat import CompatDeleteView
from pretix.helpers.i18n import get_format_without_seconds
@@ -146,7 +144,19 @@ class SubEventList(EventPermissionRequiredMixin, PaginationMixin, SubEventQueryM
s.first_quotas = s.first_quotas[:4]
quotas += list(s.first_quotas)
prepare_quotas_for_boxes(quotas)
qa = QuotaAvailability(early_out=False)
for q in quotas:
qa.queue(q)
qa.compute()
for q in quotas:
q.cached_avail = qa.results[q]
q.cached_availability_paid_orders = qa.count_paid_orders.get(q, 0)
if q.size is not None:
q.percent_paid = min(
100,
round(q.cached_availability_paid_orders / q.size * 100) if q.size > 0 else 100
)
return ctx
@@ -556,17 +566,6 @@ class SubEventDetail(EventPermissionRequiredMixin, DetailView):
for quota in ctx["quotas"]:
quota.cached_avail = qa.results[quota]
ctx['timeline'] = [
{
'date': t.datetime.astimezone(self.request.event.timezone).date(),
'entry': t,
'time': t.datetime.astimezone(self.request.event.timezone)
}
for t in timeline_for_event(self.request.event, self.object)
]
ctx['today'] = now().astimezone(self.request.event.timezone).date()
ctx['nearly_now'] = now().astimezone(self.request.event.timezone) - timedelta(seconds=20)
return super().get_context_data(
**kwargs,
**ctx,
+15
View File
@@ -46,6 +46,7 @@ from django.contrib.auth import update_session_auth_hash
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import BadRequest, PermissionDenied
from django.db import transaction
from django.http import HttpResponse
from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse
from django.utils.crypto import get_random_string
@@ -95,6 +96,20 @@ class RecentAuthenticationRequiredMixin:
def dispatch(self, request, *args, **kwargs):
tdelta = time.time() - request.session.get('pretix_auth_login_time', 0)
if tdelta > self.max_time:
if request.headers.get("X-Requested-With") == "XMLHttpRequest":
# It's not useful to return a 302 redirect on a XMLHttpRequest request, because
# the XMLHttpRequest is unable to detect redirects.
return HttpResponse(
"Authentication required",
status=401,
headers={
# Appending ?next= is handled by client, because it should be the top-level context url,
# not the URL called in the background
"X-Login-Url": reverse('control:user.reauth')
}
)
return redirect(reverse('control:user.reauth') + '?next=' + quote(request.get_full_path()))
return super().dispatch(request, *args, **kwargs)
-30
View File
@@ -1,30 +0,0 @@
from pretix.base.services.quotas import QuotaAvailability
def prepare_quotas_for_boxes(quotas):
qa = QuotaAvailability(early_out=False)
for q in quotas:
qa.queue(q)
qa.compute()
for q in quotas:
q.cached_avail = qa.results[q]
q.cached_availability_paid_orders = qa.count_paid_orders.get(q, 0)
q.used = (
qa.count_paid_orders.get(q, 0) +
qa.count_pending_orders.get(q, 0) +
qa.count_exited_orders.get(q, 0) +
qa.count_vouchers.get(q, 0) +
qa.count_waitinglist.get(q, 0) +
qa.count_cart.get(q, 0)
)
if q.size is not None:
other_blocked = q.size - q.cached_availability_paid_orders - q.cached_avail[1]
q.percent_paid = min(
100,
round(q.cached_availability_paid_orders / q.size * 100) if q.size > 0 else 100
)
q.percent_other = min(
100,
round(other_blocked / q.size * 100) if q.size > 0 else 100
)
+3
View File
@@ -53,6 +53,7 @@ from django.shortcuts import redirect, render
from django.urls import resolve, reverse
from django.utils.functional import cached_property
from django.utils.html import format_html
from django.utils.http import url_has_allowed_host_and_scheme
from django.utils.safestring import mark_safe
from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _
@@ -350,6 +351,8 @@ class VoucherUpdate(EventPermissionRequiredMixin, UpdateView):
return super().post(request, *args, **kwargs)
def get_success_url(self) -> str:
if "next" in self.request.GET and url_has_allowed_host_and_scheme(self.request.GET.get("next"), allowed_hosts=None):
return self.request.GET.get("next")
return reverse('control:event.vouchers', kwargs={
'organizer': self.request.event.organizer.slug,
'event': self.request.event.slug,
+3 -3
View File
@@ -28,7 +28,7 @@ from django.contrib.auth import login as auth_login
from django.contrib.gis import geoip2
from django.core.cache import cache
from django.utils.formats import date_format
from django.utils.timezone import now
from django.utils.timezone import localtime, now, override
from django.utils.translation import gettext_lazy as _
from django_countries.fields import Country
from geoip2.errors import AddressNotFoundError
@@ -167,13 +167,13 @@ def handle_login_source(user, request):
})
if user.known_login_sources.count() > 1:
# Do not send on first login or first login after introduction of this feature:
with language(user.locale):
with language(user.locale), override(user.timezone):
mail(
user.email,
_('New sign-in to your account'),
'pretixcontrol/email/login_notice.txt',
{
'when': date_format(src.last_seen, 'DATETIME_FORMAT'),
'when': date_format(localtime(src.last_seen), 'DATETIME_FORMAT'),
'agent': src.agent_type,
'os': src.os_type,
# ua-parser returns "Other" for unidentified desktop devices.
+32 -54
View File
@@ -4,16 +4,16 @@ msgstr ""
"Project-Id-Version: 1\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-07-07 09:30+0000\n"
"PO-Revision-Date: 2026-06-20 17:00+0000\n"
"PO-Revision-Date: 2026-07-27 17:00+0000\n"
"Last-Translator: Nikolai <nikolai@lengefeldt.de>\n"
"Language-Team: Danish <https://translate.pretix.eu/projects/pretix/pretix/da/"
">\n"
"Language-Team: Danish <https://translate.pretix.eu/projects/pretix/pretix/"
"da/>\n"
"Language: da\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=n != 1;\n"
"X-Generator: Weblate 2026.6.1\n"
"X-Generator: Weblate 2026.7.1\n"
#: htmlcov/d_daa1541d0cbf5e2b_dashboards_py.html
#: pretix/control/templates/pretixcontrol/events/index.html
@@ -177,7 +177,7 @@ msgstr "Spansk (Latinamerika)"
#: pretix/_base_settings.py
msgid "Thai"
msgstr ""
msgstr "Thai"
#: pretix/_base_settings.py
msgid "Turkish"
@@ -436,7 +436,7 @@ msgstr ""
#: pretix/api/serializers/organizer.py pretix/control/views/organizer.py
#, python-format
msgid "You've been invited to join %(organizer)s"
msgstr ""
msgstr "Du er blevet inviteret til at tilslutte dig %(organizer)s"
#: pretix/api/serializers/organizer.py pretix/control/views/organizer.py
msgid "This user already has been invited for this team."
@@ -458,9 +458,8 @@ msgid "Medium connected to other event"
msgstr "Medie er tilknyttet et andet arrangement"
#: pretix/api/views/checkin.py
#, fuzzy
msgid "You cannot exchange a medium for a medium."
msgstr "Du kan ikke annullere denne bestilling."
msgstr "Du kan ikke bytte en medium mod en anden medium."
#: pretix/api/views/oauth.py pretix/control/logdisplay.py
#, python-brace-format
@@ -4056,43 +4055,32 @@ msgid "Users"
msgstr "Brugere"
#: pretix/base/models/auth.py
#, fuzzy
#| msgid "Changes to your account at {organizer}"
msgid "Changes to your account"
msgstr "Ændringer på din konto hos {organizer}"
msgstr "Ændringer på din konto"
#: pretix/base/models/auth.py
#, fuzzy, python-brace-format
#| msgid ""
#| "to confirm changing your email address from {old_email}\n"
#| "to {new_email}, use the following code:"
#, python-brace-format
msgid ""
"To change your email address from {old_email} to {new_email}, use the "
"following code:"
msgstr ""
"For at bekræfte ændringen af din e-mailadresse fra {old_email}\n"
"til {new_email} skal du bruge følgende kode:"
"Hvis du vil ændre din e-mailadresse fra {old_email} til {new_email}, skal du "
"bruge følgende kode:"
#: pretix/base/models/auth.py
#, fuzzy, python-brace-format
#| msgid ""
#| "to confirm changing your email address from {old_email}\n"
#| "to {new_email}, use the following code:"
#, python-brace-format
msgid ""
"To verify your email address {email} on {instance}, use the following code:"
msgstr ""
"For at bekræfte ændringen af din e-mailadresse fra {old_email}\n"
"til {new_email} skal du bruge følgende kode:"
"Brug følgende kode til at bekræfte din e-mailadresse {email} på {instance}:"
#: pretix/base/models/auth.py
#, fuzzy
msgid "Your confirmation code"
msgstr "Bekræftelsestekst"
msgstr "Din bekræftelseskode"
#: pretix/base/models/auth.py
#, fuzzy
msgid "Reset your password"
msgstr "Nulstil adgangskode"
msgstr "Nulstil din adgangskode"
#: pretix/base/models/checkin.py
msgid "All products (including newly created ones)"
@@ -4253,22 +4241,16 @@ msgid "Check-in annulled"
msgstr "Check-in annulleret"
#: pretix/base/models/checkin.py
#, fuzzy
#| msgid "Ticket already used"
msgid "Ticket already exchanged"
msgstr "Billet allerede i brug"
msgstr "Billetten er allerede ombyttet"
#: pretix/base/models/checkin.py
#, fuzzy
#| msgid "Reusable media"
msgid "Reusable medium invalid"
msgstr "Genanvendelige medier"
msgstr "Genanvendeligt medium er ugyldigt"
#: pretix/base/models/checkin.py
#, fuzzy
#| msgid "Reusable media type"
msgid "Reusable medium already exists"
msgstr "Genanvendelig medietype"
msgstr "Det genanvendelige medium findes allerede"
#: pretix/base/models/customers.py
msgid "Provider name"
@@ -4843,10 +4825,8 @@ msgstr ""
"begge dele."
#: pretix/base/models/event.py
#, fuzzy
#| msgid "The bundled item must belong to the same event as the item."
msgid "Property and event must belong to the same organizer."
msgstr "Artikelpakken skal tilhøre samme arrangement som selve artiklen."
msgstr "Ejendommen og arrangementet skal tilhøre den samme arrangør."
#: pretix/base/models/event.py pretix/base/models/organizer.py
msgid "Link text"
@@ -5082,42 +5062,40 @@ msgid "Show product with info on why its unavailable"
msgstr "Vis produktet med oplysninger om, hvorfor det ikke er tilgængeligt"
#: pretix/base/models/items.py
#, fuzzy
#| msgid "Don't use re-usable media, use regular one-off tickets"
msgid "Don't use reusable media, use regular one-off tickets"
msgstr "Brug ikke genanvendelige medier, brug almindelige engangsbilletter"
msgstr "Brug ikke genanvendelige medier, men almindelige engangsbilletter"
#: pretix/base/models/items.py
msgid "Require a previously unknown medium to be newly added"
msgstr "Påkræv, at et førhen ukendt medie bliver tilføjet fornylig"
#: pretix/base/models/items.py
#, fuzzy
#| msgid "Require an existing medium to be re-used"
msgid "Require an existing medium to be reused, replacing any previous tickets"
msgstr "Påkræv genanvendelse af et eksisterende medie"
msgstr ""
"Påkræv, at et eksisterende medium genbruges, hvorved eventuelle tidligere "
"billetter erstattes"
#: pretix/base/models/items.py
#, fuzzy
#| msgid "Require either an existing or a new medium to be used"
msgid ""
"Require either an existing or a new medium to be used, replacing any "
"previous tickets"
msgstr "Påkræv, at der enten bruges et eksisterende eller et nyt medie"
msgstr ""
"Påkræv, at der anvendes enten et eksisterende eller et nyt medium, som "
"erstatter eventuelle tidligere billetter"
#: pretix/base/models/items.py
#, fuzzy
#| msgid "Require an existing medium to be re-used"
msgid "Require an existing medium to be reused, adding to any previous tickets"
msgstr "Påkræv genanvendelse af et eksisterende medie"
msgstr ""
"Påkræv, at et eksisterende medium genbruges og tilføjes samtlige tidligere "
"billetter"
#: pretix/base/models/items.py
#, fuzzy
#| msgid "Require either an existing or a new medium to be used"
msgid ""
"Require either an existing or a new medium to be used, adding to any "
"previous tickets"
msgstr "Påkræv, at der enten bruges et eksisterende eller et nyt medie"
msgstr ""
"Påkræv, at der anvendes enten et eksisterende eller et nyt medie, hvilket "
"tilføjes til eventuelle tidligere billetter"
#: pretix/base/models/items.py
msgid "Category"
+11 -18
View File
@@ -8,16 +8,16 @@ msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-07-07 09:30+0000\n"
"PO-Revision-Date: 2024-09-23 18:00+0000\n"
"PO-Revision-Date: 2026-07-27 17:00+0000\n"
"Last-Translator: Albizuri <oier@puntu.eus>\n"
"Language-Team: Basque <https://translate.pretix.eu/projects/pretix/pretix/eu/"
">\n"
"Language-Team: Basque <https://translate.pretix.eu/projects/pretix/pretix/"
"eu/>\n"
"Language: eu\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=n != 1;\n"
"X-Generator: Weblate 5.7.2\n"
"X-Generator: Weblate 2026.7.1\n"
#: htmlcov/d_daa1541d0cbf5e2b_dashboards_py.html
#: pretix/control/templates/pretixcontrol/events/index.html
@@ -13559,10 +13559,8 @@ msgstr ""
#: pretix/base/templates/pretixbase/email/order_details.html
#: pretix/presale/templates/pretixpresale/event/base.html
#: pretix/presale/templates/pretixpresale/organizers/base.html
#, fuzzy
#| msgid "Contact:"
msgid "Contact"
msgstr "Kontaktua:"
msgstr "Harremanetarako"
#: pretix/base/templates/pretixbase/email/shred_completed.txt
#, fuzzy, python-format
@@ -18285,7 +18283,7 @@ msgstr ""
#: pretix/presale/templates/pretixpresale/event/fragment_subevent_list.html
#: pretix/presale/templates/pretixpresale/organizers/index.html
msgid "Tickets"
msgstr ""
msgstr "Sarrerak"
#: pretix/control/navigation.py
#: pretix/control/templates/pretixcontrol/event/tax.html
@@ -33876,12 +33874,11 @@ msgid "Show full-size image of %(item)s"
msgstr "Erakutsi %(item)s-ren irudi osoa"
#: pretix/presale/templates/pretixpresale/event/fragment_product_list.html
#, fuzzy, python-format
#| msgid "Upcoming events"
#, python-format
msgid "%(amount)s× in your cart"
msgid_plural "%(amount)s× in your cart"
msgstr[0] "Hurrengo ekitaldiak"
msgstr[1] "Hurrengo ekitaldiak"
msgstr[0] "%(amount)s× zure saskian"
msgstr[1] "%(amount)s× zure saskian"
#: pretix/presale/templates/pretixpresale/event/fragment_product_list.html
#: pretix/presale/templates/pretixpresale/event/voucher.html
@@ -34125,18 +34122,14 @@ msgstr ""
"Kontuan izan oraindik zure ordainketaren zain gaudela prozesua osatzeko."
#: pretix/presale/templates/pretixpresale/event/order.html
#, fuzzy
#| msgid ""
#| "Please bookmark or save the link to this exact page if you want to access "
#| "your order later. We also sent you an email containing the link to the "
#| "address you specified."
msgid ""
"Please bookmark or save the link to this exact page if you want to access "
"your order later. We also sent you an email to the address you specified "
"containing the link to this page."
msgstr ""
"Mesedez, gorde orri honetarako esteka zure eskaera geroago ikusi nahi "
"baduzu. E-mail bat ere bidali dizugu, zehaztutako helbidera lotura duena."
"baduzu. E-mail bat ere bidali dizugu zehaztu duzun helbidera, orrialde "
"honetarako estekarekin."
#: pretix/presale/templates/pretixpresale/event/order.html
#, fuzzy
-1
View File
@@ -111,7 +111,6 @@ class Paypal(BasePaymentProvider):
('client_id',
forms.CharField(
label=_('Client ID'),
max_length=80,
min_length=80,
help_text=format_html(
'<a target="_blank" rel="noopener" href="{docs_url}">{text}</a>',
+1 -69
View File
@@ -19,21 +19,12 @@
# 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/>.
#
from decimal import Decimal
from django.contrib.humanize.templatetags.humanize import intcomma
from django.db.models import Sum
from django.dispatch import receiver
from django.template.loader import get_template
from django.urls import resolve, reverse
from django.utils.html import format_html
from django.utils.translation import gettext_lazy as _
from pretix.base.decimal import round_decimal
from pretix.base.models import Order, OrderPosition
from pretix.base.signals import order_paid, order_placed
from pretix.base.templatetags.money import money_filter
from pretix.control.signals import event_dashboard_statistics, nav_event
from pretix.control.signals import nav_event
@receiver(nav_event, dispatch_uid="statistics_nav")
@@ -67,62 +58,3 @@ def clear_cache(sender, *args, **kwargs):
order_placed.connect(clear_cache)
order_paid.connect(clear_cache)
@receiver(event_dashboard_statistics, dispatch_uid="statistics_event_dashboard_state")
def event_dashboard_stats_receivers(sender, request, **kwargs):
if len(event_dashboard_statistics.receivers) > 1:
# Only render this as a fallback if no other plugins exist
return ""
opqs = OrderPosition.objects
tickc = opqs.filter(
order__event=sender, item__admission=True,
order__status__in=(Order.STATUS_PAID, Order.STATUS_PENDING),
).count()
paidc = opqs.filter(
order__event=sender, item__admission=True,
order__status=Order.STATUS_PAID,
).count()
rev = Order.objects.filter(
event=sender,
status=Order.STATUS_PAID
).aggregate(sum=Sum('total'))['sum'] or Decimal('0.00')
num_widget = '<div class="numwidget"><span class="num">{num}</span><span class="text">{text}</span></div>'
widgets = [
{
'content': format_html(num_widget, num=intcomma(tickc), text=_('Attendees (ordered)')),
'display_size': 'small',
'priority': 100,
'url': reverse('control:event.orders', kwargs={
'event': sender.slug,
'organizer': sender.organizer.slug
})
},
{
'content': format_html(num_widget, num=intcomma(paidc), text=_('Attendees (paid)')),
'display_size': 'small',
'priority': 100,
'url': reverse('control:event.orders.overview', kwargs={
'event': sender.slug,
'organizer': sender.organizer.slug
})
},
{
'content': format_html(
num_widget,
num=money_filter(round_decimal(rev, sender.currency), sender.currency, hide_currency=True),
text=_('Total revenue ({currency})').format(currency=sender.currency)
),
'display_size': 'small',
'priority': 100,
'url': reverse('control:event.orders.overview', kwargs={
'event': sender.slug,
'organizer': sender.organizer.slug
})
},
]
template = get_template('pretixplugins/statistics/dashboard.html')
ctx = {'request': request, 'widgets': widgets}
return template.render(ctx)
@@ -1,15 +0,0 @@
<div class="dashboard">
{% for w in widgets %}
<div class="widget-container widget-{{ w.display_size|default:"small" }}">
{% if w.url %}
<a href="{{ w.url }}" class="widget">
{{ w.content }}
</a>
{% else %}
<div class="widget">
{{ w.content }}
</div>
{% endif %}
</div>
{% endfor %}
</div>
File diff suppressed because one or more lines are too long
@@ -1,9 +1,26 @@
/* global $,gettext */
/*global $,gettext*/
$(function () {
$('.timeline').each(function () {
let $tl = $(this)
let $first = $(this).find('.row:not(.text-muted)').first()
$tl.scrollTop($tl.scrollTop() + Math.max($first.position().top - 50, 0))
})
})
if ($("div[data-lazy-id]").length == 0) {
return;
}
$.getJSON("widgets.json" + ($("select[name='subevent']").val() ? "?subevent=" + $("select[name='subevent']").val() : ""), function (data) {
$.each(data.widgets, function (k, v) {
$("[data-lazy-id=" + v.lazy + "]").removeClass("widget-lazy-loading");
$("[data-lazy-id=" + v.lazy + "] .widget").html(v.content);
});
});
});
$(function () {
if ($("#logs_target").length == 0) {
return;
}
$.get("dashboard/partials/logs", function (data) {
$("#logs_target").html(data)
add_log_expand_handlers($("#logs_target"))
});
$.get("dashboard/partials/warnings", function (data) {
$("#warnings_loading").remove()
$("#warnings_target").html(data)
});
});
@@ -1,4 +1,4 @@
/*global $, gettext, ngettext, interpolate, htmx */
/*global $, gettext, ngettext, interpolate */
function formatPrice(price, currency, locale) {
if (!window.Intl || !Intl.NumberFormat) return price;
@@ -1120,9 +1120,3 @@ $(function () {
return $(this).find("button:not([type=button]), input[type=submit]").length > 0;
}).areYouSure( {'message': gettext('You have unsaved changes!')});
});
htmx.on('htmx:afterSettle', (e) => {
console.log("afterSwap", e)
setup_basics($(e.detail.elt))
form_handlers($(e.detail.elt))
})
@@ -1,52 +1,3 @@
.event-dashboard-header {
margin-bottom: 10px;
.pull-right a .fa-pencil {
color: $text-muted;
font-size: 0.7em;
&:hover {
color: $link-hover-color;
}
}
}
@keyframes loading-mock-pulse {
0% {
background-position: 20% 50%;
}
100% {
background-position: -100% 50%;
}
}
.loading-mock {
background: #eee;
animation: loading-mock-pulse 3s infinite;
background-size: 200% 100%;
background-image: linear-gradient(
to right,
rgb(244, 247, 250) 0%,
rgb(255, 255, 255) 10%,
rgb(244, 247, 250) 20%
);
&.loading-mock-text-short {
width: 50px;
}
&.loading-mock-text-medium {
width: 100px;
}
&.loading-mock-text-long {
width: 200px;
}
}
span.loading-mock {
display: inline-block;
border-radius: $border-radius-base;
height: 1em;
}
.dashboard {
display: flex;
flex-wrap: wrap;
+4 -47
View File
@@ -530,45 +530,18 @@ table td ul:last-child {
display: inline-block;
vertical-align: top;
width: 50px;
text-align: center;
margin-bottom: 5px;
strong {
display: block;
width: 100%;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.progress {
height: 10px;
height: 7px;
margin-bottom: 2px;
}
.numbers {
font-size: 10px;
color: $text-muted;
display: block;
text-align: center;
}
.progress-bar-unconfirmed {
opacity: 0.7;
}
&.quotabox-full {
width: 100px;
padding: 0 2px;
strong {
font-size: 12px;
}
.progress {
height: 16px;
width: 80px;
margin: auto;
}
.numbers {
font-size: 12px;
}
&.availability .progress-bar-success {
background: var(--pretix-brand-success-lighten-20);
}
}
.quotabox-more {
@@ -727,10 +700,6 @@ h1 .label {
}
.timeline {
max-height: 200px;
overflow-y: auto;
position: relative;
.col-date {
position: relative;
min-height: 1px;
@@ -929,18 +898,6 @@ tbody th {
height: auto;
}
.htmx-indicator {
opacity:0;
visibility: hidden
}
.htmx-request .htmx-indicator,
.htmx-request.htmx-indicator {
opacity:1;
visibility: visible;
transition: opacity 200ms ease-in
}
@import "../../pretixbase/scss/_rtl.scss";
@import "../../bootstrap/scss/_rtl.scss";
@import "_rtl.scss";
+16
View File
@@ -1164,6 +1164,22 @@ def test_event_update_seating(token_client, organizer, event, item, seatingplan)
assert all(seat['product'] == item.pk for seat in resp.data['results'])
@pytest.mark.django_db
def test_event_no_create_or_delete_seats(token_client, organizer, event, item, seatingplan):
resp = token_client.post(
'/api/v1/organizers/{}/events/{}/seats/'.format(organizer.slug, event.slug),
{
'blocked': False,
},
format='json'
)
assert resp.status_code == 405
resp = token_client.delete(
'/api/v1/organizers/{}/events/{}/seats/3/'.format(organizer.slug, event.slug),
)
assert resp.status_code == 405
@pytest.mark.django_db
def test_event_update_seating_invalid_product(token_client, organizer, event, item, seatingplan):
resp = token_client.patch(
+2
View File
@@ -90,6 +90,7 @@ event_urls = [
"delete/",
"dangerzone/",
"cancel/",
"quickstart/",
"settings/",
"settings/plugins",
"settings/payment",
@@ -311,6 +312,7 @@ event_permission_urls = [
("event.settings.general:write", "live/", 200, HTTP_GET),
("event.settings.general:write", "delete/", 200, HTTP_GET),
("event.settings.general:write", "dangerzone/", 200, HTTP_GET),
("event.settings.general:write", "quickstart/", 200, HTTP_GET),
("event.settings.general:write", "settings/", 200, HTTP_GET),
# ("event.settings.payment:write", "settings/payment", 200, HTTP_GET), GET allowed also with other permissions
("event.settings.payment:write", "settings/payment", 200, HTTP_POST),