Compare commits

..

8 Commits

Author SHA1 Message Date
Lukas Bockstaller
2e673b5e49 wip 2026-04-15 11:48:32 +02:00
Lukas Bockstaller
5ab3b08fca cleanup
# Conflicts:
#	src/pretix/base/services/orders.py
2026-04-15 11:47:13 +02:00
Lukas Bockstaller
c624fcfe41 move checks to classes and add distinction between process_fees and position fees 2026-04-15 11:15:01 +02:00
Lukas Bockstaller
7ffadb87b3 Apply suggestions from code review
Co-authored-by: Raphael Michel <mail@raphaelmichel.de>
2026-04-15 11:15:01 +02:00
Lukas Bockstaller
c1db94dec3 clean up 2026-04-15 11:15:01 +02:00
Lukas Bockstaller
9224c73c7f wip 2026-04-15 11:15:01 +02:00
Lukas Bockstaller
1bb2ab28ad wip 2026-04-15 11:15:01 +02:00
Lukas Bockstaller
accfc843d6 wip 2026-04-15 11:15:00 +02:00
27 changed files with 1320 additions and 993 deletions

View File

@@ -1,16 +1,11 @@
Contributing to pretix
======================
Welcome to pretix, we are happy that you would like to contribute.
Before you do so, please make sure to read the following documents:
Hey there and welcome to pretix!
- [Contribution workflow](https://docs.pretix.eu/dev/development/contribution/general.html)
- [AI-assisted contribution policy](https://docs.pretix.eu/dev/development/contribution/ai.html)
- [Coding style and quality](https://docs.pretix.eu/dev/development/contribution/style.html)
- [Development setup](https://docs.pretix.eu/dev/development/setup.html)
- [Code of Conduct](https://docs.pretix.eu/dev/development/contribution/codeofconduct.html)
* We've got a contributors guide in [our documentation](https://docs.pretix.eu/dev/development/contribution/) together with notes on the [development setup](https://docs.pretix.eu/dev/development/setup.html).
Before we can accept your first PR we'll need you to sign [our **Contributor License Agreement** (CLA)](https://pretix.eu/about/en/cla).
You can find more information about the how and why in our [License FAQ](https://docs.pretix.eu/trust/licensing/faq/) and in our [license change blog post](https://pretix.eu/about/en/blog/20210412-license/).
* Please note that we have a [Code of Conduct](https://docs.pretix.eu/dev/development/contribution/codeofconduct.html) in place that applies to all project contributions, including issues, pull requests, etc.
* Before we can accept a PR from you we'll need you to sign [our CLA](https://pretix.eu/about/en/cla). You can find more information about the how and why in our [License FAQ](https://docs.pretix.eu/trust/licensing/faq/) and in our [license change blog post](https://pretix.eu/about/en/blog/20210412-license/).
**Before contributing new functionality, always open a discussion first.**

View File

@@ -1,24 +0,0 @@
.. _`aipolicy`:
AI-assisted contribution policy
===============================
pretix is maintained by humans.
Every discussion, issue, and pull request is read and reviewed by humans (and sometimes machines, too).
We ask you to respect the time and effort put in by these humans by not sending low-effort, unqualified work, since it puts the burden of validation on the maintainer.
Therefore, the pretix project has strict rules for AI usage:
- **All AI usage in any form must be disclosed.** You must state the tool you used (e.g. Claude Code, Cursor, Amp) along with the extent that the work was AI-assisted.
- **The human-in-the-loop must fully understand all code.** If you can't explain what your changes do and how they interact with the greater system without the aid of AI tools, do not contribute to this project.
- **Issues and discussions can use AI assistance but must have a full human-in-the-loop.** This means that any content generated with AI must have been reviewed and edited by a human before submission. AI is very good at being overly verbose and including noise that distracts from the main point. Humans must do their research and trim this down.
- **No AI-generated media is allowed (art, images, videos, audio, etc.).** Text and code are the only acceptable AI-generated content, per the other rules in this policy.
- **Bad AI drivers will be excluded from the project.** People who produce bad contributions that are clearly AI (slop) will be blocked from our organization without warning.
This policy was inspired by the `ghostty project`_.
.. _ghostty project: https://github.com/ghostty-org/ghostty/blob/main/AI_POLICY.md

View File

@@ -1,39 +1,23 @@
Contribution workflow
=====================
General remarks
===============
You are interested in contributing to pretix? That is awesome!
If youre new to contributing to open source software, dont be afraid. Well happily review your code and give you
constructive and friendly feedback on your changes. Every contribution should go through the following steps.
constructive and friendly feedback on your changes.
Discussion & Design
-------------------
pretix is a large and mature project with more of a decade of history and hopefully many more decades to come.
Keeping pretix in good shape over long timeframes is first and foremost a fight against complexity.
With every additional feature, complexity grows, and both features and complexity are hard to remove.
Even if you are doing the initial work of the contribution, accepting the contribution is not free for us.
Not only will we need to maintain the feature, but every feature adds cost to the maintenance of every other feature it interacts with, and every feature adds effort for users to understand how pretix works.
Therefore, we must carefully select what features we add, based on how well they fit the system in general and of how much use they will be to our larger user base.
We strongly ask you to **create a discussion on GitHub for every new feature idea** outlining the use case and the proposed implementation design.
Pull requests without prior discussion will likely just be closed.
For bug fixes and very minor changes, you can skip this step and open a PR right away.
Development
-----------
To develop your contribution, you'll need pretix running locally on your machine. Head over to :ref:`devsetup` to learn how to do this.
First of all, you'll need pretix running locally on your machine. Head over to :ref:`devsetup` to learn how to do this.
If you run into any problems on your way, please do not hesitate to ask us anytime!
While developing, please have a look at our :ref:`aipolicy` and our guidelines on :ref:`codestyle`.
Please note that we bound ourselves to a :ref:`coc` that applies to all communication around the project. You can be
assured that we will not tolerate any form of harassment.
Sending a patch
---------------
Once you have a first draft of your changes, please `create a pull request`_ on our `GitHub repository`_.
If you improved pretix in any way, we'd be very happy if you contribute it
back to the main code base! The easiest way to do so is to `create a pull request`_
on our `GitHub repository`_.
We recommend that you create a feature branch for every issue you work on so the changes can
be reviewed individually.
@@ -41,17 +25,14 @@ Please use the test suite to check whether your changes break any existing featu
the code style checks to confirm you are consistent with pretix's coding style. You'll
find instructions on this in the :ref:`checksandtests` section of the development setup guide.
We automatically run the tests and the code style check on every pull request through GitHub Actions and we wont
We automatically run the tests and the code style check on every pull request on Travis CI and we wont
accept any pull requests without all tests passing. However, if you don't find out *why* they are not passing,
just send the pull request and tell us we'll be glad to help.
If you add a new feature, please include appropriate documentation into your patch. If you fix a bug,
please include a regression test, i.e. a test that fails without your changes and passes after applying your changes.
Again: If you get stuck, do not hesitate to contact us through GitHub discussions.
Please note that we bound ourselves to a :ref:`coc` that applies to all communication around the project. You can be
assured that we will not tolerate any form of harassment.
Again: If you get stuck, do not hesitate to contact any of us, or Raphael personally at mail@raphaelmichel.de.
.. _create a pull request: https://help.github.com/articles/creating-a-pull-request/
.. _GitHub repository: https://github.com/pretix/pretix

View File

@@ -6,5 +6,4 @@ Contributing to pretix
general
style
ai
codeofconduct

View File

@@ -1,7 +1,5 @@
.. spelling:word-list:: Rebase rebasing
.. _`codestyle`:
Coding style and quality
========================
@@ -30,6 +28,8 @@ Code
Commits and Pull Requests
-------------------------
Most commits should start as pull requests, therefore this applies to the titles of pull requests as well since
the pull request title will become the commit message on merge. We prefer merging with GitHub's "Squash and merge"
feature if the PR contains multiple commits that do not carry value to keep. If there is value in keeping the

View File

@@ -33,9 +33,9 @@ dependencies = [
"bleach==6.3.*",
"celery==5.6.*",
"chardet==5.2.*",
"cryptography>=46.0.7",
"cryptography>=44.0.0",
"css-inline==0.20.*",
"defusedcsv>=3.0.0",
"defusedcsv>=1.1.0",
"dnspython==2.*",
"Django[argon2]==5.2.*",
"django-bootstrap3==26.1",
@@ -93,11 +93,11 @@ dependencies = [
"redis==7.4.*",
"reportlab==4.4.*",
"requests==2.32.*",
"sentry-sdk==2.58.*",
"sentry-sdk==2.57.*",
"sepaxml==2.7.*",
"stripe==7.9.*",
"text-unidecode==1.*",
"tlds>=2026041800",
"tlds>=2020041600",
"tqdm==4.*",
"ua-parser==1.0.*",
"vobject==0.9.*",

View File

@@ -31,9 +31,7 @@ from pretix.api.serializers.order import OrderPositionSerializer
from pretix.api.serializers.organizer import (
CustomerSerializer, GiftCardSerializer,
)
from pretix.base.models import (
Device, Order, OrderPosition, ReusableMedium, TeamAPIToken,
)
from pretix.base.models import Order, OrderPosition, ReusableMedium
logger = logging.getLogger(__name__)
@@ -82,7 +80,8 @@ class ReusableMediaSerializer(I18nAwareModelSerializer):
)
if 'linked_orderposition' in self.context['request'].query_params.getlist('expand'):
# Permission Check performed in to_representation
# No additional permission check performed, documented limitation of the permission system
# Would get to complex/unusable otherwise since the permission depends on the event
self.fields['linked_orderposition'] = NestedOrderPositionSerializer(read_only=True)
else:
self.fields['linked_orderposition'] = serializers.PrimaryKeyRelatedField(
@@ -118,27 +117,6 @@ class ReusableMediaSerializer(I18nAwareModelSerializer):
)
return data
def to_representation(self, instance):
r = super().to_representation(instance)
request = self.context.get('request')
# late permission evaluations for checks that depend on the actual linked events
expand_nested = self.context['request'].query_params.getlist('expand')
perm_holder = request.auth if isinstance(request.auth, (Device, TeamAPIToken)) else request.user
if 'linked_orderposition' in expand_nested:
if instance.linked_orderposition is not None:
event = instance.linked_orderposition.order.event
if not perm_holder.has_event_permission(event.organizer, event, 'event.orders:read', request):
r['linked_orderposition'] = {'id': instance.linked_orderposition.id}
if 'linked_giftcard.owner_ticket' in expand_nested:
gc = instance.linked_giftcard
if gc is not None and gc.owner_ticket is not None:
event = gc.owner_ticket.order.event
if not perm_holder.has_event_permission(event.organizer, event, 'event.orders:read', request):
r['linked_giftcard']['owner_ticket'] = {'id': instance.linked_giftcard.owner_ticket.id}
return r
class Meta:
model = ReusableMedium
fields = (

View File

@@ -286,19 +286,6 @@ class GiftCardSerializer(I18nAwareModelSerializer):
)
return data
def to_representation(self, instance):
r = super().to_representation(instance)
request = self.context.get('request')
# late permission evaluations for checks that depend on the actual linked events
if 'owner_ticket' in self.context['request'].query_params.getlist('expand'):
owner_ticket = instance.owner_ticket
if owner_ticket:
event = owner_ticket.order.event
perm_holder = request.auth if isinstance(request.auth, (Device, TeamAPIToken)) else request.user
if not perm_holder.has_event_permission(event.organizer, event, 'event.orders:read', request):
r['owner_ticket'] = {'id': instance.owner_ticket.id}
return r
class Meta:
model = GiftCard
fields = ('id', 'secret', 'issuance', 'value', 'currency', 'testmode', 'expires', 'conditions', 'owner_ticket',

View File

@@ -1103,25 +1103,13 @@ class PaymentListExporter(ListExporter):
def iterate_list(self, form_data):
provider_names = dict(get_all_payment_providers())
i_numbers = Invoice.objects.filter(
order=OuterRef('order_id'),
).values('order').annotate(
m=GroupConcat('full_invoice_no', delimiter=', ')
).values(
'm'
).order_by()
payments = OrderPayment.objects.filter(
order__event__in=self.events,
state__in=form_data.get('payment_states', [])
).annotate(
order_invoice_numbers=Subquery(i_numbers, output_field=CharField()),
).select_related('order').prefetch_related('order__event').order_by('created')
refunds = OrderRefund.objects.filter(
order__event__in=self.events,
state__in=form_data.get('refund_states', [])
).annotate(
order_invoice_numbers=Subquery(i_numbers, output_field=CharField()),
).select_related('order').prefetch_related('order__event').order_by('created')
if form_data.get('end_date_range'):
@@ -1147,7 +1135,6 @@ class PaymentListExporter(ListExporter):
headers = [
_('Event slug'), _('Order'), _('Payment ID'), _('Creation date'), _('Completion date'), _('Status'),
_('Status code'), _('Amount'), _('Payment method'), _('Comment'), _('Matching ID'), _('Payment details'),
_('Invoice numbers'),
]
yield headers
@@ -1185,7 +1172,6 @@ class PaymentListExporter(ListExporter):
obj.comment if isinstance(obj, OrderRefund) else "",
matching_id,
payment_details,
obj.order_invoice_numbers,
]
yield row

View File

@@ -90,7 +90,7 @@ from pretix.base.settings import (
COUNTRIES_WITH_STATE_IN_ADDRESS, COUNTRY_STATE_LABEL,
PERSON_NAME_SALUTATIONS, PERSON_NAME_SCHEMES, PERSON_NAME_TITLE_GROUPS,
)
from pretix.base.templatetags.rich_text import URL_RE, rich_text
from pretix.base.templatetags.rich_text import rich_text
from pretix.base.timemachine import time_machine_now
from pretix.control.forms import (
ExtFileField, ExtValidationMixin, SizeValidationMixin, SplitDateTimeField,
@@ -227,15 +227,9 @@ class NamePartsFormField(forms.MultiValueField):
# bots.
r'^[^$€/%§{}<>~]*$',
message=_('Please do not use special characters in names.')
),
RegexValidator(
URL_RE,
inverse_match=True,
message=_('Please do not use special characters in names.')
)
]
}
self.max_length = defaults['max_length']
self.scheme_name = kwargs.pop('scheme')
self.titles = kwargs.pop('titles')
self.scheme = PERSON_NAME_SCHEMES.get(self.scheme_name)
@@ -293,7 +287,7 @@ class NamePartsFormField(forms.MultiValueField):
if self.require_all_fields and not all(v for v in value):
raise forms.ValidationError(self.error_messages['incomplete'], code='required')
if sum(len(v) for v in value.values() if v) > (self.max_length or 250):
if sum(len(v) for v in value.values() if v) > 250:
raise forms.ValidationError(_('Please enter a shorter name.'), code='max_length')
if value.get("salutation") == "empty":

View File

@@ -1,81 +0,0 @@
# Generated by Django 5.2.12 on 2026-04-28 11:34
import logging
from django.db import IntegrityError, migrations, transaction
from django.db.models import Count, F
logger = logging.getLogger(__name__)
def fix_cross_organizer_eventmetavalues(apps, schema_editor):
EventMetaProperty = apps.get_model("pretixbase", "EventMetaProperty")
EventMetaValue = apps.get_model("pretixbase", "EventMetaValue")
cross_org_values = EventMetaValue.objects.filter(event__organizer__pk__ne=F('property__organizer__pk'))
for emv in cross_org_values:
logger.info(f"Fixup cross-organizer value {emv.event.organizer.slug}/{emv.event.slug}\n before: {emv.property.name}({emv.property.id}@{emv.property.organizer.slug}) = {emv.value}")
try:
emv.property = emv.event.organizer.meta_properties.filter(name=emv.property.name).first()
logger.info(f" found existing EventMetaProperty in {emv.event.organizer.slug}")
if EventMetaValue.objects.filter(event=emv.event, property=emv.property).exists():
logger.info(f" EventMetaValue with property in correct organizer already exists, deleting the cross-organizer one")
emv.delete()
continue
except EventMetaProperty.DoesNotExist:
meta_prop = emv.property
meta_prop.pk = None
meta_prop.organizer = emv.event.organizer
meta_prop.save(force_insert=True)
logger.info(f" created new EventMetaProperty")
emv.property = meta_prop
logger.info(f" after: {emv.property.name}({emv.property.id}@{emv.property.organizer.slug}) = {emv.value}")
emv.save(update_fields=["property"])
def make_eventmetaproperties_unique(apps, schema_editor):
EventMetaProperty = apps.get_model("pretixbase", "EventMetaProperty")
EventMetaValue = apps.get_model("pretixbase", "EventMetaValue")
duplicates = EventMetaProperty.objects.values('organizer', 'organizer__slug', 'name').annotate(count=Count('id')).filter(count__gt=1)
for dup in duplicates:
logger.info(f"Fixup duplicate property {dup['organizer__slug']} {dup['name']}")
props = list(EventMetaProperty.objects.filter(organizer=dup['organizer'], name=dup['name']))
# TODO: any better idea than picking the property to keep more or less randomly (first in database order)?
target = props[0]
invalid = props[1:]
try:
with transaction.atomic():
affected = EventMetaValue.objects.filter(
event__organizer=dup['organizer'], property__in=invalid
).update(
property=target
)
logger.info(f" Switching {affected} value(s) over to {target.name}({target.id}@{target.organizer.slug})")
except IntegrityError as e:
logger.info(f" Failed to switch all value(s) over to {target.name}({target.id}@{target.organizer.slug})")
logger.info(f" {e}")
for prop in invalid:
newname = f'{prop.name}_DUPLICATE_{prop.id}'
logger.info(f" Renaming {prop.name}({prop.id}@{prop.organizer.slug}) to {newname}({prop.id}@{prop.organizer.slug})")
prop.name = newname
prop.save()
else:
for prop in invalid:
logger.info(f" Deleting {prop.name}({prop.id}@{prop.organizer.slug})")
prop.delete()
class Migration(migrations.Migration):
dependencies = [
("pretixbase", "0298_pluggable_permissions"),
]
operations = [
migrations.RunPython(fix_cross_organizer_eventmetavalues, migrations.RunPython.noop),
migrations.RunPython(make_eventmetaproperties_unique, migrations.RunPython.noop),
]

View File

@@ -1,17 +0,0 @@
# Generated by Django 5.2.12 on 2026-04-28 11:34
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("pretixbase", "0299_fixup_eventmetaproperties"),
]
operations = [
migrations.AlterUniqueTogether(
name="eventmetaproperty",
unique_together={("organizer", "name")},
),
]

View File

@@ -0,0 +1,234 @@
from dataclasses import dataclass
from decimal import Decimal
from functools import reduce
from typing import Callable, Dict, List, Literal, Optional, Set
from django.core.exceptions import ValidationError
from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models
from django.db.models import Prefetch
from django.utils.translation import gettext_lazy as _
from pretix.base.decimal import round_decimal
from pretix.base.models import Event, Item, ItemVariation, Order, OrderPosition
from pretix.base.reldate import ModelRelativeDateTimeField
from pretix.base.timemachine import time_machine_now
@dataclass(frozen=True)
class CancellationCheckResult:
cancellation_possible: bool
reason: str
# Maps Check identifier → cancellation check result
CancellationCheckResultsById = Dict[str, CancellationCheckResult]
CheckFn = Callable[[Order, Set[OrderPosition], Optional[OrderPosition]], CancellationCheckResultsById]
FeeType = Literal['position_fee', 'process_fee']
class Ruling:
"""
A Ruling is the result of applying a CancellationRule onto an Order or OrderPosition.
"""
rule_id: int
results: CancellationCheckResultsById
fee_type: FeeType
fee: Decimal
cancellation_possible: bool
def __init__(
self,
rule_id: int,
results: CancellationCheckResultsById,
fee_type: FeeType,
fee: Decimal
):
self.rule_id = rule_id
self.results = results
self.fee_type = fee_type
self.fee = fee
self.cancellation_possible = all(ruling.cancellation_possible for ruling in results.values())
@classmethod
def from_absolute_fee(
cls,
rule_id: int,
results: CancellationCheckResultsById,
fee_type: FeeType,
absolute_fee: Decimal
) -> "Ruling":
"""
Constructs a Ruling with an absolute fee.
:param rule_id: Id of the rule
:param results: CheckResult object
:param fee_type: If the fee is calculated for a position or process fee
:param absolute_fee: amount of the fee
:return:
"""
return Ruling(rule_id=rule_id, results=results, fee_type=fee_type, fee=absolute_fee)
@classmethod
def from_relative_fee(
cls,
rule_id: int,
results: CancellationCheckResultsById,
fee_type: Literal['position_fee'],
reference_price: Decimal,
percentage: Decimal,
currency: str
) -> "Ruling":
"""
Constructs a Ruling with an absolute fee.
:param rule_id: ID of the rule
:param results: CheckResult object
:param fee_type: Must be a position_fee as the fee can only be in reference to a position
:param reference_price: Price of the position to reference
:param percentage: Percentage of the reference_price set as the fee
:param currency: Currency of the reference_price, used for correct rounding of the fee
:return:
"""
if fee_type == "process_fee":
raise ValidationError("Process fee cannot be used with relative fees")
return Ruling(
rule_id=rule_id,
results=results,
fee_type=fee_type,
fee=round_decimal(reference_price * (percentage / 100), currency)
)
def __lt__(self, other):
if not isinstance(other, Ruling):
return NotImplemented
if self.fee_type != other.fee_type:
return NotImplemented
if self.cancellation_possible == other.cancellation_possible:
return self.fee < other.fee
else:
return self.cancellation_possible and not other.cancellation_possible
class CancellationRule(models.Model):
event = models.ForeignKey(
Event,
verbose_name=_("Event"),
related_name="orders",
on_delete=models.CASCADE
)
all_products = models.BooleanField(
verbose_name=_("All products and variations"),
default=True,
)
limit_products = models.ManyToManyField(Item, verbose_name=_("Products"), blank=True)
limit_variations = models.ManyToManyField(
ItemVariation, blank=True, verbose_name=_("Variations")
)
allowed_until = ModelRelativeDateTimeField(null=True, blank=True)
except_after = ModelRelativeDateTimeField(null=True, blank=True)
fee_percentage_per_item = models.DecimalField(
max_digits=5,
decimal_places=2,
validators=[MinValueValidator("0.00"), MaxValueValidator("100.00")],
verbose_name=_("Fee Percentage per OrderPosition"),
default=Decimal("0.00"),
) # wird als sum() kombiniert
fee_absolute_per_item = models.DecimalField(
max_digits=13,
decimal_places=2,
verbose_name=_("Absolute fee per OrderPosition"),
default=Decimal("0.00"),
) # wird als sum() kombiniert
fee_cancellation_process = models.DecimalField(
max_digits=13,
decimal_places=2,
verbose_name=_("Absolute fee per Cancellation"),
default=Decimal("0.00"),
) # wird als max() kombiniert
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.checks: List[CheckFn] = [self._check_time_window]
# TODO implement order status check
def _check_time_window(self, order: Order, keep: Set[OrderPosition], position: OrderPosition) -> CancellationCheckResultsById:
check_id = "TIME_WINDOW"
if not self.allowed_until and not self.allowed_until:
return {check_id: CancellationCheckResult(
cancellation_possible=True,
reason="No time window specified",
)}
relevant_event = position.subevent or position.event
in_allowed_until = time_machine_now() < self.allowed_until.datetime(
relevant_event) if self.allowed_until else False
in_exemption = time_machine_now() > self.except_after.datetime(
relevant_event) if self.except_after else False
if in_allowed_until and not in_exemption:
except_after_message = f" and not after {self.except_after.datetime(relevant_event)}" if self.except_after else ""
return {check_id: CancellationCheckResult(
cancellation_possible=True,
reason=f"Cancellation in required time window before {self.allowed_until.datetime(relevant_event)}{except_after_message}",
)}
elif in_allowed_until and in_exemption:
return {check_id: CancellationCheckResult(
cancellation_possible=False,
reason=f"Cancellation in exemption period after {self.except_after.datetime(relevant_event)}",
)}
else:
return {check_id: CancellationCheckResult(
cancellation_possible=False,
reason=f"Cancellation after time window ending on {self.allowed_until.datetime(relevant_event)}",
)}
def check(self, system_check_results: List[CancellationCheckResult], order: Order, keep: Set[OrderPosition], position: OrderPosition) -> Optional[Ruling]:
if not self.all_products and position.item_id not in self.limit_products.values_list('pk', flat=True):
return None
if not self.all_products and position.variation_id not in self.limit_variations.values_list('pk', flat=True):
return None
check_results = [check(order, keep, position) for check in self.checks]
if self.fee_percentage_per_item and self.fee_absolute_per_item:
raise NotImplementedError("Should never be reached")
elif self.fee_absolute_per_item != Decimal(0.00):
return Ruling.from_absolute_fee(
rule_id=self.id,
results=reduce(lambda a, b: a | b, [*system_check_results, *check_results], {}),
fee_type='position_fee',
absolute_fee=self.fee_absolute_per_item
)
else:
return Ruling.from_relative_fee(
rule_id=self.id,
results=reduce(lambda a, b: a | b, [*system_check_results, *check_results], {}),
fee_type='position_fee',
reference_price=position.price,
percentage=self.fee_absolute_per_item,
currency=order.event.currency
)
class CancellationCheck:
id: str
prefetches: List[Prefetch] = []
related_selects: List[str] = []
def check(self, order: Order, keep: Set[OrderPosition], order_position: OrderPosition) -> CancellationCheckResult:
raise NotImplementedError()

View File

@@ -877,8 +877,6 @@ class Event(EventMixin, LoggedModel):
ItemProgramTime, ItemVariationMetaValue, Question, Quota,
)
is_cross_organizer = other.organizer_id != self.organizer_id
# Note: avoid self.set_active_plugins(), it causes trouble e.g. for the badges plugin.
# Plugins can create data in installed() hook based on existing data of the event.
# Calling set_active_plugins() results in defaults being created while actually data
@@ -907,15 +905,6 @@ class Event(EventMixin, LoggedModel):
for emv in EventMetaValue.objects.filter(event=other):
emv.pk = None
emv.event = self
if is_cross_organizer:
try:
emv.property = self.organizer.meta_properties.get(name=emv.property.name)
except EventMetaProperty.DoesNotExist:
meta_prop = emv.property
meta_prop.pk = None
meta_prop.organizer = self.organizer
meta_prop.save(force_insert=True)
emv.property = meta_prop
emv.save(force_insert=True)
for fl in EventFooterLink.objects.filter(event=other):
@@ -969,13 +958,13 @@ class Event(EventMixin, LoggedModel):
if i.tax_rule_id:
i.tax_rule = tax_map[i.tax_rule_id]
if i.grant_membership_type and is_cross_organizer:
if i.grant_membership_type and other.organizer_id != self.organizer_id:
i.grant_membership_type = None
i.save() # no force_insert since i.picture.save could have already inserted
i.log_action('pretix.object.cloned')
if require_membership_types and not is_cross_organizer:
if require_membership_types and other.organizer_id == self.organizer_id:
i.require_membership_types.set(require_membership_types)
if not i.all_sales_channels:
@@ -990,7 +979,7 @@ class Event(EventMixin, LoggedModel):
v._prefetched_objects_cache = {}
v.save(force_insert=True)
if require_membership_types and not is_cross_organizer:
if require_membership_types and other.organizer_id == self.organizer_id:
v.require_membership_types.set(require_membership_types)
if not v.all_sales_channels:
v.limit_sales_channels.set(self.organizer.sales_channels.filter(identifier__in=[s.identifier for s in limit_sales_channels]))
@@ -1841,7 +1830,6 @@ class EventMetaProperty(LoggedModel):
class Meta:
ordering = ("position", "name",)
unique_together = ('organizer', 'name')
@property
def choice_keys(self):
@@ -1875,8 +1863,6 @@ class EventMetaValue(LoggedModel):
self.event.cache.clear()
def save(self, *args, **kwargs):
if self.event and self.event.organizer != self.property.organizer:
raise ValidationError(_("Property and event must belong to the same organizer."))
super().save(*args, **kwargs)
if self.event:
self.event.cache.clear()

View File

@@ -41,8 +41,9 @@ from collections import Counter, defaultdict, namedtuple
from datetime import datetime, time, timedelta
from decimal import Decimal
from functools import reduce
from itertools import chain
from time import sleep
from typing import List, Optional
from typing import Dict, List, Optional, Set
from celery.exceptions import MaxRetriesExceededError
from django.conf import settings
@@ -50,9 +51,10 @@ from django.core.cache import cache
from django.core.exceptions import ValidationError
from django.db import transaction
from django.db.models import (
Count, Exists, F, IntegerField, Max, Min, OuterRef, Q, QuerySet, Sum,
Value,
Count, Exists, F, IntegerField, Max, Min, OuterRef, Prefetch, Q, QuerySet,
Sum, Value,
)
from django.db.models import Prefetch
from django.db.models.functions import Coalesce, Greatest
from django.db.transaction import get_connection
from django.dispatch import receiver
@@ -67,10 +69,14 @@ from pretix.base.email import get_email_context
from pretix.base.i18n import get_language_without_region, language
from pretix.base.media import MEDIA_TYPES
from pretix.base.models import (
CartPosition, Device, Event, GiftCard, Item, ItemVariation, LogEntry,
CartPosition, Checkin, Device, Event, GiftCard, Item, ItemVariation,
Membership, Order, OrderPayment, OrderPosition, Quota, Seat,
SeatCategoryMapping, User, Voucher,
)
from pretix.base.models.cancellation import (
CancellationCheckResult, CancellationCheckResultsById, CancellationRule,
Ruling, CancellationCheck
)
from pretix.base.models.event import SubEvent
from pretix.base.models.orders import (
BlockedTicketSecret, InvoiceAddress, OrderFee, OrderRefund,
@@ -103,11 +109,11 @@ from pretix.base.signals import (
order_approved, order_canceled, order_changed, order_denied, order_expired,
order_expiry_changed, order_fee_calculation, order_paid, order_placed,
order_reactivated, order_split, order_valid_if_pending, periodic_task,
validate_order,
self_service_cancellation_checks, validate_order,
)
from pretix.base.timemachine import time_machine_now, time_machine_now_assigned
from pretix.celery_app import app
from pretix.helpers import OF_SELF
from pretix.helpers import OF_SELF, ensure_no_queries
from pretix.helpers.models import modelcopy
from pretix.helpers.periodic import minimum_interval
from pretix.testutils.middleware import debugflags_var
@@ -1618,7 +1624,7 @@ class OrderChangeManager:
MembershipOperation = namedtuple('MembershipOperation', ('position', 'membership'))
CancelOperation = namedtuple('CancelOperation', ('position', 'price_diff'))
AddOperation = namedtuple('AddOperation', ('item', 'variation', 'price', 'addon_to', 'subevent', 'seat', 'membership',
'valid_from', 'valid_until', 'is_bundled', 'result', 'count'))
'valid_from', 'valid_until', 'is_bundled', 'result'))
SplitOperation = namedtuple('SplitOperation', ('position',))
FeeValueOperation = namedtuple('FeeValueOperation', ('fee', 'value', 'price_diff'))
AddFeeOperation = namedtuple('AddFeeOperation', ('fee', 'price_diff'))
@@ -1632,24 +1638,16 @@ class OrderChangeManager:
ForceRecomputeOperation = namedtuple('ForceRecomputeOperation', tuple())
class AddPositionResult:
_positions: Optional[List[OrderPosition]]
_position: Optional[OrderPosition]
def __init__(self):
self._positions = None
self._position = None
@property
def position(self) -> OrderPosition:
if self._positions is None:
if self._position is None:
raise RuntimeError("Order position has not been created yet. Call commit() first on OrderChangeManager.")
if len(self._positions) != 1:
raise RuntimeError("More than one position created.")
return self._positions[0]
@property
def positions(self) -> List[OrderPosition]:
if self._positions is None:
raise RuntimeError("Order position has not been created yet. Call commit() first on OrderChangeManager.")
return self._positions
return self._position
def __init__(self, order: Order, user=None, auth=None, notify=True, reissue_invoice=True, allow_blocked_seats=False):
self.order = order
@@ -1856,12 +1854,8 @@ class OrderChangeManager:
def add_position(self, item: Item, variation: ItemVariation, price: Decimal, addon_to: OrderPosition = None,
subevent: SubEvent = None, seat: Seat = None, membership: Membership = None,
valid_from: datetime = None, valid_until: datetime = None, count: int = 1) -> 'OrderChangeManager.AddPositionResult':
if count < 1:
raise ValueError("Count must be positive")
valid_from: datetime = None, valid_until: datetime = None) -> 'OrderChangeManager.AddPositionResult':
if isinstance(seat, str):
if count > 1:
raise ValueError("Cannot combine count > 1 with seat")
if not seat:
seat = None
else:
@@ -1915,14 +1909,14 @@ class OrderChangeManager:
if self.order.event.settings.invoice_include_free or price.gross != Decimal('0.00'):
self._invoice_dirty = True
self._totaldiff_guesstimate += price.gross * count
self._quotadiff.update({q: count for q in new_quotas})
self._totaldiff_guesstimate += price.gross
self._quotadiff.update(new_quotas)
if seat:
self._seatdiff.update([seat])
result = self.AddPositionResult()
self._operations.append(self.AddOperation(item, variation, price, addon_to, subevent, seat, membership,
valid_from, valid_until, is_bundled, result, count))
valid_from, valid_until, is_bundled, result))
return result
def split(self, position: OrderPosition):
@@ -2542,35 +2536,29 @@ class OrderChangeManager:
secret_dirty.remove(position)
position.save(update_fields=['canceled', 'secret'])
elif isinstance(op, self.AddOperation):
new_pos = []
new_logs = []
for i in range(op.count):
pos = OrderPosition.objects.create(
item=op.item, variation=op.variation, addon_to=op.addon_to,
price=op.price.gross, order=self.order, tax_rate=op.price.rate, tax_code=op.price.code,
tax_value=op.price.tax, tax_rule=op.item.tax_rule,
positionid=nextposid, subevent=op.subevent, seat=op.seat,
used_membership=op.membership, valid_from=op.valid_from, valid_until=op.valid_until,
is_bundled=op.is_bundled,
)
nextposid += 1
new_pos.append(pos)
new_logs.append(self.order.log_action('pretix.event.order.changed.add', user=self.user, auth=self.auth, data={
'position': pos.pk,
'item': op.item.pk,
'variation': op.variation.pk if op.variation else None,
'addon_to': op.addon_to.pk if op.addon_to else None,
'price': op.price.gross,
'positionid': pos.positionid,
'membership': pos.used_membership_id,
'subevent': op.subevent.pk if op.subevent else None,
'seat': op.seat.pk if op.seat else None,
'valid_from': op.valid_from.isoformat() if op.valid_from else None,
'valid_until': op.valid_until.isoformat() if op.valid_until else None,
}, save=False))
op.result._positions = new_pos
LogEntry.bulk_create_and_postprocess(new_logs)
pos = OrderPosition.objects.create(
item=op.item, variation=op.variation, addon_to=op.addon_to,
price=op.price.gross, order=self.order, tax_rate=op.price.rate, tax_code=op.price.code,
tax_value=op.price.tax, tax_rule=op.item.tax_rule,
positionid=nextposid, subevent=op.subevent, seat=op.seat,
used_membership=op.membership, valid_from=op.valid_from, valid_until=op.valid_until,
is_bundled=op.is_bundled,
)
nextposid += 1
self.order.log_action('pretix.event.order.changed.add', user=self.user, auth=self.auth, data={
'position': pos.pk,
'item': op.item.pk,
'variation': op.variation.pk if op.variation else None,
'addon_to': op.addon_to.pk if op.addon_to else None,
'price': op.price.gross,
'positionid': pos.positionid,
'membership': pos.used_membership_id,
'subevent': op.subevent.pk if op.subevent else None,
'seat': op.seat.pk if op.seat else None,
'valid_from': op.valid_from.isoformat() if op.valid_from else None,
'valid_until': op.valid_until.isoformat() if op.valid_until else None,
})
op.result._position = pos
elif isinstance(op, self.SplitOperation):
position = position_cache.setdefault(op.position.pk, op.position)
split_positions.append(position)
@@ -2895,7 +2883,7 @@ class OrderChangeManager:
return total
def _check_order_size(self):
if (len(self.order.positions.all()) + sum([op.count for op in self._operations if isinstance(op, self.AddOperation)])) > settings.PRETIX_MAX_ORDER_SIZE:
if (len(self.order.positions.all()) + len([op for op in self._operations if isinstance(op, self.AddOperation)])) > settings.PRETIX_MAX_ORDER_SIZE:
raise OrderError(
self.error_messages['max_order_size'] % {
'max': settings.PRETIX_MAX_ORDER_SIZE,
@@ -2956,7 +2944,7 @@ class OrderChangeManager:
]) + len([
o for o in self._operations if isinstance(o, self.SplitOperation)
])
adds = sum([o.count for o in self._operations if isinstance(o, self.AddOperation)])
adds = len([o for o in self._operations if isinstance(o, self.AddOperation)])
if current > 0 and current - cancels + adds < 1:
raise OrderError(self.error_messages['complete_cancel'])
@@ -3003,18 +2991,17 @@ class OrderChangeManager:
elif isinstance(op, self.CancelOperation) and op.position in positions_to_fake_cart:
fake_cart.remove(positions_to_fake_cart[op.position])
elif isinstance(op, self.AddOperation):
for i in range(op.count):
cp = CartPosition(
event=self.event,
item=op.item,
variation=op.variation,
used_membership=op.membership,
subevent=op.subevent,
seat=op.seat,
)
cp.override_valid_from = op.valid_from
cp.override_valid_until = op.valid_until
fake_cart.append(cp)
cp = CartPosition(
event=self.event,
item=op.item,
variation=op.variation,
used_membership=op.membership,
subevent=op.subevent,
seat=op.seat,
)
cp.override_valid_from = op.valid_from
cp.override_valid_until = op.valid_until
fake_cart.append(cp)
try:
validate_memberships_in_order(self.order.customer, fake_cart, self.event, lock=True, ignored_order=self.order, testmode=self.order.testmode)
except ValidationError as e:
@@ -3525,3 +3512,154 @@ def signal_listener_issue_media(sender: Event, order: Order, **kwargs):
'customer': order.customer_id,
}
)
class OrderPositionNotUsedCheck(CancellationCheck):
id = "SYSTEM_TICKET_NOT_USED"
prefetches = [
Prefetch(
'checkins',
queryset=Checkin.objects.filter(list__consider_tickets_used=True),
to_attr='used_checkins' # stores result in a list attribute
)
]
related_selects = []
def check(self, order: Order, keep: Set[OrderPosition], order_position: OrderPosition) -> CancellationCheckResultsById:
if order_position.checkins.filter(list__consider_tickets_used=True).exists():
return {self.id: CancellationCheckResult(
cancellation_possible=False,
reason="Order position was used",
)}
else:
return {self.id: CancellationCheckResult(
cancellation_possible=True,
reason="Order position not yet used",
)}
@receiver(self_service_cancellation_checks, dispatch_uid="pretixbase_not_used")
def cancellation_checks_not_used(sender: Event):
return OrderPositionNotUsedCheck()
class NotDiscountedCheck(CancellationCheck):
"""
Check that ensures that orders containing discounted order_positions cannot
be canceled partially.
This is a stop-gap solution until the `discount_grouper` attribute for
AbstractPositions is introduced, allowing us to be more grannular
"""
id = "SYSTEM_NO_DISCOUNTED_ORDER_POSITIONS"
prefetches = [
]
related_selects = []
def check(self, order: Order, keep: Set[OrderPosition], order_position: OrderPosition) -> CancellationCheckResultsById:
cancellations = Set(order.positions).difference(keep)
if order_position in cancellations:
if order_position.discount_id is None:
return {self.id: CancellationCheckResult(
cancellation_possible=True,
reason=_("Order position was bought without discount"),
)}
else:
return {self.id: CancellationCheckResult(
cancellation_possible=False,
reason=_("Order position was bought with a discount"),
)}
else:
return {self.id: CancellationCheckResult(
cancellation_possible=False,
reason=_("Order position not canceled - check not applicable"),
)}
@receiver(self_service_cancellation_checks, dispatch_uid="pretixbase_not_discountend")
def cancellation_checks_not_discounted(sender: Event):
return NotDiscountedCheck()
# TODO weitere System Checks
# OrderPositions mit Item.min_per_order dürfen nur storniert werden, wenn genug übrig bleiben oder alle des gleichen Items storniert werden
# OrderPositions mit addon_to != None dürfen nur über den bestehenden Add-On-Flow storniert werden
# OrderPositions mit is_bundled dürfen nur mit der Parent-Position zusammen storniert werden
# TODO transaktion
def self_service_cancel(order: Order, keep: Set[OrderPosition], dry_run: bool):
"""
:param order:
:param keep:
:param dry_run:
:return:
"""
cancellation_checks: List[CancellationCheck] = [resp for recv, resp in self_service_cancellation_checks.send(event=order.event)]
position_rules = CancellationRule.objects.filter(event=order.event).filter("fee_cancellation_process" == Decimal("0.00")).all()
process_rules = CancellationRule.objects.filter(event=order.event).filter("fee_cancellation_process" != Decimal("0.00")).all()
# Todo get prefetches/selects from rules as well
prefetches = list(chain.from_iterable([cc.prefetches for cc in cancellation_checks]))
related_selects = list(chain.from_iterable(cc.related_selects for cc in cancellation_checks))
per_position_rulings: Dict[int, List[Ruling]] = {}
prefetched_order = Order.objects.select_related(related_selects).prefetch_related(*prefetches).get(pk=order.pk)
# All queries should be done by now
with ensure_no_queries():
for position in prefetched_order.positions:
position_rulings = []
system_check_results = [cc.check(prefetched_order, keep, position) for cc in cancellation_checks]
for rule in position_rules:
check_results = [check(prefetched_order, keep, position) for check in rule.checks]
if rule.fee_percentage_per_item and rule.fee_absolute_per_item:
raise NotImplementedError("Should never be reached")
elif rule.fee_absolute_per_item != Decimal(0.00):
position_rulings.append(
Ruling.from_absolute_fee(
rule_id=rule.id,
results=reduce(lambda a, b: a | b, [*system_check_results, *check_results], {}),
fee_type='position_fee',
absolute_fee=rule.fee_absolute_per_item
)
)
else:
position_rulings.append(
Ruling.from_relative_fee(
rule_id=rule.id,
results=reduce(lambda a, b: a | b, [*system_check_results, *check_results], {}),
fee_type='position_fee',
reference_price=position.price,
percentage=rule.fee_absolute_per_item,
currency=order.event.currency
)
)
position_rulings.sort()
per_position_rulings[position.id] = position_rulings
effective_position_rulings = [op_rulings[0] for op_rulings in per_position_rulings.values()]
process_rulings: List[Ruling] = []
for rule in process_rules:
check_results = [check(prefetched_order, keep, position) for check in rule.checks]
process_rulings.append(Ruling.from_absolute_fee(
rule_id=rule.id,
results=reduce(lambda a, b: a | b, [*check_results], {}),
fee_type='process_fee',
absolute_fee=rule.fee_cancellation_process
))
process_rulings.sort()
effective_process_ruling = process_rulings[0]
cancellation_possible = all([r.cancellation_possible for r in effective_position_rulings])
# TODO zusammenführen der Rulings

