Compare commits

..

6 Commits

Author SHA1 Message Date
Mira Weller
f8e5c7867b add log message with json path on schema validation error 2025-11-18 18:15:23 +01:00
Mira Weller
7010752bb0 Fix schema 2025-11-18 18:15:23 +01:00
Mira Weller
7d8bcd4b10 Migrate log action types to registry 2025-11-18 18:15:23 +01:00
Mira Weller
2121566ae5 Fix incorrect or missing log action types 2025-11-05 20:27:56 +01:00
Mira Weller
9f6216e6f1 Add some example schemas 2025-11-05 20:27:56 +01:00
Mira Weller
c824663946 Implement schema validation and schema-based shredding 2025-11-05 19:41:47 +01:00
57 changed files with 432 additions and 1685 deletions

View File

@@ -19,7 +19,6 @@ at :ref:`plugin-docs`.
item_bundles
item_add-ons
item_meta_properties
item_program_times
questions
question_options
quotas

View File

@@ -1,222 +0,0 @@
Item program times
==================
Resource description
--------------------
Program times for products (items) that can be set in addition to event times, e.g. to display seperate schedules within an event.
The program times resource contains the following public fields:
.. rst-class:: rest-resource-table
===================================== ========================== =======================================================
Field Type Description
===================================== ========================== =======================================================
id integer Internal ID of the program time
start datetime The start date time for this program time slot.
end datetime The end date time for this program time slot.
===================================== ========================== =======================================================
.. versionchanged:: TODO
The resource has been added.
Endpoints
---------
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/items/(item)/program_times/
Returns a list of all program times for a given item.
**Example request**:
.. sourcecode:: http
GET /api/v1/organizers/bigevents/events/sampleconf/items/11/program_times/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: application/json
{
"count": 3,
"next": null,
"previous": null,
"results": [
{
"id": 2,
"start": "2025-08-14T22:00:00Z",
"end": "2025-08-15T00:00:00Z"
},
{
"id": 3,
"start": "2025-08-12T22:00:00Z",
"end": "2025-08-13T22:00:00Z"
},
{
"id": 14,
"start": "2025-08-15T22:00:00Z",
"end": "2025-08-17T22:00:00Z"
}
]
}
:param organizer: The ``slug`` field of the organizer to fetch
:param event: The ``slug`` field of the event to fetch
:param item: The ``id`` field of the item to fetch
:statuscode 200: no error
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer/event/item does not exist **or** you have no permission to view this resource.
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/items/(item)/program_times/(id)/
Returns information on one program time, identified by its ID.
**Example request**:
.. sourcecode:: http
GET /api/v1/organizers/bigevents/events/sampleconf/items/1/program_times/1/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: application/json
{
"id": 1,
"start": "2025-08-15T22:00:00Z",
"end": "2025-10-27T23:00:00Z"
}
:param organizer: The ``slug`` field of the organizer to fetch
:param event: The ``slug`` field of the event to fetch
:param item: The ``id`` field of the item to fetch
:param id: The ``id`` field of the program time to fetch
:statuscode 200: no error
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource.
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/items/(item)/program_times/
Creates a new program time
**Example request**:
.. sourcecode:: http
POST /api/v1/organizers/bigevents/events/sampleconf/items/1/program_times/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
Content-Type: application/json
{
"start": "2025-08-15T10:00:00Z",
"end": "2025-08-15T22:00:00Z"
}
**Example response**:
.. sourcecode:: http
HTTP/1.1 201 Created
Vary: Accept
Content-Type: application/json
{
"id": 17,
"start": "2025-08-15T10:00:00Z",
"end": "2025-08-15T22:00:00Z"
}
:param organizer: The ``slug`` field of the organizer of the event/item to create a program time for
:param event: The ``slug`` field of the event to create a program time for
:param item: The ``id`` field of the item to create a program time for
:statuscode 201: no error
:statuscode 400: The program time could not be created due to invalid submitted data.
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to create this resource.
.. http:patch:: /api/v1/organizers/(organizer)/events/(event)/items/(item)/program_times/(id)/
Update a program time. You can also use ``PUT`` instead of ``PATCH``. With ``PUT``, you have to provide all fields of
the resource, other fields will be reset to default. With ``PATCH``, you only need to provide the fields that you
want to change.
You can change all fields of the resource except the ``id`` field.
**Example request**:
.. sourcecode:: http
PATCH /api/v1/organizers/bigevents/events/sampleconf/items/1/program_times/1/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
Content-Type: application/json
Content-Length: 94
{
"start": "2025-08-14T10:00:00Z"
}
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: application/json
{
"id": 1,
"start": "2025-08-14T10:00:00Z",
"end": "2025-08-15T12:00:00Z"
}
:param organizer: The ``slug`` field of the organizer to modify
:param event: The ``slug`` field of the event to modify
:param id: The ``id`` field of the item to modify
:param id: The ``id`` field of the program time to modify
:statuscode 200: no error
:statuscode 400: The program time could not be modified due to invalid submitted data
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to change this resource.
.. http:delete:: /api/v1/organizers/(organizer)/events/(event)/items/(id)/program_times/(id)/
Delete a program time.
**Example request**:
.. sourcecode:: http
DELETE /api/v1/organizers/bigevents/events/sampleconf/items/1/program_times/1/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
**Example response**:
.. sourcecode:: http
HTTP/1.1 204 No Content
Vary: Accept
:param organizer: The ``slug`` field of the organizer to modify
:param event: The ``slug`` field of the event to modify
:param id: The ``id`` field of the item to modify
:param id: The ``id`` field of the program time to delete
:statuscode 204: no error
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to delete this resource.

View File

@@ -139,9 +139,6 @@ has_variations boolean Shows whether
variations list of objects A list with one object for each variation of this item.
Can be empty. Only writable during creation,
use separate endpoint to modify this later.
program_times list of objects A list with one object for each program time of this item.
Can be empty. Only writable during creation,
use separate endpoint to modify this later.
├ id integer Internal ID of the variation
├ value multi-lingual string The "name" of the variation
├ default_price money (string) The price set directly for this variation or ``null``
@@ -228,10 +225,6 @@ meta_data object Values set fo
The ``hidden_if_item_available_mode`` attributes has been added.
.. versionchanged:: 2025.9
The ``program_times`` attribute has been added.
Notes
-----
@@ -239,9 +232,9 @@ Please note that an item either always has variations or never has. Once created
change to an item without and vice versa. To create an item with variations ensure that you POST an item with at least
one variation.
Also note that ``variations``, ``bundles``, ``addons`` and ``program_times`` are only supported on ``POST``. To update/delete variations,
bundles, add-ons and program times please use the dedicated nested endpoints. By design this endpoint does not support ``PATCH`` and ``PUT``
with nested ``variations``, ``bundles``, ``addons`` and/or ``program_times``.
Also note that ``variations``, ``bundles``, and ``addons`` are only supported on ``POST``. To update/delete variations,
bundles, and add-ons please use the dedicated nested endpoints. By design this endpoint does not support ``PATCH`` and ``PUT``
with nested ``variations``, ``bundles`` and/or ``addons``.
Endpoints
---------
@@ -380,8 +373,7 @@ Endpoints
}
],
"addons": [],
"bundles": [],
"program_times": []
"bundles": []
}
]
}
@@ -533,8 +525,7 @@ Endpoints
}
],
"addons": [],
"bundles": [],
"program_times": []
"bundles": []
}
:param organizer: The ``slug`` field of the organizer to fetch
@@ -662,13 +653,7 @@ Endpoints
}
],
"addons": [],
"bundles": [],
"program_times": [
{
"start": "2025-08-14T22:00:00Z",
"end": "2025-08-15T00:00:00Z"
}
]
"bundles": []
}
**Example response**:
@@ -788,13 +773,7 @@ Endpoints
}
],
"addons": [],
"bundles": [],
"program_times": [
{
"start": "2025-08-14T22:00:00Z",
"end": "2025-08-15T00:00:00Z"
}
]
"bundles": []
}
:param organizer: The ``slug`` field of the organizer of the event to create an item for
@@ -810,9 +789,8 @@ Endpoints
the resource, other fields will be reset to default. With ``PATCH``, you only need to provide the fields that you
want to change.
You can change all fields of the resource except the ``has_variations``, ``variations``, ``addon`` and the
``program_times`` field. If you need to update/delete variations, add-ons or program times, please use the nested
dedicated endpoints.
You can change all fields of the resource except the ``has_variations``, ``variations`` and the ``addon`` field. If
you need to update/delete variations or add-ons please use the nested dedicated endpoints.
**Example request**:
@@ -946,8 +924,7 @@ Endpoints
}
],
"addons": [],
"bundles": [],
"program_times": []
"bundles": []
}
:param organizer: The ``slug`` field of the organizer to modify

View File

