Compare commits

..
Author SHA1 Message Date
Richard Schreiber 8fed7a3513 Respect giftcard permissions on reusable media export 2026-06-09 11:48:28 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
c384bc2e7a Update bleach requirement from ==6.3.* to ==6.4.* (#6249)
Updates the requirements on [bleach](https://github.com/mozilla/bleach) to permit the latest version.
- [Changelog](https://github.com/mozilla/bleach/blob/main/CHANGES)
- [Commits](https://github.com/mozilla/bleach/compare/v6.3.0...v6.4.0)

---
updated-dependencies:
- dependency-name: bleach
  dependency-version: 6.4.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-08 17:33:50 +02:00
Raphael MichelandGitHub f16034d0cc Check-in: Fix handling of optional file questions (Z#23236493) (#6251) 2026-06-08 14:25:50 +02:00
9 changed files with 79 additions and 117 deletions
+1 -1
View File
@@ -30,7 +30,7 @@ dependencies = [
"arabic-reshaper==3.0.1", # Support for Arabic in reportlab
"babel",
"BeautifulSoup4==4.14.*",
"bleach==6.3.*",
"bleach==6.4.*",
"celery==5.6.*",
"chardet==5.2.*",
"cryptography>=48.0.0",
+4 -1
View File
@@ -788,7 +788,10 @@ def _redeem_process(*, checkinlists, raw_barcode, answers_data, datetime, force,
if str(q.pk) in answers_data:
try:
if q.type == Question.TYPE_FILE:
given_answers[q] = _handle_file_upload(answers_data[str(q.pk)], user, auth)
if answers_data[str(q.pk)]:
given_answers[q] = _handle_file_upload(answers_data[str(q.pk)], user, auth)
else:
given_answers[q] = None
else:
given_answers[q] = q.clean_answer(answers_data[str(q.pk)])
except (ValidationError, BaseValidationError):
+7 -1
View File
@@ -64,7 +64,13 @@ class ReusableMediaExporter(OrganizerLevelExportMixin, ListExporter):
yield headers
yield self.ProgressSetTotal(total=media.count())
can_read_giftcards = self.permission_holder.has_organizer_permission(self.organizer, 'organizer.giftcards:read')
for medium in media.iterator(chunk_size=1000):
giftcard_secret = medium.linked_giftcard.secret if medium.linked_giftcard_id else ''
if giftcard_secret and not can_read_giftcards:
giftcard_secret = giftcard_secret[:3] + ""
yield [
medium.type,
medium.identifier,
@@ -72,7 +78,7 @@ class ReusableMediaExporter(OrganizerLevelExportMixin, ListExporter):
date_format(medium.expires, 'SHORT_DATETIME_FORMAT') if medium.expires else '',
medium.customer.identifier if medium.customer_id else '',
', '.join([f"{op.order.code}-{op.positionid}" for op in medium.linked_orderpositions.all()]),
medium.linked_giftcard.secret if medium.linked_giftcard_id else '',
giftcard_secret,
medium.notes,
]
+4 -13
View File
@@ -19,7 +19,6 @@
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>.
#
from django.db.models import Exists, OuterRef, Q
from django.dispatch import receiver
from django.template.loader import get_template
from django.urls import resolve, reverse
@@ -77,18 +76,10 @@ def control_nav_import(sender, request=None, **kwargs):
@receiver(nav_organizer, dispatch_uid="payment_banktransfer_organav")
def control_nav_orga_import(sender, request=None, **kwargs):
url = resolve(request.path_info)
events_without_permission = request.organizer.events.filter(
~Exists(
request.user.teams.with_event_permission(
"event.orders:read"
).filter(
Q(all_events=True) | Q(limit_events=OuterRef("pk")),
organizer_id=OuterRef("organizer_id"),
)
)
).exists()
if events_without_permission:
has_any_event_perm = request.user.get_events_with_permission(
"event.orders:write", request=request
).filter(organizer=request.organizer).exists()
if not has_any_event_perm:
return []
return [
{
@@ -10,7 +10,7 @@
Therefore, you won't be able to mark any order as paid here.
{% endblocktrans %}
</div>
{% elif can_write %}
{% else %}
<div class="panel panel-default">
<div class="panel-heading">
<h3 class="panel-title">{% trans "Upload a new file" %}</h3>
@@ -93,7 +93,7 @@
</div>
</form>
<div class="col-md-2">
{% if not filter_form.is_valid and can_write %}
{% if not filter_form.is_valid %}
<form action="" method="post" class="helper-display-inline pull-right flip">
{% csrf_token %}
<button class="btn btn-danger" type="submit" name="discard" value="all">
@@ -25,7 +25,7 @@
</div>
{% endif %}
{% if num_new > 0 and can_write %}
{% if num_new > 0 %}
<form action="" method="post">
{% csrf_token %}
<button class="btn btn-primary">
@@ -20,24 +20,24 @@
{% for trans in list %}
<tr data-id="{{ trans.id }}">
<td class="actions">
{% if trans.order and trans.state == 'invalid' and can_write %}
{% if trans.order and trans.state == 'invalid' %}
<button type="button" class="btn btn-default" name="action_{{ trans.id }}" value="accept"
data-toggle="tooltip" title="{% trans "Accept anyway" %}" data-placement="right">
<span class="fa fa-check"></span>
</button>
{% elif trans.state == 'nomatch' and can_write %}
{% elif trans.state == 'nomatch' %}
<input type="text" class="form-control" placeholder="{% trans "Order code" %}">
<button class="btn btn-default" type="button" name="action_{{ trans.id }}"
value="assign" data-toggle="tooltip" title="{% trans "Assign to order" %}"
data-placement="right">
<span class="fa fa-check"></span>
</button>
{% elif trans.state == 'error' and can_write %}
{% elif trans.state == 'error' %}
<button type="button" class="btn btn-default" name="action_{{ trans.id }}" value="retry"
data-toggle="tooltip" title="{% trans "Retry" %}" data-placement="right">
<span class="fa fa-refresh"></span>
</button>
{% elif trans.state == 'already' and can_write %}
{% elif trans.state == 'already' %}
<input type="text" class="form-control" placeholder="{% trans "Order code" %}">
<div class="btn-group" role="group">
<button class="btn btn-default" type="button" name="action_{{ trans.id }}"
@@ -76,15 +76,13 @@
{% endif %}
</div>
{{ trans.reference }}
{% if can_write %}
<div class="comment-box" data-plain="{{ trans.comment }}">
<strong>{% trans "Comment:" %}</strong>
<span class="comment">{{ trans.comment|rich_text }}</span>
<a href="#" class="comment-modify btn btn-default btn-xs">
<span class="fa fa-edit"></span>
</a>
</div>
{% endif %}
<div class="comment-box" data-plain="{{ trans.comment }}">
<strong>{% trans "Comment:" %}</strong>
<span class="comment">{{ trans.comment|rich_text }}</span>
<a href="#" class="comment-modify btn btn-default btn-xs">
<span class="fa fa-edit"></span>
</a>
</div>
</td>
<td>
{% if trans.currency %}
@@ -121,12 +119,10 @@
{% endif %}
</td>
<td class="discard">
{% if can_write %}
<button type="button" class="btn btn-default" name="action_{{ trans.id }}" value="discard"
data-toggle="tooltip" title="{% trans "Discard" %}">
<span class="fa fa-trash"></span>
</button>
{% endif %}
<button type="button" class="btn btn-default" name="action_{{ trans.id }}" value="discard"
data-toggle="tooltip" title="{% trans "Discard" %}">
<span class="fa fa-trash"></span>
</button>
</td>
</tr>
{% endfor %}
+24 -79
View File
@@ -46,7 +46,7 @@ from django import forms
from django.contrib import messages
from django.core.exceptions import PermissionDenied
from django.db import transaction
from django.db.models import Count, Exists, OuterRef, Q, QuerySet
from django.db.models import Count, Q, QuerySet
from django.http import FileResponse, JsonResponse
from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
@@ -586,7 +586,6 @@ class ImportView(ListView):
def get_context_data(self, **kwargs):
ctx = super().get_context_data()
ctx['job_running'] = self.job_running
ctx['can_write'] = self.can_write
ctx['no_more_payments'] = False
ctx['filter_form'] = BankTransactionFilterForm(self.request.GET or None)
@@ -624,94 +623,45 @@ class ImportView(ListView):
return ctx
class EventPermissionOnAllEventsRequiredMixin:
@cached_property
def can_write(self):
perm_name = self.event_permission
if hasattr(self, 'write_event_permission'):
perm_name = self.write_event_permission
events_without_permission = self.request.organizer.events.filter(
~Exists(
self.request.user.teams.with_event_permission(
perm_name
).filter(
Q(all_events=True) | Q(limit_events=OuterRef("pk")),
organizer_id=OuterRef("organizer_id"),
)
)
).exists()
return not events_without_permission
class OrganizerBanktransferView:
def dispatch(self, request, *args, **kwargs):
perm_name = self.event_permission
if request.method not in ("GET", "HEAD") and hasattr(self, 'write_event_permission'):
perm_name = self.write_event_permission
events_without_permission = self.request.organizer.events.filter(
~Exists(
self.request.user.teams.with_event_permission(
perm_name
).filter(
Q(all_events=True) | Q(limit_events=OuterRef("pk")),
organizer_id=OuterRef("organizer_id"),
)
)
).exists()
if events_without_permission:
has_any_event_perm = request.user.get_events_with_permission(
"event.orders:write", request=request
).filter(organizer=request.organizer).exists()
if not has_any_event_perm:
raise PermissionDenied()
return super().dispatch(request, *args, **kwargs)
class PostEventPermissionRequiredMixin(EventPermissionRequiredMixin):
@cached_property
def can_write(self):
return self.request.user.has_event_permission(
self.request.organizer, self.request.event, self.write_permission, request=self.request
)
def dispatch(self, request, *args, **kwargs):
if request.method not in ("GET", "HEAD"):
if not self.can_write:
raise PermissionDenied()
return super().dispatch(request, *args, **kwargs)
class EventImportView(PostEventPermissionRequiredMixin, ImportView):
class EventImportView(EventPermissionRequiredMixin, ImportView):
permission = 'event.orders:write'
write_permission = 'event.orders:write'
class OrganizerImportView(EventPermissionOnAllEventsRequiredMixin, OrganizerDetailViewMixin,
class OrganizerImportView(OrganizerBanktransferView, OrganizerDetailViewMixin,
ImportView):
event_permission = 'event.orders:read'
write_event_permission = 'event.orders:write'
pass
class EventJobDetailView(EventPermissionRequiredMixin, JobDetailView):
permission = 'event.orders:read'
permission = 'event.orders:write'
class OrganizerJobDetailView(EventPermissionOnAllEventsRequiredMixin, OrganizerDetailViewMixin,
class OrganizerJobDetailView(OrganizerBanktransferView, OrganizerDetailViewMixin,
JobDetailView):
event_permission = 'event.orders:read'
pass
class EventActionView(PostEventPermissionRequiredMixin, ActionView):
permission = 'event.orders:read'
write_permission = 'event.orders:write'
class EventActionView(EventPermissionRequiredMixin, ActionView):
permission = 'event.orders:write'
class OrganizerActionView(EventPermissionOnAllEventsRequiredMixin, OrganizerDetailViewMixin,
class OrganizerActionView(OrganizerBanktransferView, OrganizerDetailViewMixin,
ActionView):
event_permission = "event.orders:read"
write_event_permission = "event.orders:write"
def order_qs(self):
# The filters here are basically pointless with EventPermissionOnAllEventsRequiredMixin
# but let's keep them for safety with future refactorings
all = self.request.user.teams.filter(
TeamQuerySet.event_permission_q("event.orders:read"),
TeamQuerySet.event_permission_q("event.orders:write"),
all_events=True,
organizer=self.request.organizer,
).exists()
@@ -721,6 +671,7 @@ class OrganizerActionView(EventPermissionOnAllEventsRequiredMixin, OrganizerDeta
return Order.objects.filter(
event_id__in=self.request.user.teams.filter(
TeamQuerySet.event_permission_q("event.orders:read"),
TeamQuerySet.event_permission_q("event.orders:write"),
organizer=self.request.organizer,
).values_list('limit_events__id', flat=True)
)
@@ -761,7 +712,6 @@ class RefundExportListView(ListView):
def get_context_data(self, **kwargs):
ctx = super().get_context_data()
ctx['num_new'] = self.get_unexported().count()
ctx['can_write'] = self.can_write
ctx['basetpl'] = "pretixcontrol/event/base.html"
if not hasattr(self.request, 'event'):
ctx['basetpl'] = "pretixcontrol/organizers/base.html"
@@ -814,9 +764,8 @@ class RefundExportListView(ListView):
return redirect(self.get_success_url())
class EventRefundExportListView(PostEventPermissionRequiredMixin, RefundExportListView):
permission = 'event.orders:read'
write_permission = 'event.orders:write'
class EventRefundExportListView(EventPermissionRequiredMixin, RefundExportListView):
permission = 'event.orders:write'
def get_success_url(self):
return reverse('plugins:banktransfer:refunds.list', kwargs={
@@ -838,9 +787,7 @@ class EventRefundExportListView(PostEventPermissionRequiredMixin, RefundExportLi
)
class OrganizerRefundExportListView(EventPermissionOnAllEventsRequiredMixin, RefundExportListView):
event_permission = 'event.orders:read'
write_event_permission = 'event.orders:write'
class OrganizerRefundExportListView(OrganizerBanktransferView, RefundExportListView):
def get_success_url(self):
return reverse('plugins:banktransfer:refunds.list', kwargs={
@@ -873,7 +820,7 @@ class DownloadRefundExportView(DetailView):
class EventDownloadRefundExportView(EventPermissionRequiredMixin, DownloadRefundExportView):
permission = 'event.orders:read'
permission = 'event.orders:write'
def get_object(self, *args, **kwargs):
return get_object_or_404(
@@ -883,8 +830,7 @@ class EventDownloadRefundExportView(EventPermissionRequiredMixin, DownloadRefund
)
class OrganizerDownloadRefundExportView(EventPermissionOnAllEventsRequiredMixin, OrganizerDetailViewMixin, DownloadRefundExportView):
event_permission = 'event.orders:read'
class OrganizerDownloadRefundExportView(OrganizerBanktransferView, OrganizerDetailViewMixin, DownloadRefundExportView):
def get_object(self, *args, **kwargs):
return get_object_or_404(
@@ -931,7 +877,7 @@ class SepaXMLExportView(SingleObjectMixin, FormView):
class EventSepaXMLExportView(EventPermissionRequiredMixin, SepaXMLExportView):
permission = 'event.orders:read'
permission = 'event.orders:write'
def get_object(self, *args, **kwargs):
return get_object_or_404(
@@ -946,8 +892,7 @@ class EventSepaXMLExportView(EventPermissionRequiredMixin, SepaXMLExportView):
return form
class OrganizerSepaXMLExportView(EventPermissionOnAllEventsRequiredMixin, OrganizerDetailViewMixin, SepaXMLExportView):
permission = 'event.orders:read'
class OrganizerSepaXMLExportView(OrganizerBanktransferView, OrganizerDetailViewMixin, SepaXMLExportView):
def get_object(self, *args, **kwargs):
return get_object_or_404(
+21
View File
@@ -1098,6 +1098,27 @@ def test_question_upload(token_client, organizer, clist, event, order, question)
assert order.positions.first().answers.get(question=question[0]).file
@pytest.mark.django_db
def test_question_upload_optional(token_client, organizer, clist, event, order, question):
with scopes_disabled():
p = order.positions.first()
question[0].type = 'F'
question[0].required = False
question[0].save()
resp = _redeem(token_client, organizer, clist, p.pk, {})
assert resp.status_code == 400
assert resp.data['status'] == 'incomplete'
with scopes_disabled():
assert resp.data['questions'] == [QuestionSerializer(question[0]).data]
resp = _redeem(token_client, organizer, clist, p.pk, {'answers': {question[0].pk: ""}})
assert resp.status_code == 201
assert resp.data['status'] == 'ok'
with scopes_disabled():
assert not order.positions.first().answers.filter(question=question[0]).exists()
@pytest.mark.django_db
def test_store_failed(token_client, organizer, clist, event, order):
with scopes_disabled():