mirror of
https://github.com/pretix/pretix.git
synced 2026-07-21 07:41:40 +00:00
Compare commits
54
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
421c098ec2 | ||
|
|
e8785f4117 | ||
|
|
accb9f8b13 | ||
|
|
98d290992c | ||
|
|
9a56874083 | ||
|
|
82dd417a8e | ||
|
|
ba2c6e1e58 | ||
|
|
38b8269f14 | ||
|
|
749f5c7e6c | ||
|
|
d1e8504481 | ||
|
|
108408366e | ||
|
|
4543d8093f | ||
|
|
513a90f976 | ||
|
|
6f61155deb | ||
|
|
714ce28b6a | ||
|
|
b3bcad38a8 | ||
|
|
90978e5cab | ||
|
|
84fb481cdb | ||
|
|
854b41c955 | ||
|
|
79ee89bde9 | ||
|
|
d8d31bab51 | ||
|
|
d47bebb403 | ||
|
|
32927bfd4f | ||
|
|
41c8d646d9 | ||
|
|
c828873d21 | ||
|
|
b4e372ce04 | ||
|
|
7b301b6027 | ||
|
|
68430f01a3 | ||
|
|
363c62a6ca | ||
|
|
a7f32b8647 | ||
|
|
8786397910 | ||
|
|
d078a42250 | ||
|
|
f1a73cd440 | ||
|
|
8bba1a2ea6 | ||
|
|
aeb5c52bfe | ||
|
|
a684aca212 | ||
|
|
59d46ddded | ||
|
|
e4e7d50659 | ||
|
|
1d46a96821 | ||
|
|
8b81ef6f43 | ||
|
|
cb734510ac | ||
|
|
b29f5c69ed | ||
|
|
56ce37225c | ||
|
|
afae6fdd45 | ||
|
|
4cad8eae93 | ||
|
|
c5b7ff66b7 | ||
|
|
04e16bbb39 | ||
|
|
eea48af60a | ||
|
|
fc63f60960 | ||
|
|
3b94125471 | ||
|
|
00d901b04b | ||
|
|
a7f9e100d2 | ||
|
|
59f409b1c6 | ||
|
|
e03bebf5ab |
@@ -26,10 +26,10 @@ jobs:
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Set up Python 3.9
|
||||
- name: Set up Python 3.11
|
||||
uses: actions/setup-python@v1
|
||||
with:
|
||||
python-version: 3.9
|
||||
python-version: 3.11
|
||||
- uses: actions/cache@v1
|
||||
with:
|
||||
path: ~/.cache/pip
|
||||
|
||||
@@ -24,10 +24,10 @@ jobs:
|
||||
name: Check gettext syntax
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Set up Python 3.9
|
||||
- name: Set up Python 3.11
|
||||
uses: actions/setup-python@v1
|
||||
with:
|
||||
python-version: 3.9
|
||||
python-version: 3.11
|
||||
- uses: actions/cache@v1
|
||||
with:
|
||||
path: ~/.cache/pip
|
||||
@@ -50,10 +50,10 @@ jobs:
|
||||
name: Spellcheck
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Set up Python 3.9
|
||||
- name: Set up Python 3.11
|
||||
uses: actions/setup-python@v1
|
||||
with:
|
||||
python-version: 3.9
|
||||
python-version: 3.11
|
||||
- uses: actions/cache@v1
|
||||
with:
|
||||
path: ~/.cache/pip
|
||||
|
||||
@@ -24,10 +24,10 @@ jobs:
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Set up Python 3.9
|
||||
- name: Set up Python 3.11
|
||||
uses: actions/setup-python@v1
|
||||
with:
|
||||
python-version: 3.9
|
||||
python-version: 3.11
|
||||
- uses: actions/cache@v1
|
||||
with:
|
||||
path: ~/.cache/pip
|
||||
@@ -45,10 +45,10 @@ jobs:
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Set up Python 3.9
|
||||
- name: Set up Python 3.11
|
||||
uses: actions/setup-python@v1
|
||||
with:
|
||||
python-version: 3.9
|
||||
python-version: 3.11
|
||||
- uses: actions/cache@v1
|
||||
with:
|
||||
path: ~/.cache/pip
|
||||
@@ -66,10 +66,10 @@ jobs:
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Set up Python 3.9
|
||||
- name: Set up Python 3.11
|
||||
uses: actions/setup-python@v1
|
||||
with:
|
||||
python-version: 3.9
|
||||
python-version: 3.11
|
||||
- name: Install Dependencies
|
||||
run: pip3 install licenseheaders
|
||||
- name: Run licenseheaders
|
||||
|
||||
@@ -24,22 +24,22 @@ jobs:
|
||||
name: Tests
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.7", "3.9", "3.10"]
|
||||
python-version: ["3.9", "3.10", "3.11"]
|
||||
database: [sqlite, postgres, mysql]
|
||||
exclude:
|
||||
- database: mysql
|
||||
python-version: "3.10"
|
||||
- database: mysql
|
||||
python-version: "3.9"
|
||||
- database: mysql
|
||||
python-version: "3.11"
|
||||
- database: sqlite
|
||||
python-version: "3.7"
|
||||
python-version: "3.9"
|
||||
- database: sqlite
|
||||
python-version: "3.10"
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: getong/mariadb-action@v1.1
|
||||
with:
|
||||
mariadb version: '10.4'
|
||||
mariadb version: '10.10'
|
||||
mysql database: 'pretix'
|
||||
mysql root password: ''
|
||||
if: matrix.database == 'mysql'
|
||||
@@ -83,4 +83,4 @@ jobs:
|
||||
file: src/coverage.xml
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
fail_ci_if_error: true
|
||||
if: matrix.database == 'postgres' && matrix.python-version == '3.10'
|
||||
if: matrix.database == 'postgres' && matrix.python-version == '3.11'
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
FROM python:3.9-bullseye
|
||||
FROM python:3.11-bullseye
|
||||
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends \
|
||||
|
||||
@@ -23,7 +23,7 @@ installation guides):
|
||||
|
||||
* A SMTP server to send out mails, e.g. `Postfix`_ on your machine or some third-party server you have credentials for
|
||||
* A HTTP reverse proxy, e.g. `nginx`_ or Apache to allow HTTPS connections
|
||||
* A `PostgreSQL`_ 9.6+ database server
|
||||
* A `PostgreSQL`_ 11+ database server
|
||||
* A `redis`_ server
|
||||
* A `nodejs`_ installation
|
||||
|
||||
@@ -127,7 +127,7 @@ We now install pretix, its direct dependencies and gunicorn::
|
||||
|
||||
(venv)$ pip3 install pretix gunicorn
|
||||
|
||||
Note that you need Python 3.7 or newer. You can find out your Python version using ``python -V``.
|
||||
Note that you need Python 3.9 or newer. You can find out your Python version using ``python -V``.
|
||||
|
||||
We also need to create a data directory::
|
||||
|
||||
|
||||
@@ -104,6 +104,12 @@ Install ``pgloader``::
|
||||
|
||||
# apt install pgloader
|
||||
|
||||
.. note::
|
||||
|
||||
If you are using Ubuntu 20.04, the ``pgloader`` version from the repositories seems to be incompatible with PostgreSQL
|
||||
12+. You can install ``pgloader`` from the `PostgreSQL repositories`_ instead.
|
||||
See also `this discussion <https://github.com/pretix/pretix/issues/3090>`_.
|
||||
|
||||
Create a new file ``/tmp/pretix.load``, replacing the MySQL and PostgreSQL connection strings with the correct user names, passwords, and/or database names::
|
||||
|
||||
LOAD DATABASE
|
||||
@@ -146,3 +152,5 @@ Now, restart pretix. Maybe stop your MySQL server as a verification step that yo
|
||||
And you're done! After you've verified everything has been copied correctly, you can delete the old MySQL database.
|
||||
|
||||
.. note:: Don't forget to update your backup process to back up your PostgreSQL database instead of your MySQL database now.
|
||||
|
||||
.. _PostgreSQL repositories: https://wiki.postgresql.org/wiki/Apt
|
||||
|
||||
@@ -42,6 +42,7 @@ introductory_text string Text to be prin
|
||||
additional_text string Text to be printed below the product list
|
||||
payment_provider_text string Text to be printed below the product list with
|
||||
payment information
|
||||
payment_provider_stamp string Short text to be visibly printed to indicate payment status
|
||||
footer_text string Text to be printed in the page footer area
|
||||
lines list of objects The actual invoice contents
|
||||
├ position integer Number of the line within an invoice.
|
||||
@@ -178,6 +179,7 @@ Endpoints
|
||||
"internal_reference": "",
|
||||
"additional_text": "We are looking forward to see you on our conference!",
|
||||
"payment_provider_text": "Please transfer the money to our account ABC…",
|
||||
"payment_provider_stamp": null,
|
||||
"footer_text": "Big Events LLC - Registration No. 123456 - VAT ID: EU0987654321",
|
||||
"lines": [
|
||||
{
|
||||
@@ -268,6 +270,7 @@ Endpoints
|
||||
"internal_reference": "",
|
||||
"additional_text": "We are looking forward to see you on our conference!",
|
||||
"payment_provider_text": "Please transfer the money to our account ABC…",
|
||||
"payment_provider_stamp": null,
|
||||
"footer_text": "Big Events LLC - Registration No. 123456 - VAT ID: EU0987654321",
|
||||
"lines": [
|
||||
{
|
||||
|
||||
@@ -26,6 +26,7 @@ limit_events list of strings If ``all_events
|
||||
action_types list of strings A list of action type filters that limit the
|
||||
notifications sent to this webhook. See below for
|
||||
valid values
|
||||
comment string Internal comment on this webhook, default ``null``
|
||||
===================================== ========================== =======================================================
|
||||
|
||||
The following values for ``action_types`` are valid with pretix core:
|
||||
@@ -56,6 +57,7 @@ The following values for ``action_types`` are valid with pretix core:
|
||||
* ``pretix.subevent.added``
|
||||
* ``pretix.subevent.changed``
|
||||
* ``pretix.subevent.deleted``
|
||||
* ``pretix.event.item.*``
|
||||
* ``pretix.event.live.activated``
|
||||
* ``pretix.event.live.deactivated``
|
||||
* ``pretix.event.testmode.activated``
|
||||
@@ -98,7 +100,8 @@ Endpoints
|
||||
"target_url": "https://httpstat.us/200",
|
||||
"all_events": false,
|
||||
"limit_events": ["democon"],
|
||||
"action_types": ["pretix.event.order.modified", "pretix.event.order.changed.*"]
|
||||
"action_types": ["pretix.event.order.modified", "pretix.event.order.changed.*"],
|
||||
"comment": null
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -135,7 +138,8 @@ Endpoints
|
||||
"target_url": "https://httpstat.us/200",
|
||||
"all_events": false,
|
||||
"limit_events": ["democon"],
|
||||
"action_types": ["pretix.event.order.modified", "pretix.event.order.changed.*"]
|
||||
"action_types": ["pretix.event.order.modified", "pretix.event.order.changed.*"],
|
||||
"comment": null
|
||||
}
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer to fetch
|
||||
@@ -162,7 +166,8 @@ Endpoints
|
||||
"target_url": "https://httpstat.us/200",
|
||||
"all_events": false,
|
||||
"limit_events": ["democon"],
|
||||
"action_types": ["pretix.event.order.modified", "pretix.event.order.changed.*"]
|
||||
"action_types": ["pretix.event.order.modified", "pretix.event.order.changed.*"],
|
||||
"comment": "Called for changes"
|
||||
}
|
||||
|
||||
**Example response**:
|
||||
@@ -179,7 +184,8 @@ Endpoints
|
||||
"target_url": "https://httpstat.us/200",
|
||||
"all_events": false,
|
||||
"limit_events": ["democon"],
|
||||
"action_types": ["pretix.event.order.modified", "pretix.event.order.changed.*"]
|
||||
"action_types": ["pretix.event.order.modified", "pretix.event.order.changed.*"],
|
||||
"comment": "Called for changes"
|
||||
}
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer to create a webhook for
|
||||
@@ -224,7 +230,8 @@ Endpoints
|
||||
"target_url": "https://httpstat.us/200",
|
||||
"all_events": false,
|
||||
"limit_events": ["democon"],
|
||||
"action_types": ["pretix.event.order.modified", "pretix.event.order.changed.*"]
|
||||
"action_types": ["pretix.event.order.modified", "pretix.event.order.changed.*"],
|
||||
"comment": null
|
||||
}
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer to modify
|
||||
|
||||
@@ -102,6 +102,8 @@ The provider class
|
||||
|
||||
.. automethod:: render_invoice_text
|
||||
|
||||
.. automethod:: render_invoice_stamp
|
||||
|
||||
.. automethod:: order_change_allowed
|
||||
|
||||
.. automethod:: payment_prepare
|
||||
|
||||
@@ -19,4 +19,4 @@
|
||||
# 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/>.
|
||||
#
|
||||
__version__ = "4.16.0"
|
||||
__version__ = "4.17.0.dev0"
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 3.2.17 on 2023-02-07 12:18
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pretixapi', '0009_auto_20221217_1847'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='webhook',
|
||||
name='comment',
|
||||
field=models.CharField(max_length=255, null=True),
|
||||
),
|
||||
]
|
||||
@@ -112,6 +112,7 @@ class WebHook(models.Model):
|
||||
target_url = models.URLField(verbose_name=_("Target URL"), max_length=255)
|
||||
all_events = models.BooleanField(default=True, verbose_name=_("All events (including newly created ones)"))
|
||||
limit_events = models.ManyToManyField('pretixbase.Event', verbose_name=_("Limit to events"), blank=True)
|
||||
comment = models.CharField(verbose_name=_("Comment"), max_length=255, null=True, blank=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ('id',)
|
||||
|
||||
@@ -1484,9 +1484,9 @@ class InvoiceSerializer(I18nAwareModelSerializer):
|
||||
'invoice_to', 'invoice_to_company', 'invoice_to_name', 'invoice_to_street', 'invoice_to_zipcode',
|
||||
'invoice_to_city', 'invoice_to_state', 'invoice_to_country', 'invoice_to_vat_id', 'invoice_to_beneficiary',
|
||||
'custom_field', 'date', 'refers', 'locale',
|
||||
'introductory_text', 'additional_text', 'payment_provider_text', 'footer_text', 'lines',
|
||||
'foreign_currency_display', 'foreign_currency_rate', 'foreign_currency_rate_date',
|
||||
'internal_reference')
|
||||
'introductory_text', 'additional_text', 'payment_provider_text', 'payment_provider_stamp',
|
||||
'footer_text', 'lines', 'foreign_currency_display', 'foreign_currency_rate',
|
||||
'foreign_currency_rate_date', 'internal_reference')
|
||||
|
||||
|
||||
class OrderPaymentCreateSerializer(I18nAwareModelSerializer):
|
||||
|
||||
@@ -55,7 +55,7 @@ class WebHookSerializer(I18nAwareModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = WebHook
|
||||
fields = ('id', 'enabled', 'target_url', 'all_events', 'limit_events', 'action_types')
|
||||
fields = ('id', 'enabled', 'target_url', 'all_events', 'limit_events', 'action_types', 'comment')
|
||||
|
||||
def validate(self, data):
|
||||
data = super().validate(data)
|
||||
|
||||
+23
-27
@@ -96,7 +96,7 @@ def get_all_webhook_events():
|
||||
return types
|
||||
|
||||
|
||||
class ParametrizedOrderWebhookEvent(WebhookEvent):
|
||||
class ParametrizedWebhookEvent(WebhookEvent):
|
||||
def __init__(self, action_type, verbose_name):
|
||||
self._action_type = action_type
|
||||
self._verbose_name = verbose_name
|
||||
@@ -110,6 +110,8 @@ class ParametrizedOrderWebhookEvent(WebhookEvent):
|
||||
def verbose_name(self):
|
||||
return self._verbose_name
|
||||
|
||||
|
||||
class ParametrizedOrderWebhookEvent(ParametrizedWebhookEvent):
|
||||
def build_payload(self, logentry: LogEntry):
|
||||
order = logentry.content_object
|
||||
if not order:
|
||||
@@ -124,19 +126,7 @@ class ParametrizedOrderWebhookEvent(WebhookEvent):
|
||||
}
|
||||
|
||||
|
||||
class ParametrizedEventWebhookEvent(WebhookEvent):
|
||||
def __init__(self, action_type, verbose_name):
|
||||
self._action_type = action_type
|
||||
self._verbose_name = verbose_name
|
||||
super().__init__()
|
||||
|
||||
@property
|
||||
def action_type(self):
|
||||
return self._action_type
|
||||
|
||||
@property
|
||||
def verbose_name(self):
|
||||
return self._verbose_name
|
||||
class ParametrizedEventWebhookEvent(ParametrizedWebhookEvent):
|
||||
|
||||
def build_payload(self, logentry: LogEntry):
|
||||
if logentry.action_type == 'pretix.event.deleted':
|
||||
@@ -160,19 +150,7 @@ class ParametrizedEventWebhookEvent(WebhookEvent):
|
||||
}
|
||||
|
||||
|
||||
class ParametrizedSubEventWebhookEvent(WebhookEvent):
|
||||
def __init__(self, action_type, verbose_name):
|
||||
self._action_type = action_type
|
||||
self._verbose_name = verbose_name
|
||||
super().__init__()
|
||||
|
||||
@property
|
||||
def action_type(self):
|
||||
return self._action_type
|
||||
|
||||
@property
|
||||
def verbose_name(self):
|
||||
return self._verbose_name
|
||||
class ParametrizedSubEventWebhookEvent(ParametrizedWebhookEvent):
|
||||
|
||||
def build_payload(self, logentry: LogEntry):
|
||||
# do not use content_object, this is also called in deletion
|
||||
@@ -185,6 +163,19 @@ class ParametrizedSubEventWebhookEvent(WebhookEvent):
|
||||
}
|
||||
|
||||
|
||||
class ParametrizedItemWebhookEvent(ParametrizedWebhookEvent):
|
||||
|
||||
def build_payload(self, logentry: LogEntry):
|
||||
# do not use content_object, this is also called in deletion
|
||||
return {
|
||||
'notification_id': logentry.pk,
|
||||
'organizer': logentry.event.organizer.slug,
|
||||
'event': logentry.event.slug,
|
||||
'item': logentry.object_id,
|
||||
'action': logentry.action_type,
|
||||
}
|
||||
|
||||
|
||||
class ParametrizedOrderPositionWebhookEvent(ParametrizedOrderWebhookEvent):
|
||||
|
||||
def build_payload(self, logentry: LogEntry):
|
||||
@@ -305,6 +296,11 @@ def register_default_webhook_events(sender, **kwargs):
|
||||
'pretix.subevent.deleted',
|
||||
pgettext_lazy('subevent', 'Event series date deleted'),
|
||||
),
|
||||
ParametrizedItemWebhookEvent(
|
||||
'pretix.event.item.*',
|
||||
_('Product changed (including product added or deleted and including changes to nested objects like '
|
||||
'variations or bundles)'),
|
||||
),
|
||||
ParametrizedEventWebhookEvent(
|
||||
'pretix.event.live.activated',
|
||||
_('Shop taken live'),
|
||||
|
||||
@@ -35,8 +35,8 @@ from django.utils.formats import date_format, localize
|
||||
from django.utils.translation import (
|
||||
get_language, gettext, gettext_lazy, pgettext,
|
||||
)
|
||||
from reportlab.lib import pagesizes
|
||||
from reportlab.lib.enums import TA_LEFT, TA_RIGHT
|
||||
from reportlab.lib import colors, pagesizes
|
||||
from reportlab.lib.enums import TA_CENTER, TA_LEFT, TA_RIGHT
|
||||
from reportlab.lib.styles import ParagraphStyle, StyleSheet1
|
||||
from reportlab.lib.units import mm
|
||||
from reportlab.pdfbase import pdfmetrics
|
||||
@@ -44,8 +44,8 @@ from reportlab.pdfbase.pdfmetrics import stringWidth
|
||||
from reportlab.pdfbase.ttfonts import TTFont
|
||||
from reportlab.pdfgen.canvas import Canvas
|
||||
from reportlab.platypus import (
|
||||
BaseDocTemplate, Frame, KeepTogether, NextPageTemplate, PageTemplate,
|
||||
Paragraph, Spacer, Table, TableStyle,
|
||||
BaseDocTemplate, Flowable, Frame, KeepTogether, NextPageTemplate,
|
||||
PageTemplate, Paragraph, Spacer, Table, TableStyle,
|
||||
)
|
||||
|
||||
from pretix.base.decimal import round_decimal
|
||||
@@ -147,6 +147,8 @@ class BaseReportlabInvoiceRenderer(BaseInvoiceRenderer):
|
||||
"""
|
||||
stylesheet = StyleSheet1()
|
||||
stylesheet.add(ParagraphStyle(name='Normal', fontName=self.font_regular, fontSize=10, leading=12))
|
||||
stylesheet.add(ParagraphStyle(name='BoldInverseCenter', fontName=self.font_bold, fontSize=10, leading=12,
|
||||
textColor=colors.white, alignment=TA_CENTER))
|
||||
stylesheet.add(ParagraphStyle(name='InvoiceFrom', parent=stylesheet['Normal']))
|
||||
stylesheet.add(ParagraphStyle(name='Heading1', fontName=self.font_bold, fontSize=15, leading=15 * 1.2))
|
||||
stylesheet.add(ParagraphStyle(name='FineprintHeading', fontName=self.font_bold, fontSize=8, leading=12))
|
||||
@@ -249,6 +251,31 @@ class BaseReportlabInvoiceRenderer(BaseInvoiceRenderer):
|
||||
).strip().replace('<br>', '<br />').replace('\n', '<br />\n')
|
||||
|
||||
|
||||
class PaidMarker(Flowable):
|
||||
def __init__(self, text='paid', color=None, font='OpenSansBd', size=20):
|
||||
super().__init__()
|
||||
self.text = text
|
||||
self.color = color
|
||||
self.font = font
|
||||
self.size = size
|
||||
self._showBoundary = True
|
||||
|
||||
def wrap(self, availwidth, availheight):
|
||||
# Fake a size, we don't care if we exceed the table
|
||||
return 10, self.size / 2
|
||||
|
||||
def draw(self):
|
||||
self.canv.translate(0, - self.size / 2)
|
||||
self.canv.rotate(2)
|
||||
self.canv.setFont(self.font, self.size)
|
||||
self.canv.setFillColor(self.color)
|
||||
width = self.canv.stringWidth(self.text, self.font, self.size)
|
||||
self.canv.drawRightString(0, 0, self.text)
|
||||
|
||||
self.canv.setStrokeColor(self.color)
|
||||
self.canv.roundRect(-width - self.size / 2, -self.size / 4, width + self.size, self.size + self.size / 4, 3)
|
||||
|
||||
|
||||
class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
|
||||
identifier = 'classic'
|
||||
verbose_name = pgettext('invoice', 'Classic renderer (pretix 1.0)')
|
||||
@@ -612,10 +639,10 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
|
||||
tdata.append([
|
||||
pgettext('invoice', 'Invoice total'), '', money_filter(total, self.invoice.event.currency)
|
||||
])
|
||||
colwidths = [a * doc.width for a in (.65, .05, .30)]
|
||||
colwidths = [a * doc.width for a in (.65, .20, .15)]
|
||||
|
||||
if self.invoice.event.settings.invoice_show_payments and not self.invoice.is_cancellation:
|
||||
if self.invoice.order.status == Order.STATUS_PENDING:
|
||||
if not self.invoice.is_cancellation:
|
||||
if self.invoice.event.settings.invoice_show_payments and self.invoice.order.status == Order.STATUS_PENDING:
|
||||
pending_sum = self.invoice.order.pending_sum
|
||||
if pending_sum != total:
|
||||
tdata.append([pgettext('invoice', 'Received payments')] + (['', '', ''] if has_taxes else ['']) + [
|
||||
@@ -627,7 +654,7 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
|
||||
tstyledata += [
|
||||
('FONTNAME', (0, len(tdata) - 3), (-1, len(tdata) - 3), self.font_bold),
|
||||
]
|
||||
elif self.invoice.order.payments.filter(
|
||||
elif self.invoice.event.settings.invoice_show_payments and self.invoice.order.payments.filter(
|
||||
state__in=(OrderPayment.PAYMENT_STATE_CONFIRMED, OrderPayment.PAYMENT_STATE_REFUNDED), provider='giftcard'
|
||||
).exists():
|
||||
giftcard_sum = self.invoice.order.payments.filter(
|
||||
@@ -645,6 +672,13 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
|
||||
tstyledata += [
|
||||
('FONTNAME', (0, len(tdata) - 3), (-1, len(tdata) - 3), self.font_bold),
|
||||
]
|
||||
elif self.invoice.payment_provider_stamp:
|
||||
pm = PaidMarker(
|
||||
text=self.invoice.payment_provider_stamp,
|
||||
color=colors.HexColor(self.event.settings.theme_color_success),
|
||||
size=16
|
||||
)
|
||||
tdata[-1][-2] = pm
|
||||
|
||||
table = Table(tdata, colWidths=colwidths, repeatRows=1)
|
||||
table.setStyle(TableStyle(tstyledata))
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
# Generated by Django 3.2.17 on 2023-02-07 10:00
|
||||
|
||||
import django.core.serializers.json
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pretixbase', '0228_scheduledeventexport_scheduledorganizerexport'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='invoice',
|
||||
name='payment_provider_stamp',
|
||||
field=models.CharField(max_length=100, null=True),
|
||||
),
|
||||
]
|
||||
@@ -36,7 +36,7 @@ import logging
|
||||
import os
|
||||
import string
|
||||
import uuid
|
||||
from collections import OrderedDict
|
||||
from collections import Counter, OrderedDict, defaultdict
|
||||
from datetime import datetime, time, timedelta
|
||||
from operator import attrgetter
|
||||
from urllib.parse import urljoin
|
||||
@@ -340,64 +340,104 @@ class EventMixin:
|
||||
)
|
||||
)
|
||||
|
||||
@cached_property
|
||||
@property
|
||||
def best_availability_state(self):
|
||||
return self.best_availability[0]
|
||||
|
||||
@property
|
||||
def best_availability_is_low(self):
|
||||
"""
|
||||
Returns ``True`` if the availability of tickets in this event is lower than the percentage
|
||||
given in setting ``low_availability_percentage``.
|
||||
"""
|
||||
if not self.settings.low_availability_percentage:
|
||||
return False
|
||||
ba = self.best_availability
|
||||
if ba[1] is None or not ba[2]:
|
||||
return False
|
||||
|
||||
percentage = ba[1] / ba[2] * 100
|
||||
return percentage < self.settings.low_availability_percentage
|
||||
|
||||
@cached_property
|
||||
def best_availability(self):
|
||||
"""
|
||||
Returns a 3-tuple of
|
||||
|
||||
- The availability state of this event (one of the ``Quota.AVAILABILITY_*`` constants)
|
||||
- The number of tickets currently available (or ``None``)
|
||||
- The number of tickets "originally" available (or ``None``)
|
||||
|
||||
This can only be called on objects obtained through a queryset that has been passed through ``.annotated()``.
|
||||
"""
|
||||
from .items import Quota
|
||||
|
||||
if not hasattr(self, 'active_quotas'):
|
||||
raise TypeError("Call this only if you fetched the subevents via Event/SubEvent.annotated()")
|
||||
items_available = set()
|
||||
vars_available = set()
|
||||
items_reserved = set()
|
||||
vars_reserved = set()
|
||||
items_gone = set()
|
||||
vars_gone = set()
|
||||
items_disabled = set()
|
||||
vars_disabled = set()
|
||||
|
||||
if hasattr(self, 'disabled_items'): # SubEventItem
|
||||
items_disabled = set(self.disabled_items.split(","))
|
||||
else:
|
||||
items_disabled = set()
|
||||
|
||||
if hasattr(self, 'disabled_vars'): # SubEventItemVariation
|
||||
vars_disabled = set(self.disabled_vars.split(","))
|
||||
else:
|
||||
vars_disabled = set()
|
||||
|
||||
# Compute the availability of all quotas and build a item→quotas mapping with all non-disabled items
|
||||
r = getattr(self, '_quota_cache', {})
|
||||
quotas_for_item = defaultdict(list)
|
||||
quotas_for_variation = defaultdict(list)
|
||||
for q in self.active_quotas:
|
||||
res = r[q] if q in r else q.availability(allow_cache=True)
|
||||
if q not in r:
|
||||
r[q] = q.availability(allow_cache=True)
|
||||
|
||||
if res[0] == Quota.AVAILABILITY_OK:
|
||||
if q.active_items:
|
||||
items_available.update(q.active_items.split(","))
|
||||
if q.active_variations:
|
||||
vars_available.update(q.active_variations.split(","))
|
||||
elif res[0] == Quota.AVAILABILITY_RESERVED:
|
||||
if q.active_items:
|
||||
items_reserved.update(q.active_items.split(","))
|
||||
if q.active_variations:
|
||||
vars_reserved.update(q.active_variations.split(","))
|
||||
elif res[0] < Quota.AVAILABILITY_RESERVED:
|
||||
if q.active_items:
|
||||
items_gone.update(q.active_items.split(","))
|
||||
if q.active_variations:
|
||||
vars_gone.update(q.active_variations.split(","))
|
||||
if q.active_items:
|
||||
for item_id in q.active_items.split(","):
|
||||
if item_id not in items_disabled:
|
||||
quotas_for_item[item_id].append(q)
|
||||
if q.active_variations:
|
||||
for var_id in q.active_variations.split(","):
|
||||
if var_id not in vars_disabled:
|
||||
quotas_for_variation[var_id].append(q)
|
||||
|
||||
items_available -= items_disabled
|
||||
items_reserved -= items_disabled
|
||||
items_gone -= items_disabled
|
||||
vars_available -= vars_disabled
|
||||
vars_reserved -= vars_disabled
|
||||
vars_gone -= vars_disabled
|
||||
if not self.active_quotas or (not quotas_for_item and not quotas_for_variation):
|
||||
# No item is enabled for this event, treat the event as "unknown"
|
||||
return None, None, None
|
||||
|
||||
if not self.active_quotas or (
|
||||
not items_available and not items_reserved and not items_gone and not vars_gone and not vars_available and not vars_reserved
|
||||
):
|
||||
return None
|
||||
# We iterate over all items and variations and keep track of
|
||||
# - `best_state_found` - the best availability state we have seen so far. If one item is available, the event is available!
|
||||
# - `num_tickets_found` - the number of tickets currently available in total. We sum up all the items and variations, but keep
|
||||
# track of them per-quota in `quota_used_for_found_tickets` to make sure we don't count the same tickets twice if two or more
|
||||
# items share the same quota
|
||||
# - `num_tickets_possible` - basically the same thing, just with the total size of quotas instead of their currently availability
|
||||
# since we need that for the percentage calculation
|
||||
best_state_found = Quota.AVAILABILITY_GONE
|
||||
num_tickets_found = 0
|
||||
num_tickets_possible = 0
|
||||
quota_used_for_found_tickets = Counter()
|
||||
quota_used_for_possible_tickets = Counter()
|
||||
for quota_list in list(quotas_for_item.values()) + list(quotas_for_variation.values()):
|
||||
worst_state_for_ticket = min(r[q][0] for q in quota_list)
|
||||
quotas_that_are_not_unlimited = [q for q in quota_list if q.size is not None]
|
||||
if not quotas_that_are_not_unlimited:
|
||||
# We found an unlimited ticket, no more need to do anything else
|
||||
return Quota.AVAILABILITY_OK, None, None
|
||||
|
||||
if items_available - items_reserved - items_gone or vars_available - vars_reserved - vars_gone:
|
||||
return Quota.AVAILABILITY_OK
|
||||
if items_reserved - items_gone or vars_reserved - vars_gone:
|
||||
return Quota.AVAILABILITY_RESERVED
|
||||
return Quota.AVAILABILITY_GONE
|
||||
if worst_state_for_ticket == Quota.AVAILABILITY_OK:
|
||||
availability_of_this = min(max(0, r[q][1] - quota_used_for_found_tickets[q]) for q in quotas_that_are_not_unlimited)
|
||||
num_tickets_found += availability_of_this
|
||||
for q in quota_list:
|
||||
quota_used_for_found_tickets[q] += availability_of_this
|
||||
|
||||
possible_of_this = min(max(0, q.size - quota_used_for_possible_tickets[q]) for q in quotas_that_are_not_unlimited)
|
||||
num_tickets_possible += possible_of_this
|
||||
for q in quota_list:
|
||||
quota_used_for_possible_tickets[q] += possible_of_this
|
||||
|
||||
best_state_found = max(best_state_found, worst_state_for_ticket)
|
||||
return best_state_found, num_tickets_found, num_tickets_possible
|
||||
|
||||
def free_seats(self, ignore_voucher=None, sales_channel='web', include_blocked=False):
|
||||
qs_annotated = self._seats(ignore_voucher=ignore_voucher)
|
||||
@@ -591,6 +631,7 @@ class Event(EventMixin, LoggedModel):
|
||||
self.settings.invoice_email_attachment = True
|
||||
self.settings.name_scheme = 'given_family'
|
||||
self.settings.payment_banktransfer_invoice_immediately = True
|
||||
self.settings.low_availability_percentage = 10
|
||||
|
||||
@property
|
||||
def social_image(self):
|
||||
|
||||
@@ -95,6 +95,8 @@ class Invoice(models.Model):
|
||||
:type additional_text: str
|
||||
:param payment_provider_text: A payment provider specific text
|
||||
:type payment_provider_text: str
|
||||
:param payment_provider_stamp: A payment provider specific stamp
|
||||
:type payment_provider_stamp: str
|
||||
:param footer_text: A footer text, displayed smaller and centered on every page
|
||||
:type footer_text: str
|
||||
:param foreign_currency_display: A different currency that taxes should also be displayed in.
|
||||
@@ -144,6 +146,7 @@ class Invoice(models.Model):
|
||||
additional_text = models.TextField(blank=True)
|
||||
reverse_charge = models.BooleanField(default=False)
|
||||
payment_provider_text = models.TextField(blank=True)
|
||||
payment_provider_stamp = models.CharField(max_length=100, null=True, blank=True)
|
||||
footer_text = models.TextField(blank=True)
|
||||
|
||||
foreign_currency_display = models.CharField(max_length=50, null=True, blank=True)
|
||||
|
||||
@@ -1635,7 +1635,7 @@ class OrderPayment(models.Model):
|
||||
logger.info('Failed payment {} but ignored due to likely race condition.'.format(
|
||||
self.full_id,
|
||||
))
|
||||
return
|
||||
return False
|
||||
|
||||
if isinstance(info, str):
|
||||
locked_instance.info = info
|
||||
@@ -1651,6 +1651,7 @@ class OrderPayment(models.Model):
|
||||
'info': info,
|
||||
'data': log_data,
|
||||
}, user=user, auth=auth)
|
||||
return True
|
||||
|
||||
def confirm(self, count_waitinglist=True, send_mail=True, force=False, user=None, auth=None, mail_text='',
|
||||
ignore_date=False, lock=True, payment_date=None, generate_invoice=True):
|
||||
|
||||
@@ -464,6 +464,16 @@ class BasePaymentProvider:
|
||||
return pgettext_lazy('invoice', 'The payment for this invoice has already been received.')
|
||||
return self.settings.get('_invoice_text', as_type=LazyI18nString, default='')
|
||||
|
||||
def render_invoice_stamp(self, order: Order, payment: OrderPayment) -> str:
|
||||
"""
|
||||
This is called when an invoice for an order with this payment provider is generated.
|
||||
The default implementation returns "paid" if the order was already paid, and ``None``
|
||||
otherwise. You can override this with a string, but it should be *really* short to make
|
||||
the invoice look pretty.
|
||||
"""
|
||||
if order.status == Order.STATUS_PAID:
|
||||
return _('paid')
|
||||
|
||||
@property
|
||||
def payment_form_fields(self) -> dict:
|
||||
"""
|
||||
|
||||
+22
-9
@@ -62,7 +62,7 @@ from django.utils.html import conditional_escape
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import gettext_lazy as _, pgettext
|
||||
from i18nfield.strings import LazyI18nString
|
||||
from PyPDF2 import PdfReader
|
||||
from pypdf import PdfReader
|
||||
from pytz import timezone
|
||||
from reportlab.graphics import renderPDF
|
||||
from reportlab.graphics.barcode.qr import QrCodeWidget
|
||||
@@ -848,12 +848,17 @@ class Renderer:
|
||||
'center': TA_CENTER,
|
||||
'right': TA_RIGHT
|
||||
}
|
||||
# lineheight display differs from browser canvas. This calc is just empirical values to get
|
||||
# reportlab render similarly to browser canvas.
|
||||
# for backwards compatability use „uncorrected“ lineheight of 1.0 instead of 1.15
|
||||
lineheight = float(o['lineheight']) * 1.15 if 'lineheight' in o else 1.0
|
||||
style = ParagraphStyle(
|
||||
name=uuid.uuid4().hex,
|
||||
fontName=font,
|
||||
fontSize=float(o['fontsize']),
|
||||
leading=float(o['fontsize']),
|
||||
autoLeading="max",
|
||||
leading=lineheight * float(o['fontsize']),
|
||||
# for backwards compatability use autoLeading if no lineheight is given
|
||||
autoLeading='off' if 'lineheight' in o else 'max',
|
||||
textColor=Color(o['color'][0] / 255, o['color'][1] / 255, o['color'][2] / 255),
|
||||
alignment=align_map[o['align']]
|
||||
)
|
||||
@@ -862,6 +867,10 @@ class Renderer:
|
||||
self._get_text_content(op, order, o) or "",
|
||||
).replace("\n", "<br/>\n").replace("-", "- ")
|
||||
|
||||
# reportlab does not support unicode combination characters
|
||||
# It's important we do this before we use ArabicReshaper
|
||||
text = unicodedata.normalize("NFKC", text)
|
||||
|
||||
# reportlab does not support RTL, ligature-heavy scripts like Arabic. Therefore, we use ArabicReshaper
|
||||
# to resolve all ligatures and python-bidi to switch RTL texts.
|
||||
try:
|
||||
@@ -869,9 +878,6 @@ class Renderer:
|
||||
except:
|
||||
logger.exception('Reshaping/Bidi fixes failed on string {}'.format(repr(text)))
|
||||
|
||||
# reportlab does not support unicode combination characters
|
||||
text = unicodedata.normalize("NFKC", text)
|
||||
|
||||
p = Paragraph(text, style=style)
|
||||
w, h = p.wrapOn(canvas, float(o['width']) * mm, 1000 * mm)
|
||||
# p_size = p.wrap(float(o['width']) * mm, 1000 * mm)
|
||||
@@ -881,8 +887,15 @@ class Renderer:
|
||||
if o.get('downward', False):
|
||||
canvas.translate(float(o['left']) * mm, float(o['bottom']) * mm)
|
||||
canvas.rotate(o.get('rotation', 0) * -1)
|
||||
p.drawOn(canvas, 0, -h - ad[1] / 2)
|
||||
p.drawOn(canvas, 0, -h - ad[1] / 2.5)
|
||||
else:
|
||||
if lineheight != 1.0:
|
||||
# lineheight adds to ascent/descent offsets, just empirical values again to get
|
||||
# reportlab to render similarly to browser canvas
|
||||
ad = (
|
||||
ad[0],
|
||||
ad[1] + (lineheight - 1.0) * float(o['fontsize']) * 1.05
|
||||
)
|
||||
canvas.translate(float(o['left']) * mm, float(o['bottom']) * mm + h)
|
||||
canvas.rotate(o.get('rotation', 0) * -1)
|
||||
p.drawOn(canvas, 0, -h - ad[1])
|
||||
@@ -937,8 +950,8 @@ class Renderer:
|
||||
with open(os.path.join(d, 'out.pdf'), 'rb') as f:
|
||||
return BytesIO(f.read())
|
||||
else:
|
||||
from PyPDF2 import PdfReader, PdfWriter, Transformation
|
||||
from PyPDF2.generic import RectangleObject
|
||||
from pypdf import PdfReader, PdfWriter, Transformation
|
||||
from pypdf.generic import RectangleObject
|
||||
buffer.seek(0)
|
||||
new_pdf = PdfReader(buffer)
|
||||
output = PdfWriter()
|
||||
|
||||
@@ -43,7 +43,9 @@ from django.db import DatabaseError, transaction
|
||||
from django.db.models import Count, Exists, IntegerField, OuterRef, Q, Value
|
||||
from django.dispatch import receiver
|
||||
from django.utils.timezone import make_aware, now
|
||||
from django.utils.translation import gettext as _, pgettext_lazy
|
||||
from django.utils.translation import (
|
||||
gettext as _, gettext_lazy, ngettext_lazy, pgettext_lazy,
|
||||
)
|
||||
from django_scopes import scopes_disabled
|
||||
|
||||
from pretix.base.channels import get_all_sales_channels
|
||||
@@ -81,74 +83,119 @@ class CartError(Exception):
|
||||
if msgargs:
|
||||
msg = _(msg) % msgargs
|
||||
else:
|
||||
msg = _(msg)
|
||||
# force msg to string to make sure lazy-translation is done in current locale-context
|
||||
# otherwise translation might happen in celery-context, which uses default-locale
|
||||
# also translate with _/gettext to keep it backwards compatible
|
||||
msg = _(str(msg))
|
||||
super().__init__(msg)
|
||||
|
||||
|
||||
error_messages = {
|
||||
'busy': _('We were not able to process your request completely as the '
|
||||
'server was too busy. Please try again.'),
|
||||
'empty': _('You did not select any products.'),
|
||||
'unknown_position': _('Unknown cart position.'),
|
||||
'busy': gettext_lazy(
|
||||
'We were not able to process your request completely as the '
|
||||
'server was too busy. Please try again.'
|
||||
),
|
||||
'empty': gettext_lazy('You did not select any products.'),
|
||||
'unknown_position': gettext_lazy('Unknown cart position.'),
|
||||
'subevent_required': pgettext_lazy('subevent', 'No date was specified.'),
|
||||
'not_for_sale': _('You selected a product which is not available for sale.'),
|
||||
'unavailable': _('Some of the products you selected are no longer available. '
|
||||
'Please see below for details.'),
|
||||
'in_part': _('Some of the products you selected are no longer available in '
|
||||
'the quantity you selected. Please see below for details.'),
|
||||
'max_items': _("You cannot select more than %s items per order."),
|
||||
'max_items_per_product': _("You cannot select more than %(max)s items of the product %(product)s."),
|
||||
'min_items_per_product': _("You need to select at least %(min)s items of the product %(product)s."),
|
||||
'min_items_per_product_removed': _("We removed %(product)s from your cart as you can not buy less than "
|
||||
"%(min)s items of it."),
|
||||
'not_started': _('The booking period for this event has not yet started.'),
|
||||
'ended': _('The booking period for this event has ended.'),
|
||||
'payment_ended': _('All payments for this event need to be confirmed already, so no new orders can be created.'),
|
||||
'some_subevent_not_started': _('The booking period for this event has not yet started. The affected positions '
|
||||
'have been removed from your cart.'),
|
||||
'some_subevent_ended': _('The booking period for one of the events in your cart has ended. The affected '
|
||||
'positions have been removed from your cart.'),
|
||||
'price_too_high': _('The entered price is to high.'),
|
||||
'voucher_invalid': _('This voucher code is not known in our database.'),
|
||||
'voucher_min_usages': _('The voucher code "%(voucher)s" can only be used if you select at least %(number)s '
|
||||
'matching products.'),
|
||||
'voucher_min_usages_removed': _('The voucher code "%(voucher)s" can only be used if you select at least '
|
||||
'%(number)s matching products. We have therefore removed some positions from '
|
||||
'your cart that can no longer be purchased like this.'),
|
||||
'voucher_redeemed': _('This voucher code has already been used the maximum number of times allowed.'),
|
||||
'voucher_redeemed_cart': _('This voucher code is currently locked since it is already contained in a cart. This '
|
||||
'might mean that someone else is redeeming this voucher right now, or that you tried '
|
||||
'to redeem it before but did not complete the checkout process. You can try to use it '
|
||||
'again in %d minutes.'),
|
||||
'voucher_redeemed_partial': _('This voucher code can only be redeemed %d more times.'),
|
||||
'voucher_double': _('You already used this voucher code. Remove the associated line from your '
|
||||
'cart if you want to use it for a different product.'),
|
||||
'voucher_expired': _('This voucher is expired.'),
|
||||
'voucher_invalid_item': _('This voucher is not valid for this product.'),
|
||||
'voucher_invalid_seat': _('This voucher is not valid for this seat.'),
|
||||
'voucher_no_match': _('We did not find any position in your cart that we could use this voucher for. If you want '
|
||||
'to add something new to your cart using that voucher, you can do so with the voucher '
|
||||
'redemption option on the bottom of the page.'),
|
||||
'voucher_item_not_available': _(
|
||||
'not_for_sale': gettext_lazy('You selected a product which is not available for sale.'),
|
||||
'unavailable': gettext_lazy(
|
||||
'Some of the products you selected are no longer available. '
|
||||
'Please see below for details.'
|
||||
),
|
||||
'in_part': gettext_lazy(
|
||||
'Some of the products you selected are no longer available in '
|
||||
'the quantity you selected. Please see below for details.'
|
||||
),
|
||||
'max_items': ngettext_lazy(
|
||||
"You cannot select more than %s item per order.",
|
||||
"You cannot select more than %s items per order."
|
||||
),
|
||||
'max_items_per_product': ngettext_lazy(
|
||||
"You cannot select more than %(max)s item of the product %(product)s.",
|
||||
"You cannot select more than %(max)s items of the product %(product)s.",
|
||||
"max"
|
||||
),
|
||||
'min_items_per_product': ngettext_lazy(
|
||||
"You need to select at least %(min)s item of the product %(product)s.",
|
||||
"You need to select at least %(min)s items of the product %(product)s.",
|
||||
"min"
|
||||
),
|
||||
'min_items_per_product_removed': ngettext_lazy(
|
||||
"We removed %(product)s from your cart as you can not buy less than %(min)s item of it.",
|
||||
"We removed %(product)s from your cart as you can not buy less than %(min)s items of it.",
|
||||
"min"
|
||||
),
|
||||
'not_started': gettext_lazy('The booking period for this event has not yet started.'),
|
||||
'ended': gettext_lazy('The booking period for this event has ended.'),
|
||||
'payment_ended': gettext_lazy('All payments for this event need to be confirmed already, so no new orders can be created.'),
|
||||
'some_subevent_not_started': gettext_lazy(
|
||||
'The booking period for this event has not yet started. The affected positions '
|
||||
'have been removed from your cart.'),
|
||||
'some_subevent_ended': gettext_lazy(
|
||||
'The booking period for one of the events in your cart has ended. The affected '
|
||||
'positions have been removed from your cart.'),
|
||||
'price_too_high': gettext_lazy('The entered price is to high.'),
|
||||
'voucher_invalid': gettext_lazy('This voucher code is not known in our database.'),
|
||||
'voucher_min_usages': gettext_lazy(
|
||||
'The voucher code "%(voucher)s" can only be used if you select at least %(number)s '
|
||||
'matching products.'
|
||||
),
|
||||
'voucher_min_usages_removed': ngettext_lazy(
|
||||
'The voucher code "%(voucher)s" can only be used if you select at least %(number)s matching products. '
|
||||
'We have therefore removed some positions from your cart that can no longer be purchased like this.',
|
||||
'The voucher code "%(voucher)s" can only be used if you select at least %(number)s matching products. '
|
||||
'We have therefore removed some positions from your cart that can no longer be purchased like this.',
|
||||
'number'
|
||||
),
|
||||
'voucher_redeemed': gettext_lazy('This voucher code has already been used the maximum number of times allowed.'),
|
||||
'voucher_redeemed_cart': gettext_lazy(
|
||||
'This voucher code is currently locked since it is already contained in a cart. This '
|
||||
'might mean that someone else is redeeming this voucher right now, or that you tried '
|
||||
'to redeem it before but did not complete the checkout process. You can try to use it '
|
||||
'again in %d minutes.'
|
||||
),
|
||||
'voucher_redeemed_partial': gettext_lazy('This voucher code can only be redeemed %d more times.'),
|
||||
'voucher_whole_cart_not_combined': gettext_lazy('Applying a voucher to the whole cart should not be combined with other operations.'),
|
||||
'voucher_double': gettext_lazy(
|
||||
'You already used this voucher code. Remove the associated line from your '
|
||||
'cart if you want to use it for a different product.'
|
||||
),
|
||||
'voucher_expired': gettext_lazy('This voucher is expired.'),
|
||||
'voucher_invalid_item': gettext_lazy('This voucher is not valid for this product.'),
|
||||
'voucher_invalid_seat': gettext_lazy('This voucher is not valid for this seat.'),
|
||||
'voucher_no_match': gettext_lazy(
|
||||
'We did not find any position in your cart that we could use this voucher for. If you want '
|
||||
'to add something new to your cart using that voucher, you can do so with the voucher '
|
||||
'redemption option on the bottom of the page.'
|
||||
),
|
||||
'voucher_item_not_available': gettext_lazy(
|
||||
'Your voucher is valid for a product that is currently not for sale.'),
|
||||
'voucher_invalid_subevent': pgettext_lazy('subevent', 'This voucher is not valid for this event date.'),
|
||||
'voucher_required': _('You need a valid voucher code to order this product.'),
|
||||
'voucher_required': gettext_lazy('You need a valid voucher code to order this product.'),
|
||||
'inactive_subevent': pgettext_lazy('subevent', 'The selected event date is not active.'),
|
||||
'addon_invalid_base': _('You can not select an add-on for the selected product.'),
|
||||
'addon_duplicate_item': _('You can not select two variations of the same add-on product.'),
|
||||
'addon_max_count': _('You can select at most %(max)s add-ons from the category %(cat)s for the product %(base)s.'),
|
||||
'addon_min_count': _('You need to select at least %(min)s add-ons from the category %(cat)s for the '
|
||||
'product %(base)s.'),
|
||||
'addon_no_multi': _('You can select every add-ons from the category %(cat)s for the product %(base)s at most once.'),
|
||||
'addon_only': _('One of the products you selected can only be bought as an add-on to another product.'),
|
||||
'bundled_only': _('One of the products you selected can only be bought part of a bundle.'),
|
||||
'seat_required': _('You need to select a specific seat.'),
|
||||
'seat_invalid': _('Please select a valid seat.'),
|
||||
'seat_forbidden': _('You can not select a seat for this position.'),
|
||||
'seat_unavailable': _('The seat you selected has already been taken. Please select a different seat.'),
|
||||
'seat_multiple': _('You can not select the same seat multiple times.'),
|
||||
'gift_card': _("You entered a gift card instead of a voucher. Gift cards can be entered later on when you're asked for your payment details."),
|
||||
'country_blocked': _('One of the selected products is not available in the selected country.'),
|
||||
'addon_invalid_base': gettext_lazy('You can not select an add-on for the selected product.'),
|
||||
'addon_duplicate_item': gettext_lazy('You can not select two variations of the same add-on product.'),
|
||||
'addon_max_count': ngettext_lazy(
|
||||
'You can select at most %(max)s add-on from the category %(cat)s for the product %(base)s.',
|
||||
'You can select at most %(max)s add-ons from the category %(cat)s for the product %(base)s.',
|
||||
'max'
|
||||
),
|
||||
'addon_min_count': ngettext_lazy(
|
||||
'You need to select at least %(min)s add-on from the category %(cat)s for the product %(base)s.',
|
||||
'You need to select at least %(min)s add-ons from the category %(cat)s for the product %(base)s.',
|
||||
'min'
|
||||
),
|
||||
'addon_no_multi': gettext_lazy('You can select every add-ons from the category %(cat)s for the product %(base)s at most once.'),
|
||||
'addon_only': gettext_lazy('One of the products you selected can only be bought as an add-on to another product.'),
|
||||
'bundled_only': gettext_lazy('One of the products you selected can only be bought part of a bundle.'),
|
||||
'seat_required': gettext_lazy('You need to select a specific seat.'),
|
||||
'seat_invalid': gettext_lazy('Please select a valid seat.'),
|
||||
'seat_forbidden': gettext_lazy('You can not select a seat for this position.'),
|
||||
'seat_unavailable': gettext_lazy('The seat you selected has already been taken. Please select a different seat.'),
|
||||
'seat_multiple': gettext_lazy('You can not select the same seat multiple times.'),
|
||||
'gift_card': gettext_lazy("You entered a gift card instead of a voucher. Gift cards can be entered later on when you're asked for your payment details."),
|
||||
'country_blocked': gettext_lazy('One of the selected products is not available in the selected country.'),
|
||||
}
|
||||
|
||||
|
||||
@@ -320,8 +367,7 @@ class CartManager:
|
||||
cartsize -= len([1 for op in self._operations if isinstance(op, self.RemoveOperation) if
|
||||
not op.position.addon_to_id])
|
||||
if cartsize > int(self.event.settings.max_items_per_order):
|
||||
# TODO: i18n plurals
|
||||
raise CartError(_(error_messages['max_items']) % (self.event.settings.max_items_per_order,))
|
||||
raise CartError(error_messages['max_items'] % self.event.settings.max_items_per_order)
|
||||
|
||||
def _check_item_constraints(self, op, current_ops=[]):
|
||||
if isinstance(op, (self.AddOperation, self.ExtendOperation)):
|
||||
@@ -494,7 +540,7 @@ class CartManager:
|
||||
|
||||
def apply_voucher(self, voucher_code: str):
|
||||
if self._operations:
|
||||
raise CartError('Applying a voucher to the whole cart should not be combined with other operations.')
|
||||
raise CartError(error_messages['voucher_whole_cart_not_combined'])
|
||||
try:
|
||||
voucher = self.event.vouchers.get(code__iexact=voucher_code.strip())
|
||||
except Voucher.DoesNotExist:
|
||||
@@ -539,7 +585,7 @@ class CartManager:
|
||||
for voucher, cnt in list(voucher_use_diff.items()):
|
||||
if 0 < cnt < voucher.min_usages_remaining:
|
||||
raise CartError(
|
||||
_(error_messages['voucher_min_usages']) % {
|
||||
error_messages['voucher_min_usages'] % {
|
||||
'voucher': voucher.code,
|
||||
'number': voucher.min_usages_remaining,
|
||||
}
|
||||
@@ -843,22 +889,16 @@ class CartManager:
|
||||
for (i, v), c in selected.items():
|
||||
n_per_i[i] += c
|
||||
if sum(selected.values()) > iao.max_count:
|
||||
# TODO: Proper i18n
|
||||
# TODO: Proper pluralization
|
||||
raise CartError(
|
||||
error_messages['addon_max_count'],
|
||||
{
|
||||
error_messages['addon_max_count'] % {
|
||||
'base': str(item.name),
|
||||
'max': iao.max_count,
|
||||
'cat': str(iao.addon_category.name),
|
||||
}
|
||||
)
|
||||
elif sum(selected.values()) < iao.min_count:
|
||||
# TODO: Proper i18n
|
||||
# TODO: Proper pluralization
|
||||
raise CartError(
|
||||
error_messages['addon_min_count'],
|
||||
{
|
||||
error_messages['addon_min_count'] % {
|
||||
'base': str(item.name),
|
||||
'min': iao.min_count,
|
||||
'cat': str(iao.addon_category.name),
|
||||
@@ -866,8 +906,7 @@ class CartManager:
|
||||
)
|
||||
elif any(v > 1 for v in n_per_i.values()) and not iao.multi_allowed:
|
||||
raise CartError(
|
||||
error_messages['addon_no_multi'],
|
||||
{
|
||||
error_messages['addon_no_multi'] % {
|
||||
'base': str(item.name),
|
||||
'cat': str(iao.addon_category.name),
|
||||
}
|
||||
@@ -931,7 +970,7 @@ class CartManager:
|
||||
|
||||
if item.max_per_order and count > item.max_per_order:
|
||||
raise CartError(
|
||||
_(error_messages['max_items_per_product']) % {
|
||||
error_messages['max_items_per_product'] % {
|
||||
'max': item.max_per_order,
|
||||
'product': item.name
|
||||
}
|
||||
@@ -945,13 +984,13 @@ class CartManager:
|
||||
for p in self.positions:
|
||||
if p.item_id == item.pk and p.pk not in removals:
|
||||
self._operations.append(self.RemoveOperation(position=p))
|
||||
err = _(error_messages['min_items_per_product_removed']) % {
|
||||
err = error_messages['min_items_per_product_removed'] % {
|
||||
'min': item.min_per_order,
|
||||
'product': item.name
|
||||
}
|
||||
if not err:
|
||||
raise CartError(
|
||||
_(error_messages['min_items_per_product']) % {
|
||||
error_messages['min_items_per_product'] % {
|
||||
'min': item.min_per_order,
|
||||
'product': item.name
|
||||
}
|
||||
@@ -980,13 +1019,13 @@ class CartManager:
|
||||
for p in self.positions:
|
||||
if p.voucher_id == voucher.pk and p.pk not in removals:
|
||||
self._operations.append(self.RemoveOperation(position=p))
|
||||
err = _(error_messages['voucher_min_usages_removed']) % {
|
||||
err = error_messages['voucher_min_usages_removed'] % {
|
||||
'voucher': voucher.code,
|
||||
'number': voucher.min_usages_remaining,
|
||||
}
|
||||
if not err:
|
||||
raise CartError(
|
||||
_(error_messages['voucher_min_usages']) % {
|
||||
error_messages['voucher_min_usages'] % {
|
||||
'voucher': voucher.code,
|
||||
'number': voucher.min_usages_remaining,
|
||||
}
|
||||
@@ -1034,11 +1073,11 @@ class CartManager:
|
||||
|
||||
if voucher_available_count < 1:
|
||||
if op.voucher in self._voucher_depend_on_cart:
|
||||
err = err or _(error_messages['voucher_redeemed_cart']) % self.event.settings.reservation_time
|
||||
err = err or (error_messages['voucher_redeemed_cart'] % self.event.settings.reservation_time)
|
||||
else:
|
||||
err = err or error_messages['voucher_redeemed']
|
||||
elif voucher_available_count < requested_count:
|
||||
err = err or _(error_messages['voucher_redeemed_partial']) % voucher_available_count
|
||||
err = err or (error_messages['voucher_redeemed_partial'] % voucher_available_count)
|
||||
|
||||
available_count = min(quota_available_count, voucher_available_count)
|
||||
|
||||
|
||||
@@ -98,8 +98,10 @@ def build_invoice(invoice: Invoice) -> Invoice:
|
||||
payment = str(lp.payment_provider.render_invoice_text(invoice.order, lp))
|
||||
else:
|
||||
payment = str(lp.payment_provider.render_invoice_text(invoice.order))
|
||||
payment_stamp = lp.payment_provider.render_invoice_stamp(invoice.order, lp)
|
||||
else:
|
||||
payment = ""
|
||||
payment_stamp = None
|
||||
if invoice.event.settings.invoice_include_expire_date and invoice.order.status == Order.STATUS_PENDING:
|
||||
if payment:
|
||||
payment += "<br /><br />"
|
||||
@@ -111,6 +113,7 @@ def build_invoice(invoice: Invoice) -> Invoice:
|
||||
invoice.additional_text = str(additional).replace('\n', '<br />')
|
||||
invoice.footer_text = str(footer)
|
||||
invoice.payment_provider_text = str(payment).replace('\n', '<br />')
|
||||
invoice.payment_provider_stamp = str(payment_stamp) if payment_stamp else None
|
||||
|
||||
try:
|
||||
ia = invoice.order.invoice_address
|
||||
@@ -325,6 +328,7 @@ def generate_cancellation(invoice: Invoice, trigger_pdf=True):
|
||||
cancellation.is_cancellation = True
|
||||
cancellation.date = timezone.now().date()
|
||||
cancellation.payment_provider_text = ''
|
||||
cancellation.payment_provider_stamp = ''
|
||||
cancellation.file = None
|
||||
cancellation.sent_to_organizer = None
|
||||
cancellation.sent_to_customer = None
|
||||
@@ -436,6 +440,7 @@ def build_preview_invoice_pdf(event):
|
||||
invoice.additional_text = str(additional).replace('\n', '<br />')
|
||||
invoice.footer_text = str(footer)
|
||||
invoice.payment_provider_text = str(payment).replace('\n', '<br />')
|
||||
invoice.payment_provider_stamp = _('paid')
|
||||
invoice.invoice_to_name = _("John Doe")
|
||||
invoice.invoice_to_street = _("214th Example Street")
|
||||
invoice.invoice_to_zipcode = _("012345")
|
||||
|
||||
@@ -54,15 +54,13 @@ from django.db.transaction import get_connection
|
||||
from django.dispatch import receiver
|
||||
from django.utils.functional import cached_property
|
||||
from django.utils.timezone import make_aware, now
|
||||
from django.utils.translation import gettext as _
|
||||
from django.utils.translation import gettext as _, gettext_lazy, ngettext_lazy
|
||||
from django_scopes import scopes_disabled
|
||||
|
||||
from pretix.api.models import OAuthApplication
|
||||
from pretix.base.channels import get_all_sales_channels
|
||||
from pretix.base.email import get_email_context
|
||||
from pretix.base.i18n import (
|
||||
LazyLocaleException, get_language_without_region, language,
|
||||
)
|
||||
from pretix.base.i18n import get_language_without_region, language
|
||||
from pretix.base.models import (
|
||||
CartPosition, Device, Event, GiftCard, Item, ItemVariation, Membership,
|
||||
Order, OrderPayment, OrderPosition, Quota, Seat, SeatCategoryMapping, User,
|
||||
@@ -101,47 +99,94 @@ from pretix.helpers import OF_SELF
|
||||
from pretix.helpers.models import modelcopy
|
||||
from pretix.helpers.periodic import minimum_interval
|
||||
|
||||
|
||||
class OrderError(Exception):
|
||||
def __init__(self, *args):
|
||||
msg = args[0]
|
||||
msgargs = args[1] if len(args) > 1 else None
|
||||
self.args = args
|
||||
if msgargs:
|
||||
msg = _(msg) % msgargs
|
||||
else:
|
||||
# force msg to string to make sure lazy-translation is done in current locale-context
|
||||
# otherwise translation might happen in celery-context, which uses default-locale
|
||||
# also translate with _/gettext to keep it backwards compatible
|
||||
msg = _(str(msg))
|
||||
super().__init__(msg)
|
||||
|
||||
|
||||
error_messages = {
|
||||
'unavailable': _('Some of the products you selected were no longer available. '
|
||||
'Please see below for details.'),
|
||||
'in_part': _('Some of the products you selected were no longer available in '
|
||||
'the quantity you selected. Please see below for details.'),
|
||||
'price_changed': _('The price of some of the items in your cart has changed in the '
|
||||
'meantime. Please see below for details.'),
|
||||
'internal': _("An internal error occurred, please try again."),
|
||||
'empty': _("Your cart is empty."),
|
||||
'max_items_per_product': _("You cannot select more than %(max)s items of the product %(product)s. We removed the "
|
||||
"surplus items from your cart."),
|
||||
'busy': _('We were not able to process your request completely as the '
|
||||
'server was too busy. Please try again.'),
|
||||
'not_started': _('The booking period for this event has not yet started.'),
|
||||
'ended': _('The booking period has ended.'),
|
||||
'voucher_min_usages': _('The voucher code "%(voucher)s" can only be used if you select at least %(number)s '
|
||||
'matching products.'),
|
||||
'voucher_invalid': _('The voucher code used for one of the items in your cart is not known in our database.'),
|
||||
'voucher_redeemed': _('The voucher code used for one of the items in your cart has already been used the maximum '
|
||||
'number of times allowed. We removed this item from your cart.'),
|
||||
'voucher_budget_used': _('The voucher code used for one of the items in your cart has already been too often. We '
|
||||
'adjusted the price of the item in your cart.'),
|
||||
'voucher_expired': _('The voucher code used for one of the items in your cart is expired. We removed this item '
|
||||
'from your cart.'),
|
||||
'voucher_invalid_item': _('The voucher code used for one of the items in your cart is not valid for this item. We '
|
||||
'removed this item from your cart.'),
|
||||
'voucher_required': _('You need a valid voucher code to order one of the products.'),
|
||||
'some_subevent_not_started': _('The booking period for one of the events in your cart has not yet started. The '
|
||||
'affected positions have been removed from your cart.'),
|
||||
'some_subevent_ended': _('The booking period for one of the events in your cart has ended. The affected '
|
||||
'positions have been removed from your cart.'),
|
||||
'seat_invalid': _('One of the seats in your order was invalid, we removed the position from your cart.'),
|
||||
'seat_unavailable': _('One of the seats in your order has been taken in the meantime, we removed the position from your cart.'),
|
||||
'country_blocked': _('One of the selected products is not available in the selected country.'),
|
||||
'not_for_sale': _('You selected a product which is not available for sale.'),
|
||||
'addon_invalid_base': _('You can not select an add-on for the selected product.'),
|
||||
'addon_duplicate_item': _('You can not select two variations of the same add-on product.'),
|
||||
'addon_max_count': _('You can select at most %(max)s add-ons from the category %(cat)s for the product %(base)s.'),
|
||||
'addon_min_count': _('You need to select at least %(min)s add-ons from the category %(cat)s for the '
|
||||
'product %(base)s.'),
|
||||
'addon_no_multi': _('You can select every add-ons from the category %(cat)s for the product %(base)s at most once.'),
|
||||
'unavailable': gettext_lazy(
|
||||
'Some of the products you selected were no longer available. '
|
||||
'Please see below for details.'
|
||||
),
|
||||
'in_part': gettext_lazy(
|
||||
'Some of the products you selected were no longer available in '
|
||||
'the quantity you selected. Please see below for details.'
|
||||
),
|
||||
'price_changed': gettext_lazy(
|
||||
'The price of some of the items in your cart has changed in the '
|
||||
'meantime. Please see below for details.'
|
||||
),
|
||||
'internal': gettext_lazy("An internal error occurred, please try again."),
|
||||
'empty': gettext_lazy("Your cart is empty."),
|
||||
'max_items_per_product': ngettext_lazy(
|
||||
"You cannot select more than %(max)s item of the product %(product)s. We removed the surplus items from your cart.",
|
||||
"You cannot select more than %(max)s items of the product %(product)s. We removed the surplus items from your cart.",
|
||||
"max"
|
||||
),
|
||||
'busy': gettext_lazy(
|
||||
'We were not able to process your request completely as the '
|
||||
'server was too busy. Please try again.'
|
||||
),
|
||||
'not_started': gettext_lazy('The booking period for this event has not yet started.'),
|
||||
'ended': gettext_lazy('The booking period has ended.'),
|
||||
'voucher_min_usages': ngettext_lazy(
|
||||
'The voucher code "%(voucher)s" can only be used if you select at least %(number)s matching products.',
|
||||
'The voucher code "%(voucher)s" can only be used if you select at least %(number)s matching products.',
|
||||
'number'
|
||||
),
|
||||
'voucher_invalid': gettext_lazy('The voucher code used for one of the items in your cart is not known in our database.'),
|
||||
'voucher_redeemed': gettext_lazy(
|
||||
'The voucher code used for one of the items in your cart has already been used the maximum '
|
||||
'number of times allowed. We removed this item from your cart.'
|
||||
),
|
||||
'voucher_budget_used': gettext_lazy(
|
||||
'The voucher code used for one of the items in your cart has already been too often. We '
|
||||
'adjusted the price of the item in your cart.'
|
||||
),
|
||||
'voucher_expired': gettext_lazy(
|
||||
'The voucher code used for one of the items in your cart is expired. We removed this item from your cart.'
|
||||
),
|
||||
'voucher_invalid_item': gettext_lazy(
|
||||
'The voucher code used for one of the items in your cart is not valid for this item. We removed this item from your cart.'
|
||||
),
|
||||
'voucher_required': gettext_lazy('You need a valid voucher code to order one of the products.'),
|
||||
'some_subevent_not_started': gettext_lazy(
|
||||
'The booking period for one of the events in your cart has not yet started. The '
|
||||
'affected positions have been removed from your cart.'
|
||||
),
|
||||
'some_subevent_ended': gettext_lazy(
|
||||
'The booking period for one of the events in your cart has ended. The affected '
|
||||
'positions have been removed from your cart.'
|
||||
),
|
||||
'seat_invalid': gettext_lazy('One of the seats in your order was invalid, we removed the position from your cart.'),
|
||||
'seat_unavailable': gettext_lazy('One of the seats in your order has been taken in the meantime, we removed the position from your cart.'),
|
||||
'country_blocked': gettext_lazy('One of the selected products is not available in the selected country.'),
|
||||
'not_for_sale': gettext_lazy('You selected a product which is not available for sale.'),
|
||||
'addon_invalid_base': gettext_lazy('You can not select an add-on for the selected product.'),
|
||||
'addon_duplicate_item': gettext_lazy('You can not select two variations of the same add-on product.'),
|
||||
'addon_max_count': ngettext_lazy(
|
||||
'You can select at most %(max)s add-on from the category %(cat)s for the product %(base)s.',
|
||||
'You can select at most %(max)s add-ons from the category %(cat)s for the product %(base)s.',
|
||||
'max'
|
||||
),
|
||||
'addon_min_count': ngettext_lazy(
|
||||
'You need to select at least %(min)s add-on from the category %(cat)s for the product %(base)s.',
|
||||
'You need to select at least %(min)s add-ons from the category %(cat)s for the product %(base)s.',
|
||||
'min'
|
||||
),
|
||||
'addon_no_multi': gettext_lazy('You can select every add-ons from the category %(cat)s for the product %(base)s at most once.'),
|
||||
}
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -157,7 +202,7 @@ def reactivate_order(order: Order, force: bool=False, user: User=None, auth=None
|
||||
enough quota.
|
||||
"""
|
||||
if order.status != Order.STATUS_CANCELED:
|
||||
raise OrderError('The order was not canceled.')
|
||||
raise OrderError(_('The order was not canceled.'))
|
||||
|
||||
with order.event.lock() as now_dt:
|
||||
is_available = order._is_still_available(now_dt, count_waitinglist=False, check_voucher_usage=True,
|
||||
@@ -538,18 +583,6 @@ def _cancel_order(order, user=None, send_mail: bool=True, api_token=None, device
|
||||
return order.pk
|
||||
|
||||
|
||||
class OrderError(LazyLocaleException):
|
||||
def __init__(self, *args):
|
||||
msg = args[0]
|
||||
msgargs = args[1] if len(args) > 1 else None
|
||||
self.args = args
|
||||
if msgargs:
|
||||
msg = _(msg) % msgargs
|
||||
else:
|
||||
msg = _(msg)
|
||||
super().__init__(msg)
|
||||
|
||||
|
||||
def _check_date(event: Event, now_dt: datetime):
|
||||
if event.presale_start and now_dt < event.presale_start:
|
||||
raise OrderError(error_messages['not_started'])
|
||||
@@ -570,7 +603,6 @@ def _check_date(event: Event, now_dt: datetime):
|
||||
def _check_positions(event: Event, now_dt: datetime, positions: List[CartPosition], address: InvoiceAddress=None,
|
||||
sales_channel='web', customer=None):
|
||||
err = None
|
||||
errargs = None
|
||||
_check_date(event, now_dt)
|
||||
|
||||
products_seen = Counter()
|
||||
@@ -607,9 +639,10 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio
|
||||
|
||||
products_seen[cp.item] += 1
|
||||
if cp.item.max_per_order and products_seen[cp.item] > cp.item.max_per_order:
|
||||
err = error_messages['max_items_per_product']
|
||||
errargs = {'max': cp.item.max_per_order,
|
||||
'product': cp.item.name}
|
||||
err = error_messages['max_items_per_product'] % {
|
||||
'max': cp.item.max_per_order,
|
||||
'product': cp.item.name
|
||||
}
|
||||
delete(cp)
|
||||
break
|
||||
|
||||
@@ -728,7 +761,7 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio
|
||||
|
||||
for voucher, cnt in v_usages.items():
|
||||
if 0 < cnt < voucher.min_usages_remaining:
|
||||
raise OrderError(error_messages['voucher_min_usages'], {
|
||||
raise OrderError(error_messages['voucher_min_usages'] % {
|
||||
'voucher': voucher.code,
|
||||
'number': voucher.min_usages_remaining,
|
||||
})
|
||||
@@ -791,7 +824,7 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio
|
||||
cp.save()
|
||||
|
||||
if err:
|
||||
raise OrderError(err, errargs)
|
||||
raise OrderError(err)
|
||||
|
||||
|
||||
def _get_fees(positions: List[CartPosition], payment_requests: List[dict], address: InvoiceAddress,
|
||||
@@ -1344,22 +1377,26 @@ def notify_user_changed_order(order, user=None, auth=None, invoices=[]):
|
||||
|
||||
class OrderChangeManager:
|
||||
error_messages = {
|
||||
'product_without_variation': _('You need to select a variation of the product.'),
|
||||
'quota': _('The quota {name} does not have enough capacity left to perform the operation.'),
|
||||
'quota_missing': _('There is no quota defined that allows this operation.'),
|
||||
'product_invalid': _('The selected product is not active or has no price set.'),
|
||||
'complete_cancel': _('This operation would leave the order empty. Please cancel the order itself instead.'),
|
||||
'paid_to_free_exceeded': _('This operation would make the order free and therefore immediately paid, however '
|
||||
'no quota is available.'),
|
||||
'addon_to_required': _('This is an add-on product, please select the base position it should be added to.'),
|
||||
'addon_invalid': _('The selected base position does not allow you to add this product as an add-on.'),
|
||||
'subevent_required': _('You need to choose a subevent for the new position.'),
|
||||
'seat_unavailable': _('The selected seat "{seat}" is not available.'),
|
||||
'seat_subevent_mismatch': _('You selected seat "{seat}" for a date that does not match the selected ticket date. Please choose a seat again.'),
|
||||
'seat_required': _('The selected product requires you to select a seat.'),
|
||||
'seat_forbidden': _('The selected product does not allow to select a seat.'),
|
||||
'tax_rule_country_blocked': _('The selected country is blocked by your tax rule.'),
|
||||
'gift_card_change': _('You cannot change the price of a position that has been used to issue a gift card.'),
|
||||
'product_without_variation': gettext_lazy('You need to select a variation of the product.'),
|
||||
'quota': gettext_lazy('The quota {name} does not have enough capacity left to perform the operation.'),
|
||||
'quota_missing': gettext_lazy('There is no quota defined that allows this operation.'),
|
||||
'product_invalid': gettext_lazy('The selected product is not active or has no price set.'),
|
||||
'complete_cancel': gettext_lazy('This operation would leave the order empty. Please cancel the order itself instead.'),
|
||||
'paid_to_free_exceeded': gettext_lazy(
|
||||
'This operation would make the order free and therefore immediately paid, however '
|
||||
'no quota is available.'
|
||||
),
|
||||
'addon_to_required': gettext_lazy('This is an add-on product, please select the base position it should be added to.'),
|
||||
'addon_invalid': gettext_lazy('The selected base position does not allow you to add this product as an add-on.'),
|
||||
'subevent_required': gettext_lazy('You need to choose a subevent for the new position.'),
|
||||
'seat_unavailable': gettext_lazy('The selected seat "{seat}" is not available.'),
|
||||
'seat_subevent_mismatch': gettext_lazy(
|
||||
'You selected seat "{seat}" for a date that does not match the selected ticket date. Please choose a seat again.'
|
||||
),
|
||||
'seat_required': gettext_lazy('The selected product requires you to select a seat.'),
|
||||
'seat_forbidden': gettext_lazy('The selected product does not allow to select a seat.'),
|
||||
'tax_rule_country_blocked': gettext_lazy('The selected country is blocked by your tax rule.'),
|
||||
'gift_card_change': gettext_lazy('You cannot change the price of a position that has been used to issue a gift card.'),
|
||||
}
|
||||
ItemOperation = namedtuple('ItemOperation', ('position', 'item', 'variation'))
|
||||
SubeventOperation = namedtuple('SubeventOperation', ('position', 'subevent'))
|
||||
@@ -1766,22 +1803,16 @@ class OrderChangeManager:
|
||||
for (i, v), c in selected.items():
|
||||
n_per_i[i] += c
|
||||
if sum(selected.values()) > iao.max_count:
|
||||
# TODO: Proper i18n
|
||||
# TODO: Proper pluralization
|
||||
raise OrderError(
|
||||
error_messages['addon_max_count'],
|
||||
{
|
||||
error_messages['addon_max_count'] % {
|
||||
'base': str(item.name),
|
||||
'max': iao.max_count,
|
||||
'cat': str(iao.addon_category.name),
|
||||
}
|
||||
)
|
||||
elif sum(selected.values()) < iao.min_count:
|
||||
# TODO: Proper i18n
|
||||
# TODO: Proper pluralization
|
||||
raise OrderError(
|
||||
error_messages['addon_min_count'],
|
||||
{
|
||||
error_messages['addon_min_count'] % {
|
||||
'base': str(item.name),
|
||||
'min': iao.min_count,
|
||||
'cat': str(iao.addon_category.name),
|
||||
@@ -1789,8 +1820,7 @@ class OrderChangeManager:
|
||||
)
|
||||
elif any(v > 1 for v in n_per_i.values()) and not iao.multi_allowed:
|
||||
raise OrderError(
|
||||
error_messages['addon_no_multi'],
|
||||
{
|
||||
error_messages['addon_no_multi'] % {
|
||||
'base': str(item.name),
|
||||
'cat': str(iao.addon_category.name),
|
||||
}
|
||||
@@ -2471,7 +2501,7 @@ def perform_order(self, event: Event, payments: List[dict], positions: List[str]
|
||||
except LockTimeoutException:
|
||||
self.retry()
|
||||
except (MaxRetriesExceededError, LockTimeoutException):
|
||||
raise OrderError(str(error_messages['busy']))
|
||||
raise OrderError(error_messages['busy'])
|
||||
|
||||
|
||||
_unset = object()
|
||||
|
||||
@@ -1308,6 +1308,25 @@ DEFAULTS = {
|
||||
"the email. Does not affect orders performed through other sales channels."),
|
||||
)
|
||||
},
|
||||
'low_availability_percentage': {
|
||||
'default': None,
|
||||
'type': int,
|
||||
'serializer_class': serializers.IntegerField,
|
||||
'form_class': forms.IntegerField,
|
||||
'serializer_kwargs': dict(
|
||||
min_value=0,
|
||||
max_value=100,
|
||||
),
|
||||
'form_kwargs': dict(
|
||||
label=_('Low availability threshold'),
|
||||
help_text=_('If the availability of tickets falls below this percentage, the event (or a date, if it is an '
|
||||
'event series) will be highlighted to have low availability in the event list or calendar. If '
|
||||
'you keep this option empty, low availability will not be shown publicly.'),
|
||||
min_value=0,
|
||||
max_value=100,
|
||||
required=False
|
||||
)
|
||||
},
|
||||
'event_list_availability': {
|
||||
'default': 'True',
|
||||
'type': bool,
|
||||
|
||||
@@ -293,14 +293,16 @@ class DateFrameWidget(forms.MultiWidget):
|
||||
if '/' in value:
|
||||
return [
|
||||
'custom',
|
||||
date.fromisoformat(value.split('/', 1)[0]),
|
||||
date.fromisoformat(value.split('/', 1)[-1]),
|
||||
date.fromisoformat(value.split('/', 1)[0]) if value.split('/', 1)[0] else None,
|
||||
date.fromisoformat(value.split('/', 1)[-1]) if value.split('/', 1)[-1] else None,
|
||||
]
|
||||
return [value, None, None]
|
||||
|
||||
def get_context(self, name, value, attrs):
|
||||
ctx = super().get_context(name, value, attrs)
|
||||
ctx['required'] = self.timeframe_choices[0][0] == 'unset'
|
||||
ctx['widget']['subwidgets'][1]['attrs'].pop('required', None)
|
||||
ctx['widget']['subwidgets'][2]['attrs'].pop('required', None)
|
||||
return ctx
|
||||
|
||||
|
||||
|
||||
@@ -507,6 +507,7 @@ class EventSettingsForm(SettingsForm):
|
||||
'meta_noindex',
|
||||
'redirect_to_checkout_directly',
|
||||
'frontpage_subevent_ordering',
|
||||
'low_availability_percentage',
|
||||
'event_list_type',
|
||||
'event_list_available_only',
|
||||
'frontpage_text',
|
||||
|
||||
@@ -57,8 +57,8 @@ from pretix.base.forms.widgets import (
|
||||
from pretix.base.models import (
|
||||
Checkin, CheckinList, Device, Event, EventMetaProperty, EventMetaValue,
|
||||
Gate, Invoice, InvoiceAddress, Item, Order, OrderPayment, OrderPosition,
|
||||
OrderRefund, Organizer, Question, QuestionAnswer, SubEvent, Team,
|
||||
TeamAPIToken, TeamInvite,
|
||||
OrderRefund, Organizer, Question, QuestionAnswer, SubEvent,
|
||||
SubEventMetaValue, Team, TeamAPIToken, TeamInvite,
|
||||
)
|
||||
from pretix.base.signals import register_payment_providers
|
||||
from pretix.control.forms.widgets import Select2
|
||||
@@ -1116,9 +1116,25 @@ class SubEventFilterForm(FilterForm):
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.event = kwargs.pop('event')
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields['date_from'].widget = DatePickerWidget()
|
||||
self.fields['date_until'].widget = DatePickerWidget()
|
||||
for p in self.meta_properties.all():
|
||||
self.fields['meta_{}'.format(p.name)] = forms.CharField(
|
||||
label=p.name,
|
||||
required=False,
|
||||
widget=forms.TextInput(
|
||||
attrs={
|
||||
'data-typeahead-url': reverse('control:event.subevents.meta.typeahead', kwargs={
|
||||
'organizer': self.event.organizer.slug,
|
||||
'event': self.event.slug
|
||||
}) + '?' + urlencode({
|
||||
'property': p.name,
|
||||
})
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
def filter_qs(self, qs):
|
||||
fdata = self.cleaned_data
|
||||
@@ -1181,6 +1197,31 @@ class SubEventFilterForm(FilterForm):
|
||||
if fdata.get('time_from'):
|
||||
qs = qs.filter(date_from__time__gte=fdata.get('time_from'))
|
||||
|
||||
filters_by_property_name = {}
|
||||
for i, p in enumerate(self.meta_properties):
|
||||
d = fdata.get('meta_{}'.format(p.name))
|
||||
if d:
|
||||
semv_with_value = SubEventMetaValue.objects.filter(
|
||||
subevent=OuterRef('pk'),
|
||||
property__pk=p.pk,
|
||||
value=d
|
||||
)
|
||||
semv_with_any_value = SubEventMetaValue.objects.filter(
|
||||
subevent=OuterRef('pk'),
|
||||
property__pk=p.pk,
|
||||
)
|
||||
qs = qs.annotate(**{'attr_{}'.format(i): Exists(semv_with_value)})
|
||||
if p.name in filters_by_property_name:
|
||||
filters_by_property_name[p.name] |= Q(**{'attr_{}'.format(i): True})
|
||||
else:
|
||||
filters_by_property_name[p.name] = Q(**{'attr_{}'.format(i): True})
|
||||
default = self.event.meta_data[p.name]
|
||||
if default == d:
|
||||
qs = qs.annotate(**{'attr_{}_any'.format(i): Exists(semv_with_any_value)})
|
||||
filters_by_property_name[p.name] |= Q(**{'attr_{}_any'.format(i): False})
|
||||
for f in filters_by_property_name.values():
|
||||
qs = qs.filter(f)
|
||||
|
||||
if fdata.get('ordering'):
|
||||
qs = qs.order_by(self.get_order_by())
|
||||
else:
|
||||
@@ -1188,6 +1229,10 @@ class SubEventFilterForm(FilterForm):
|
||||
|
||||
return qs
|
||||
|
||||
@cached_property
|
||||
def meta_properties(self):
|
||||
return self.event.organizer.meta_properties.filter(filter_allowed=True)
|
||||
|
||||
|
||||
class OrganizerFilterForm(FilterForm):
|
||||
orders = {
|
||||
|
||||
@@ -564,7 +564,7 @@ class WebHookForm(forms.ModelForm):
|
||||
|
||||
class Meta:
|
||||
model = WebHook
|
||||
fields = ['target_url', 'enabled', 'all_events', 'limit_events']
|
||||
fields = ['target_url', 'enabled', 'all_events', 'limit_events', 'comment']
|
||||
widgets = {
|
||||
'limit_events': forms.CheckboxSelectMultiple(attrs={
|
||||
'data-inverse-dependency': '#id_all_events'
|
||||
|
||||
@@ -23,6 +23,7 @@ from datetime import datetime
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from django import forms
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.forms import formset_factory
|
||||
from django.forms.utils import ErrorDict
|
||||
from django.urls import reverse
|
||||
@@ -273,6 +274,13 @@ class SubEventItemForm(SubEventItemOrVariationFormMixin, forms.ModelForm):
|
||||
'available_until': SplitDateTimeField,
|
||||
}
|
||||
|
||||
def clean(self):
|
||||
d = super().clean()
|
||||
if d.get('available_from') and d.get('available_until'):
|
||||
if d.get('available_from') > d.get('available_until'):
|
||||
raise ValidationError(_('The end of availability should be after the start of availability.'))
|
||||
return d
|
||||
|
||||
|
||||
class SubEventItemVariationForm(SubEventItemOrVariationFormMixin, forms.ModelForm):
|
||||
def __init__(self, *args, **kwargs):
|
||||
@@ -293,6 +301,13 @@ class SubEventItemVariationForm(SubEventItemOrVariationFormMixin, forms.ModelFor
|
||||
'available_until': SplitDateTimeField,
|
||||
}
|
||||
|
||||
def clean(self):
|
||||
d = super().clean()
|
||||
if d.get('available_from') and d.get('available_until'):
|
||||
if d.get('available_from') > d.get('available_until'):
|
||||
raise ValidationError(_('The end of availability should be after the start of availability.'))
|
||||
return d
|
||||
|
||||
|
||||
class BulkSubEventItemForm(SubEventItemForm):
|
||||
rel_available_from = RelativeDateTimeField(
|
||||
|
||||
@@ -314,7 +314,8 @@
|
||||
{% if sform.event_list_available_only %}
|
||||
{% bootstrap_field sform.event_list_available_only layout="control" %}
|
||||
{% endif %}
|
||||
|
||||
{% bootstrap_field sform.low_availability_percentage layout="control" %}
|
||||
|
||||
{% url "control:organizer.edit" organizer=request.organizer.slug as org_url %}
|
||||
{% propagated request.event org_url "meta_noindex" %}
|
||||
{% bootstrap_field sform.meta_noindex layout="control" %}
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
{% csrf_token %}
|
||||
{% bootstrap_form_errors form %}
|
||||
{% bootstrap_field form.target_url layout="control" %}
|
||||
{% bootstrap_field form.comment layout="control" %}
|
||||
{% bootstrap_field form.enabled layout="control" %}
|
||||
{% bootstrap_field form.events layout="control" %}
|
||||
{% bootstrap_field form.all_events layout="control" %}
|
||||
|
||||
@@ -90,7 +90,7 @@
|
||||
</div>
|
||||
</details>
|
||||
{% empty %}
|
||||
<div class="alert-info">{% trans "This webhook did not receive any events in the last 30 days." %}</div>
|
||||
<div class="alert alert-info">{% trans "This webhook did not receive any events in the last 30 days." %}</div>
|
||||
{% endfor %}
|
||||
{% include "pretixcontrol/pagination.html" %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -35,6 +35,7 @@
|
||||
<tr>
|
||||
<th>{% trans "Target URL" %}</th>
|
||||
<th>{% trans "Events" %}</th>
|
||||
<th>{% trans "Comment" %}</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -61,6 +62,11 @@
|
||||
</ul>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if w.comment %}
|
||||
{{ w.comment }}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-right flip">
|
||||
<a href="{% url "control:organizer.webhook.edit" organizer=request.organizer.slug webhook=w.id %}"
|
||||
class="btn btn-default btn-sm" data-toggle="tooltip" title="{% trans "Edit" %}">
|
||||
|
||||
@@ -275,6 +275,18 @@
|
||||
<input type="number" value="13" class="input-block-level form-control" step="0.1"
|
||||
id="toolbox-fontsize">
|
||||
</div>
|
||||
<div class="col-sm-6">
|
||||
<label>{% trans "Line height" %}</label><br>
|
||||
<input type="number" value="1" class="input-block-level form-control" step="0.1"
|
||||
id="toolbox-lineheight">
|
||||
</div>
|
||||
</div>
|
||||
<div class="row control-group text">
|
||||
<div class="col-sm-6">
|
||||
<label>{% trans "Text color" %}</label><br>
|
||||
<input type="text" value="#000000" class="input-block-level form-control colorpickerfield"
|
||||
id="toolbox-col">
|
||||
</div>
|
||||
<div class="col-sm-6">
|
||||
<label> </label><br>
|
||||
<div class="btn-group btn-group-justified" role="group">
|
||||
@@ -295,15 +307,6 @@
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row control-group text">
|
||||
<div class="col-sm-6">
|
||||
<label>{% trans "Text color" %}</label><br>
|
||||
<input type="text" value="#000000" class="input-block-level form-control colorpickerfield"
|
||||
id="toolbox-col">
|
||||
</div>
|
||||
<div class="col-sm-6">
|
||||
<label> </label><br>
|
||||
<div class="btn-group btn-group-justified" id="toolbox-align">
|
||||
<div class="btn-group" role="group">
|
||||
|
||||
@@ -487,6 +487,7 @@
|
||||
{% trans "These settings are optional, if you leave them empty, the default values from the product settings will be used." %}
|
||||
</p>
|
||||
{% for f in itemvar_forms %}
|
||||
{% bootstrap_form_errors f %}
|
||||
<div class="form-group subevent-itemvar-group">
|
||||
<label class="col-md-3 control-label" for="id_{{ f.prefix }}-price">
|
||||
{% if f.variation %}{{ f.item }} – {{ f.variation }}{% else %}{{ f.item }}{% endif %}
|
||||
|
||||
@@ -151,6 +151,7 @@
|
||||
<fieldset>
|
||||
<legend>{% trans "Item prices" %}</legend>
|
||||
{% for f in itemvar_forms %}
|
||||
{% bootstrap_form_errors f %}
|
||||
<div class="form-group subevent-itemvar-group">
|
||||
<label class="col-md-3 control-label" for="id_{{ f.prefix }}-price">
|
||||
{% if f.variation %}{{ f.item }} – {{ f.variation }}{% else %}{{ f.item }}{% endif %}
|
||||
|
||||
@@ -129,6 +129,7 @@
|
||||
{% trans "These settings are optional, if you leave them empty, the default values from the product settings will be used." %}
|
||||
</p>
|
||||
{% for f in itemvar_forms %}
|
||||
{% bootstrap_form_errors f %}
|
||||
<div class="form-group subevent-itemvar-group">
|
||||
<label class="col-md-3 control-label" for="id_{{ f.prefix }}-price">
|
||||
{% if f.variation %}{{ f.item }} – {{ f.variation }}{% else %}{{ f.item }}{% endif %}
|
||||
|
||||
@@ -50,6 +50,11 @@
|
||||
<div class="col-xs-12 one-line-checkboxes">
|
||||
{% bootstrap_field filter_form.weekday %}
|
||||
</div>
|
||||
{% for mf in meta_fields %}
|
||||
<div class="col-md-3 col-sm-6 col-xs-12">
|
||||
{% bootstrap_field mf %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="text-right flip">
|
||||
|
||||
@@ -261,6 +261,7 @@ urlpatterns = [
|
||||
re_path(r'^subevents/bulk_add$', subevents.SubEventBulkCreate.as_view(), name='event.subevents.bulk'),
|
||||
re_path(r'^subevents/bulk_action$', subevents.SubEventBulkAction.as_view(), name='event.subevents.bulkaction'),
|
||||
re_path(r'^subevents/bulk_edit$', subevents.SubEventBulkEdit.as_view(), name='event.subevents.bulkedit'),
|
||||
re_path(r'^subevents/typeahead/meta/$', typeahead.subevent_meta_values, name='event.subevents.meta.typeahead'),
|
||||
re_path(r'^items/$', item.ItemList.as_view(), name='event.items'),
|
||||
re_path(r'^items/add$', item.ItemCreate.as_view(), name='event.items.add'),
|
||||
re_path(r'^items/(?P<item>\d+)/$', item.ItemUpdateGeneral.as_view(), name='event.item'),
|
||||
|
||||
@@ -42,6 +42,7 @@ from itertools import groupby
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
import bleach
|
||||
from django.apps import apps
|
||||
from django.conf import settings
|
||||
from django.contrib import messages
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
@@ -1025,9 +1026,27 @@ class EventDelete(RecentAuthenticationRequiredMixin, EventPermissionRequiredMixi
|
||||
self.request.event.delete()
|
||||
messages.success(self.request, _('The event has been deleted.'))
|
||||
return redirect(self.get_success_url())
|
||||
except ProtectedError:
|
||||
messages.error(self.request, _('The event could not be deleted as some constraints (e.g. data created by '
|
||||
'plug-ins) do not allow it.'))
|
||||
except ProtectedError as e:
|
||||
err = gettext('The event could not be deleted as some constraints (e.g. data created by plug-ins) do not allow it.')
|
||||
|
||||
app_labels = set()
|
||||
for e in e.protected_objects:
|
||||
app_labels.add(type(e)._meta.app_label)
|
||||
|
||||
plugin_names = []
|
||||
for app_label in app_labels:
|
||||
app = apps.get_app_config(app_label)
|
||||
if hasattr(app, 'PretixPluginMeta'):
|
||||
plugin_names.append(str(app.PretixPluginMeta.name))
|
||||
else:
|
||||
plugin_names.append(str(app.verbose_name))
|
||||
|
||||
if plugin_names:
|
||||
err += ' ' + gettext(
|
||||
'Specifically, the following plugins still contain data depends on this event: {plugin_names}'
|
||||
).format(plugin_names=', '.join(plugin_names))
|
||||
|
||||
messages.error(self.request, err)
|
||||
return self.get(self.request, *self.args, **self.kwargs)
|
||||
|
||||
def get_success_url(self) -> str:
|
||||
|
||||
@@ -280,7 +280,9 @@ class EventWizard(SafeSessionWizardView):
|
||||
elif event.organizer.settings.event_team_provisioning:
|
||||
t = Team.objects.create(
|
||||
organizer=event.organizer,
|
||||
name=_('Team {event}').format(event=str(event.name)[:188] + "…" if len(str(event.name)) > 190 else str(event.name)),
|
||||
name=_('Team {event}').format(
|
||||
event=str(event.name)[:100] + "…" if len(str(event.name)) > 100 else str(event.name)
|
||||
),
|
||||
can_change_event_settings=True, can_change_items=True,
|
||||
can_view_orders=True, can_change_orders=True, can_view_vouchers=True,
|
||||
can_change_vouchers=True
|
||||
|
||||
@@ -393,9 +393,23 @@ class OrganizerDelete(AdministratorPermissionRequiredMixin, FormView):
|
||||
self.request.organizer.delete()
|
||||
messages.success(self.request, _('The organizer has been deleted.'))
|
||||
return redirect(self.get_success_url())
|
||||
except ProtectedError:
|
||||
messages.error(self.request, _('The organizer could not be deleted as some constraints (e.g. data created by '
|
||||
'plug-ins) do not allow it.'))
|
||||
except ProtectedError as e:
|
||||
err = gettext('The organizer could not be deleted as some constraints (e.g. data created by plug-ins) do not allow it.')
|
||||
|
||||
# Unlike deleting events (which is done by regular backend users), this feature can only be used by sysadmins,
|
||||
# so we expose more technical / less polished information.
|
||||
affected_models = set()
|
||||
for m in e.protected_objects:
|
||||
affected_models.add(type(m)._meta.label)
|
||||
|
||||
if affected_models:
|
||||
err += ' ' + gettext(
|
||||
'The following database models still contain data that cannot be deleted automatically: {affected_models}'
|
||||
).format(
|
||||
affected_models=', '.join(list(affected_models))
|
||||
)
|
||||
|
||||
messages.error(self.request, err)
|
||||
return self.get(self.request, *self.args, **self.kwargs)
|
||||
|
||||
def get_success_url(self) -> str:
|
||||
|
||||
@@ -39,8 +39,8 @@ from django.utils.crypto import get_random_string
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import gettext as _
|
||||
from django.views.generic import TemplateView
|
||||
from PyPDF2 import PdfReader, PdfWriter
|
||||
from PyPDF2.errors import PdfReadError
|
||||
from pypdf import PdfReader, PdfWriter
|
||||
from pypdf.errors import PdfReadError
|
||||
from reportlab.lib.units import mm
|
||||
|
||||
from pretix.base.i18n import language
|
||||
|
||||
@@ -110,7 +110,7 @@ class SubEventQueryMixin:
|
||||
|
||||
@cached_property
|
||||
def filter_form(self):
|
||||
return SubEventFilterForm(data=self.request_data, prefix='filter')
|
||||
return SubEventFilterForm(data=self.request_data, prefix='filter', event=self.request.event)
|
||||
|
||||
|
||||
class SubEventList(EventPermissionRequiredMixin, PaginationMixin, SubEventQueryMixin, ListView):
|
||||
@@ -125,6 +125,9 @@ class SubEventList(EventPermissionRequiredMixin, PaginationMixin, SubEventQueryM
|
||||
def get_context_data(self, **kwargs):
|
||||
ctx = super().get_context_data(**kwargs)
|
||||
ctx['filter_form'] = self.filter_form
|
||||
ctx['meta_fields'] = [
|
||||
self.filter_form['meta_{}'.format(p.name)] for p in self.request.organizer.meta_properties.filter(filter_allowed=True)
|
||||
]
|
||||
|
||||
quotas = []
|
||||
for s in ctx['subevents']:
|
||||
|
||||
@@ -48,7 +48,8 @@ from django.utils.translation import gettext as _, pgettext
|
||||
|
||||
from pretix.base.models import (
|
||||
EventMetaProperty, EventMetaValue, ItemMetaProperty, ItemMetaValue,
|
||||
ItemVariation, ItemVariationMetaValue, Order, Organizer, User, Voucher,
|
||||
ItemVariation, ItemVariationMetaValue, Order, Organizer, SubEventMetaValue,
|
||||
User, Voucher,
|
||||
)
|
||||
from pretix.control.forms.event import EventWizardCopyForm
|
||||
from pretix.control.permissions import (
|
||||
@@ -556,7 +557,7 @@ def itemvarquota_select2(request, **kwargs):
|
||||
|
||||
if not request.event.has_subevents:
|
||||
# We are very unlikely to need pagination
|
||||
itemqs = request.event.items.prefetch_related('variations').filter(name__icontains=i18ncomp(query))
|
||||
itemqs = request.event.items.prefetch_related('variations').filter(Q(name__icontains=i18ncomp(query)) | Q(internal_name__icontains=query))
|
||||
quotaqs = request.event.quotas.filter(name__icontains=query)
|
||||
more = False
|
||||
else:
|
||||
@@ -743,6 +744,38 @@ def meta_values(request):
|
||||
})
|
||||
|
||||
|
||||
def subevent_meta_values(request, organizer, event):
|
||||
q = request.GET.get('q')
|
||||
propname = request.GET.get('property')
|
||||
|
||||
matches = SubEventMetaValue.objects.filter(
|
||||
value__icontains=q,
|
||||
property__name=propname,
|
||||
subevent__event_id=request.event.pk,
|
||||
)
|
||||
event_matches = EventMetaValue.objects.filter(
|
||||
value__icontains=q,
|
||||
property__name=propname,
|
||||
event_id=request.event.pk,
|
||||
)
|
||||
defaults = EventMetaProperty.objects.filter(
|
||||
default__icontains=q,
|
||||
name=propname,
|
||||
organizer_id=request.organizer.pk,
|
||||
)
|
||||
|
||||
return JsonResponse({
|
||||
'results': [
|
||||
{'name': v, 'id': v}
|
||||
for v in sorted(
|
||||
set(defaults.values_list('default', flat=True)[:10]) |
|
||||
set(matches.values_list('value', flat=True)[:10]) |
|
||||
set(event_matches.values_list('value', flat=True)[:10])
|
||||
)
|
||||
]
|
||||
})
|
||||
|
||||
|
||||
def item_meta_values(request, organizer, event):
|
||||
q = request.GET.get('q')
|
||||
propname = request.GET.get('property')
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,7 +7,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2023-01-30 11:33+0000\n"
|
||||
"POT-Creation-Date: 2023-02-01 13:31+0000\n"
|
||||
"PO-Revision-Date: 2021-09-15 11:22+0000\n"
|
||||
"Last-Translator: Mohamed Tawfiq <mtawfiq@wafyapp.com>\n"
|
||||
"Language-Team: Arabic <https://translate.pretix.eu/projects/pretix/pretix-js/"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,7 +7,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2023-01-30 11:33+0000\n"
|
||||
"POT-Creation-Date: 2023-02-01 13:31+0000\n"
|
||||
"PO-Revision-Date: 2020-12-19 07:00+0000\n"
|
||||
"Last-Translator: albert <albert.serra.monner@gmail.com>\n"
|
||||
"Language-Team: Catalan <https://translate.pretix.eu/projects/pretix/pretix-"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,7 +7,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2023-01-30 11:33+0000\n"
|
||||
"POT-Creation-Date: 2023-02-01 13:31+0000\n"
|
||||
"PO-Revision-Date: 2023-01-13 08:00+0000\n"
|
||||
"Last-Translator: tree <jan.stransky@arnal.cz>\n"
|
||||
"Language-Team: Czech <https://translate.pretix.eu/projects/pretix/pretix-js/"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -6,7 +6,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: \n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2023-01-30 11:33+0000\n"
|
||||
"POT-Creation-Date: 2023-02-01 13:31+0000\n"
|
||||
"PO-Revision-Date: 2022-12-01 17:00+0000\n"
|
||||
"Last-Translator: Mie Frydensbjerg <mif@aarhus.dk>\n"
|
||||
"Language-Team: Danish <https://translate.pretix.eu/projects/pretix/pretix-js/"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,7 +7,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: \n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2023-01-30 11:33+0000\n"
|
||||
"POT-Creation-Date: 2023-02-01 13:31+0000\n"
|
||||
"PO-Revision-Date: 2022-07-26 08:58+0000\n"
|
||||
"Last-Translator: Raphael Michel <michel@rami.io>\n"
|
||||
"Language-Team: German <https://translate.pretix.eu/projects/pretix/pretix-js/"
|
||||
|
||||
@@ -48,6 +48,7 @@ BezahlCode
|
||||
Bezahlmethode
|
||||
Blackberry
|
||||
BLIK
|
||||
Blogpost
|
||||
BN
|
||||
Branding
|
||||
Browsereinstellungen
|
||||
@@ -165,6 +166,7 @@ Linktext
|
||||
loszulegen
|
||||
Ltd
|
||||
max
|
||||
MariaDB
|
||||
MapQuest
|
||||
Mercado
|
||||
Merchandise
|
||||
@@ -206,6 +208,7 @@ Plugin
|
||||
Plugins
|
||||
POS
|
||||
Postfix
|
||||
PostgreSQL
|
||||
prefix
|
||||
pretix
|
||||
pretixdesk
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,7 +7,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: \n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2023-01-30 11:33+0000\n"
|
||||
"POT-Creation-Date: 2023-02-01 13:31+0000\n"
|
||||
"PO-Revision-Date: 2022-07-26 08:58+0000\n"
|
||||
"Last-Translator: Raphael Michel <michel@rami.io>\n"
|
||||
"Language-Team: German (informal) <https://translate.pretix.eu/projects/"
|
||||
|
||||
@@ -48,6 +48,7 @@ BezahlCode
|
||||
Bezahlmethode
|
||||
Blackberry
|
||||
BLIK
|
||||
Blogpost
|
||||
BN
|
||||
Branding
|
||||
Browsereinstellungen
|
||||
@@ -165,6 +166,7 @@ Linktext
|
||||
loszulegen
|
||||
Ltd
|
||||
max
|
||||
MariaDB
|
||||
MapQuest
|
||||
Mercado
|
||||
Merchandise
|
||||
@@ -206,6 +208,7 @@ Plugin
|
||||
Plugins
|
||||
POS
|
||||
Postfix
|
||||
PostgreSQL
|
||||
prefix
|
||||
pretix
|
||||
pretixdesk
|
||||
|
||||
+833
-745
File diff suppressed because it is too large
Load Diff
@@ -8,7 +8,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2023-01-30 11:33+0000\n"
|
||||
"POT-Creation-Date: 2023-02-01 13:31+0000\n"
|
||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||
"Language-Team: LANGUAGE <LL@li.org>\n"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,7 +7,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2023-01-30 11:33+0000\n"
|
||||
"POT-Creation-Date: 2023-02-01 13:31+0000\n"
|
||||
"PO-Revision-Date: 2019-10-03 19:00+0000\n"
|
||||
"Last-Translator: Chris Spy <chrispiropoulou@hotmail.com>\n"
|
||||
"Language-Team: Greek <https://translate.pretix.eu/projects/pretix/pretix-js/"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,7 +7,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2023-01-30 11:33+0000\n"
|
||||
"POT-Creation-Date: 2023-02-01 13:31+0000\n"
|
||||
"PO-Revision-Date: 2021-11-25 21:00+0000\n"
|
||||
"Last-Translator: Ismael Menéndez Fernández <ismael.menendez@balidea.com>\n"
|
||||
"Language-Team: Spanish <https://translate.pretix.eu/projects/pretix/pretix-"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,7 +7,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2023-01-30 11:33+0000\n"
|
||||
"POT-Creation-Date: 2023-02-01 13:31+0000\n"
|
||||
"PO-Revision-Date: 2021-11-10 05:00+0000\n"
|
||||
"Last-Translator: Jaakko Rinta-Filppula <jaakko@r-f.fi>\n"
|
||||
"Language-Team: Finnish <https://translate.pretix.eu/projects/pretix/pretix-"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -6,7 +6,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: French\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2023-01-30 11:33+0000\n"
|
||||
"POT-Creation-Date: 2023-02-01 13:31+0000\n"
|
||||
"PO-Revision-Date: 2023-01-17 01:00+0000\n"
|
||||
"Last-Translator: Maurice Kaag <maurice@kaag.me>\n"
|
||||
"Language-Team: French <https://translate.pretix.eu/projects/pretix/pretix-js/"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,7 +7,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2023-01-30 11:33+0000\n"
|
||||
"POT-Creation-Date: 2023-02-01 13:31+0000\n"
|
||||
"PO-Revision-Date: 2022-02-22 22:00+0000\n"
|
||||
"Last-Translator: Ismael Menéndez Fernández <ismael.menendez@balidea.com>\n"
|
||||
"Language-Team: Galician <https://translate.pretix.eu/projects/pretix/pretix-"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,7 +7,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2023-01-30 11:33+0000\n"
|
||||
"POT-Creation-Date: 2023-02-01 13:31+0000\n"
|
||||
"PO-Revision-Date: 2021-09-24 13:54+0000\n"
|
||||
"Last-Translator: ofirtro <ofir.tro@gmail.com>\n"
|
||||
"Language-Team: Hebrew <https://translate.pretix.eu/projects/pretix/pretix-js/"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,7 +7,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2023-01-30 11:33+0000\n"
|
||||
"POT-Creation-Date: 2023-02-01 13:31+0000\n"
|
||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||
"Last-Translator: Automatically generated\n"
|
||||
"Language-Team: none\n"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,7 +7,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2023-01-30 11:33+0000\n"
|
||||
"POT-Creation-Date: 2023-02-01 13:31+0000\n"
|
||||
"PO-Revision-Date: 2020-01-24 08:00+0000\n"
|
||||
"Last-Translator: Prokaj Miklós <mixolid0@gmail.com>\n"
|
||||
"Language-Team: Hungarian <https://translate.pretix.eu/projects/pretix/pretix-"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,7 +7,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2023-01-30 11:33+0000\n"
|
||||
"POT-Creation-Date: 2023-02-01 13:31+0000\n"
|
||||
"PO-Revision-Date: 2022-05-08 19:00+0000\n"
|
||||
"Last-Translator: Emanuele Signoretta <signorettae@gmail.com>\n"
|
||||
"Language-Team: Italian <https://translate.pretix.eu/projects/pretix/pretix-"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,7 +7,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2023-01-30 11:33+0000\n"
|
||||
"POT-Creation-Date: 2023-02-01 13:31+0000\n"
|
||||
"PO-Revision-Date: 2022-03-15 00:00+0000\n"
|
||||
"Last-Translator: Yuriko Matsunami <y.matsunami@enobyte.com>\n"
|
||||
"Language-Team: Japanese <https://translate.pretix.eu/projects/pretix/pretix-"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,7 +7,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2023-01-30 11:33+0000\n"
|
||||
"POT-Creation-Date: 2023-02-01 13:31+0000\n"
|
||||
"PO-Revision-Date: 2022-04-06 03:00+0000\n"
|
||||
"Last-Translator: Liga V <lerning_by_dreaming@gmx.de>\n"
|
||||
"Language-Team: Latvian <https://translate.pretix.eu/projects/pretix/pretix-"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,7 +7,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2023-01-30 11:33+0000\n"
|
||||
"POT-Creation-Date: 2023-02-01 13:31+0000\n"
|
||||
"PO-Revision-Date: 2022-06-20 02:00+0000\n"
|
||||
"Last-Translator: fyksen <fredrik@fyksen.me>\n"
|
||||
"Language-Team: Norwegian Bokmål <https://translate.pretix.eu/projects/pretix/"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -6,7 +6,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: 1\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2023-01-30 11:33+0000\n"
|
||||
"POT-Creation-Date: 2023-02-01 13:31+0000\n"
|
||||
"PO-Revision-Date: 2021-10-29 02:00+0000\n"
|
||||
"Last-Translator: Maarten van den Berg <maartenberg1@gmail.com>\n"
|
||||
"Language-Team: Dutch <https://translate.pretix.eu/projects/pretix/pretix-js/"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,7 +7,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2023-01-30 11:33+0000\n"
|
||||
"POT-Creation-Date: 2023-02-01 13:31+0000\n"
|
||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||
"Last-Translator: Automatically generated\n"
|
||||
"Language-Team: none\n"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,7 +7,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2023-01-30 11:33+0000\n"
|
||||
"POT-Creation-Date: 2023-02-01 13:31+0000\n"
|
||||
"PO-Revision-Date: 2021-08-05 04:00+0000\n"
|
||||
"Last-Translator: Maarten van den Berg <maartenberg1@gmail.com>\n"
|
||||
"Language-Team: Dutch (informal) <https://translate.pretix.eu/projects/pretix/"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,7 +7,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2023-01-30 11:33+0000\n"
|
||||
"POT-Creation-Date: 2023-02-01 13:31+0000\n"
|
||||
"PO-Revision-Date: 2019-09-24 19:00+0000\n"
|
||||
"Last-Translator: Serge Bazanski <q3k@hackerspace.pl>\n"
|
||||
"Language-Team: Polish <https://translate.pretix.eu/projects/pretix/pretix-js/"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,7 +7,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2023-01-30 11:33+0000\n"
|
||||
"POT-Creation-Date: 2023-02-01 13:31+0000\n"
|
||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||
"Last-Translator: Automatically generated\n"
|
||||
"Language-Team: none\n"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -8,7 +8,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2023-01-30 11:33+0000\n"
|
||||
"POT-Creation-Date: 2023-02-01 13:31+0000\n"
|
||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||
"Language-Team: LANGUAGE <LL@li.org>\n"
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user