@@ -35,7 +35,7 @@ dependencies = [
"cryptography>=44.0.0",
"css-inline==0.18.*",
"defusedcsv>=1.1.0",
"Django[argon2]==4.2.*,>=4.2.26",
"Django[argon2]==4.2.*,>=4.2.24",
"django-bootstrap3==25.2",
"django-compressor==4.5.1",
"django-countries==7.6.*",

View File

@@ -47,9 +47,8 @@ from pretix.api.serializers.event import MetaDataField
from pretix.api.serializers.fields import UploadedFileField
from pretix.api.serializers.i18n import I18nAwareModelSerializer
from pretix.base.models import (
Item, ItemAddOn, ItemBundle, ItemCategory, ItemMetaValue, ItemProgramTime,
ItemVariation, ItemVariationMetaValue, Question, QuestionOption, Quota,
SalesChannel,
Item, ItemAddOn, ItemBundle, ItemCategory, ItemMetaValue, ItemVariation,
ItemVariationMetaValue, Question, QuestionOption, Quota, SalesChannel,
)
@@ -188,12 +187,6 @@ class InlineItemAddOnSerializer(serializers.ModelSerializer):
'position', 'price_included', 'multi_allowed')
class InlineItemProgramTimeSerializer(serializers.ModelSerializer):
class Meta:
model = ItemProgramTime
fields = ('start', 'end')
class ItemBundleSerializer(serializers.ModelSerializer):
class Meta:
model = ItemBundle
@@ -219,31 +212,6 @@ class ItemBundleSerializer(serializers.ModelSerializer):
return data
class ItemProgramTimeSerializer(serializers.ModelSerializer):
class Meta:
model = ItemProgramTime
fields = ('id', 'start', 'end')
def validate(self, data):
data = super().validate(data)
full_data = self.to_internal_value(self.to_representation(self.instance)) if self.instance else {}
full_data.update(data)
start = full_data.get('start')
if not start:
raise ValidationError(_("The program start must not be empty."))
end = full_data.get('end')
if not end:
raise ValidationError(_("The program end must not be empty."))
if start > end:
raise ValidationError(_("The program end must not be before the program start."))
return data
class ItemAddOnSerializer(serializers.ModelSerializer):
class Meta:
model = ItemAddOn
@@ -282,7 +250,6 @@ class ItemSerializer(SalesChannelMigrationMixin, I18nAwareModelSerializer):
addons = InlineItemAddOnSerializer(many=True, required=False)
bundles = InlineItemBundleSerializer(many=True, required=False)
variations = InlineItemVariationSerializer(many=True, required=False)
program_times = InlineItemProgramTimeSerializer(many=True, required=False)
tax_rate = ItemTaxRateField(source='*', read_only=True)
meta_data = MetaDataField(required=False, source='*')
picture = UploadedFileField(required=False, allow_null=True, allowed_types=(
@@ -304,7 +271,7 @@ class ItemSerializer(SalesChannelMigrationMixin, I18nAwareModelSerializer):
'available_from', 'available_from_mode', 'available_until', 'available_until_mode',
'require_voucher', 'hide_without_voucher', 'allow_cancel', 'require_bundling',
'min_per_order', 'max_per_order', 'checkin_attention', 'checkin_text', 'has_variations', 'variations',
'addons', 'bundles', 'program_times', 'original_price', 'require_approval', 'generate_tickets',
'addons', 'bundles', 'original_price', 'require_approval', 'generate_tickets',
'show_quota_left', 'hidden_if_available', 'hidden_if_item_available', 'hidden_if_item_available_mode', 'allow_waitinglist',
'issue_giftcard', 'meta_data',
'require_membership', 'require_membership_types', 'require_membership_hidden', 'grant_membership_type',
@@ -327,9 +294,9 @@ class ItemSerializer(SalesChannelMigrationMixin, I18nAwareModelSerializer):
def validate(self, data):
data = super().validate(data)
if self.instance and ('addons' in data or 'variations' in data or 'bundles' in data or 'program_times' in data):
raise ValidationError(_('Updating add-ons, bundles, program times or variations via PATCH/PUT is not '
'supported. Please use the dedicated nested endpoint.'))
if self.instance and ('addons' in data or 'variations' in data or 'bundles' in data):
raise ValidationError(_('Updating add-ons, bundles, or variations via PATCH/PUT is not supported. Please use the '
'dedicated nested endpoint.'))
Item.clean_per_order(data.get('min_per_order'), data.get('max_per_order'))
Item.clean_available(data.get('available_from'), data.get('available_until'))
@@ -380,13 +347,6 @@ class ItemSerializer(SalesChannelMigrationMixin, I18nAwareModelSerializer):
ItemAddOn.clean_max_min_count(addon_data.get('max_count', 0), addon_data.get('min_count', 0))
return value
def validate_program_times(self, value):
if not self.instance:
for program_time_data in value:
ItemProgramTime.clean_start_end(self, start=program_time_data.get('start', None),
end=program_time_data.get('end', None))
return value
@cached_property
def item_meta_properties(self):
return {
@@ -404,7 +364,6 @@ class ItemSerializer(SalesChannelMigrationMixin, I18nAwareModelSerializer):
variations_data = validated_data.pop('variations') if 'variations' in validated_data else {}
addons_data = validated_data.pop('addons') if 'addons' in validated_data else {}
bundles_data = validated_data.pop('bundles') if 'bundles' in validated_data else {}
program_times_data = validated_data.pop('program_times') if 'program_times' in validated_data else {}
meta_data = validated_data.pop('meta_data', None)
picture = validated_data.pop('picture', None)
require_membership_types = validated_data.pop('require_membership_types', [])
@@ -439,8 +398,6 @@ class ItemSerializer(SalesChannelMigrationMixin, I18nAwareModelSerializer):
ItemAddOn.objects.create(base_item=item, **addon_data)
for bundle_data in bundles_data:
ItemBundle.objects.create(base_item=item, **bundle_data)
for program_time_data in program_times_data:
ItemProgramTime.objects.create(item=item, **program_time_data)
# Meta data
if meta_data is not None:
@@ -555,8 +512,7 @@ class QuestionSerializer(I18nAwareModelSerializer):
def validate_dependency_question(self, value):
if value:
if value.type not in (Question.TYPE_CHOICE, Question.TYPE_BOOLEAN, Question.TYPE_CHOICE_MULTIPLE,
Question.TYPE_COUNTRYCODE):
if value.type not in (Question.TYPE_CHOICE, Question.TYPE_BOOLEAN, Question.TYPE_CHOICE_MULTIPLE):
raise ValidationError('Question dependencies can only be set to boolean or choice questions.')
if value == self.instance:
raise ValidationError('A question cannot depend on itself.')

View File

@@ -112,7 +112,6 @@ item_router = routers.DefaultRouter()
item_router.register(r'variations', item.ItemVariationViewSet)
item_router.register(r'addons', item.ItemAddOnViewSet)
item_router.register(r'bundles', item.ItemBundleViewSet)
item_router.register(r'program_times', item.ItemProgramTimeViewSet)
order_router = routers.DefaultRouter()
order_router.register(r'payments', order.PaymentViewSet)

View File

@@ -46,13 +46,13 @@ from rest_framework.response import Response
from pretix.api.pagination import TotalOrderingFilter
from pretix.api.serializers.item import (
ItemAddOnSerializer, ItemBundleSerializer, ItemCategorySerializer,
ItemProgramTimeSerializer, ItemSerializer, ItemVariationSerializer,
QuestionOptionSerializer, QuestionSerializer, QuotaSerializer,
ItemSerializer, ItemVariationSerializer, QuestionOptionSerializer,
QuestionSerializer, QuotaSerializer,
)
from pretix.api.views import ConditionalListView
from pretix.base.models import (
CartPosition, Item, ItemAddOn, ItemBundle, ItemCategory, ItemProgramTime,
ItemVariation, Question, QuestionOption, Quota,
CartPosition, Item, ItemAddOn, ItemBundle, ItemCategory, ItemVariation,
Question, QuestionOption, Quota,
)
from pretix.base.services.quotas import QuotaAvailability
from pretix.helpers.dicts import merge_dicts
@@ -279,57 +279,6 @@ class ItemBundleViewSet(viewsets.ModelViewSet):
)
class ItemProgramTimeViewSet(viewsets.ModelViewSet):
serializer_class = ItemProgramTimeSerializer
queryset = ItemProgramTime.objects.none()
filter_backends = (DjangoFilterBackend, TotalOrderingFilter,)
ordering_fields = ('id',)
ordering = ('id',)
permission = None
write_permission = 'can_change_items'
@cached_property
def item(self):
return get_object_or_404(Item, pk=self.kwargs['item'], event=self.request.event)
def get_queryset(self):
return self.item.program_times.all()
def get_serializer_context(self):
ctx = super().get_serializer_context()
ctx['event'] = self.request.event
ctx['item'] = self.item
return ctx
def perform_create(self, serializer):
item = get_object_or_404(Item, pk=self.kwargs['item'], event=self.request.event)
serializer.save(item=item)
item.log_action(
'pretix.event.item.program_times.added',
user=self.request.user,
auth=self.request.auth,
data=merge_dicts(self.request.data, {'id': serializer.instance.pk})
)
def perform_update(self, serializer):
serializer.save(event=self.request.event)
serializer.instance.item.log_action(
'pretix.event.item.program_times.changed',
user=self.request.user,
auth=self.request.auth,
data=merge_dicts(self.request.data, {'id': serializer.instance.pk})
)
def perform_destroy(self, instance):
super().perform_destroy(instance)
instance.item.log_action(
'pretix.event.item.program_times.removed',
user=self.request.user,
auth=self.request.auth,
data={'start': instance.start, 'end': instance.end}
)
class ItemAddOnViewSet(viewsets.ModelViewSet):
serializer_class = ItemAddOnSerializer
queryset = ItemAddOn.objects.none()

View File

@@ -800,7 +800,7 @@ class SalesChannelViewSet(viewsets.ModelViewSet):
identifier=serializer.instance.identifier,
)
inst.log_action(
'pretix.sales_channel.changed',
'pretix.saleschannel.changed',
user=self.request.user,
auth=self.request.auth,
data=self.request.data,

View File

@@ -149,7 +149,7 @@ class ItemDataExporter(ListExporter):
row += [
_("Yes") if i.active and v.active else "",
", ".join([str(sn.label) for sn in sales_channels]),
v.default_price if v.default_price is not None else i.default_price,
v.default_price or i.default_price,
_("Yes") if i.free_price else "",
str(i.tax_rule) if i.tax_rule else "",
_("Yes") if i.admission else "",

View File

@@ -214,38 +214,21 @@ class PasswordRecoverForm(forms.Form):
error_messages = {
'pw_mismatch': _("Please enter the same password twice"),
}
email = forms.EmailField(
max_length=255,
disabled=True,
label=_("Your email address"),
widget=forms.EmailInput(
attrs={'autocomplete': 'username'},
),
)
password = forms.CharField(
label=_('Password'),
widget=forms.PasswordInput(attrs={
'autocomplete': 'new-password',
}),
widget=forms.PasswordInput,
max_length=4096,
required=True
)
password_repeat = forms.CharField(
label=_('Repeat password'),
widget=forms.PasswordInput(attrs={
'autocomplete': 'new-password',
}),
widget=forms.PasswordInput,
max_length=4096,
)
def __init__(self, user_id=None, *args, **kwargs):
initial = kwargs.pop('initial', {})
try:
self.user = User.objects.get(id=user_id)
initial['email'] = self.user.email
except User.DoesNotExist:
self.user = None
super().__init__(*args, initial=initial, **kwargs)
self.user_id = user_id
super().__init__(*args, **kwargs)
def clean(self):
password1 = self.cleaned_data.get('password', '')
@@ -260,7 +243,11 @@ class PasswordRecoverForm(forms.Form):
def clean_password(self):
password1 = self.cleaned_data.get('password', '')
if validate_password(password1, user=self.user) is not None:
try:
user = User.objects.get(id=self.user_id)
except User.DoesNotExist:
user = None
if validate_password(password1, user=user) is not None:
raise forms.ValidationError(_(password_validators_help_texts()), code='pw_invalid')
return password1
@@ -320,10 +307,3 @@ class ReauthForm(forms.Form):
self.error_messages['inactive'],
code='inactive',
)
class ConfirmationCodeForm(forms.Form):
code = forms.IntegerField(
label=_('Confirmation code'),
widget=forms.NumberInput(attrs={'class': 'confirmation-code-input', 'inputmode': 'numeric', 'type': 'text'}),
)

View File

@@ -1061,7 +1061,6 @@ class BaseQuestionsForm(forms.Form):
return (
('True' in qvals and dval)
or ('False' in qvals and not dval)
or (parentq.type == Question.TYPE_COUNTRYCODE and str(dval) in qvals)
or (isinstance(dval, QuestionOption) and dval.identifier in qvals)
or (isinstance(dval, (list, QuerySet)) and any(qval in [o.identifier for o in dval] for qval in qvals))
)

View File

@@ -39,16 +39,37 @@ from django.contrib.auth.password_validation import (
password_validators_help_texts, validate_password,
)
from django.db.models import Q
from django.urls.base import reverse
from django.utils.translation import gettext_lazy as _
from pytz import common_timezones
from pretix.base.models import User
from pretix.control.forms import SingleLanguageWidget
from pretix.helpers.format import format_map
class UserSettingsForm(forms.ModelForm):
error_messages = {
'duplicate_identifier': _("There already is an account associated with this email address. "
"Please choose a different one."),
'pw_current': _("Please enter your current password if you want to change your email address "
"or password."),
'pw_current_wrong': _("The current password you entered was not correct."),
'pw_mismatch': _("Please enter the same password twice"),
'rate_limit': _("For security reasons, please wait 5 minutes before you try again."),
'pw_equal': _("Please choose a password different to your current one.")
}
old_pw = forms.CharField(max_length=255,
required=False,
label=_("Your current password"),
widget=forms.PasswordInput())
new_pw = forms.CharField(max_length=255,
required=False,
label=_("New password"),
widget=forms.PasswordInput())
new_pw_repeat = forms.CharField(max_length=255,
required=False,
label=_("Repeat new password"),
widget=forms.PasswordInput())
timezone = forms.ChoiceField(
choices=((a, a) for a in common_timezones),
label=_("Default timezone"),
@@ -72,60 +93,11 @@ class UserSettingsForm(forms.ModelForm):
self.user = kwargs.pop('user')
super().__init__(*args, **kwargs)
self.fields['email'].required = True
self.fields['email'].disabled = True
self.fields['email'].help_text = format_map('<a href="{link}"><span class="fa fa-edit"></span> {text}</a>', {
'text': _("Change email address"),
'link': reverse('control:user.settings.email.change')
})
class User2FADeviceAddForm(forms.Form):
name = forms.CharField(label=_('Device name'), max_length=64)
devicetype = forms.ChoiceField(label=_('Device type'), widget=forms.RadioSelect, choices=(
('totp', _('Smartphone with the Authenticator application')),
('webauthn', _('WebAuthn-compatible hardware token (e.g. Yubikey)')),
))
class UserPasswordChangeForm(forms.Form):
error_messages = {
'pw_current': _("Please enter your current password if you want to change your email address "
"or password."),
'pw_current_wrong': _("The current password you entered was not correct."),
'pw_mismatch': _("Please enter the same password twice"),
'rate_limit': _("For security reasons, please wait 5 minutes before you try again."),
'pw_equal': _("Please choose a password different to your current one.")
}
email = forms.EmailField(max_length=255,
disabled=True,
label=_("Your email address"),
widget=forms.EmailInput(
attrs={'autocomplete': 'username'},
))
old_pw = forms.CharField(max_length=255,
required=False,
label=_("Your current password"),
widget=forms.PasswordInput(
attrs={'autocomplete': 'current-password'},
))
new_pw = forms.CharField(max_length=255,
required=False,
label=_("New password"),
widget=forms.PasswordInput(
attrs={'autocomplete': 'new-password'},
))
new_pw_repeat = forms.CharField(max_length=255,
required=False,
label=_("Repeat new password"),
widget=forms.PasswordInput(
attrs={'autocomplete': 'new-password'},
))
def __init__(self, *args, **kwargs):
self.user = kwargs.pop('user')
initial = kwargs.pop('initial', {})
initial['email'] = self.user.email
super().__init__(*args, initial=initial, **kwargs)
if self.user.auth_backend != 'native':
del self.fields['old_pw']
del self.fields['new_pw']
del self.fields['new_pw_repeat']
self.fields['email'].disabled = True
def clean_old_pw(self):
old_pw = self.cleaned_data.get('old_pw')
@@ -149,6 +121,15 @@ class UserPasswordChangeForm(forms.Form):
return old_pw
def clean_email(self):
email = self.cleaned_data['email']
if User.objects.filter(Q(email__iexact=email) & ~Q(pk=self.instance.pk)).exists():
raise forms.ValidationError(
self.error_messages['duplicate_identifier'],
code='duplicate_identifier',
)
return email
def clean_new_pw(self):
password1 = self.cleaned_data.get('new_pw', '')
if password1 and validate_password(password1, user=self.user) is not None:
@@ -167,24 +148,32 @@ class UserPasswordChangeForm(forms.Form):
code='pw_mismatch'
)
def clean(self):
password1 = self.cleaned_data.get('new_pw')
email = self.cleaned_data.get('email')
old_pw = self.cleaned_data.get('old_pw')
class UserEmailChangeForm(forms.Form):
error_messages = {
'duplicate_identifier': _("There already is an account associated with this email address. "
"Please choose a different one."),
}
old_email = forms.EmailField(label=_('Old email address'), disabled=True)
new_email = forms.EmailField(label=_('New email address'))
def __init__(self, *args, **kwargs):
self.user = kwargs.pop('user')
super().__init__(*args, **kwargs)
def clean_new_email(self):
email = self.cleaned_data['new_email']
if User.objects.filter(Q(email__iexact=email) & ~Q(pk=self.user.pk)).exists():
if (password1 or email != self.user.email) and not old_pw:
raise forms.ValidationError(
self.error_messages['duplicate_identifier'],
code='duplicate_identifier',
self.error_messages['pw_current'],
code='pw_current'
)
return email
if password1 and password1 == old_pw:
raise forms.ValidationError(
self.error_messages['pw_equal'],
code='pw_equal'
)
if password1:
self.instance.set_password(password1)
return self.cleaned_data
class User2FADeviceAddForm(forms.Form):
name = forms.CharField(label=_('Device name'), max_length=64)
devicetype = forms.ChoiceField(label=_('Device type'), widget=forms.RadioSelect, choices=(
('totp', _('Smartphone with the Authenticator application')),
('webauthn', _('WebAuthn-compatible hardware token (e.g. Yubikey)')),
))

View File

@@ -23,7 +23,6 @@ import datetime
import logging
import math
import re
import textwrap
import unicodedata
from collections import defaultdict
from decimal import Decimal
@@ -753,59 +752,11 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
return dt.astimezone(tz).date()
total = Decimal('0.00')
if has_taxes:
colwidths = [a * doc.width for a in (.50, .05, .15, .15, .15)]
else:
colwidths = [a * doc.width for a in (.65, .20, .15)]
for (description, tax_rate, tax_name, net_value, gross_value, subevent, period_start, period_end), lines in addon_aware_groupby(
all_lines,
key=_group_key,
is_addon=lambda l: l.description.startswith(" +"),
):
# split description into multiple Paragraphs so each fits in a table cell on a single page
# otherwise PDF-build fails
description_p_list = []
# normalize linebreaks to newlines instead of HTML so we can safely substring
description = description.replace('<br>', '<br />').replace('<br />\n', '\n').replace('<br />', '\n')
# start first line with different settings than the rest of the description
curr_description = description.split("\n", maxsplit=1)[0]
cellpadding = 6 # default cellpadding is only set on right side of column
max_width = colwidths[0] - cellpadding
max_height = self.stylesheet['Normal'].leading * 5
p_style = self.stylesheet['Normal']
for __ in range(1000):
p = FontFallbackParagraph(
self._clean_text(curr_description, tags=['br']),
p_style
)
h = p.wrap(max_width, doc.height)[1]
if h <= max_height:
description_p_list.append(p)
if curr_description == description:
break
description = description[len(curr_description):].lstrip()
curr_description = description.split("\n", maxsplit=1)[0]
# use different settings for all except first line
max_width = sum(colwidths[0:3 if has_taxes else 2]) - cellpadding
max_height = self.stylesheet['Fineprint'].leading * 8
p_style = self.stylesheet['Fineprint']
continue
if not description_p_list:
# first "manual" line is larger than 5 "real" lines => only allow one line and set rest in Fineprint
max_height = self.stylesheet['Normal'].leading
if h > max_height * 1.1:
# quickly bring the text-length down to a managable length to then stepwise reduce
wrap_to = math.ceil(len(curr_description) * max_height * 1.1 / h)
else:
# trim to 95% length, but at most 10 chars to not have strangely short lines in the middle of a paragraph
wrap_to = max(len(curr_description) - 10, math.ceil(len(curr_description) * 0.95))
curr_description = textwrap.wrap(curr_description, wrap_to, replace_whitespace=False, drop_whitespace=False)[0]
# Try to be clever and figure out when organizers would want to show the period. This heuristic is
# not perfect and the only "fully correct" way would be to include the period on every line always,
# however this will cause confusion (a) due to useless repetition of the same date all over the invoice
@@ -859,10 +810,7 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
# Group together at the end of the invoice
request_show_service_date = period_line
elif period_line:
description_p_list.append(FontFallbackParagraph(
period_line,
self.stylesheet['Fineprint']
))
description += "\n" + period_line
lines = list(lines)
if has_taxes:
@@ -871,13 +819,13 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
net_price=money_filter(net_value, self.invoice.event.currency),
gross_price=money_filter(gross_value, self.invoice.event.currency),
)
description_p_list.append(FontFallbackParagraph(
single_price_line,
self.stylesheet['Fineprint']
))
description = description + "\n" + single_price_line
tdata.append((
description_p_list.pop(0),
FontFallbackParagraph(
self._clean_text(description, tags=['br']),
self.stylesheet['Normal']
),
str(len(lines)),
localize(tax_rate) + " %",
FontFallbackParagraph(
@@ -889,52 +837,23 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
self.stylesheet['NormalRight']
),
))
for p in description_p_list:
tdata.append((p, "", "", "", ""))
tstyledata.append((
'SPAN',
(0, len(tdata) - 1),
(2, len(tdata) - 1),
))
else:
if len(lines) > 1:
single_price_line = pgettext('invoice', 'Single price: {price}').format(
price=money_filter(gross_value, self.invoice.event.currency),
)
description_p_list.append(FontFallbackParagraph(
single_price_line,
self.stylesheet['Fineprint']
))
description = description + "\n" + single_price_line
tdata.append((
description_p_list.pop(0),
FontFallbackParagraph(
self._clean_text(description, tags=['br']),
self.stylesheet['Normal']
),
str(len(lines)),
FontFallbackParagraph(
money_filter(gross_value * len(lines), self.invoice.event.currency).replace('\xa0', ' '),
self.stylesheet['NormalRight']
),
))
for p in description_p_list:
tdata.append((p, "", ""))
tstyledata.append((
'SPAN',
(0, len(tdata) - 1),
(1, len(tdata) - 1),
))
tstyledata += [
(
'BOTTOMPADDING',
(0, len(tdata) - len(description_p_list)),
(-1, len(tdata) - 2),
0
),
(
'TOPPADDING',
(0, len(tdata) - len(description_p_list)),
(-1, len(tdata) - 1),
0
),
]
taxvalue_map[tax_rate, tax_name] += (gross_value - net_value) * len(lines)
grossvalue_map[tax_rate, tax_name] += gross_value * len(lines)
total += gross_value * len(lines)
@@ -944,11 +863,13 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
FontFallbackParagraph(self._normalize(pgettext('invoice', 'Invoice total')), self.stylesheet['Bold']), '', '', '',
money_filter(total, self.invoice.event.currency)
])
colwidths = [a * doc.width for a in (.50, .05, .15, .15, .15)]
else:
tdata.append([
FontFallbackParagraph(self._normalize(pgettext('invoice', 'Invoice total')), self.stylesheet['Bold']), '',
money_filter(total, self.invoice.event.currency)
])
colwidths = [a * doc.width for a in (.65, .20, .15)]
if not self.invoice.is_cancellation:
if self.invoice.event.settings.invoice_show_payments and self.invoice.order.status == Order.STATUS_PENDING:

View File

@@ -19,15 +19,20 @@
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>.
#
import json
import logging
from collections import defaultdict
from functools import cached_property
from typing import Optional
import jsonschema
from django.urls import reverse
from django.utils.html import format_html
from django.utils.translation import gettext_lazy as _
from pretix.base.signals import PluginAwareRegistry
logger = logging.getLogger(__name__)
def make_link(a_map, wrapper, is_active=True, event=None, plugin_name=None):
if a_map:
@@ -105,12 +110,38 @@ They are annotated with their ``action_type`` and the defining ``plugin``.
log_entry_types = LogEntryTypeRegistry()
def prepare_schema(schema):
def handle_properties(t):
return {"shred_properties": [k for k, v in t["properties"].items() if v["shred"]]}
def walk_tree(schema):
if type(schema) is dict:
new_keys = {}
for k, v in schema.items():
if k == "properties":
new_keys = handle_properties(schema)
walk_tree(v)
if schema.get("type") == "object" and "additionalProperties" not in new_keys:
new_keys["additionalProperties"] = False
schema.update(new_keys)
elif type(schema) is list:
for v in schema:
walk_tree(v)
walk_tree(schema)
return schema
class LogEntryType:
"""
Base class for a type of LogEntry, identified by its action_type.
"""
data_schema = None # {"type": "object", "properties": []}
def __init__(self, action_type=None, plain=None):
if self.data_schema:
print(self.__class__.__name__, "has schema", self._prepared_schema)
if action_type:
self.action_type = action_type
if plain:
@@ -147,12 +178,37 @@ class LogEntryType:
object_link_wrapper = '{val}'
def validate_data(self, parsed_data):
if not self._prepared_schema:
return
try:
jsonschema.validate(parsed_data, self._prepared_schema)
except jsonschema.exceptions.ValidationError as ex:
logger.warning("%s schema validation failed: %s %s", type(self).__name__, ex.json_path, ex.message)
raise
@cached_property
def _prepared_schema(self):
if self.data_schema:
return prepare_schema(self.data_schema)
def shred_pii(self, logentry):
"""
To be used for shredding personally identified information contained in the data field of a LogEntry of this
type.
"""
raise NotImplementedError
if self._prepared_schema:
def shred_fun(validator, value, instance, schema):
for key in value:
instance[key] = "##########"
v = jsonschema.validators.extend(jsonschema.validators.Draft202012Validator,
validators={"shred_properties": shred_fun})
data = logentry.parsed_data
jsonschema.validate(data, self._prepared_schema, v)
logentry.data = json.dumps(data)
else:
raise NotImplementedError
class NoOpShredderMixin:

View File

@@ -1,25 +0,0 @@
# Generated by Django 4.2.19 on 2025-08-11 10:25
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0293_cartposition_price_includes_rounding_correction_and_more'),
]
operations = [
migrations.CreateModel(
name='ItemProgramTime',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
('start', models.DateTimeField()),
('end', models.DateTimeField()),
('item',
models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='program_times',
to='pretixbase.item')),
],
),
]

View File

@@ -1,18 +0,0 @@
# Generated by Django 4.2.23 on 2025-09-04 16:06
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("pretixbase", "0294_item_program_time"),
]
operations = [
migrations.AddField(
model_name="user",
name="is_verified",
field=models.BooleanField(default=False),
),
]

View File

@@ -36,9 +36,8 @@ from .giftcards import GiftCard, GiftCardAcceptance, GiftCardTransaction
from .invoices import Invoice, InvoiceLine, invoice_filename
from .items import (
Item, ItemAddOn, ItemBundle, ItemCategory, ItemMetaProperty, ItemMetaValue,
ItemProgramTime, ItemVariation, ItemVariationMetaValue, Question,
QuestionOption, Quota, SubEventItem, SubEventItemVariation,
itempicture_upload_to,
ItemVariation, ItemVariationMetaValue, Question, QuestionOption, Quota,
SubEventItem, SubEventItemVariation, itempicture_upload_to,
)
from .log import LogEntry
from .media import ReusableMedium

View File

