mirror of
https://github.com/pretix/pretix.git
synced 2026-05-19 17:34:03 +00:00
Compare commits
8 Commits
fix-clone-
...
self-servi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2e673b5e49 | ||
|
|
5ab3b08fca | ||
|
|
c624fcfe41 | ||
|
|
7ffadb87b3 | ||
|
|
c1db94dec3 | ||
|
|
9224c73c7f | ||
|
|
1bb2ab28ad | ||
|
|
accfc843d6 |
@@ -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.**
|
||||
@@ -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
|
||||
@@ -1,39 +1,23 @@
|
||||
Contribution workflow
|
||||
=====================
|
||||
General remarks
|
||||
===============
|
||||
|
||||
You are interested in contributing to pretix? That is awesome!
|
||||
|
||||
If you’re new to contributing to open source software, don’t be afraid. We’ll 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 won’t
|
||||
We automatically run the tests and the code style check on every pull request on Travis CI and we won’t
|
||||
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
|
||||
|
||||
@@ -6,5 +6,4 @@ Contributing to pretix
|
||||
|
||||
general
|
||||
style
|
||||
ai
|
||||
codeofconduct
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.*",
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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),
|
||||
]
|
||||
@@ -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")},
|
||||
),
|
||||
]
|
||||
234
src/pretix/base/models/cancellation.py
Normal file
234
src/pretix/base/models/cancellation.py
Normal 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()
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
"""
|
||||
|
||||
@@ -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
@@ -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 ""
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
216
src/tests/base/test_self_service_cancellation.py
Normal file
216
src/tests/base/test_self_service_cancellation.py
Normal 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
|
||||
Reference in New Issue
Block a user