View File

@@ -1206,3 +1206,11 @@ This signal is sent out each time the information for a Device is modified.
Both the original and updated versions of the Device are included to allow
receivers to see what has been updated.
"""
self_service_cancellation_checks = EventPluginSignal()
"""
This signal is sent out to collect checks to approve or deny a self service cancellation.
You are expected to return a class instance that implements CancellationCheck.
It is is expected that that the CheckFn will not issue any further queries.
As with all event-plugin signals, the ``sender`` keyword argument will contain the event.
"""

View File

@@ -20,6 +20,7 @@
# <https://www.gnu.org/licenses/>.
#
import contextlib
import logging
from django.conf import settings
from django.core.exceptions import FieldDoesNotExist, ImproperlyConfigured
@@ -29,6 +30,8 @@ from django.db.models import (
)
from django.utils.functional import lazy
logger = logging.getLogger(__name__)
class DummyRollbackException(Exception):
pass
@@ -280,3 +283,21 @@ def get_deterministic_ordering(model, ordering):
# on the primary key to provide total ordering.
ordering.append("-pk")
return ordering
@contextlib.contextmanager
def ensure_no_queries():
"""
Ensures that no database queries are being made in that context.
Raises a RuntimeError if running in DEBUG mode, otherwise logs
an error.
:return:
"""
def blocker(*args, **kwargs):
if settings.DEBUG:
raise RuntimeError(f"Unexpected DB query: {args[0]}")
else:
logger.error("Unexpected DB query: %s", args[0])
with connection.execute_wrapper(blocker):
yield

File diff suppressed because it is too large Load Diff

View File

@@ -8,8 +8,8 @@ msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-03-30 11:22+0000\n"
"PO-Revision-Date: 2026-04-17 03:00+0000\n"
"Last-Translator: Tim <plicnetwork@gmail.com>\n"
"PO-Revision-Date: 2026-03-31 17:00+0000\n"
"Last-Translator: CVZ-es <damien.bremont@casadevelazquez.org>\n"
"Language-Team: Spanish <https://translate.pretix.eu/projects/pretix/pretix/"
"es/>\n"
"Language: es\n"
@@ -17,7 +17,7 @@ msgstr ""
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=n != 1;\n"
"X-Generator: Weblate 5.17\n"
"X-Generator: Weblate 5.16.2\n"
#: pretix/_base_settings.py:87
msgid "English"
@@ -4150,7 +4150,7 @@ msgstr "Se encontraron varios productos coincidentes."
#: pretix/base/modelimport_vouchers.py:205 pretix/base/models/items.py:1257
#: pretix/base/models/vouchers.py:266 pretix/base/models/waitinglist.py:99
msgid "Product variation"
msgstr "Variante de producto"
msgstr "Variación del producto"
#: pretix/base/modelimport_orders.py:161
msgid "The variation can be specified by its internal ID or full name."
@@ -4312,7 +4312,7 @@ msgstr "Ya existe un vale de compra con este código."
#: pretix/base/models/vouchers.py:199 pretix/control/views/vouchers.py:121
#: pretix/presale/templates/pretixpresale/organizers/customer_membership.html:52
msgid "Maximum usages"
msgstr "Número máximo de usos"
msgstr "Usos máximos"
#: pretix/base/modelimport_vouchers.py:79
msgid "The maximum number of usages must be set."
@@ -4333,14 +4333,14 @@ msgstr "Reservar entrada con cargo a la cuota"
#: pretix/base/modelimport_vouchers.py:127 pretix/base/models/vouchers.py:236
msgid "Allow to bypass quota"
msgstr "Permitir omitir la cuota"
msgstr "Permitir que se anule la cuota"
#: pretix/base/modelimport_vouchers.py:135 pretix/base/models/vouchers.py:242
#: pretix/control/templates/pretixcontrol/vouchers/bulk.html:44
#: pretix/control/templates/pretixcontrol/vouchers/detail.html:70
#: pretix/control/views/vouchers.py:121
msgid "Price effect"
msgstr "Efecto en el precio"
msgstr "Efecto sobre los precios"
#: pretix/base/modelimport_vouchers.py:150
#, python-brace-format
@@ -4351,7 +4351,7 @@ msgstr ""
#: pretix/base/modelimport_vouchers.py:160 pretix/base/models/vouchers.py:248
msgid "Voucher value"
msgstr "Valor del vale"
msgstr "Valor del vale de compra"
#: pretix/base/modelimport_vouchers.py:165
msgid "It is pointless to set a value without a price effect."
@@ -4394,7 +4394,7 @@ msgstr "Etiqueta"
#: pretix/base/modelimport_vouchers.py:334 pretix/base/models/vouchers.py:300
msgid "Shows hidden products that match this voucher"
msgstr "Muestra los productos ocultos vinculados a este vale"
msgstr "Mostrar los ocultados productos válidos con este vale de compra"
#: pretix/base/modelimport_vouchers.py:343 pretix/base/models/vouchers.py:304
msgid "Offer all add-on products for free when redeeming this voucher"
@@ -7322,8 +7322,8 @@ msgid ""
"If activated, a holder of this voucher code can buy tickets, even if there "
"are none left."
msgstr ""
"Si se activa, el poseedor de este código de vale podrá comprar entradas "
"incluso si no quedan existencias."
"Si se activa, un titular de este vale de compra puede comprar entradas, "
"incluso si no queda ninguna."
#: pretix/base/models/vouchers.py:257 pretix/control/forms/vouchers.py:69
msgid ""
@@ -7338,14 +7338,14 @@ msgstr ""
#: pretix/base/models/vouchers.py:268
msgid "This variation of the product select above is being used."
msgstr "Se aplica a la variante del producto seleccionado arriba."
msgstr "Esta variación del producto seleccionado arriba está siendo utilizada."
#: pretix/base/models/vouchers.py:277
msgid ""
"If enabled, the voucher is valid for any product affected by this quota."
msgstr ""
"Si se activa, el vale será válido para cualquier producto incluido en esta "
"cuota."
"Si está habilitado, el vale de compra es válido para cualquier producto "
"afectado por esta cuota."
#: pretix/base/models/vouchers.py:284
msgid "Specific seat"
@@ -7357,16 +7357,16 @@ msgid ""
"same value for multiple vouchers, you can get statistics on how many of them "
"have been redeemed etc."
msgstr ""
"Puedes usar este campo para agrupar varios vales. Si introduces el mismo "
"valor en distintos vales, podrás obtener estadísticas sobre cuántos se han "
"canjeado, etc."
"Puede utilizar este campo para agrupar múltiples vales de compra. Si "
"introduce el mismo valor para varios vales de compra, puede obtener "
"estadísticas sobre cuántos de ellos se han canjeado, etc."
#: pretix/base/models/vouchers.py:316 pretix/base/permissions.py:242
#: pretix/control/navigation.py:289
#: pretix/control/templates/pretixcontrol/vouchers/index.html:6
#: pretix/control/templates/pretixcontrol/vouchers/index.html:8
msgid "Vouchers"
msgstr "Vales"
msgstr "Vales de compra"
#: pretix/base/models/vouchers.py:342
msgid "You cannot select a quota that belongs to a different event."
@@ -13365,7 +13365,7 @@ msgstr "Esto eliminará todos los números de teléfono de los pedidos."
#: pretix/base/shredder.py:290
msgid "Emails"
msgstr "Correos"
msgstr "Correos electrónicos"
#: pretix/base/shredder.py:292
msgid ""
@@ -15170,7 +15170,7 @@ msgstr "Todos los productos"
#: pretix/control/views/typeahead.py:780
#, python-brace-format
msgid "{product} Any variation"
msgstr "{product} Cualquier variación"
msgstr "{product} - Cualquier variación"
#: pretix/control/forms/filter.py:566 pretix/control/forms/orders.py:862
msgctxt "subevent"
@@ -15469,7 +15469,7 @@ msgstr "Buscar vale de compra"
#: pretix/control/views/vouchers.py:133
#, python-brace-format
msgid "Any product in quota \"{quota}\""
msgstr "Cualquier producto en la cuota \"{quota}\""
msgstr "Cualquier producto del contingente \"{quota}\""
#: pretix/control/forms/filter.py:2440
msgid "Refund status"
@@ -17068,15 +17068,15 @@ msgstr "ID de butaca específico"
#: pretix/control/forms/vouchers.py:200 pretix/presale/forms/waitinglist.py:103
msgid "Invalid product selected."
msgstr "Se ha seleccionado un producto no válido."
msgstr "Producto no válido seleccionado."
#: pretix/control/forms/vouchers.py:225
msgid ""
"The voucher only matches hidden products but you have not selected that it "
"should show them."
msgstr ""
"El vale solo coincide con productos ocultos, pero no has seleccionado que "
"deba mostrarlos."
"El vale de compra solo coincide con productos ocultos pero no has "
"seleccionado que los muestre."
#: pretix/control/forms/vouchers.py:271
msgid "Codes"
@@ -21474,7 +21474,7 @@ msgstr ""
#: pretix/plugins/ticketoutputpdf/views.py:172
#: pretix/presale/views/customer.py:544 pretix/presale/views/customer.py:597
msgid "Your changes have been saved."
msgstr "Se han guardado los cambios."
msgstr "Los cambios se han guardado."
#: pretix/control/templates/pretixcontrol/event/plugins.html:34
#: pretix/control/templates/pretixcontrol/organizers/plugins.html:34
@@ -28698,7 +28698,7 @@ msgstr "Se ha creado la nueva lista de asistentes."
#: pretix/plugins/ticketoutputpdf/views.py:132
msgid "We could not save your changes. See below for details."
msgstr ""
"No se pudieron guardar los cambios. Consulta los detalles a continuación."
"No hemos podido guardar los cambios. Consulte los detalles a continuación."
#: pretix/control/views/checkin.py:421 pretix/control/views/checkin.py:458
msgid "The requested list does not exist."
@@ -30864,7 +30864,7 @@ msgstr ""
#: pretix/plugins/badges/forms.py:33
msgid "Template"
msgstr "Template"
msgstr "Plantilla"
#: pretix/plugins/badges/forms.py:34
msgid ""

View File

@@ -8,8 +8,8 @@ msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-03-30 11:22+0000\n"
"PO-Revision-Date: 2026-04-20 08:07+0000\n"
"Last-Translator: Yasunobu YesNo Kawaguchi <kawaguti@gmail.com>\n"
"PO-Revision-Date: 2026-04-08 18:00+0000\n"
"Last-Translator: Hijiri Umemoto <hijiri@umemoto.org>\n"
"Language-Team: Japanese <https://translate.pretix.eu/projects/pretix/pretix/"
"ja/>\n"
"Language: ja\n"
@@ -17,7 +17,7 @@ msgstr ""
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=1; plural=0;\n"
"X-Generator: Weblate 5.17\n"
"X-Generator: Weblate 5.16.2\n"
#: pretix/_base_settings.py:87
msgid "English"
@@ -664,7 +664,7 @@ msgstr "ギフトカードを取引で使用済み"
#: pretix/plugins/banktransfer/payment.py:483
#: pretix/presale/forms/customer.py:152
msgid "This field is required."
msgstr "このフィールドは必須です。"
msgstr "この項目は必須です。"
#: pretix/base/addressvalidation.py:213
msgid "Enter a postal code in the format XXX."
@@ -3800,7 +3800,7 @@ msgstr "単価:{net_price} 税抜 / {gross_price} 税込"
#, python-brace-format
msgctxt "invoice"
msgid "Single price: {price}"
msgstr "単価: {price}"
msgstr "単価{price}"
#: pretix/base/invoicing/pdf.py:947 pretix/base/invoicing/pdf.py:952
msgctxt "invoice"
@@ -8206,7 +8206,7 @@ msgstr "参加者の呼びかけに使う名前"
#: pretix/base/services/placeholders.py:732
#: pretix/control/forms/organizer.py:799
msgid "Mr Doe"
msgstr "山田 太郎"
msgstr "山田"
#: pretix/base/pdf.py:672 pretix/base/pdf.py:679
#: pretix/plugins/badges/exporters.py:501
@@ -11001,7 +11001,7 @@ msgstr ""
#: pretix/base/settings.py:1869 pretix/base/settings.py:1877
#: pretix/presale/templates/pretixpresale/fragment_calendar_nav.html:8
msgid "List"
msgstr "一覧"
msgstr "リスト"
#: pretix/base/settings.py:1870 pretix/base/settings.py:1878
msgid "Week calendar"
@@ -12855,7 +12855,7 @@ msgstr "Dr"
#: pretix/base/settings.py:3819 pretix/base/settings.py:3836
msgid "First name"
msgstr "名"
msgstr "名(First Name)"
#: pretix/base/settings.py:3820 pretix/base/settings.py:3837
msgid "Middle name"
@@ -19423,7 +19423,7 @@ msgstr "カスタムチェックインルール"
#: pretix/control/templates/pretixcontrol/vouchers/bulk.html:117
#: pretix/plugins/sendmail/templates/pretixplugins/sendmail/send_form.html:85
msgid "Edit"
msgstr "編集"
msgstr "編集する"
#: pretix/control/templates/pretixcontrol/checkin/list_edit.html:89
msgid "Visualize"
@@ -20280,7 +20280,7 @@ msgstr "地理座標"
#: pretix/control/templates/pretixcontrol/subevents/bulk.html:271
#: pretix/control/templates/pretixcontrol/subevents/bulk.html:275
msgid "Optional"
msgstr "任意"
msgstr "オプション(必須でない項目)"
#: pretix/control/templates/pretixcontrol/event/fragment_geodata.html:22
#: pretix/control/templates/pretixcontrol/subevents/bulk_edit.html:58
@@ -23636,8 +23636,8 @@ msgid ""
"this product was part of the discount calculation for a different product in "
"this order."
msgstr ""
"自動割引によりこの商品の価格が引き下げられたか、同じ注文の別の商品に対する"
"引計算の対象になっています。"
"この製品の価格は自動割引により減額されたか、この製品がこの注文の別の製品の割"
"引計算の一部になっています。"
#: pretix/control/templates/pretixcontrol/order/index.html:496
#: pretix/presale/templates/pretixpresale/event/fragment_cart.html:103
@@ -25008,7 +25008,7 @@ msgstr "デバイスの概要"
#: pretix/control/templates/pretixcontrol/organizers/device_edit.html:6
msgid "Device:"
msgstr "デバイス"
msgstr "デバイス:"
#: pretix/control/templates/pretixcontrol/organizers/device_edit.html:8
msgid "Connect a new device"
@@ -25897,7 +25897,7 @@ msgstr "二要素認証が無効です"
#: pretix/control/templates/pretixcontrol/organizers/team_members.html:57
msgid "invited, pending response"
msgstr "招待済み、答待ち"
msgstr "招待済み、答待ち"
#: pretix/control/templates/pretixcontrol/organizers/team_members.html:59
msgid "resend invite"
@@ -33303,7 +33303,7 @@ msgstr "本当にStripeアカウントを切断しますか"
#: pretix/plugins/stripe/templates/pretixplugins/stripe/oauth_disconnect.html:16
msgid "Disconnect"
msgstr "切断"
msgstr "切断します"
#: pretix/plugins/stripe/templates/pretixplugins/stripe/pending.html:6
msgid "Payment instructions"

View File

@@ -32,8 +32,11 @@
# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations under the License.
from itertools import chain
from django import forms
from django.core.exceptions import ValidationError
from django.utils.encoding import force_str
from django.utils.formats import date_format
from django.utils.html import escape
from django.utils.safestring import mark_safe
@@ -165,6 +168,46 @@ class QuestionsForm(BaseQuestionsForm):
)
class AddOnRadioSelect(forms.RadioSelect):
option_template_name = 'pretixpresale/forms/addon_choice_option.html'
def optgroups(self, name, value, attrs=None):
attrs = attrs or {}
groups = []
has_selected = False
for index, (option_value, option_label, option_desc) in enumerate(chain(self.choices)):
if option_value is None:
option_value = ''
if isinstance(option_label, (list, tuple)):
raise TypeError('Choice groups are not supported here')
group_name = None
subgroup = []
groups.append((group_name, subgroup, index))
selected = (
force_str(option_value) in value and
(has_selected is False or self.allow_multiple_selected)
)
if selected is True and has_selected is False:
has_selected = True
attrs['description'] = option_desc
subgroup.append(self.create_option(
name, option_value, option_label, selected, index,
subindex=None, attrs=attrs,
))
return groups
class AddOnVariationField(forms.ChoiceField):
def valid_value(self, value):
text_value = force_str(value)
for k, v, d in self.choices:
if value == k or text_value == force_str(k):
return True
return False
class MembershipForm(forms.Form):
required_css_class = 'required'

View File

@@ -172,7 +172,7 @@ class RegistrationForm(forms.Form):
)
self.fields['name_parts'] = NamePartsFormField(
max_length=70,
max_length=255,
required=True,
scheme=request.organizer.settings.name_scheme,
titles=request.organizer.settings.name_scheme_titles,

View File

@@ -0,0 +1,3 @@
{% load rich_text %}
<label{% if widget.attrs.id %} for="{{ widget.attrs.id }}"{% endif %}>{% include "django/forms/widgets/input.html" %} {{ widget.label }}</label> {% if widget.attrs.description %}<span class="fa fa-info-circle toggle-variation-description" aria-hidden="true"></span>
<div class="variation-description addon-variation-description">{{ widget.attrs.description|rich_text }}</div>{% endif %}

View File

@@ -171,35 +171,6 @@ def test_giftcard_detail_expand(token_client, organizer, event, giftcard):
}
@pytest.mark.django_db
def test_giftcard_detail_expand_without_permissions(team, token_client, organizer, event, giftcard):
with scopes_disabled():
o = Order.objects.create(
code='FOO', event=event, email='dummy@dummy.test',
status=Order.STATUS_PENDING, datetime=now(), expires=now() + timedelta(days=10),
sales_channel=event.organizer.sales_channels.get(identifier="web"),
total=14, locale='en'
)
ticket = event.items.create(name='Early-bird ticket', category=None, default_price=23, admission=True,
personalized=True)
op = o.positions.create(item=ticket, price=Decimal("14"))
giftcard.owner_ticket = op
giftcard.save()
team.all_event_permissions = False
team.save()
res = dict(TEST_GC_RES)
res["id"] = giftcard.pk
res["issuance"] = giftcard.issuance.isoformat().replace('+00:00', 'Z')
resp = token_client.get('/api/v1/organizers/{}/giftcards/{}/?expand=owner_ticket'.format(organizer.slug, giftcard.pk))
assert resp.status_code == 200
assert resp.data["owner_ticket"] == {
"id": op.pk,
}
TEST_GIFTCARD_CREATE_PAYLOAD = {
"secret": "DEFABC",
"value": "12.00",

View File

@@ -252,76 +252,6 @@ def test_medium_detail(token_client, organizer, event, medium, giftcard, custome
}
@pytest.mark.django_db
def test_medium_detail_event_permission_missing(token_client, organizer, event, medium, giftcard, customer, team):
team.all_organizer_permissions = False
team.limit_organizer_permissions = {
"organizer.reusablemedia:read": True,
"organizer.customers:read": True,
"organizer.giftcards:read": True,
}
team.all_event_permissions = False
team.save()
with scopes_disabled():
o = Order.objects.create(
code='FOO', event=event, email='dummy@dummy.test',
status=Order.STATUS_PENDING, datetime=now(), expires=now() + timedelta(days=10),
sales_channel=event.organizer.sales_channels.get(identifier="web"),
total=14, locale='en'
)
ticket = event.items.create(name='Early-bird ticket', category=None, default_price=23, admission=True,
personalized=True)
op = o.positions.create(item=ticket, price=Decimal("14"))
medium.linked_orderposition = op
medium.linked_giftcard = giftcard
medium.customer = customer
medium.save()
giftcard.owner_ticket = op
giftcard.save()
resp = token_client.get(
'/api/v1/organizers/{}/reusablemedia/{}/?expand=linked_giftcard&expand='
'linked_giftcard.owner_ticket&expand=linked_orderposition&expand=customer'.format(
organizer.slug, medium.pk
)
)
assert resp.status_code == 200
assert resp.data["linked_orderposition"] == {
"id": op.pk,
}
assert resp.data["linked_giftcard"] == {
"id": giftcard.pk,
"secret": "ABCDEF",
"issuance": giftcard.issuance.isoformat().replace("+00:00", "Z"),
"value": "23.00",
"currency": "EUR",
"testmode": False,
"expires": None,
"conditions": None,
"owner_ticket": {"id": op.pk},
"issuer": "dummy",
}
assert resp.data["customer"] == {
"identifier": customer.identifier,
"external_identifier": None,
"email": "foo@example.org",
"phone": None,
"name": "Foo",
"name_parts": {"_legacy": "Foo"},
"is_active": True,
"is_verified": False,
"last_login": None,
"date_joined": customer.date_joined.isoformat().replace("+00:00", "Z"),
"locale": "en",
"last_modified": customer.last_modified.isoformat().replace("+00:00", "Z"),
"notes": None
}
TEST_MEDIUM_CREATE_PAYLOAD = {
"type": "barcode",
"identifier": "FOOBAR",

View File

@@ -238,9 +238,6 @@ def test_full_clone_cross_organizer_differences():
sc1_c = organizer.sales_channels.create(identifier="c")
sc2_a = organizer2.sales_channels.get(identifier="web")
sc2_c = organizer2.sales_channels.create(identifier="c")
o1_meta_prop_a = organizer.meta_properties.create(name="Prop to copy")
o1_meta_prop_b = organizer.meta_properties.create(name="Prop to find")
o2_meta_prop_b = organizer2.meta_properties.create(name="Prop to find")
event = Event.objects.create(
organizer=organizer, name='Dummy', slug='dummy',
@@ -265,9 +262,6 @@ def test_full_clone_cross_organizer_differences():
event.settings.payment_giftcard__enabled = True
event.settings.payment_giftcard__restrict_to_sales_channels = ['web', 'b', 'c']
event.meta_values.create(property=o1_meta_prop_a, value='a')
event.meta_values.create(property=o1_meta_prop_b, value='b')
copied_event = Event.objects.create(
organizer=organizer2, name='Dummy2', slug='dummy2',
date_from=datetime.datetime(2022, 4, 15, 9, 0, 0, tzinfo=datetime.timezone.utc),
@@ -290,9 +284,3 @@ def test_full_clone_cross_organizer_differences():
assert event.settings.get('payment_giftcard__restrict_to_sales_channels', as_type=list) == ['web', 'b', 'c']
assert copied_event.settings.get('payment_giftcard__restrict_to_sales_channels', as_type=list) == ['web', 'c']
assert event.meta_values.get(property__name=o1_meta_prop_a.name).property.organizer == organizer
assert copied_event.meta_values.get(property__name=o1_meta_prop_a.name).value == 'a'
assert copied_event.meta_values.get(property__name=o1_meta_prop_a.name).property.organizer == organizer2
assert copied_event.meta_values.get(property=o2_meta_prop_b).value == 'b'
assert copied_event.meta_values.get(property=o2_meta_prop_b).property.organizer == organizer2

View File

@@ -0,0 +1,216 @@
from datetime import date, datetime, timedelta
from decimal import Decimal
from zoneinfo import ZoneInfo
import pytest
from django.utils.timezone import make_aware, now
from django_scopes import scope
from freezegun import freeze_time
from pretix.base.models import Event, Item, Order, OrderPosition, Organizer
from pretix.base.models.cancellation import CancellationRule, OrderDiff
NOW = now()
DAYS_UNTIL_EVENT=60
EVENT_START = NOW+timedelta(days=DAYS_UNTIL_EVENT)
@pytest.fixture()
def event():
o = Organizer.objects.create(name='Dummy', slug='dummy', plugins='pretix.plugins.banktransfer')
event = Event.objects.create(
organizer=o, name='Dummy', slug='dummy',
date_from=EVENT_START,
plugins='pretix.plugins.banktransfer'
)
return event
@pytest.fixture()
def item1(event):
return Item.objects.create(event=event, name='Early-bird item1',
default_price=Decimal('23.00'), admission=True)
@pytest.fixture()
def order(event):
return Order.objects.create(
code='FOO', event=event, email='dummy@dummy.test',
status=Order.STATUS_PENDING, locale='en',
datetime=NOW,
total=0,
sales_channel=event.organizer.sales_channels.get(identifier="web"),
)
@pytest.mark.django_db
def test_status_rule(event, item1, order):
with scope(organizer=event.organizer, event=event):
op = OrderPosition.objects.create(
order=order, item=item1, variation=None,
price=Decimal("0.00"), attendee_name_parts={'full_name': "Peter"}, positionid=1
)
cancellation_rule = CancellationRule.objects.create(
organizer=event.organizer, event=event,
allowed_if_in_order_status=Order.STATUS_PENDING
)
cancellation_rule.items.set([item1])
diff = OrderDiff.cancel_all(order)
assert cancellation_rule._check_order_status(diff=diff, order_position=op) == {
'ORDER_STATUS': CheckRes(
cancellation_possible=True,
reason="Order in required status: 'n'",
),
}
cancellation_rule = CancellationRule.objects.create(
organizer=event.organizer, event=event,
allowed_if_in_order_status=Order.STATUS_PAID
)
cancellation_rule.items.set([item1])
assert cancellation_rule._check_order_status(diff=diff, order_position=op) == {
'ORDER_STATUS': CheckRes(
cancellation_possible=False,
reason="Order in status 'n' cannot be canceled",
),
}
@pytest.mark.django_db
def test_timing(event, item1, order):
with scope(organizer=event.organizer, event=event):
order.status = Order.STATUS_PAID
order.save()
OrderPosition.objects.create(
order=order, item=item1, variation=None,
price=Decimal("0.00"), attendee_name_parts={'full_name': "Peter"}, positionid=1
)
cr1 = CancellationRule.objects.create(
organizer=event.organizer, event=event,
allowed_until=now() + timedelta(hours=1),
)
cr1.items.set([item1])
diff = OrderDiff.cancel_all(order)
with freeze_time(now()):
possible, verdicts = CancellationRule.objects.all().cancellation_possible(diff)
assert possible == True
with freeze_time(now()+timedelta(hours=2)):
possible, verdicts=CancellationRule.objects.all().cancellation_possible(diff)
assert possible == False
@pytest.mark.django_db
def test_multiple_limits(event, item1, order):
with (scope(organizer=event.organizer, event=event)):
order.status = Order.STATUS_PAID
order.save()
OrderPosition.objects.create(
order=order, item=item1, variation=None,
price=Decimal("100.00"), attendee_name_parts={'full_name': "Peter"}, positionid=1
)
# free in the first hour after booking
cr1=CancellationRule.objects.create(
organizer=event.organizer, event=event,
allowed_until=NOW + timedelta(hours=1),
)
cr1.items.set([item1])
# free until 30 days before event
cr2 = CancellationRule.objects.create(
organizer=event.organizer, event=event,
allowed_until=EVENT_START - timedelta(days=30),
)
cr2.items.set([item1])
# 50% until 14 days before event
cr3 = CancellationRule.objects.create(
organizer=event.organizer, event=event,
allowed_until=EVENT_START - timedelta(days=14),
fee_percentage_per_item=Decimal(50.0)
)
cr3.items.set([item1])
# 80% until 7 days before event
cr4 = CancellationRule.objects.create(
organizer=event.organizer, event=event,
allowed_until=EVENT_START - timedelta(days=7),
fee_percentage_per_item=Decimal(80.0)
)
cr4.items.set([item1])
# 100% until 1 day before event
cr5 = CancellationRule.objects.create(
organizer=event.organizer, event=event,
allowed_until=EVENT_START - timedelta(days=1),
fee_percentage_per_item=Decimal(100)
)
cr5.items.set([item1])
# Cancellation is not allowed at all, but rule doesn't match the item
CancellationRule.objects.create(
organizer=event.organizer, event=event,
allowed_until=NOW,
fee_percentage_per_item=Decimal(100)
)
diff = OrderDiff.cancel_all(order)
possible_trace = []
cost_trace = []
for days in range(DAYS_UNTIL_EVENT):
today = NOW + timedelta(days=days)
with freeze_time(today):
possible, verdicts=CancellationRule.objects.all().cancellation_possible(
diff)
possible_trace.append(possible)
cost_trace.append(verdicts[0].total_fee)
assert possible_trace == [True] * 59 + [False]
assert cost_trace == [Decimal("0.0000")] * 30 + \
[Decimal("50.0000")] * 16 + \
[Decimal("80.0000")] * 7 + \
[Decimal("100.0000")] * 6 + \
[Decimal("0.0000")]
@pytest.mark.django_db
def test_cancellation_rule_query_set(event, item1, order):
with scope(organizer=event.organizer, event=event):
OrderPosition.objects.create(
order=order, item=item1, variation=None,
price=Decimal("0.00"), attendee_name_parts={'full_name': "Peter"}, positionid=1
)
cr1 = CancellationRule.objects.create(
organizer=event.organizer, event=event,
allowed_if_in_order_status=Order.STATUS_PENDING, fee_absolute_per_order=Decimal('10.00'),
)
cr1.items.set([item1])
cr2 = CancellationRule.objects.create(
organizer=event.organizer, event=event,
allowed_if_in_order_status=Order.STATUS_PAID
)
cr2.items.set([item1])
diff = OrderDiff.cancel_all(order)
possible, verdicts = CancellationRule.objects.all().cancellation_possible(diff)
assert possible == True