@@ -35,7 +35,6 @@
import binascii
import json
import operator
import secrets
from datetime import timedelta
from functools import reduce
@@ -45,7 +44,6 @@ from django.contrib.auth.models import (
)
from django.contrib.auth.tokens import default_token_generator
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import BadRequest, PermissionDenied
from django.db import IntegrityError, models, transaction
from django.db.models import Q
from django.utils.crypto import get_random_string, salted_hmac
@@ -241,11 +239,9 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
USERNAME_FIELD = 'email'
REQUIRED_FIELDS = []
MAX_CONFIRMATION_CODE_ATTEMPTS = 10
email = models.EmailField(unique=True, db_index=True, null=True, blank=True,
verbose_name=_('Email'), max_length=190)
is_verified = models.BooleanField(default=False, verbose_name=_('Verified email address'))
fullname = models.CharField(max_length=255, blank=True, null=True,
verbose_name=_('Full name'))
is_active = models.BooleanField(default=True,
@@ -357,77 +353,6 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
except SendMailException:
pass # Already logged
def send_confirmation_code(self, session, reason, email=None, state=None):
"""
Sends a confirmation code via email to the user. The code is only valid for the action specified by `reason`.
The email is either sent to the email address currently on file for the user, or to the one given in the optional `email` parameter.
A `state` value can be provided which is bound to this confirmation code, and returned on successfully checking the code.
:param session: the user's request session
:param reason: the action which should be confirmed using this confirmation code (currently, only `email_change` is allowed)
:param email: optional, the email address to send the confirmation code to
:param state: optional
"""
from pretix.base.services.mail import mail
with language(self.locale):
if reason == 'email_change':
msg = str(_('to confirm changing your email address from {old_email}\nto {new_email}, use the following code:').format(
old_email=self.email, new_email=email,
))
elif reason == 'email_verify':
msg = str(_('to confirm that your email address {email} belongs to your pretix account, use the following code:').format(
email=self.email,
))
else:
raise Exception('Invalid confirmation code reason')
code = "%07d" % secrets.SystemRandom().randint(0, 9999999)
session['user_confirmation_code:' + reason] = {
'code': code,
'state': state,
'attempts': 0,
}
mail(
email or self.email,
_('pretix confirmation code'),
'pretixcontrol/email/confirmation_code.txt',
{
'user': self,
'reason': msg,
'code': code,
},
event=None,
user=self,
locale=self.locale
)
def check_confirmation_code(self, session, reason, code):
"""
Checks a confirmation code entered by the user against the valid code stored in the session.
If the code is correct, an optional state bound to the code is returned.
If the code is incorrect, PermissionDenied is raised. If the code could not be validated, either because no
code for the given reason is stored, or the number of input attempts is exceeded, BadRequest is raised.
:param session: the user's request session
:param reason: the action which should be confirmed using this confirmation code
:param code: the code entered by the user
:return: optional state bound to this code using the state parameter of send_confirmation_code, None otherwise
"""
stored = session.get('user_confirmation_code:' + reason)
if not stored:
raise BadRequest
if stored['attempts'] > User.MAX_CONFIRMATION_CODE_ATTEMPTS:
raise BadRequest
if int(stored['code']) == int(code):
del session['user_confirmation_code:' + reason]
return stored['state']
else:
stored['attempts'] += 1
session['user_confirmation_code:' + reason] = stored
raise PermissionDenied
def send_password_reset(self):
from pretix.base.services.mail import mail

View File

@@ -80,6 +80,7 @@ class LoggingMixin:
from pretix.api.models import OAuthAccessToken, OAuthApplication
from pretix.api.webhooks import notify_webhooks
from ..logentrytype_registry import log_entry_types
from ..services.notifications import notify
from .devices import Device
from .event import Event
@@ -124,7 +125,13 @@ class LoggingMixin:
if (sensitivekey in k) and v:
data[k] = "********"
type, meta = log_entry_types.get(action_type=action)
if not type:
raise TypeError("Undefined log entry type '%s'" % action)
logentry.data = json.dumps(data, cls=CustomJSONEncoder, sort_keys=True)
type.validate_data(json.loads(logentry.data))
elif data:
raise TypeError("You should only supply dictionaries as log data.")
if save:

View File

@@ -847,7 +847,7 @@ class Event(EventMixin, LoggedModel):
from ..signals import event_copy_data
from . import (
Discount, Item, ItemAddOn, ItemBundle, ItemCategory, ItemMetaValue,
ItemProgramTime, ItemVariationMetaValue, Question, Quota,
ItemVariationMetaValue, Question, Quota,
)
# Note: avoid self.set_active_plugins(), it causes trouble e.g. for the badges plugin.
@@ -990,11 +990,6 @@ class Event(EventMixin, LoggedModel):
ia.bundled_variation = variation_map[ia.bundled_variation.pk]
ia.save(force_insert=True)
for ipt in ItemProgramTime.objects.filter(item__event=other).prefetch_related('item'):
ipt.pk = None
ipt.item = item_map[ipt.item.pk]
ipt.save(force_insert=True)
quota_map = {}
for q in Quota.objects.filter(event=other, subevent__isnull=True).prefetch_related('items', 'variations'):
quota_map[q.pk] = q

View File

@@ -2294,27 +2294,3 @@ class ItemVariationMetaValue(LoggedModel):
class Meta:
unique_together = ('variation', 'property')
class ItemProgramTime(models.Model):
"""
This model can be used to add a program time to an item.
:param item: The item the program time applies to
:type item: Item
:param start: The date and time this program time starts
:type start: datetime
:param end: The date and time this program time ends
:type end: datetime
"""
item = models.ForeignKey('Item', related_name='program_times', on_delete=models.CASCADE)
start = models.DateTimeField(verbose_name=_("Start"))
end = models.DateTimeField(verbose_name=_("End"))
def clean(self):
self.clean_start_end(start=self.start, end=self.end)
super().clean()
def clean_start_end(self, start: datetime = None, end: datetime = None):
if start and end and start > end:
raise ValidationError(_("The program end must not be before the program start."))

View File

@@ -1623,7 +1623,6 @@ class AbstractPosition(RoundingCorrectionMixin, models.Model):
return (
('True' in qvals and self.answ[parentid].answer == 'True')
or ('False' in qvals and self.answ[parentid].answer == 'False')
or (parentq.type == Question.TYPE_COUNTRYCODE and self.answ[parentid].answer in qvals)
or (any(qval in [o.identifier for o in self.answ[parentid].options.all()] for qval in qvals))
)

View File

@@ -280,13 +280,13 @@ class Seat(models.Model):
def is_available(self, ignore_cart=None, ignore_orderpos=None, ignore_voucher_id=None,
sales_channel='web',
ignore_distancing=False, distance_ignore_cart_id=None, always_allow_blocked=False):
ignore_distancing=False, distance_ignore_cart_id=None):
from .orders import Order
from .organizer import SalesChannel
if isinstance(sales_channel, SalesChannel):
sales_channel = sales_channel.identifier
if not always_allow_blocked and self.blocked and sales_channel not in self.event.settings.seating_allow_blocked_seats_for_channel:
if self.blocked and sales_channel not in self.event.settings.seating_allow_blocked_seats_for_channel:
return False
opqs = self.orderposition_set.filter(
order__status__in=[Order.STATUS_PENDING, Order.STATUS_PAID],

View File

@@ -84,7 +84,6 @@ from pretix.base.settings import PERSON_NAME_SCHEMES
from pretix.base.signals import layout_image_variables, layout_text_variables
from pretix.base.templatetags.money import money_filter
from pretix.base.templatetags.phone_format import phone_format
from pretix.helpers.daterange import datetimerange
from pretix.helpers.reportlab import ThumbnailingImageReader, reshaper
from pretix.presale.style import get_fonts
@@ -491,12 +490,6 @@ DEFAULT_VARIABLES = OrderedDict((
"TIME_FORMAT"
) if op.valid_until else ""
}),
("program_times", {
"label": _("Program times: date and time"),
"editor_sample": _(
"2017-05-31 10:00 12:00\n2017-05-31 14:00 16:00\n2017-05-31 14:00 2017-06-01 14:00"),
"evaluate": lambda op, order, ev: get_program_times(op, ev)
}),
("medium_identifier", {
"label": _("Reusable Medium ID"),
"editor_sample": "ABC1234DEF4567",
@@ -741,16 +734,6 @@ def get_seat(op: OrderPosition):
return None
def get_program_times(op: OrderPosition, ev: Event):
return '\n'.join([
datetimerange(
pt.start.astimezone(ev.timezone),
pt.end.astimezone(ev.timezone),
as_html=False
) for pt in op.item.program_times.all()
])
def generate_compressed_addon_list(op, order, event):
itemcount = defaultdict(int)
addons = [p for p in (

View File

@@ -1670,14 +1670,13 @@ class OrderChangeManager:
AddBlockOperation = namedtuple('AddBlockOperation', ('position', 'block_name', 'ignore_from_quota_while_blocked'))
RemoveBlockOperation = namedtuple('RemoveBlockOperation', ('position', 'block_name', 'ignore_from_quota_while_blocked'))
def __init__(self, order: Order, user=None, auth=None, notify=True, reissue_invoice=True, allow_blocked_seats=False):
def __init__(self, order: Order, user=None, auth=None, notify=True, reissue_invoice=True):
self.order = order
self.user = user
self.auth = auth
self.event = order.event
self.split_order = None
self.reissue_invoice = reissue_invoice
self.allow_blocked_seats = allow_blocked_seats
self._committed = False
self._totaldiff_guesstimate = 0
self._quotadiff = Counter()
@@ -2198,7 +2197,7 @@ class OrderChangeManager:
for seat, diff in self._seatdiff.items():
if diff <= 0:
continue
if not seat.is_available(sales_channel=self.order.sales_channel, ignore_distancing=True, always_allow_blocked=self.allow_blocked_seats) or diff > 1:
if not seat.is_available(sales_channel=self.order.sales_channel, ignore_distancing=True) or diff > 1:
raise OrderError(self.error_messages['seat_unavailable'].format(seat=seat.name))
if self.event.has_subevents:

View File

@@ -56,8 +56,7 @@ from i18nfield.forms import I18nFormField, I18nTextarea
from pretix.base.forms import I18nFormSet, I18nMarkdownTextarea, I18nModelForm
from pretix.base.forms.widgets import DatePickerWidget
from pretix.base.models import (
Item, ItemCategory, ItemProgramTime, ItemVariation, Question,
QuestionOption, Quota,
Item, ItemCategory, ItemVariation, Question, QuestionOption, Quota,
)
from pretix.base.models.items import ItemAddOn, ItemBundle, ItemMetaValue
from pretix.base.signals import item_copy_data
@@ -155,7 +154,7 @@ class QuestionForm(I18nModelForm):
self.fields['items'].queryset = self.instance.event.items.all()
self.fields['items'].required = True
self.fields['dependency_question'].queryset = self.instance.event.questions.filter(
type__in=(Question.TYPE_BOOLEAN, Question.TYPE_CHOICE, Question.TYPE_CHOICE_MULTIPLE, Question.TYPE_COUNTRYCODE),
type__in=(Question.TYPE_BOOLEAN, Question.TYPE_CHOICE, Question.TYPE_CHOICE_MULTIPLE),
ask_during_checkin=False
)
if self.instance.pk:
@@ -573,8 +572,6 @@ class ItemCreateForm(I18nModelForm):
for b in self.cleaned_data['copy_from'].bundles.all():
instance.bundles.create(bundled_item=b.bundled_item, bundled_variation=b.bundled_variation,
count=b.count, designated_price=b.designated_price)
for pt in self.cleaned_data['copy_from'].program_times.all():
instance.program_times.create(start=pt.start, end=pt.end)
item_copy_data.send(sender=self.event, source=self.cleaned_data['copy_from'], target=instance)
@@ -1324,49 +1321,3 @@ class ItemMetaValueForm(forms.ModelForm):
widgets = {
'value': forms.TextInput()
}
class ItemProgramTimeFormSet(I18nFormSet):
template = "pretixcontrol/item/include_program_times.html"
title = _('Program times')
def _construct_form(self, i, **kwargs):
kwargs['event'] = self.event
return super()._construct_form(i, **kwargs)
@property
def empty_form(self):
self.is_valid()
form = self.form(
auto_id=self.auto_id,
prefix=self.add_prefix('__prefix__'),
empty_permitted=True,
use_required_attribute=False,
locales=self.locales,
event=self.event
)
self.add_fields(form, None)
return form
class ItemProgramTimeForm(I18nModelForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['end'].widget.attrs['data-date-after'] = '#id_{prefix}-start_0'.format(prefix=self.prefix)
class Meta:
model = ItemProgramTime
localized_fields = '__all__'
fields = [
'start',
'end',
]
field_classes = {
'start': forms.SplitDateTimeField,
'end': forms.SplitDateTimeField,
}
widgets = {
'start': SplitDateTimePickerWidget(),
'end': SplitDateTimePickerWidget(),
}

View File

@@ -69,7 +69,6 @@ class UserEditForm(forms.ModelForm):
'email',
'require_2fa',
'is_active',
'is_verified',
'is_staff',
'needs_password_change',
'last_login'

View File

@@ -503,7 +503,6 @@ def pretixcontrol_orderposition_blocked_display(sender: Event, orderposition, bl
'pretix.event.order.valid_if_pending.set': _('The order has been set to be usable before it is paid.'),
'pretix.event.order.valid_if_pending.unset': _('The order has been set to require payment before use.'),
'pretix.event.order.expired': _('The order has been marked as expired.'),
'pretix.event.order.paid': _('The order has been marked as paid.'),
'pretix.event.order.cancellationrequest.deleted': _('The cancellation request has been deleted.'),
'pretix.event.order.refunded': _('The order has been refunded.'),
'pretix.event.order.reactivated': _('The order has been reactivated.'),
@@ -535,7 +534,7 @@ def pretixcontrol_orderposition_blocked_display(sender: Event, orderposition, bl
'pretix.event.order.checkin_attention': _('The order\'s flag to require attention at check-in has been '
'toggled.'),
'pretix.event.order.checkin_text': _('The order\'s check-in text has been changed.'),
'pretix.event.order.pretix.event.order.valid_if_pending': _('The order\'s flag to be considered valid even if '
'pretix.event.order.valid_if_pending': _('The order\'s flag to be considered valid even if '
'unpaid has been toggled.'),
'pretix.event.order.payment.changed': _('A new payment {local_id} has been started instead of the previous one.'),
'pretix.event.order.email.sent': _('An unidentified type email has been sent.'),
@@ -575,6 +574,21 @@ class CoreOrderLogEntryType(OrderLogEntryType):
pass
@log_entry_types.new()
class OrderPaidLogEntryType(CoreOrderLogEntryType):
action_type = 'pretix.event.order.paid'
plain = _('The order has been marked as paid.')
data_schema = {
"type": "object",
"properties": {
"provider": {"type": ["null", "string"], "shred": False, },
"info": {"type": ["null", "string", "object"], "shred": True, },
"date": {"type": ["null", "string"], "shred": False, },
"force": {"type": "boolean", "shred": False, },
},
}
@log_entry_types.new_from_dict({
'pretix.voucher.added': _('The voucher has been created.'),
'pretix.voucher.sent': _('The voucher has been sent to {recipient}.'),
@@ -585,13 +599,63 @@ class CoreOrderLogEntryType(OrderLogEntryType):
'pretix.voucher.added.waitinglist': _('The voucher has been assigned to {email} through the waiting list.'),
})
class CoreVoucherLogEntryType(VoucherLogEntryType):
pass
data_schema = {
"type": "object",
"properties": {
"item": {"type": ["null", "number"], "shred": False, },
"variation": {"type": ["null", "number"], "shred": False, },
"tag": {"type": "string", "shred": False,},
"block_quota": {"type": "boolean", "shred": False, },
"valid_until": {"type": ["null", "string"], "shred": False, },
"min_usages": {"type": "number", "shred": False, },
"max_usages": {"type": "number", "shred": False, },
"subevent": {"type": ["null", "number", "object"], "shred": False, },
"source": {"type": "string", "shred": False,},
"allow_ignore_quota": {"type": "boolean", "shred": False, },
"code": {"type": "string", "shred": False,},
"comment": {"type": "string", "shred": True,},
"price_mode": {"type": "string", "shred": False,},
"seat": {"type": "string", "shred": False,},
"quota": {"type": ["null", "number"], "shred": False,},
"value": {"type": ["null", "string"], "shred": False,},
"redeemed": {"type": "number", "shred": False,},
"all_addons_included": {"type": "boolean", "shred": False, },
"all_bundles_included": {"type": "boolean", "shred": False, },
"budget": {"type": ["null", "number"], "shred": False, },
"itemvar": {"type": "string", "shred": False,},
"show_hidden_items": {"type": "boolean", "shred": False, },
# bulk create:
"bulk": {"type": "boolean", "shred": False,},
"seats": {"type": "array", "shred": False,},
"send": {"type": ["string", "boolean"], "shred": False,},
"send_recipients": {"type": "array", "shred": True,},
"send_subject": {"type": "string", "shred": False,},
"send_message": {"type": "string", "shred": True,},
# pretix.voucher.sent
"recipient": {"type": "string", "shred": True,},
"name": {"type": "string", "shred": True,},
"subject": {"type": "string", "shred": False,},
"message": {"type": "string", "shred": True,},
# pretix.voucher.added.waitinglist
"email": {"type": "string", "shred": True,},
"waitinglistentry": {"type": "number", "shred": False, },
},
}
@log_entry_types.new()
class VoucherRedeemedLogEntryType(VoucherLogEntryType):
action_type = 'pretix.voucher.redeemed'
plain = _('The voucher has been redeemed in order {order_code}.')
data_schema = {
"type": "object",
"properties": {
"order_code": {"type": "string", "shred": False, },
},
}
def display(self, logentry, data):
url = reverse('control:event.order', kwargs={
@@ -634,9 +698,16 @@ class TeamMembershipLogEntryType(LogEntryType):
'pretix.team.member.removed': _('{user} has been removed from the team.'),
'pretix.team.invite.created': _('{user} has been invited to the team.'),
'pretix.team.invite.resent': _('Invite for {user} has been resent.'),
'pretix.team.invite.deleted': _('Invite for {user} has been deleted.'),
})
class CoreTeamMembershipLogEntryType(TeamMembershipLogEntryType):
pass
data_schema = {
"type": "object",
"properties": {
"email": {"type": "string", "shred": True, },
"user": {"type": "number", "shred": False, },
},
}
@log_entry_types.new()
@@ -667,14 +738,6 @@ class UserSettingsChangedLogEntryType(LogEntryType):
return text
@log_entry_types.new_from_dict({
'pretix.user.email.changed': _('Your email address has been changed from {old_email} to {email}.'),
'pretix.user.email.confirmed': _('Your email address {email} has been confirmed.'),
})
class UserEmailChangedLogEntryType(LogEntryType):
pass
class UserImpersonatedLogEntryType(LogEntryType):
def display(self, logentry, data):
return self.plain.format(data['other_email'])
@@ -841,6 +904,10 @@ class OrganizerPluginStateLogEntryType(LogEntryType):
'pretix.event.permissions.invited': _('A user has been invited to the event team.'),
'pretix.event.permissions.changed': _('A user\'s permissions have been changed.'),
'pretix.event.permissions.deleted': _('A user has been removed from the event team.'),
'pretix.event.seats.blocks.changed': _('A seat in the seating plan has been blocked or unblocked.'),
'pretix.seatingplan.added': _('A seating plan has been added.'),
'pretix.seatingplan.changed': _('A seating plan has been changed.'),
'pretix.seatingplan.deleted': _('A seating plan has been deleted.'),
})
class CoreEventLogEntryType(EventLogEntryType):
pass
@@ -890,9 +957,6 @@ class EventPluginStateLogEntryType(EventLogEntryType):
'pretix.event.item.bundles.added': _('A bundled item has been added to this product.'),
'pretix.event.item.bundles.removed': _('A bundled item has been removed from this product.'),
'pretix.event.item.bundles.changed': _('A bundled item has been changed on this product.'),
'pretix.event.item.program_times.added': _('A program time has been added to this product.'),
'pretix.event.item.program_times.changed': _('A program time has been changed on this product.'),
'pretix.event.item.program_times.removed': _('A program time has been removed from this product.'),
})
class CoreItemLogEntryType(ItemLogEntryType):
pass

View File

@@ -72,7 +72,7 @@ class PermissionMiddleware:
)
EXCEPTIONS_FORCED_PW_CHANGE = (
"user.settings.password.change",
"user.settings",
"auth.logout"
)
@@ -139,7 +139,7 @@ class PermissionMiddleware:
return redirect_to_url(reverse('control:user.reauth') + '?next=' + quote(request.get_full_path()))
except SessionPasswordChangeRequired:
if url_name not in self.EXCEPTIONS_FORCED_PW_CHANGE:
return redirect_to_url(reverse('control:user.settings.password.change') + '?next=' + quote(request.get_full_path()))
return redirect_to_url(reverse('control:user.settings') + '?next=' + quote(request.get_full_path()))
except Session2FASetupRequired:
if url_name not in self.EXCEPTIONS_2FA:
return redirect_to_url(reverse('control:user.settings.2fa'))

View File

@@ -7,7 +7,6 @@
<h3>{% trans "Set new password" %}</h3>
{% csrf_token %}
{% bootstrap_form_errors form type='all' layout='inline' %}
{% bootstrap_field form.email %}
{% bootstrap_field form.password %}
{% bootstrap_field form.password_repeat %}
<div class="form-group buttons">

View File

@@ -1,13 +0,0 @@
{% load i18n %}{% blocktrans with url=url|safe messages=messages|safe %}Hello,
{{ reason }}
{{ code }}
Please do never give this code to another person. Our support team will never ask for this code.
If this code was not requested by you, please contact us immediately.
Best regards,
Your pretix team
{% endblocktrans %}

View File

@@ -1,70 +0,0 @@
{% load i18n %}
{% load bootstrap3 %}
{% load formset_tags %}
<p>
{% blocktrans trimmed %}
With program times, you can set specific dates and times for this product.
This is useful if this product represents access to parts of your event that happen at different times than your event in general.
This will not affect access control, but will affect calendar invites and ticket output.
{% endblocktrans %}
</p>
<div class="formset" data-formset data-formset-prefix="{{ formset.prefix }}">
{{ formset.management_form }}
{% bootstrap_formset_errors formset %}
<div data-formset-body>
{% for form in formset %}
<div class="panel panel-default" data-formset-form>
<div class="sr-only">
{{ form.id }}
{% bootstrap_field form.DELETE form_group_class="" layout="inline" %}
</div>
<div class="panel-heading">
<div class="row">
<div class="col-sm-8">
<h3 class="panel-title">{% trans "Program time" %}</h3>
</div>
<div class="col-sm-4 text-right flip">
<button type="button" class="btn btn-xs btn-danger" data-formset-delete-button>
<i class="fa fa-trash"></i></button>
</div>
</div>
</div>
<div class="panel-body form-horizontal">
{% bootstrap_form_errors form %}
{% bootstrap_field form.start layout="control" %}
{% bootstrap_field form.end layout="control" %}
</div>
</div>
{% endfor %}
</div>
<script type="form-template" data-formset-empty-form>
{% escapescript %}
<div class="panel panel-default" data-formset-form>
<div class="sr-only">
{{ formset.empty_form.id }}
{% bootstrap_field formset.empty_form.DELETE form_group_class="" layout="inline" %}
</div>
<div class="panel-heading">
<div class="row">
<div class="col-sm-8">
<h3 class="panel-title">{% trans "Program time" %}</h3>
</div>
<div class="col-sm-4 text-right flip">
<button type="button" class="btn btn-xs btn-danger" data-formset-delete-button>
<i class="fa fa-trash"></i></button>
</div>
</div>
</div>
<div class="panel-body form-horizontal">
{% bootstrap_field formset.empty_form.start layout="control" %}
{% bootstrap_field formset.empty_form.end layout="control" %}
</div>
</div>
{% endescapescript %}
</script>
<p>
<button type="button" class="btn btn-default" data-formset-add>
<i class="fa fa-plus"></i> {% trans "Add a program time" %}</button>
</p>
</div>

View File

@@ -143,7 +143,6 @@
</div>
<div class="col-md-5">
<script type="text/plain" id="dependency_value_val">{{ form.instance.dependency_values|escapejson_dumps }}</script>
<script type="text/plain" id="countries">{{ countries|escapejson_dumps }}</script>
{% bootstrap_field form.dependency_values layout="inline" form_group_class="inner" %}
</div>
</div>

View File

@@ -1,29 +0,0 @@
{% extends "pretixcontrol/base.html" %}
{% load i18n %}
{% load bootstrap3 %}
{% block title %}{% trans "Change login email address" %}{% endblock %}
{% block content %}
<form action="" method="post" class="form centered-form">
<h1>
{% trans "Change login email address" %}
</h1>
{% csrf_token %}
{% bootstrap_form_errors form %}
<p class="text-muted">
{% trans "This changes the email address used to login to your account, as well as where we send email notifications." %}
</p>
{% bootstrap_field form.old_email %}
{% bootstrap_field form.new_email %}
<p>
{% trans "We will send a confirmation code to your new email address, which you need to enter in the next step to confirm the email address is correct." %}
</p>
<div class="form-group submit-group">
<a href="{% url "control:user.settings" %}" class="btn btn-default btn-cancel">
{% trans "Cancel" %}
</a>
<button type="submit" class="btn btn-primary btn-save btn-lg">
{% trans "Continue" %}
</button>
</div>
</form>
{% endblock %}

View File

@@ -1,24 +0,0 @@
{% extends "pretixcontrol/base.html" %}
{% load i18n %}
{% load bootstrap3 %}
{% block title %}{% trans "Change password" %}{% endblock %}
{% block content %}
<form action="" method="post" class="form centered-form">
<h1>
{% trans "Change password" %}
</h1>
<br>
{% csrf_token %}
{% bootstrap_form_errors form %}
{% bootstrap_field form.email %}
{% bootstrap_field form.old_pw %}
{% bootstrap_field form.new_pw %}
{% bootstrap_field form.new_pw_repeat %}
<div class="form-group submit-group">
<a href="{% url "control:user.settings" %}" class="btn btn-default btn-cancel">{% trans "Cancel" %}</a>
<button type="submit" class="btn btn-primary btn-save btn-lg">
{% trans "Change password" %}
</button>
</div>
</form>
{% endblock %}

View File

@@ -1,21 +0,0 @@
{% extends "pretixcontrol/base.html" %}
{% load i18n %}
{% load bootstrap3 %}
{% block title %}{% trans "Enter confirmation code" %}{% endblock %}
{% block content %}
<form action="" method="post" class="form centered-form">
<h1>
{% trans "Enter confirmation code" %}
</h1>
{% csrf_token %}
{% bootstrap_form_errors form type='all' layout='inline' %}
<p>{{ message }}</p>
{% bootstrap_field form.code %}
<div class="form-group submit-group">
<a href="{{ cancel_url }}" class="btn btn-default btn-cancel">{% trans "Cancel" %}</a>
<button type="submit" class="btn btn-primary btn-save btn-lg">
{% trans "Continue" %}
</button>
</div>
</form>
{% endblock %}

View File

@@ -3,26 +3,8 @@
{% load bootstrap3 %}
{% block title %}{% trans "Account settings" %}{% endblock %}
{% block content %}
{% if not user.is_verified %}
<div class="alert alert-info">
<p>
{% blocktrans trimmed %}
Your email address is not confirmed yet. To secure your account, please confirm your email address using
a confirmation code we will send to your email address.
{% endblocktrans %}
</p>
<p>
<form action="{% url "control:user.settings.email.send_verification_code" %}" method="post" class="form-horizontal">
{% csrf_token %}
<button type="submit" class="btn btn-primary">
{% trans "Send confirmation email" %}
</button>
</form>
</p>
</div>
{% endif %}
<h1>{% trans "Account settings" %}</h1>
<form action="" method="post" class="form-horizontal" data-testid="usersettingsform">
<form action="" method="post" class="form-horizontal">
{% csrf_token %}
{% bootstrap_form_errors form %}
<fieldset>
@@ -31,7 +13,7 @@
{% bootstrap_field form.locale layout='horizontal' %}
{% bootstrap_field form.timezone layout='horizontal' %}
<div class="form-group">
<label class="col-md-3 control-label">{% trans "Notifications" %}</label>
<label class="col-md-3 control-label" for="id_new_pw_repeat">{% trans "Notifications" %}</label>
<div class="col-md-9 static-form-row">
{% if request.user.notifications_send and request.user.notification_settings.exists %}
<span class="label label-success">
@@ -59,18 +41,8 @@
{% bootstrap_field form.new_pw layout='horizontal' %}
{% bootstrap_field form.new_pw_repeat layout='horizontal' %}
{% endif %}
{% if user.auth_backend == 'native' %}
<div class="form-group">
<label class="col-md-3 control-label">{% trans "Password" %}</label>
<div class="col-md-9 static-form-row">
<a href="{% url "control:user.settings.password.change" %}">
<span class="fa fa-edit"></span> {% trans "Change password" %}
</a>
</div>
</div>
{% endif %}
<div class="form-group">
<label class="col-md-3 control-label">{% trans "Two-factor authentication" %}</label>
<label class="col-md-3 control-label" for="id_new_pw_repeat">{% trans "Two-factor authentication" %}</label>
<div class="col-md-9 static-form-row">
{% if user.require_2fa %}
<span class="label label-success">{% trans "Enabled" %}</span> &nbsp;
@@ -86,7 +58,7 @@
</div>
</div>
<div class="form-group">
<label class="col-md-3 control-label">{% trans "Authorized applications" %}</label>
<label class="col-md-3 control-label" for="">{% trans "Authorized applications" %}</label>
<div class="col-md-9 static-form-row">
<a href="{% url "control:user.settings.oauth.list" %}">
<span class="fa fa-plug"></span>
@@ -95,7 +67,7 @@
</div>
</div>
<div class="form-group">
<label class="col-md-3 control-label">{% trans "Account history" %}</label>
<label class="col-md-3 control-label" for="">{% trans "Account history" %}</label>
<div class="col-md-9 static-form-row">
<a href="{% url "control:user.settings.history" %}">
<span class="fa fa-history"></span>

View File

@@ -56,7 +56,6 @@
{% if form.new_pw %}
{% bootstrap_field form.new_pw layout='control' %}
{% bootstrap_field form.new_pw_repeat layout='control' %}
{% bootstrap_field form.is_verified layout='control' %}
{% endif %}
{% bootstrap_field form.last_login layout='control' %}
{% bootstrap_field form.require_2fa layout='control' %}

View File

@@ -110,10 +110,6 @@ urlpatterns = [
name='user.settings.2fa.confirm.webauthn'),
re_path(r'^settings/2fa/(?P<devicetype>[^/]+)/(?P<device>[0-9]+)/delete', user.User2FADeviceDeleteView.as_view(),
name='user.settings.2fa.delete'),
re_path(r'^settings/email/confirm$', user.UserEmailConfirmView.as_view(), name='user.settings.email.confirm'),
re_path(r'^settings/email/change$', user.UserEmailChangeView.as_view(), name='user.settings.email.change'),
re_path(r'^settings/email/verify', user.UserEmailVerifyView.as_view(), name='user.settings.email.send_verification_code'),
re_path(r'^settings/password/change$', user.UserPasswordChangeView.as_view(), name='user.settings.password.change'),
re_path(r'^organizers/$', organizer.OrganizerList.as_view(), name='organizers'),
re_path(r'^organizers/add$', organizer.OrganizerCreate.as_view(), name='organizers.add'),
re_path(r'^organizers/select2$', typeahead.organizer_select2, name='organizers.select2'),

View File

@@ -60,14 +60,13 @@ from django.views.generic.detail import DetailView, SingleObjectMixin
from django_countries.fields import Country
from pretix.api.serializers.item import (
ItemAddOnSerializer, ItemBundleSerializer, ItemProgramTimeSerializer,
ItemVariationSerializer,
ItemAddOnSerializer, ItemBundleSerializer, ItemVariationSerializer,
)
from pretix.base.forms import I18nFormSet
from pretix.base.models import (
CartPosition, Item, ItemCategory, ItemProgramTime, ItemVariation, Order,
OrderPosition, Question, QuestionAnswer, QuestionOption, Quota,
SeatCategoryMapping, Voucher,
CartPosition, Item, ItemCategory, ItemVariation, Order, OrderPosition,
Question, QuestionAnswer, QuestionOption, Quota, SeatCategoryMapping,
Voucher,
)
from pretix.base.models.event import SubEvent
from pretix.base.models.items import ItemAddOn, ItemBundle, ItemMetaValue
@@ -76,9 +75,9 @@ from pretix.base.services.tickets import invalidate_cache
from pretix.base.signals import quota_availability
from pretix.control.forms.item import (
CategoryForm, ItemAddOnForm, ItemAddOnsFormSet, ItemBundleForm,
ItemBundleFormSet, ItemCreateForm, ItemMetaValueForm, ItemProgramTimeForm,
ItemProgramTimeFormSet, ItemUpdateForm, ItemVariationForm,
ItemVariationsFormSet, QuestionForm, QuestionOptionForm, QuotaForm,
ItemBundleFormSet, ItemCreateForm, ItemMetaValueForm, ItemUpdateForm,
ItemVariationForm, ItemVariationsFormSet, QuestionForm, QuestionOptionForm,
QuotaForm,
)
from pretix.control.permissions import (
EventPermissionRequiredMixin, event_permission_required,
@@ -87,7 +86,6 @@ from pretix.control.signals import item_forms, item_formsets
from pretix.helpers.models import modelcopy
from ...helpers.compat import CompatDeleteView
from ...helpers.countries import CachedCountries
from . import ChartContainingView, CreateView, PaginationMixin, UpdateView
@@ -658,7 +656,6 @@ class QuestionMixin:
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
ctx['formset'] = self.formset
ctx['countries'] = [{"id": k, "name": str(c)} for k, c in dict(CachedCountries().countries).items()]
return ctx
@@ -1434,8 +1431,7 @@ class ItemUpdateGeneral(ItemDetailMixin, EventPermissionRequiredMixin, MetaDataE
form.instance.position = i
setattr(form.instance, attr, self.get_object())
created = not form.instance.pk
if form.has_changed():
form.save()
form.save()
if form.has_changed() and any(a for a in form.changed_data if a != 'ORDER'):
change_data = {k: form.cleaned_data.get(k) for k in form.changed_data}
if key == 'variations':
@@ -1501,16 +1497,6 @@ class ItemUpdateGeneral(ItemDetailMixin, EventPermissionRequiredMixin, MetaDataE
'bundles', 'bundles', 'base_item', order=False,
serializer=ItemBundleSerializer
)
elif k == 'program_times':
self.save_formset(
'program_times', 'program_times', order=False,
serializer=ItemProgramTimeSerializer
)
if not change_data:
for f in v.forms:
if (f in v.deleted_forms and f.instance.pk) or f.has_changed():
invalidate_cache.apply_async(kwargs={'event': self.request.event.pk, 'item': self.object.pk})
break
else:
v.save()
@@ -1573,20 +1559,9 @@ class ItemUpdateGeneral(ItemDetailMixin, EventPermissionRequiredMixin, MetaDataE
queryset=ItemBundle.objects.filter(base_item=self.get_object()),
event=self.request.event, item=self.item, prefix="bundles"
)),
('program_times', inlineformset_factory(
Item, ItemProgramTime,
form=ItemProgramTimeForm, formset=ItemProgramTimeFormSet,
can_order=False, can_delete=True, extra=0
)(
self.request.POST if self.request.method == "POST" else None,
queryset=ItemProgramTime.objects.filter(item=self.get_object()),
event=self.request.event, prefix="program_times"
)),
])
if not self.object.has_variations:
del f['variations']
if self.item.event.has_subevents:
del f['program_times']
i = 0
for rec, resp in item_formsets.send(sender=self.request.event, item=self.item, request=self.request):

View File

@@ -2146,8 +2146,7 @@ class OrderChange(OrderView):
self.order,
user=self.request.user,
notify=notify,
reissue_invoice=self.other_form.cleaned_data['reissue_invoice'] if self.other_form.is_valid() else True,
allow_blocked_seats=True,
reissue_invoice=self.other_form.cleaned_data['reissue_invoice'] if self.other_form.is_valid() else True
)
form_valid = (self._process_add_fees(ocm) and
self._process_add_positions(ocm) and

View File

@@ -44,9 +44,7 @@ from pypdf.errors import PdfReadError
from reportlab.lib.units import mm
from pretix.base.i18n import language
from pretix.base.models import (
CachedFile, InvoiceAddress, ItemProgramTime, OrderPosition,
)
from pretix.base.models import CachedFile, InvoiceAddress, OrderPosition
from pretix.base.pdf import get_images, get_variables
from pretix.base.settings import PERSON_NAME_SCHEMES
from pretix.control.permissions import EventPermissionRequiredMixin
@@ -97,9 +95,6 @@ class BaseEditorView(EventPermissionRequiredMixin, TemplateView):
description=_("Sample product description"))
item2 = self.request.event.items.create(name=_("Sample workshop"), default_price=Decimal('23.40'))
ItemProgramTime.objects.create(start=now(), end=now(), item=item)
ItemProgramTime.objects.create(start=now(), end=now(), item=item2)
from pretix.base.models import Order
order = self.request.event.orders.create(status=Order.STATUS_PENDING, datetime=now(),
email='sample@pretix.eu',

View File

@@ -44,13 +44,11 @@ from django.conf import settings
from django.contrib import messages
from django.contrib.auth import update_session_auth_hash
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import BadRequest, PermissionDenied
from django.db import transaction
from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse
from django.utils.crypto import get_random_string
from django.utils.functional import cached_property
from django.utils.html import format_html
from django.utils.http import url_has_allowed_host_and_scheme
from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _
@@ -62,11 +60,8 @@ from django_scopes import scopes_disabled
from webauthn.helpers import generate_challenge, generate_user_handle
from pretix.base.auth import get_auth_backends
from pretix.base.forms.auth import ConfirmationCodeForm, ReauthForm
from pretix.base.forms.user import (
User2FADeviceAddForm, UserEmailChangeForm, UserPasswordChangeForm,
UserSettingsForm,
)
from pretix.base.forms.auth import ReauthForm
from pretix.base.forms.user import User2FADeviceAddForm, UserSettingsForm
from pretix.base.models import (
Event, LogEntry, NotificationSetting, U2FDevice, User, WebAuthnDevice,
)
@@ -242,7 +237,25 @@ class UserSettings(UpdateView):
data = {}
for k in form.changed_data:
data[k] = form.cleaned_data[k]
if k not in ('old_pw', 'new_pw_repeat'):
if 'new_pw' == k:
data['new_pw'] = True
else:
data[k] = form.cleaned_data[k]
msgs = []
if 'new_pw' in form.changed_data:
self.request.user.needs_password_change = False
msgs.append(_('Your password has been changed.'))
if 'email' in form.changed_data:
msgs.append(_('Your email address has been changed to {email}.').format(email=form.cleaned_data['email']))
if msgs:
self.request.user.send_security_notice(msgs, email=form.cleaned_data['email'])
if self._old_email != form.cleaned_data['email']:
self.request.user.send_security_notice(msgs, email=self._old_email)
sup = super().form_valid(form)
self.request.user.log_action('pretix.user.settings.changed', user=self.request.user, data=data)
@@ -821,159 +834,3 @@ class EditStaffSession(StaffMemberRequiredMixin, UpdateView):
return get_object_or_404(StaffSession, pk=self.kwargs['id'])
else:
return get_object_or_404(StaffSession, pk=self.kwargs['id'], user=self.request.user)
class UserPasswordChangeView(FormView):
max_time = 300
form_class = UserPasswordChangeForm
template_name = 'pretixcontrol/user/change_password.html'
def get_form_kwargs(self):
if self.request.user.auth_backend != 'native':
raise PermissionDenied
return {
**super().get_form_kwargs(),
"user": self.request.user,
}
def form_valid(self, form):
with transaction.atomic():
self.request.user.set_password(form.cleaned_data['new_pw'])
self.request.user.needs_password_change = False
self.request.user.save()
msgs = []
msgs.append(_('Your password has been changed.'))
self.request.user.send_security_notice(msgs)
self.request.user.log_action('pretix.user.settings.changed', user=self.request.user, data={'new_pw': True})
update_session_auth_hash(self.request, self.request.user)
messages.success(self.request, _('Your changes have been saved.'))
return redirect(self.get_success_url())
def form_invalid(self, form):
messages.error(self.request, _('We could not save your changes. See below for details.'))
return super().form_invalid(form)
def get_success_url(self):
if "next" in self.request.GET and url_has_allowed_host_and_scheme(self.request.GET.get("next"), allowed_hosts=None):
return self.request.GET.get("next")
return reverse('control:user.settings')
class UserEmailChangeView(RecentAuthenticationRequiredMixin, FormView):
max_time = 300
form_class = UserEmailChangeForm
template_name = 'pretixcontrol/user/change_email.html'
def get_form_kwargs(self):
if self.request.user.auth_backend != 'native':
raise PermissionDenied
return {
**super().get_form_kwargs(),
"user": self.request.user,
}
def get_initial(self):
return {
"old_email": self.request.user.email
}
def form_valid(self, form):
self.request.user.send_confirmation_code(
session=self.request.session,
reason='email_change',
email=form.cleaned_data['new_email'],
state=form.cleaned_data['new_email'],
)
self.request.session['email_confirmation_destination'] = form.cleaned_data['new_email']
return redirect(reverse('control:user.settings.email.confirm', kwargs={}) + '?reason=email_change')
def form_invalid(self, form):
messages.error(self.request, _('We could not save your changes. See below for details.'))
return super().form_invalid(form)
class UserEmailVerifyView(View):
def post(self, request, *args, **kwargs):
if self.request.user.is_verified:
messages.success(self.request, _('Your email address was already verified.'))
return redirect(reverse('control:user.settings', kwargs={}))
self.request.user.send_confirmation_code(
session=self.request.session,
reason='email_verify',
email=self.request.user.email,
state=self.request.user.email,
)
self.request.session['email_confirmation_destination'] = self.request.user.email
return redirect(reverse('control:user.settings.email.confirm', kwargs={}) + '?reason=email_verify')
class UserEmailConfirmView(FormView):
form_class = ConfirmationCodeForm
template_name = 'pretixcontrol/user/confirmation_code_dialog.html'
def get_context_data(self, **kwargs):
return {
**super().get_context_data(**kwargs),
"cancel_url": reverse('control:user.settings', kwargs={}),
"message": format_html(
_("Please enter the confirmation code we sent to your email address <strong>{email}</strong>."),
email=self.request.session.get('email_confirmation_destination', ''),
),
}
@transaction.atomic()
def form_valid(self, form):
reason = self.request.GET['reason']
if reason not in ('email_change', 'email_verify'):
raise PermissionDenied
try:
new_email = self.request.user.check_confirmation_code(
session=self.request.session,
reason=reason,
code=form.cleaned_data['code'],
)
except PermissionDenied:
return self.form_invalid(form)
except BadRequest:
messages.error(self.request, _(
'We were unable to verify your confirmation code. Please try again.'
))
return redirect(reverse('control:user.settings', kwargs={}))
log_data = {
'email': new_email,
'email_verified': True,
}
if reason == 'email_change':
msgs = []
msgs.append(_('Your email address has been changed to {email}.').format(email=new_email))
log_data['old_email'] = old_email = self.request.user.email
self.request.user.send_security_notice(msgs, email=old_email)
self.request.user.send_security_notice(msgs, email=new_email)
log_action = 'pretix.user.email.changed'
else:
log_action = 'pretix.user.email.confirmed'
self.request.user.email = new_email
self.request.user.is_verified = True
self.request.user.save()
self.request.user.log_action(log_action, user=self.request.user, data=log_data)
update_session_auth_hash(self.request, self.request.user)
if reason == 'email_change':
messages.success(self.request, _('Your email address has been changed successfully.'))
else:
messages.success(self.request, _('Your email address has been confirmed successfully.'))
return redirect(reverse('control:user.settings', kwargs={}))
def form_invalid(self, form):
messages.error(self.request, _('The entered confirmation code is not correct. Please try again.'))
return super().form_invalid(form)

View File

@@ -26,6 +26,7 @@ from django.dispatch import receiver
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
from pretix.base.logentrytype_registry import LogEntryType, log_entry_types
from pretix.base.models import Checkin, OrderPayment
from pretix.base.signals import (
checkin_created, event_copy_data, item_copy_data, logentry_display,
@@ -64,19 +65,13 @@ def nav_event_receiver(sender, request, **kwargs):
]
@receiver(signal=logentry_display)
def logentry_display_receiver(sender, logentry, **kwargs):
plains = {
"pretix.plugins.autocheckin.rule.added": _("An auto check-in rule was created"),
"pretix.plugins.autocheckin.rule.changed": _(
"An auto check-in rule was updated"
),
"pretix.plugins.autocheckin.rule.deleted": _(
"An auto check-in rule was deleted"
),
}
if logentry.action_type in plains:
return plains[logentry.action_type]
@log_entry_types.new_from_dict({
"pretix.plugins.autocheckin.rule.added": _("An auto check-in rule was created"),
"pretix.plugins.autocheckin.rule.changed": _("An auto check-in rule was updated"),
"pretix.plugins.autocheckin.rule.deleted": _("An auto check-in rule was deleted"),
})
class AutocheckinLogEntryType(LogEntryType):
pass
@receiver(item_copy_data, dispatch_uid="autocheckin_item_copy")

View File

@@ -31,6 +31,7 @@ from django.utils.translation import gettext_lazy as _
from paypalhttp import HttpResponse
from pretix.base.forms import SecretKeySettingsField
from pretix.base.logentrytype_registry import LogEntryType, log_entry_types
from pretix.base.middleware import _merge_csp, _parse_csp, _render_csp
from pretix.base.settings import settings_hierarkey
from pretix.base.signals import (
@@ -80,37 +81,36 @@ def html_head_presale(sender, request=None, **kwargs):
return ""
@receiver(signal=logentry_display, dispatch_uid="stripe_logentry_display")
def pretixcontrol_logentry_display(sender, logentry, **kwargs):
if logentry.action_type != 'pretix.plugins.stripe.event':
return
@log_entry_types.new()
class StripeEvent(LogEntryType):
action_type = 'pretix.plugins.stripe.event'
data = json.loads(logentry.data)
event_type = data.get('type')
text = None
plains = {
'charge.succeeded': _('Charge succeeded.'),
'charge.refunded': _('Charge refunded.'),
'charge.updated': _('Charge updated.'),
'charge.pending': _('Charge pending'),
'source.chargeable': _('Payment authorized.'),
'source.canceled': _('Payment authorization canceled.'),
'source.failed': _('Payment authorization failed.')
}
def display(self, logentry, data):
event_type = data.get('type')
text = None
plains = {
'charge.succeeded': _('Charge succeeded.'),
'charge.refunded': _('Charge refunded.'),
'charge.updated': _('Charge updated.'),
'charge.pending': _('Charge pending'),
'source.chargeable': _('Payment authorized.'),
'source.canceled': _('Payment authorization canceled.'),
'source.failed': _('Payment authorization failed.')
}
if event_type in plains:
text = plains[event_type]
elif event_type == 'charge.failed':
text = _('Charge failed. Reason: {}').format(data['data']['object']['failure_message'])
elif event_type == 'charge.dispute.created':
text = _('Dispute created. Reason: {}').format(data['data']['object']['reason'])
elif event_type == 'charge.dispute.updated':
text = _('Dispute updated. Reason: {}').format(data['data']['object']['reason'])
elif event_type == 'charge.dispute.closed':
text = _('Dispute closed. Status: {}').format(data['data']['object']['status'])
if event_type in plains:
text = plains[event_type]
elif event_type == 'charge.failed':
text = _('Charge failed. Reason: {}').format(data['data']['object']['failure_message'])
elif event_type == 'charge.dispute.created':
text = _('Dispute created. Reason: {}').format(data['data']['object']['reason'])
elif event_type == 'charge.dispute.updated':
text = _('Dispute updated. Reason: {}').format(data['data']['object']['reason'])
elif event_type == 'charge.dispute.closed':
text = _('Dispute closed. Status: {}').format(data['data']['object']['status'])
if text:
return _('Stripe reported an event: {}').format(text)
if text:
return _('Stripe reported an event: {}').format(text)
settings_hierarkey.add_default('payment_stripe_method_card', True, bool)

View File

@@ -1058,7 +1058,6 @@ class QuestionsStep(QuestionsViewMixin, CartMixin, TemplateFlowStep):
return (
('True' in qvals and answ[parentid].answer == 'True')
or ('False' in qvals and answ[parentid].answer == 'False')
or (parentq.type == Question.TYPE_COUNTRYCODE and answ[parentid].answer in qvals)
or (any(qval in [o.identifier for o in answ[parentid].options.all()] for qval in qvals))
)

View File

@@ -20,12 +20,10 @@
# <https://www.gnu.org/licenses/>.
#
import datetime
from collections import namedtuple
from urllib.parse import urlparse
import vobject
from django.conf import settings
from django.db.models import prefetch_related_objects
from django.utils.formats import date_format
from django.utils.translation import gettext as _
@@ -124,109 +122,61 @@ def get_private_icals(event, positions):
creation_time = datetime.datetime.now(datetime.timezone.utc)
calobjects = []
calentries = set() # using set for automatic deduplication of CalEntries
CalEntry = namedtuple('CalEntry', ['summary', 'description', 'location', 'dtstart', 'dtend', 'uid'])
# collecting the positions' calendar entries, preferring the most exact date and time available (positions > subevent > event)
prefetch_related_objects(positions, 'item__program_times')
for p in positions:
ev = p.subevent or event
program_times = p.item.program_times.all()
if program_times:
# if program times have been configured, they are preferred for the position's calendar entries
evs = set(p.subevent or event for p in positions)
for ev in evs:
if isinstance(ev, Event):
url = build_absolute_uri(event, 'presale:event.index')
for index, pt in enumerate(program_times):
summary = _('{event} - {item}').format(event=ev, item=p.item.name)
if event.settings.mail_attach_ical_description:
ctx = get_email_context(event=event, event_or_subevent=ev)
description = format_map(str(event.settings.mail_attach_ical_description), ctx)
else:
# Default description
descr = []
descr.append(_('Tickets: {url}').format(url=url))
descr.append(str(_('Start: {datetime}')).format(
datetime=date_format(pt.start.astimezone(tz), 'SHORT_DATETIME_FORMAT')
))
descr.append(str(_('End: {datetime}')).format(
datetime=date_format(pt.end.astimezone(tz), 'SHORT_DATETIME_FORMAT')
))
# Actual ical organizer field is not useful since it will cause "your invitation was accepted" emails to the organizer
descr.append(_('Organizer: {organizer}').format(organizer=event.organizer.name))
description = '\n'.join(descr)
location = None
dtstart = pt.start.astimezone(tz)
dtend = pt.end.astimezone(tz)
uid = 'pretix-{}-{}-{}-{}@{}'.format(
event.organizer.slug,
event.slug,
p.item.id,
index,
urlparse(url).netloc
)
calentries.add(CalEntry(summary, description, location, dtstart, dtend, uid))
else:
# without program times, the subevent or event times are used for calendar entries, preferring subevents
if p.subevent:
url = build_absolute_uri(event, 'presale:event.index', {
'subevent': p.subevent.pk
})
else:
url = build_absolute_uri(event, 'presale:event.index')
url = build_absolute_uri(event, 'presale:event.index', {
'subevent': ev.pk
})
if event.settings.mail_attach_ical_description:
ctx = get_email_context(event=event, event_or_subevent=ev)
description = format_map(str(event.settings.mail_attach_ical_description), ctx)
else:
# Default description
descr = []
descr.append(_('Tickets: {url}').format(url=url))
if ev.date_admission:
descr.append(str(_('Admission: {datetime}')).format(
datetime=date_format(ev.date_admission.astimezone(tz), 'SHORT_DATETIME_FORMAT')
))
if event.settings.mail_attach_ical_description:
ctx = get_email_context(event=event, event_or_subevent=ev)
description = format_map(str(event.settings.mail_attach_ical_description), ctx)
else:
# Default description
descr = []
descr.append(_('Tickets: {url}').format(url=url))
if ev.date_admission:
descr.append(str(_('Admission: {datetime}')).format(
datetime=date_format(ev.date_admission.astimezone(tz), 'SHORT_DATETIME_FORMAT')
))
# Actual ical organizer field is not useful since it will cause "your invitation was accepted" emails to the organizer
descr.append(_('Organizer: {organizer}').format(organizer=event.organizer.name))
description = '\n'.join(descr)
summary = str(ev.name)
if ev.location:
location = ", ".join(l.strip() for l in str(ev.location).splitlines() if l.strip())
else:
location = None
if event.settings.show_times:
dtstart = ev.date_from.astimezone(tz)
else:
dtstart = ev.date_from.astimezone(tz).date()
if event.settings.show_date_to and ev.date_to:
if event.settings.show_times:
dtend = ev.date_to.astimezone(tz)
else:
# with full-day events date_to in pretix is included (e.g. last day)
# whereas dtend in vcalendar is non-inclusive => add one day for export
dtend = ev.date_to.astimezone(tz).date() + datetime.timedelta(days=1)
else:
dtend = None
uid = 'pretix-{}-{}-{}@{}'.format(
event.organizer.slug,
event.slug,
ev.pk if p.subevent else '0',
urlparse(url).netloc
)
calentries.add(CalEntry(summary, description, location, dtstart, dtend, uid))
# Actual ical organizer field is not useful since it will cause "your invitation was accepted" emails to the organizer
descr.append(_('Organizer: {organizer}').format(organizer=event.organizer.name))
description = '\n'.join(descr)
for calentry in calentries:
cal = vobject.iCalendar()
cal.add('prodid').value = '-//pretix//{}//'.format(settings.PRETIX_INSTANCE_NAME.replace(" ", "_"))
vevent = cal.add('vevent')
vevent.add('summary').value = calentry.summary
vevent.add('description').value = calentry.description
vevent.add('summary').value = str(ev.name)
vevent.add('description').value = description
vevent.add('dtstamp').value = creation_time
if calentry.location:
vevent.add('location').value = calentry.location
vevent.add('uid').value = calentry.uid
vevent.add('dtstart').value = calentry.dtstart
if calentry.dtend:
vevent.add('dtend').value = calentry.dtend
if ev.location:
vevent.add('location').value = ", ".join(l.strip() for l in str(ev.location).splitlines() if l.strip())
vevent.add('uid').value = 'pretix-{}-{}-{}@{}'.format(
event.organizer.slug,
event.slug,
ev.pk if not isinstance(ev, Event) else '0',
urlparse(url).netloc
)
if event.settings.show_times:
vevent.add('dtstart').value = ev.date_from.astimezone(tz)
else:
vevent.add('dtstart').value = ev.date_from.astimezone(tz).date()
if event.settings.show_date_to and ev.date_to:
if event.settings.show_times:
vevent.add('dtend').value = ev.date_to.astimezone(tz)
else:
# with full-day events date_to in pretix is included (e.g. last day)
# whereas dtend in vcalendar is non-inclusive => add one day for export
vevent.add('dtend').value = ev.date_to.astimezone(tz).date() + datetime.timedelta(days=1)
calobjects.append(cal)
return calobjects

View File

@@ -263,11 +263,3 @@ svg.svg-icon {
@include table-row-variant('warning', var(--pretix-brand-warning-lighten-40), var(--pretix-brand-warning-lighten-35));
@include table-row-variant('danger', var(--pretix-brand-danger-lighten-30), var(--pretix-brand-danger-lighten-25));
.confirmation-code-input {
font-size: 200%;
font-family: monospace;
font-stretch: expanded;
text-align: center;
height: 50px;
margin: 10px 0;
}

View File

@@ -114,7 +114,6 @@ $(function () {
var $val = $("#id_dependency_values");
var $dq = $("#id_dependency_question");
var oldval = JSON.parse($("#dependency_value_val").text());
var countries = JSON.parse($("#countries").text());
function update_dependency_options() {
$val.parent().find(".loading-indicator").remove();
$("#id_dependency_values option").remove();
@@ -136,10 +135,6 @@ $(function () {
if (data.type === "B") {
$val.append($("<option>").attr("value", "True").text(gettext("Yes")));
$val.append($("<option>").attr("value", "False").text(gettext("No")));
} else if (data.type === "CC") {
for (var c of countries) {
$val.append($("<option>").attr("value", c.id).text(c.name));
}
} else {
for (var i = 0; i < data.options.length; i++) {
var opt = data.options[i];

View File

@@ -938,25 +938,3 @@ details {
}
}
}
@media (min-width: $screen-lg-min) {
.centered-form {
margin: 80px auto;
max-width: 800px;
border: 1px solid #ddd;
padding: 20px 40px 0;
border-radius: 4px;
box-shadow: 2px 2px 2px #eee;
}
.form.centered-form .submit-group {
margin: 25px -40px 0 !important;
padding-right: 40px;
padding-left: 40px;
}
.centered-form p {
margin: 20px 0;
}
}

View File

@@ -11,7 +11,7 @@ function questions_toggle_dependent(ev) {
var $dependency_el;
if ($("select[name=" + dependency_name + "]").length) {
// dependency is type C or CC
// dependency is type C
$dependency_el = $("select[name=" + dependency_name + "]");
if (!$dependency_el.closest(".form-group").hasClass("dependency-hidden")) { // do not show things that depend on hidden things
return q_should_be_shown($dependency_el) && $.inArray($dependency_el.val(), dependency_values) > -1;

View File

@@ -46,8 +46,7 @@ from tests.const import SAMPLE_PNG
from pretix.base.models import (
CartPosition, InvoiceAddress, Item, ItemAddOn, ItemBundle, ItemCategory,
ItemProgramTime, ItemVariation, Order, OrderPosition, Question,
QuestionOption, Quota,
ItemVariation, Order, OrderPosition, Question, QuestionOption, Quota,
)
from pretix.base.models.orders import OrderFee
@@ -332,7 +331,6 @@ TEST_ITEM_RES = {
"variations": [],
"addons": [],
"bundles": [],
"program_times": [],
"show_quota_left": None,
"original_price": None,
"free_price_suggestion": None,
@@ -511,24 +509,6 @@ def test_item_detail_bundles(token_client, organizer, event, team, item, categor
assert res == resp.data
@pytest.mark.django_db
def test_item_detail_program_times(token_client, organizer, event, team, item, category):
with scopes_disabled():
item.program_times.create(item=item, start=datetime(2017, 12, 27, 0, 0, 0, tzinfo=timezone.utc),
end=datetime(2017, 12, 28, 0, 0, 0, tzinfo=timezone.utc))
res = dict(TEST_ITEM_RES)
res["id"] = item.pk
res["program_times"] = [{
"start": "2017-12-27T00:00:00Z",
"end": "2017-12-28T00:00:00Z",
}]
resp = token_client.get('/api/v1/organizers/{}/events/{}/items/{}/'.format(organizer.slug, event.slug,
item.pk))
assert resp.status_code == 200
assert res == resp.data
@pytest.mark.django_db
def test_item_create(token_client, organizer, event, item, category, taxrule, membership_type):
resp = token_client.post(
@@ -1092,57 +1072,6 @@ def test_item_create_with_bundle(token_client, organizer, event, item, category,
assert resp.content.decode() == '{"bundles":["The chosen variation does not belong to this item."]}'
@pytest.mark.django_db
def test_item_create_with_product_time(token_client, organizer, event, item, category, taxrule):
resp = token_client.post(
'/api/v1/organizers/{}/events/{}/items/'.format(organizer.slug, event.slug),
{
"category": category.pk,
"name": {
"en": "Ticket"
},
"active": True,
"description": None,
"default_price": "23.00",
"free_price": False,
"tax_rate": "19.00",
"tax_rule": taxrule.pk,
"admission": True,
"issue_giftcard": False,
"position": 0,
"picture": None,
"available_from": None,
"available_until": None,
"require_voucher": False,
"hide_without_voucher": False,
"allow_cancel": True,
"min_per_order": None,
"max_per_order": None,
"checkin_attention": False,
"checkin_text": None,
"has_variations": False,
"program_times": [
{
"start": "2017-12-27T00:00:00Z",
"end": "2017-12-28T00:00:00Z",
},
{
"start": "2017-12-29T00:00:00Z",
"end": "2017-12-30T00:00:00Z",
}
]
},
format='json'
)
assert resp.status_code == 201
with scopes_disabled():
new_item = Item.objects.get(pk=resp.data['id'])
assert new_item.program_times.first().start == datetime(2017, 12, 27, 0, 0, 0, tzinfo=timezone.utc)
assert new_item.program_times.first().end == datetime(2017, 12, 28, 0, 0, 0, tzinfo=timezone.utc)
assert new_item.program_times.last().start == datetime(2017, 12, 29, 0, 0, 0, tzinfo=timezone.utc)
assert new_item.program_times.last().end == datetime(2017, 12, 30, 0, 0, 0, tzinfo=timezone.utc)
@pytest.mark.django_db(transaction=True)
def test_item_update(token_client, organizer, event, item, category, item2, category2, taxrule2):
resp = token_client.patch(
@@ -1218,8 +1147,8 @@ def test_item_update(token_client, organizer, event, item, category, item2, cate
format='json'
)
assert resp.status_code == 400
assert resp.content.decode() == '{"non_field_errors":["Updating add-ons, bundles, program times or variations via ' \
'PATCH/PUT is not supported. Please use the dedicated nested endpoint."]}'
assert resp.content.decode() == '{"non_field_errors":["Updating add-ons, bundles, or variations via PATCH/PUT is not supported. Please use ' \
'the dedicated nested endpoint."]}'
resp = token_client.patch(
'/api/v1/organizers/{}/events/{}/items/{}/'.format(organizer.slug, event.slug, item.pk),
@@ -1236,8 +1165,8 @@ def test_item_update(token_client, organizer, event, item, category, item2, cate
format='json'
)
assert resp.status_code == 400
assert resp.content.decode() == '{"non_field_errors":["Updating add-ons, bundles, program times or variations via ' \
'PATCH/PUT is not supported. Please use the dedicated nested endpoint."]}'
assert resp.content.decode() == '{"non_field_errors":["Updating add-ons, bundles, or variations via PATCH/PUT is not supported. Please use ' \
'the dedicated nested endpoint."]}'
item.personalized = True
item.admission = True
@@ -1393,8 +1322,8 @@ def test_item_update_with_variation(token_client, organizer, event, item):
format='json'
)
assert resp.status_code == 400
assert resp.content.decode() == '{"non_field_errors":["Updating add-ons, bundles, program times or variations via ' \
'PATCH/PUT is not supported. Please use the dedicated nested endpoint."]}'
assert resp.content.decode() == '{"non_field_errors":["Updating add-ons, bundles, or variations via PATCH/PUT is not supported. Please use ' \
'the dedicated nested endpoint."]}'
@pytest.mark.django_db
@@ -1416,8 +1345,8 @@ def test_item_update_with_addon(token_client, organizer, event, item, category):
format='json'
)
assert resp.status_code == 400
assert resp.content.decode() == '{"non_field_errors":["Updating add-ons, bundles, program times or variations via ' \
'PATCH/PUT is not supported. Please use the dedicated nested endpoint."]}'
assert resp.content.decode() == '{"non_field_errors":["Updating add-ons, bundles, or variations via PATCH/PUT is not supported. Please use ' \
'the dedicated nested endpoint."]}'
@pytest.mark.django_db
@@ -1952,123 +1881,6 @@ def test_addons_delete(token_client, organizer, event, item, addon):
assert not item.addons.filter(pk=addon.id).exists()
@pytest.fixture
def program_time(item, category):
return item.program_times.create(start=datetime(2017, 12, 27, 0, 0, 0, tzinfo=timezone.utc),
end=datetime(2017, 12, 28, 0, 0, 0, tzinfo=timezone.utc))
@pytest.fixture
def program_time2(item, category):
return item.program_times.create(start=datetime(2017, 12, 29, 0, 0, 0, tzinfo=timezone.utc),
end=datetime(2017, 12, 30, 0, 0, 0, tzinfo=timezone.utc))
TEST_PROGRAM_TIMES_RES = {
0: {
"start": "2017-12-27T00:00:00Z",
"end": "2017-12-28T00:00:00Z",
},
1: {
"start": "2017-12-29T00:00:00Z",
"end": "2017-12-30T00:00:00Z",
}
}
@pytest.mark.django_db
def test_program_times_list(token_client, organizer, event, item, program_time, program_time2):
res = dict(TEST_PROGRAM_TIMES_RES)
res[0]["id"] = program_time.pk
res[1]["id"] = program_time2.pk
resp = token_client.get('/api/v1/organizers/{}/events/{}/items/{}/program_times/'.format(organizer.slug, event.slug,
item.pk))
assert resp.status_code == 200
assert res[0]['start'] == resp.data['results'][0]['start']
assert res[0]['end'] == resp.data['results'][0]['end']
assert res[0]['id'] == resp.data['results'][0]['id']
assert res[1]['start'] == resp.data['results'][1]['start']
assert res[1]['end'] == resp.data['results'][1]['end']
assert res[1]['id'] == resp.data['results'][1]['id']
@pytest.mark.django_db
def test_program_times_detail(token_client, organizer, event, item, program_time):
res = dict(TEST_PROGRAM_TIMES_RES)
res[0]["id"] = program_time.pk
resp = token_client.get(
'/api/v1/organizers/{}/events/{}/items/{}/program_times/{}/'.format(organizer.slug, event.slug,
item.pk, program_time.pk))
assert resp.status_code == 200
assert res[0] == resp.data
@pytest.mark.django_db
def test_program_times_create(token_client, organizer, event, item):
resp = token_client.post(
'/api/v1/organizers/{}/events/{}/items/{}/program_times/'.format(organizer.slug, event.slug, item.pk),
{
"start": "2017-12-27T00:00:00Z",
"end": "2017-12-28T00:00:00Z"
},
format='json'
)
assert resp.status_code == 201
with scopes_disabled():
program_time = ItemProgramTime.objects.get(pk=resp.data['id'])
assert datetime(2017, 12, 27, 0, 0, 0, tzinfo=timezone.utc) == program_time.start
assert datetime(2017, 12, 28, 0, 0, 0, tzinfo=timezone.utc) == program_time.end
resp = token_client.post(
'/api/v1/organizers/{}/events/{}/items/{}/program_times/'.format(organizer.slug, event.slug, item.pk),
{
"start": "2017-12-28T00:00:00Z",
"end": "2017-12-27T00:00:00Z"
},
format='json'
)
assert resp.status_code == 400
assert resp.content.decode() == '{"non_field_errors":["The program end must not be before the program start."]}'
@pytest.mark.django_db
def test_program_times_update(token_client, organizer, event, item, program_time):
resp = token_client.patch(
'/api/v1/organizers/{}/events/{}/items/{}/program_times/{}/'.format(organizer.slug, event.slug, item.pk,
program_time.pk),
{
"start": "2017-12-26T00:00:00Z"
},
format='json'
)
assert resp.status_code == 200
with scopes_disabled():
program_time = ItemProgramTime.objects.get(pk=resp.data['id'])
assert datetime(2017, 12, 26, 0, 0, 0, tzinfo=timezone.utc) == program_time.start
assert datetime(2017, 12, 28, 0, 0, 0, tzinfo=timezone.utc) == program_time.end
resp = token_client.patch(
'/api/v1/organizers/{}/events/{}/items/{}/program_times/{}/'.format(organizer.slug, event.slug, item.pk,
program_time.pk),
{
"start": "2017-12-30T00:00:00Z"
},
format='json'
)
assert resp.status_code == 400
assert resp.content.decode() == '{"non_field_errors":["The program end must not be before the program start."]}'
@pytest.mark.django_db
def test_program_times_delete(token_client, organizer, event, item, program_time):
resp = token_client.delete(
'/api/v1/organizers/{}/events/{}/items/{}/program_times/{}/'.format(organizer.slug, event.slug,
item.pk, program_time.pk))
assert resp.status_code == 204
with scopes_disabled():
assert not item.program_times.filter(pk=program_time.id).exists()
@pytest.fixture
def quota(event, item):
q = event.quotas.create(name="Budget Quota", size=200)

View File

@@ -1996,7 +1996,7 @@ def test_pdf_data(token_client, organizer, event, order, django_assert_max_num_q
assert not resp.data['positions'][0].get('pdf_data')
# order list
with django_assert_max_num_queries(33):
with django_assert_max_num_queries(32):
resp = token_client.get('/api/v1/organizers/{}/events/{}/orders/?pdf_data=true'.format(
organizer.slug, event.slug
))

View File

@@ -39,9 +39,7 @@ import pytest
from django.utils.timezone import now
from django_scopes import scopes_disabled
from pretix.base.models import (
Event, ItemProgramTime, Organizer, Question, SeatingPlan,
)
from pretix.base.models import Event, Organizer, Question, SeatingPlan
from pretix.base.models.items import ItemAddOn, ItemBundle, ItemMetaValue
@@ -80,10 +78,6 @@ def test_full_clone_same_organizer():
# todo: test that item pictures are copied, not linked
ItemMetaValue.objects.create(item=item1, property=item_meta, value="Foo")
assert item1.meta_data
ItemProgramTime.objects.create(item=item1,
start=datetime.datetime(2017, 12, 27, 0, 0, 0, tzinfo=datetime.timezone.utc),
end=datetime.datetime(2017, 12, 28, 0, 0, 0, tzinfo=datetime.timezone.utc))
assert item1.program_times
item2 = event.items.create(category=category, tax_rule=tax_rule, name="T-shirt", default_price=15,
hidden_if_item_available=item1)
item2v = item2.variations.create(value="red", default_price=15, all_sales_channels=False)
@@ -167,8 +161,6 @@ def test_full_clone_same_organizer():
assert copied_item1.category == copied_event.categories.get(name='Tickets')
assert copied_item1.limit_sales_channels.get() == sc
assert copied_item1.meta_data == item1.meta_data
assert copied_item1.program_times.first().start == item1.program_times.first().start
assert copied_item1.program_times.first().end == item1.program_times.first().end
assert copied_item2.variations.get().meta_data == item2v.meta_data
assert copied_item1.hidden_if_available == copied_q2
assert copied_item1.grant_membership_type == membership_type

View File

@@ -1134,7 +1134,7 @@ class PasswordChangeRequiredTest(TestCase):
super().setUp()
self.user = User.objects.create_user('dummy@dummy.dummy', 'dummy')
def test_redirect_to_password_change(self):
def test_redirect_to_settings(self):
self.user.needs_password_change = True
self.user.save()
self.client.login(email='dummy@dummy.dummy', password='dummy')
@@ -1143,9 +1143,9 @@ class PasswordChangeRequiredTest(TestCase):
self.assertEqual(response.status_code, 302)
assert self.user.needs_password_change is True
self.assertIn('/control/settings/password/change?next=/control/events/', response['Location'])
self.assertIn('/control/settings?next=/control/events/', response['Location'])
def test_redirect_to_2fa_to_password_change(self):
def test_redirect_to_2fa_to_settings(self):
self.user.require_2fa = True
self.user.needs_password_change = True
self.user.save()
@@ -1168,4 +1168,4 @@ class PasswordChangeRequiredTest(TestCase):
response = self.client.get('/control/events/')
self.assertEqual(response.status_code, 302)
self.assertIn('/control/settings/password/change?next=/control/events/', response['Location'])
self.assertIn('/control/settings?next=/control/events/', response['Location'])

View File

@@ -681,10 +681,6 @@ class ItemsTest(ItemFormTest):
self.var2.save()
prop = self.event1.item_meta_properties.create(name="Foo")
self.item2.meta_values.create(property=prop, value="Bar")
self.item2.program_times.create(start=datetime.datetime(2017, 12, 27, 0, 0, 0,
tzinfo=datetime.timezone.utc),
end=datetime.datetime(2017, 12, 28, 0, 0, 0,
tzinfo=datetime.timezone.utc))
doc = self.get_doc('/control/event/%s/%s/items/add?copy_from=%d' % (self.orga1.slug, self.event1.slug, self.item2.pk))
data = extract_form_fields(doc.select("form")[0])
@@ -713,8 +709,6 @@ class ItemsTest(ItemFormTest):
assert i_new.meta_data == i_old.meta_data == {"Foo": "Bar"}
assert set(i_new.questions.all()) == set(i_old.questions.all())
assert set([str(v.value) for v in i_new.variations.all()]) == set([str(v.value) for v in i_old.variations.all()])
assert i_old.program_times.first().start == i_new.program_times.first().start
assert i_old.program_times.first().end == i_new.program_times.first().end
def test_add_to_existing_quota(self):
with scopes_disabled():

View File

@@ -19,11 +19,22 @@
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>.
#
import re
# This file is based on an earlier version of pretix which was released under the Apache License 2.0. The full text of
# the Apache License 2.0 can be obtained at <http://www.apache.org/licenses/LICENSE-2.0>.
#
# This file may have since been changed and any changes are released under the terms of AGPLv3 as described above. A
# full history of changes and contributors is available at <https://github.com/pretix/pretix>.
#
# This file contains Apache-licensed contributions copyrighted by: Jason Estibeiro
#
# Unless required by applicable law or agreed to in writing, software distributed under the Apache License 2.0 is
# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations under the License.
import time
import pytest
from django.core import mail as djmail
from django.utils.timezone import now
from django_otp.oath import TOTP
from django_otp.plugins.otp_static.models import StaticDevice
@@ -45,7 +56,7 @@ class UserSettingsTest(SoupTest):
self.user = User.objects.create_user('dummy@dummy.dummy', 'barfoofoo')
self.client.login(email='dummy@dummy.dummy', password='barfoofoo')
doc = self.get_doc('/control/settings')
self.form_data = extract_form_fields(doc.select('form[data-testid="usersettingsform"]')[0])
self.form_data = extract_form_fields(doc.select('.container-fluid form')[0])
def save(self, data):
form_data = self.form_data.copy()
@@ -60,107 +71,33 @@ class UserSettingsTest(SoupTest):
self.user = User.objects.get(pk=self.user.pk)
assert self.user.fullname == 'Peter Miller'
def test_set_locale_and_timezone(self):
def test_change_email_require_password(self):
doc = self.save({
'locale': 'fr',
'timezone': 'Europe/Paris',
'email': 'foo@example.com',
})
assert doc.select(".alert-success")
assert doc.select(".alert-danger")
self.user = User.objects.get(pk=self.user.pk)
assert self.user.locale == 'fr'
assert self.user.timezone == 'Europe/Paris'
class UserEmailChangeTest(SoupTest):
def setUp(self):
super().setUp()
self.user = User.objects.create_user('dummy@dummy.dummy', 'barfoofoo')
self.client.login(email='dummy@dummy.dummy', password='barfoofoo')
session = self.client.session
session['pretix_auth_login_time'] = int(time.time())
session.save()
doc = self.get_doc('/control/settings/email/change')
self.form_data = extract_form_fields(doc.select('.container-fluid form')[0])
def test_require_reauth(self):
session = self.client.session
session['pretix_auth_login_time'] = int(time.time()) - 3600 * 2
session.save()
response = self.client.get('/control/settings/email/change')
self.assertIn('/control/reauth', response['Location'])
self.assertEqual(response.status_code, 302)
response = self.client.post('/control/reauth/?next=/control/settings/email/change', {
'password': 'barfoofoo'
})
self.assertIn('/control/settings/email/change', response['Location'])
self.assertEqual(response.status_code, 302)
def submit_step_1(self, data):
form_data = self.form_data.copy()
form_data.update(data)
return self.post_doc('/control/settings/email/change', form_data)
def submit_step_2(self, data):
form_data = self.form_data.copy()
form_data.update(data)
return self.post_doc('/control/settings/email/confirm?reason=email_change', form_data)
assert self.user.email == 'dummy@dummy.dummy'
def test_change_email_success(self):
djmail.outbox = []
doc = self.submit_step_1({
'new_email': 'foo@example.com',
})
assert len(djmail.outbox) == 1
assert djmail.outbox[0].to == ['foo@example.com']
code = re.search("[0-9]{7}", djmail.outbox[0].body).group(0)
doc = self.submit_step_2({
'code': code,
doc = self.save({
'email': 'foo@example.com',
'old_pw': 'barfoofoo'
})
assert doc.select(".alert-success")
self.user = User.objects.get(pk=self.user.pk)
assert self.user.email == 'foo@example.com'
def test_change_email_wrong_code(self):
djmail.outbox = []
doc = self.submit_step_1({
'new_email': 'foo@example.com',
})
assert len(djmail.outbox) == 1
assert djmail.outbox[0].to == ['foo@example.com']
code = re.search("[0-9]{7}", djmail.outbox[0].body).group(0)
wrong_code = '0000000' if code == '1234567' else '1234567'
doc = self.submit_step_2({
'code': wrong_code,
})
assert doc.select(".alert-danger")
self.user = User.objects.get(pk=self.user.pk)
assert self.user.email == 'dummy@dummy.dummy'
def test_change_email_no_duplicates(self):
User.objects.create_user('foo@example.com', 'foo')
doc = self.submit_step_1({
'new_email': 'foo@example.com',
doc = self.save({
'email': 'foo@example.com',
'old_pw': 'barfoofoo'
})
assert doc.select(".alert-danger")
self.user = User.objects.get(pk=self.user.pk)
assert self.user.email == 'dummy@dummy.dummy'
class UserPasswordChangeTest(SoupTest):
def setUp(self):
super().setUp()
self.user = User.objects.create_user('dummy@dummy.dummy', 'barfoofoo')
self.client.login(email='dummy@dummy.dummy', password='barfoofoo')
doc = self.get_doc('/control/settings/password/change')
self.form_data = extract_form_fields(doc.select('.container-fluid form')[0])
def save(self, data):
form_data = self.form_data.copy()
form_data.update(data)
return self.post_doc('/control/settings/password/change', form_data)
def test_change_password_require_password(self):
doc = self.save({
'new_pw': 'foo',
@@ -256,6 +193,18 @@ class UserPasswordChangeTest(SoupTest):
})
assert doc.select(".alert-danger")
def test_needs_password_change(self):
self.user.needs_password_change = True
self.user.save()
doc = self.save({
'email': 'foo@example.com',
'old_pw': 'barfoofoo'
})
assert doc.select(".alert-success")
assert doc.select(".alert-warning")
self.user.refresh_from_db()
assert self.user.needs_password_change is True
def test_needs_password_change_changed(self):
self.user.needs_password_change = True
self.user.save()