Compare commits

...
10 Commits
33 changed files with 579 additions and 538 deletions
+1 -1
View File
@@ -83,7 +83,7 @@ Dashboards
.. automodule:: pretix.control.signals
:no-index:
:members: event_dashboard_widgets, user_dashboard_widgets, event_dashboard_top
:members: event_dashboard_statistics, user_dashboard_widgets, event_dashboard_top
Ticket designs
""""""""""""""
+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', kwargs={
'control:event.subevent.edit', kwargs={
'event': event.slug,
'organizer': event.organizer.slug,
'subevent': subevent.pk
+3 -11
View File
@@ -133,22 +133,14 @@ 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_widgets = EventPluginSignal()
event_dashboard_statistics = EventPluginSignal()
"""
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)
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.
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,6 +23,7 @@
{% 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>
@@ -0,0 +1,23 @@
{% 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 %}
@@ -0,0 +1,17 @@
{% 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>
@@ -0,0 +1,16 @@
{% 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>
@@ -0,0 +1,4 @@
{% load i18n %}
{% for q in quotas %}
{% include "pretixcontrol/fragment_quota_box.html" with quota=q full=1 %}
{% endfor %}
@@ -0,0 +1,11 @@
{% 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,4 +1,5 @@
{% load i18n %}
{% load eventsignal %}
{% if has_overpaid_orders %}
<div class="alert alert-warning">
{% blocktrans trimmed %}
@@ -56,3 +57,4 @@
class="btn btn-primary">{% trans "Show sync problems" %}</a>
</div>
{% endif %}
{% eventsignal request.event "pretix.control.signals.event_dashboard_top" request=request %}
@@ -0,0 +1,19 @@
{% 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>
@@ -1,12 +0,0 @@
<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 %}
<div class="panel panel-default items">
<div class="panel-heading">
<details class="panel panel-default items" open>
<summary class="panel-heading">
<h3 class="panel-title">
{% trans "Your timeline" %}
{% trans "Timeline" %}
</h3>
</div>
</summary>
<div class="panel-body timeline">
{% regroup timeline by date as tl_list %}
{% for day in tl_list %}
@@ -33,4 +33,4 @@
</div>
{% endfor %}
</div>
</div>
</details>
@@ -3,10 +3,10 @@
{% load eventurl %}
{% load bootstrap3 %}
{% load static %}
{% load eventsignal %}
{% load icon %}
{% block title %}{{ request.event.name }}{% endblock %}
{% block content %}
<h1>
<h1 class="event-dashboard-header">
{{ request.event.name }}
<small>
{% if request.event.has_subevents %}
@@ -14,11 +14,43 @@
{% else %}
{{ request.event.get_date_range_display }}
{% endif %}
<span id="warnings_loading" class="fa fa-cog fa-spin"></span>
<span id="warnings_indicator" class="htmx-indicator">{% icon "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">
{% trans "Shop URL:" %}
<strong>{% trans "Shop URL:" %}</strong>
<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>
@@ -31,75 +63,76 @@
{% include "pretixcontrol/event/fragment_qr_dropdown.html" with url=0 %}
</div>
<div class="clearfix"></div>
</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 %}
{% include "pretixcontrol/event/dashboard_partial_comment.html" with url=0 %}
</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>
<div class="panel panel-default items">
<div class="panel-heading">
<h3 class="panel-title">
{% trans "Internal comment" %}
</h3>
</div>
<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" %}
{% 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>
{% if not comment_form.readonly %}
<p class="text-right flip">
<br>
<button class="btn btn-default">
{% trans "Update comment" %}
</button>
</p>
{% endif %}
</form>
</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>
</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>
</div>
</div>
{% endif %}
{% 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">
@@ -107,10 +140,27 @@
{% trans "Event logs" %}
</h3>
</div>
<ul class="list-group" id="logs_target">
<div class="logs-lazy-loading">
<span class="fa fa-cog fa-4x"></span>
</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>
<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 "Paid tickets per quota" %}
{% trans "Quota utilization" %}
</th>
<th>
{% trans "Status" %}
@@ -151,7 +151,7 @@
</td>
<td>
{% for q in e.first_quotas|slice:":3" %}
{% include "pretixcontrol/fragment_quota_box_paid.html" with quota=q %}
{% include "pretixcontrol/fragment_quota_box.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,18 +1,26 @@
{% load i18n %}
<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 %}">
{% 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 %}
{% 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 or q.cached_avail.0 >= 100 %}danger{% else %}warning{% endif %} progress-bar-{{ q.inv_percent }}">
<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>
</div>
{% endif %}
<div class="numbers">
{{ q.cached_avail.1|default_if_none:"∞" }} / {{ q.size|default_if_none:"∞" }}
{{ q.used|default_if_none:"∞" }} / {{ q.size|default_if_none:"∞" }}<br>
{% blocktrans trimmed with n=q.cached_availability_paid_orders %}
{{ n }} paid
{% endblocktrans %}
</div>
</div>
</a>
@@ -0,0 +1,10 @@
<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>
@@ -1,17 +0,0 @@
{% 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>
@@ -72,6 +72,7 @@
{% 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 "Paid tickets per quota" %}
{% trans "Quota utilization" %}
</th>
<th>
{% trans "Status" %}
@@ -155,7 +155,7 @@
</td>
<td>
{% for q in s.first_quotas|slice:":3" %}
{% include "pretixcontrol/fragment_quota_box_paid.html" with quota=q %}
{% include "pretixcontrol/fragment_quota_box.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 }}"
+4 -3
View File
@@ -272,15 +272,16 @@ 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'),
+63 -330
View File
@@ -33,317 +33,48 @@
# 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, Prefetch, Q, Subquery, Sum,
Count, IntegerField, Max, Min, OuterRef, Q, Subquery,
)
from django.db.models.functions import Coalesce, Greatest
from django.dispatch import receiver
from django.http import JsonResponse
from django.http import Http404, 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
from django.utils.html import (
conditional_escape, escape, format_html, format_html_join,
)
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, ItemVariation, Order, OrderPosition, OrderRefund,
Question, Quota, SubEvent, Voucher, WaitingListEntry,
Item, ItemCategory, Order, OrderRefund, Question, Quota, Voucher,
WaitingListEntry,
)
from pretix.base.services.quotas import QuotaAvailability
from pretix.base.timeline import timeline_for_event
from pretix.control.signals import (
event_dashboard_widgets, user_dashboard_widgets,
event_dashboard_statistics, 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
NUM_WIDGET = '<div class="numwidget"><span class="num">{num}</span><span class="text">{text}</span></div>'
from .utils import prepare_quotas_for_boxes
@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 [
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',
{
'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)
}]
'count': wles.count,
}
)
def build_json_response(widgets):
@@ -353,62 +84,38 @@ def build_json_response(widgets):
def event_index(request, organizer, event):
from pretix.control.forms.event import CommentForm
can_view_orders = request.user.has_event_permission(
request.organizer,
request.event,
'event.orders:read',
request=request
)
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 = []
stats = []
if can_view_orders:
for r, result in event_dashboard_widgets.send(sender=request.event, subevent=subevent, lazy=True):
widgets.extend(result)
for r, result in event_dashboard_statistics.send(sender=request.event, request=request):
stats.append(result)
ctx = {
'widgets': rearrange(widgets),
'subevent': subevent,
'comment_form': CommentForm(initial={'comment': request.event.comment}, readonly=not can_change_event_settings),
'stats': format_html_join("", "{}", [(s,) for s in stats]),
}
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)
]
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['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)
@@ -445,6 +152,32 @@ 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')
+16 -18
View File
@@ -57,10 +57,9 @@ 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, HttpResponseNotAllowed,
JsonResponse,
Http404, HttpResponse, HttpResponseBadRequest, JsonResponse,
)
from django.shortcuts import redirect
from django.shortcuts import redirect, render
from django.urls import NoReverseMatch, reverse
from django.utils.functional import cached_property
from django.utils.html import conditional_escape, format_html
@@ -1299,24 +1298,23 @@ class EventLog(EventPermissionRequiredMixin, PaginationMixin, ListView):
return LogFilterForm(data=self.request.GET, organizer=self.request.organizer)
class EventComment(EventPermissionRequiredMixin, View):
class EventComment(EventPermissionRequiredMixin, UpdateView):
permission = 'event.settings.general:write'
form_class = CommentForm
template_name = 'pretixcontrol/event/dashboard_partial_comment_form.html'
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 get_object(self, queryset=None):
return self.request.event
def get(self, *args, **kwargs):
return HttpResponseNotAllowed(['POST'])
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_success_url(self) -> str:
return reverse('control:event.index', kwargs={
+2 -14
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,19 +117,7 @@ class EventList(PaginationMixin, ListView):
s.first_quotas = s.first_quotas[:4]
quotas += list(s.first_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
)
prepare_quotas_for_boxes(quotas)
return ctx
@cached_property
+14 -13
View File
@@ -69,6 +69,7 @@ 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
@@ -83,6 +84,7 @@ 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
@@ -144,19 +146,7 @@ class SubEventList(EventPermissionRequiredMixin, PaginationMixin, SubEventQueryM
s.first_quotas = s.first_quotas[:4]
quotas += list(s.first_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
)
prepare_quotas_for_boxes(quotas)
return ctx
@@ -566,6 +556,17 @@ 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,
+30
View File
@@ -0,0 +1,30 @@
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
)
+69 -1
View File
@@ -19,12 +19,21 @@
# 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.control.signals import nav_event
from pretix.base.templatetags.money import money_filter
from pretix.control.signals import event_dashboard_statistics, nav_event
@receiver(nav_event, dispatch_uid="statistics_nav")
@@ -58,3 +67,62 @@ 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)
@@ -0,0 +1,15 @@
<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,26 +1,9 @@
/*global $,gettext*/
/* global $,gettext */
$(function () {
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)
});
});
$('.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))
})
})
@@ -1,4 +1,4 @@
/*global $, gettext, ngettext, interpolate */
/*global $, gettext, ngettext, interpolate, htmx */
function formatPrice(price, currency, locale) {
if (!window.Intl || !Intl.NumberFormat) return price;
@@ -1120,3 +1120,9 @@ $(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,3 +1,52 @@
.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;
+47 -4
View File
@@ -530,18 +530,45 @@ 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: 7px;
height: 10px;
margin-bottom: 2px;
}
.numbers {
font-size: 10px;
color: $text-muted;
display: block;
text-align: center;
}
&.availability .progress-bar-success {
background: var(--pretix-brand-success-lighten-20);
.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;
}
}
}
.quotabox-more {
@@ -700,6 +727,10 @@ h1 .label {
}
.timeline {
max-height: 200px;
overflow-y: auto;
position: relative;
.col-date {
position: relative;
min-height: 1px;
@@ -898,6 +929,18 @@ 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";