Bank transfer: More restrictive approach to permissions (Z#23235727)

This commit is contained in:
Raphael Michel
2026-06-09 11:11:44 +02:00
parent 93469d33e5
commit 902acda340
5 changed files with 114 additions and 46 deletions

View File

@@ -19,6 +19,7 @@
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see # 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/>. # <https://www.gnu.org/licenses/>.
# #
from django.db.models import Exists, OuterRef, Q
from django.dispatch import receiver from django.dispatch import receiver
from django.template.loader import get_template from django.template.loader import get_template
from django.urls import resolve, reverse from django.urls import resolve, reverse
@@ -76,10 +77,18 @@ def control_nav_import(sender, request=None, **kwargs):
@receiver(nav_organizer, dispatch_uid="payment_banktransfer_organav") @receiver(nav_organizer, dispatch_uid="payment_banktransfer_organav")
def control_nav_orga_import(sender, request=None, **kwargs): def control_nav_orga_import(sender, request=None, **kwargs):
url = resolve(request.path_info) url = resolve(request.path_info)
has_any_event_perm = request.user.get_events_with_permission(
"event.orders:write", request=request events_without_permission = request.organizer.events.filter(
).filter(organizer=request.organizer).exists() ~Exists(
if not has_any_event_perm: 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:
return [] return []
return [ return [
{ {

View File

@@ -10,7 +10,7 @@
Therefore, you won't be able to mark any order as paid here. Therefore, you won't be able to mark any order as paid here.
{% endblocktrans %} {% endblocktrans %}
</div> </div>
{% else %} {% elif can_write %}
<div class="panel panel-default"> <div class="panel panel-default">
<div class="panel-heading"> <div class="panel-heading">
<h3 class="panel-title">{% trans "Upload a new file" %}</h3> <h3 class="panel-title">{% trans "Upload a new file" %}</h3>
@@ -93,7 +93,7 @@
</div> </div>
</form> </form>
<div class="col-md-2"> <div class="col-md-2">
{% if not filter_form.is_valid %} {% if not filter_form.is_valid and can_write %}
<form action="" method="post" class="helper-display-inline pull-right flip"> <form action="" method="post" class="helper-display-inline pull-right flip">
{% csrf_token %} {% csrf_token %}
<button class="btn btn-danger" type="submit" name="discard" value="all"> <button class="btn btn-danger" type="submit" name="discard" value="all">

View File

@@ -25,7 +25,7 @@
</div> </div>
{% endif %} {% endif %}
{% if num_new > 0 %} {% if num_new > 0 and can_write %}
<form action="" method="post"> <form action="" method="post">
{% csrf_token %} {% csrf_token %}
<button class="btn btn-primary"> <button class="btn btn-primary">

View File

@@ -20,24 +20,24 @@
{% for trans in list %} {% for trans in list %}
<tr data-id="{{ trans.id }}"> <tr data-id="{{ trans.id }}">
<td class="actions"> <td class="actions">
{% if trans.order and trans.state == 'invalid' %} {% if trans.order and trans.state == 'invalid' and can_write %}
<button type="button" class="btn btn-default" name="action_{{ trans.id }}" value="accept" <button type="button" class="btn btn-default" name="action_{{ trans.id }}" value="accept"
data-toggle="tooltip" title="{% trans "Accept anyway" %}" data-placement="right"> data-toggle="tooltip" title="{% trans "Accept anyway" %}" data-placement="right">
<span class="fa fa-check"></span> <span class="fa fa-check"></span>
</button> </button>
{% elif trans.state == 'nomatch' %} {% elif trans.state == 'nomatch' and can_write %}
<input type="text" class="form-control" placeholder="{% trans "Order code" %}"> <input type="text" class="form-control" placeholder="{% trans "Order code" %}">
<button class="btn btn-default" type="button" name="action_{{ trans.id }}" <button class="btn btn-default" type="button" name="action_{{ trans.id }}"
value="assign" data-toggle="tooltip" title="{% trans "Assign to order" %}" value="assign" data-toggle="tooltip" title="{% trans "Assign to order" %}"
data-placement="right"> data-placement="right">
<span class="fa fa-check"></span> <span class="fa fa-check"></span>
</button> </button>
{% elif trans.state == 'error' %} {% elif trans.state == 'error' and can_write %}
<button type="button" class="btn btn-default" name="action_{{ trans.id }}" value="retry" <button type="button" class="btn btn-default" name="action_{{ trans.id }}" value="retry"
data-toggle="tooltip" title="{% trans "Retry" %}" data-placement="right"> data-toggle="tooltip" title="{% trans "Retry" %}" data-placement="right">
<span class="fa fa-refresh"></span> <span class="fa fa-refresh"></span>
</button> </button>
{% elif trans.state == 'already' %} {% elif trans.state == 'already' and can_write %}
<input type="text" class="form-control" placeholder="{% trans "Order code" %}"> <input type="text" class="form-control" placeholder="{% trans "Order code" %}">
<div class="btn-group" role="group"> <div class="btn-group" role="group">
<button class="btn btn-default" type="button" name="action_{{ trans.id }}" <button class="btn btn-default" type="button" name="action_{{ trans.id }}"
@@ -76,13 +76,15 @@
{% endif %} {% endif %}
</div> </div>
{{ trans.reference }} {{ trans.reference }}
<div class="comment-box" data-plain="{{ trans.comment }}"> {% if can_write %}
<strong>{% trans "Comment:" %}</strong> <div class="comment-box" data-plain="{{ trans.comment }}">
<span class="comment">{{ trans.comment|rich_text }}</span> <strong>{% trans "Comment:" %}</strong>
<a href="#" class="comment-modify btn btn-default btn-xs"> <span class="comment">{{ trans.comment|rich_text }}</span>
<span class="fa fa-edit"></span> <a href="#" class="comment-modify btn btn-default btn-xs">
</a> <span class="fa fa-edit"></span>
</div> </a>
</div>
{% endif %}
</td> </td>
<td> <td>
{% if trans.currency %} {% if trans.currency %}
@@ -119,10 +121,12 @@
{% endif %} {% endif %}
</td> </td>
<td class="discard"> <td class="discard">
<button type="button" class="btn btn-default" name="action_{{ trans.id }}" value="discard" {% if can_write %}
data-toggle="tooltip" title="{% trans "Discard" %}"> <button type="button" class="btn btn-default" name="action_{{ trans.id }}" value="discard"
<span class="fa fa-trash"></span> data-toggle="tooltip" title="{% trans "Discard" %}">
</button> <span class="fa fa-trash"></span>
</button>
{% endif %}
</td> </td>
</tr> </tr>
{% endfor %} {% endfor %}

View File

@@ -46,7 +46,7 @@ from django import forms
from django.contrib import messages from django.contrib import messages
from django.core.exceptions import PermissionDenied from django.core.exceptions import PermissionDenied
from django.db import transaction from django.db import transaction
from django.db.models import Count, Q, QuerySet from django.db.models import Count, Exists, OuterRef, Q, QuerySet
from django.http import FileResponse, JsonResponse from django.http import FileResponse, JsonResponse
from django.shortcuts import get_object_or_404, redirect, render from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse from django.urls import reverse
@@ -586,6 +586,7 @@ class ImportView(ListView):
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
ctx = super().get_context_data() ctx = super().get_context_data()
ctx['job_running'] = self.job_running ctx['job_running'] = self.job_running
ctx['can_write'] = self.can_write
ctx['no_more_payments'] = False ctx['no_more_payments'] = False
ctx['filter_form'] = BankTransactionFilterForm(self.request.GET or None) ctx['filter_form'] = BankTransactionFilterForm(self.request.GET or None)
@@ -623,45 +624,94 @@ class ImportView(ListView):
return ctx return ctx
class OrganizerBanktransferView: 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
def dispatch(self, request, *args, **kwargs): def dispatch(self, request, *args, **kwargs):
has_any_event_perm = request.user.get_events_with_permission( perm_name = self.event_permission
"event.orders:write", request=request if request.method not in ("GET", "HEAD") and hasattr(self, 'write_event_permission'):
).filter(organizer=request.organizer).exists() perm_name = self.write_event_permission
if not has_any_event_perm:
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:
raise PermissionDenied() raise PermissionDenied()
return super().dispatch(request, *args, **kwargs) return super().dispatch(request, *args, **kwargs)
class EventImportView(EventPermissionRequiredMixin, ImportView): 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):
permission = 'event.orders:write' permission = 'event.orders:write'
write_permission = 'event.orders:write'
class OrganizerImportView(OrganizerBanktransferView, OrganizerDetailViewMixin, class OrganizerImportView(EventPermissionOnAllEventsRequiredMixin, OrganizerDetailViewMixin,
ImportView): ImportView):
pass event_permission = 'event.orders:read'
write_event_permission = 'event.orders:write'
class EventJobDetailView(EventPermissionRequiredMixin, JobDetailView): class EventJobDetailView(EventPermissionRequiredMixin, JobDetailView):
permission = 'event.orders:write' permission = 'event.orders:read'
class OrganizerJobDetailView(OrganizerBanktransferView, OrganizerDetailViewMixin, class OrganizerJobDetailView(EventPermissionOnAllEventsRequiredMixin, OrganizerDetailViewMixin,
JobDetailView): JobDetailView):
pass event_permission = 'event.orders:read'
class EventActionView(EventPermissionRequiredMixin, ActionView): class EventActionView(PostEventPermissionRequiredMixin, ActionView):
permission = 'event.orders:write' permission = 'event.orders:read'
write_permission = 'event.orders:write'
class OrganizerActionView(OrganizerBanktransferView, OrganizerDetailViewMixin, class OrganizerActionView(EventPermissionOnAllEventsRequiredMixin, OrganizerDetailViewMixin,
ActionView): ActionView):
event_permission = "event.orders:read"
write_event_permission = "event.orders:write"
def order_qs(self): 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( all = self.request.user.teams.filter(
TeamQuerySet.event_permission_q("event.orders:read"), TeamQuerySet.event_permission_q("event.orders:read"),
TeamQuerySet.event_permission_q("event.orders:write"),
all_events=True, all_events=True,
organizer=self.request.organizer, organizer=self.request.organizer,
).exists() ).exists()
@@ -671,7 +721,6 @@ class OrganizerActionView(OrganizerBanktransferView, OrganizerDetailViewMixin,
return Order.objects.filter( return Order.objects.filter(
event_id__in=self.request.user.teams.filter( event_id__in=self.request.user.teams.filter(
TeamQuerySet.event_permission_q("event.orders:read"), TeamQuerySet.event_permission_q("event.orders:read"),
TeamQuerySet.event_permission_q("event.orders:write"),
organizer=self.request.organizer, organizer=self.request.organizer,
).values_list('limit_events__id', flat=True) ).values_list('limit_events__id', flat=True)
) )
@@ -712,6 +761,7 @@ class RefundExportListView(ListView):
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
ctx = super().get_context_data() ctx = super().get_context_data()
ctx['num_new'] = self.get_unexported().count() ctx['num_new'] = self.get_unexported().count()
ctx['can_write'] = self.can_write
ctx['basetpl'] = "pretixcontrol/event/base.html" ctx['basetpl'] = "pretixcontrol/event/base.html"
if not hasattr(self.request, 'event'): if not hasattr(self.request, 'event'):
ctx['basetpl'] = "pretixcontrol/organizers/base.html" ctx['basetpl'] = "pretixcontrol/organizers/base.html"
@@ -764,8 +814,9 @@ class RefundExportListView(ListView):
return redirect(self.get_success_url()) return redirect(self.get_success_url())
class EventRefundExportListView(EventPermissionRequiredMixin, RefundExportListView): class EventRefundExportListView(PostEventPermissionRequiredMixin, RefundExportListView):
permission = 'event.orders:write' permission = 'event.orders:read'
write_permission = 'event.orders:write'
def get_success_url(self): def get_success_url(self):
return reverse('plugins:banktransfer:refunds.list', kwargs={ return reverse('plugins:banktransfer:refunds.list', kwargs={
@@ -787,7 +838,9 @@ class EventRefundExportListView(EventPermissionRequiredMixin, RefundExportListVi
) )
class OrganizerRefundExportListView(OrganizerBanktransferView, RefundExportListView): class OrganizerRefundExportListView(EventPermissionOnAllEventsRequiredMixin, RefundExportListView):
event_permission = 'event.orders:read'
write_event_permission = 'event.orders:write'
def get_success_url(self): def get_success_url(self):
return reverse('plugins:banktransfer:refunds.list', kwargs={ return reverse('plugins:banktransfer:refunds.list', kwargs={
@@ -820,7 +873,7 @@ class DownloadRefundExportView(DetailView):
class EventDownloadRefundExportView(EventPermissionRequiredMixin, DownloadRefundExportView): class EventDownloadRefundExportView(EventPermissionRequiredMixin, DownloadRefundExportView):
permission = 'event.orders:write' permission = 'event.orders:read'
def get_object(self, *args, **kwargs): def get_object(self, *args, **kwargs):
return get_object_or_404( return get_object_or_404(
@@ -830,7 +883,8 @@ class EventDownloadRefundExportView(EventPermissionRequiredMixin, DownloadRefund
) )
class OrganizerDownloadRefundExportView(OrganizerBanktransferView, OrganizerDetailViewMixin, DownloadRefundExportView): class OrganizerDownloadRefundExportView(EventPermissionOnAllEventsRequiredMixin, OrganizerDetailViewMixin, DownloadRefundExportView):
event_permission = 'event.orders:read'
def get_object(self, *args, **kwargs): def get_object(self, *args, **kwargs):
return get_object_or_404( return get_object_or_404(
@@ -877,7 +931,7 @@ class SepaXMLExportView(SingleObjectMixin, FormView):
class EventSepaXMLExportView(EventPermissionRequiredMixin, SepaXMLExportView): class EventSepaXMLExportView(EventPermissionRequiredMixin, SepaXMLExportView):
permission = 'event.orders:write' permission = 'event.orders:read'
def get_object(self, *args, **kwargs): def get_object(self, *args, **kwargs):
return get_object_or_404( return get_object_or_404(
@@ -892,7 +946,8 @@ class EventSepaXMLExportView(EventPermissionRequiredMixin, SepaXMLExportView):
return form return form
class OrganizerSepaXMLExportView(OrganizerBanktransferView, OrganizerDetailViewMixin, SepaXMLExportView): class OrganizerSepaXMLExportView(EventPermissionOnAllEventsRequiredMixin, OrganizerDetailViewMixin, SepaXMLExportView):
permission = 'event.orders:read'
def get_object(self, *args, **kwargs): def get_object(self, *args, **kwargs):
return get_object_or_404( return get_object_or_404(