Compare commits

...
Author SHA1 Message Date
Raphael Michel 09cdf05f73 Update signals 2026-09-24 11:56:53 +02:00
Raphael Michel 15cd6c6f5e Organizer-level notifications 2026-09-24 10:50:21 +02:00
Raphael Michel b8f8e49cce API: Add additional tests for modifying meta data (Z#23247634) 2026-09-23 19:01:52 +02:00
Raphael Michel 806d0a5748 User details: Show list of 2FA devices and allow to reset drift (#6569)
* User details: Show list of 2FA devices

* Add reset button

* Reset throttle

* Refactoring

* Fix delete paths in tests
2026-09-23 17:51:10 +02:00
Raphael Michel 69e541e5af Product list: Fix edge case in dependent availabilities (Z#23243701) (#6532) 2026-09-23 17:22:54 +02:00
Raphael Michel 0723e6015c Event header: Remove date directly above other date (Z#23245589) (#6524)
* Event header: Fix wrapping of date in h2 header (Z#23245589)

This was already correct if the header was a h1

* Revert "Event header: Fix wrapping of date in h2 header (Z#23245589)"

This reverts commit 3d25316fe3.

* Event header: Remove date directly above other date

This removes the date from the <h2> option used when a header image is
uploaded and the header should still be shown. Rendering the date here
makes little sense, since the infobox with the date is always just a few
lines below. It still makes sense for the <h1> option where it becomes
part of the site header.
2026-09-23 15:54:47 +02:00
Raphael Michel b8e1ab8258 Voucher API: Add search parameter (Z#23240589) (#6523) 2026-09-23 15:54:44 +02:00
Raphael Michel 81764278ae Email settings: Collapse attendee texts if unused (Z#23243417) (#6535) 2026-09-23 14:00:56 +02:00
Nate Horst f3068e627a Translations: Update Thai
Currently translated at 52.2% (3356 of 6419 strings)

Translation: pretix/pretix
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix/th/

powered by weblate
2026-09-23 11:41:45 +02:00
Nate Horst 993a3f96e9 Translations: Update Thai
Currently translated at 52.1% (3346 of 6419 strings)

Translation: pretix/pretix
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix/th/

powered by weblate
2026-09-23 11:41:45 +02:00
Nate Horst 1af0b5e442 Translations: Update Thai
Currently translated at 51.8% (3330 of 6419 strings)

Translation: pretix/pretix
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix/th/

powered by weblate
2026-09-23 11:41:45 +02:00
Nate Horst 71b6bf630f Translations: Update Thai
Currently translated at 45.6% (2930 of 6419 strings)

Translation: pretix/pretix
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix/th/

powered by weblate
2026-09-23 11:41:45 +02:00
Raphael MichelandMartin Gross 59026da06b Delete PayPal integration v1 (#6530)
* Delete PayPal integration v1

Leaving only migrations behind

* Try to fix CI

* Fix tests some more

* Rename more plugin name instances in tests and docs

---------

Co-authored-by: Martin Gross <martin@pc-coholic.de>
2026-09-23 11:38:16 +02:00
58 changed files with 1733 additions and 2729 deletions
-2
View File
@@ -16,8 +16,6 @@ recursive-include src/pretix/plugins/banktransfer/templates *
recursive-include src/pretix/plugins/banktransfer/static *
recursive-include src/pretix/plugins/manualpayment/templates *
recursive-include src/pretix/plugins/manualpayment/static *
recursive-include src/pretix/plugins/paypal/templates *
recursive-include src/pretix/plugins/paypal/static *
recursive-include src/pretix/plugins/paypal2/templates *
recursive-include src/pretix/plugins/paypal2/static *
recursive-include src/pretix/plugins/src/pretixdroid/templates *
+8 -8
View File
@@ -110,7 +110,7 @@ Endpoints
"plugins": [
"pretix.plugins.banktransfer",
"pretix.plugins.stripe",
"pretix.plugins.paypal",
"pretix.plugins.paypal2",
"pretix.plugins.ticketoutputpdf"
],
"all_sales_channels": false,
@@ -199,7 +199,7 @@ Endpoints
"plugins": [
"pretix.plugins.banktransfer",
"pretix.plugins.stripe",
"pretix.plugins.paypal",
"pretix.plugins.paypal2",
"pretix.plugins.ticketoutputpdf"
],
"valid_keys": {
@@ -262,7 +262,7 @@ Endpoints
"item_meta_properties": {},
"plugins": [
"pretix.plugins.stripe",
"pretix.plugins.paypal"
"pretix.plugins.paypal2"
],
"all_sales_channels": true,
"limit_sales_channels": []
@@ -299,7 +299,7 @@ Endpoints
"item_meta_properties": {},
"plugins": [
"pretix.plugins.stripe",
"pretix.plugins.paypal"
"pretix.plugins.paypal2"
],
"all_sales_channels": true,
"limit_sales_channels": [],
@@ -364,7 +364,7 @@ Endpoints
"item_meta_properties": {},
"plugins": [
"pretix.plugins.stripe",
"pretix.plugins.paypal"
"pretix.plugins.paypal2"
],
"all_sales_channels": true,
"limit_sales_channels": []
@@ -401,7 +401,7 @@ Endpoints
"item_meta_properties": {},
"plugins": [
"pretix.plugins.stripe",
"pretix.plugins.paypal"
"pretix.plugins.paypal2"
],
"all_sales_channels": true,
"limit_sales_channels": [],
@@ -438,7 +438,7 @@ Endpoints
"plugins": [
"pretix.plugins.banktransfer",
"pretix.plugins.stripe",
"pretix.plugins.paypal",
"pretix.plugins.paypal2",
"pretix.plugins.pretixdroid"
]
}
@@ -475,7 +475,7 @@ Endpoints
"plugins": [
"pretix.plugins.banktransfer",
"pretix.plugins.stripe",
"pretix.plugins.paypal",
"pretix.plugins.paypal2",
"pretix.plugins.pretixdroid"
],
"all_sales_channels": true,
+1
View File
@@ -116,6 +116,7 @@ Endpoints
:query integer page: The page number in case of a multi-page result set, default is 1
:query string code: Only show the voucher with the given voucher code.
:query string search: Only show the voucher with the given query found in the code, tag, or comment.
:query integer max_usages: Only show vouchers with the given maximal number of usages.
:query integer redeemed: Only show vouchers with the given number of redemptions. Note that this doesn't tell you if
the voucher can still be redeemed, as this also depends on ``max_usages``. See the
-1
View File
@@ -26,7 +26,6 @@ ignore =
src/tests/plugins/*
src/tests/plugins/badges/*
src/tests/plugins/banktransfer/*
src/tests/plugins/paypal/*
src/tests/plugins/paypal2/*
src/tests/plugins/pretixdroid/*
src/tests/plugins/stripe/*
+4
View File
@@ -40,6 +40,7 @@ with scopes_disabled():
class VoucherFilter(FilterSet):
active = BooleanFilter(method='filter_active')
code = CharFilter(lookup_expr='iexact')
search = CharFilter(method='search_qs')
class Meta:
model = Voucher
@@ -54,6 +55,9 @@ with scopes_disabled():
return queryset.filter(Q(redeemed__gte=F('max_usages')) |
(Q(valid_until__isnull=False) & Q(valid_until__lte=now())))
def search_qs(self, qs, name, value):
return qs.filter(Q(code__icontains=value) | Q(tag__icontains=value) | Q(comment__icontains=value))
class VoucherViewSet(viewsets.ModelViewSet):
serializer_class = VoucherSerializer
+2 -2
View File
@@ -82,8 +82,8 @@ class UserSettingsForm(forms.ModelForm):
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)')),
('otp_totp.totpdevice', _('Smartphone with the Authenticator application')),
('pretixbase.webauthndevice', _('WebAuthn-compatible hardware token (e.g. Yubikey)')),
))
+20 -1
View File
@@ -42,7 +42,7 @@ from django.conf import settings
from django.core.mail import get_connection
from django.core.validators import MinLengthValidator, RegexValidator
from django.db import models
from django.db.models import Q
from django.db.models import Exists, OuterRef, Q
from django.urls import reverse
from django.utils.crypto import get_random_string
from django.utils.functional import cached_property
@@ -312,6 +312,25 @@ class Organizer(LoggedModel):
)
i += 1
def get_users_with_permission(self, permission):
"""
Returns a queryset of users who have a specific permission to this organizer.
:return: Iterable of User
"""
from .auth import User
if permission:
qs = Team.objects.with_organizer_permission(permission)
else:
qs = Team.objects.all()
team_with_perm = qs.filter(
members__pk=OuterRef('pk'),
organizer=self,
)
return User.objects.annotate(twp=Exists(team_with_perm)).filter(twp=True)
def generate_invite_token():
return get_random_string(length=32, allowed_chars=string.ascii_lowercase + string.digits)
+32 -11
View File
@@ -35,12 +35,13 @@
import logging
from collections import OrderedDict, namedtuple
from itertools import groupby
from typing import Union
from django.dispatch import receiver
from django.utils.formats import date_format
from django.utils.translation import gettext_lazy as _, pgettext_lazy
from pretix.base.models import Event, LogEntry
from pretix.base.models import Event, LogEntry, Organizer
from pretix.base.signals import register_notification_types
from pretix.base.templatetags.money import money_filter
from pretix.helpers.urls import mainreverse_absolute
@@ -57,7 +58,8 @@ class Notification:
"""
Represents a notification that is sent/shown to a user. A notification consists of:
* one ``event`` reference
* one ``event`` reference (can be ``Ǹone``)
* one ``organizer`` reference
* one ``title`` text that is shown e.g. in the email subject or in a headline
* optionally one ``detail`` text that may or may not be shown depending on the notification method
* optionally one ``url`` that should be absolute and point to the context of an notification (e.g. an order)
@@ -67,8 +69,10 @@ class Notification:
each consisting of a button label and an absolute URL to point to.
"""
def __init__(self, event: Event, title: str, detail: str=None, url: str=None):
def __init__(self, event: Event, title: str, detail: str=None, url: str=None, organizer: Organizer=None):
assert event or organizer
self.title = title
self.organizer = organizer or event.organizer
self.event = event
self.detail = detail
self.url = url
@@ -91,8 +95,16 @@ class Notification:
class NotificationType:
def __init__(self, event: Event = None):
self.event = event
def __init__(self, event_or_organizer: Union[Event, Organizer] = None):
if isinstance(event_or_organizer, Event):
self.event = event_or_organizer
self.organizer = event_or_organizer.organizer
elif isinstance(event_or_organizer, Organizer):
self.event = None
self.organizer = event_or_organizer
else:
self.event = None
self.organizer = None
def __repr__(self):
return '<NotificationType: {}>'.format(self.action_type)
@@ -121,31 +133,39 @@ class NotificationType:
"""
raise NotImplementedError() # NOQA
@property
def is_event_level(self) -> bool:
"""
Return `True` if this notification type can be configured per event (the default).
"""
return True
def build_notification(self, logentry: LogEntry) -> Notification:
"""
This is the main function that you should override. It is supposed to turn a log entry
object into a notification object that can then be rendered e.g. into an email.
"""
return Notification(
logentry.event,
logentry.display()
event=logentry.event,
title=logentry.display(),
organizer=logentry.organizer,
)
def get_all_notification_types(event=None):
def get_all_notification_types(event_or_organizer=None):
global _ALL_TYPES
if event is None and _ALL_TYPES:
if event_or_organizer is None and _ALL_TYPES:
return _ALL_TYPES
types = OrderedDict()
for recv, ret in register_notification_types.send(event):
for recv, ret in register_notification_types.send(event_or_organizer):
if isinstance(ret, (list, tuple)):
for r in ret:
types[r.action_type] = r
else:
types[ret.action_type] = ret
if event is None:
if event_or_organizer is None:
_ALL_TYPES = types
return types
@@ -181,6 +201,7 @@ class ParametrizedOrderNotificationType(NotificationType):
n = Notification(
event=logentry.event,
organizer=logentry.organizer,
title=self._title.format(order=order, event=logentry.event),
url=order_url
)
+42 -20
View File
@@ -34,7 +34,9 @@ from pretix.base.models import (
from pretix.base.notifications import Notification, get_all_notification_types
from pretix.base.services.mail import mail_send_task
from pretix.base.services.tasks import ProfiledTask, TransactionAwareTask
from pretix.base.signals import notification
from pretix.base.signals import (
event_notification_sent, organizer_notification_sent,
)
from pretix.celery_app import app
from pretix.helpers.celery import get_task_priority
from pretix.helpers.urls import mainreverse_absolute
@@ -47,14 +49,14 @@ def notify(logentry_ids: list):
logentry_ids = [logentry_ids]
qs = LogEntry.all.select_related(
'event', 'event__organizer'
'event', 'event__organizer', 'organizer'
).order_by(
'action_type', 'event_id',
'action_type', 'event_id', 'organizer_id',
).filter(id__in=logentry_ids)
_event, _at, notify_specific, notify_global = None, None, None, None
_event, _organizer, _at, notify_specific, notify_global = None, None, None, None, None
for logentry in qs:
if not logentry.event:
if not logentry.event and not logentry.organizer:
break # Ignore, we only have event-related notifications right now
notification_type = logentry.notification_type
@@ -62,25 +64,36 @@ def notify(logentry_ids: list):
if not notification_type:
break # No suitable plugin
if _event != logentry.event or _at != logentry.action_type or notify_global is None:
if _event != logentry.event or _organizer != logentry.organizer or _at != logentry.action_type or notify_global is None:
_event = logentry.event
_organizer = logentry.organizer
_at = logentry.action_type
# All users that have the permission to get the notification
users = logentry.event.get_users_with_permission(
notification_type.required_permission
).filter(notifications_send=True, is_active=True)
if logentry.event:
# All users that have the permission to get the notification
users = logentry.event.get_users_with_permission(
notification_type.required_permission
).filter(notifications_send=True, is_active=True)
else:
users = logentry.organizer.get_users_with_permission(
notification_type.required_permission
).filter(notifications_send=True, is_active=True)
if logentry.user:
users = users.exclude(pk=logentry.user.pk)
# Get all notification settings, both specific to this event as well as global
notify_specific = {
(ns.user, ns.method): ns.enabled
for ns in NotificationSetting.objects.filter(
event=logentry.event,
action_type=notification_type.action_type,
user__pk__in=users.values_list('pk', flat=True)
)
}
if logentry.event:
notify_specific = {
(ns.user, ns.method): ns.enabled
for ns in NotificationSetting.objects.filter(
event=logentry.event,
action_type=notification_type.action_type,
user__pk__in=users.values_list('pk', flat=True)
)
}
else:
notify_specific = {}
notify_global = {
(ns.user, ns.method): ns.enabled
for ns in NotificationSetting.objects.filter(
@@ -106,7 +119,10 @@ def notify(logentry_ids: list):
priority=get_task_priority("notifications", logentry.organizer_id),
)
notification.send(logentry.event, logentry_id=logentry.id, notification_type=notification_type.action_type)
if logentry.event:
event_notification_sent.send(logentry.event, logentry_id=logentry.id, notification_type=notification_type.action_type)
elif logentry.organizer:
organizer_notification_sent.send(logentry.organizer, logentry_id=logentry.id, notification_type=notification_type.action_type)
@app.task(base=ProfiledTask, acks_late=True, max_retries=9, default_retry_delay=900)
@@ -158,13 +174,19 @@ def send_notification_mail(notification: Notification, user: User):
body_plain = tpl_plain.render(ctx)
guid = uuid.uuid4()
settings_holder = notification.event or notification.organizer
prefix = settings_holder.settings.mail_prefix
if not prefix and notification.event:
prefix = notification.event.slug.upper()
elif notification.organizer:
prefix = notification.organizer.name
m = OutgoingMail.objects.create(
guid=guid,
user=user,
to=[user.email],
subject='[{}] {}: {}'.format(
settings.PRETIX_INSTANCE_NAME,
notification.event.settings.mail_prefix or notification.event.slug.upper(),
prefix,
notification.title
),
body_plain=body_plain,
+13 -5
View File
@@ -605,15 +605,14 @@ subclass of pretix.base.ticketoutput.BaseTicketOutput
As with all event-plugin signals, the ``sender`` keyword argument will contain the event.
"""
register_notification_types = EventPluginSignal()
register_notification_types = GlobalSignal()
"""
This signal is sent out to get all known notification types. Receivers should return an
instance of a subclass of pretix.base.notifications.NotificationType or a list of such
instances.
As with all event-plugin signals, the ``sender`` keyword argument will contain the event,
however for this signal, the ``sender`` **may also be None** to allow creating the general
notification settings!
When called for actually sending notifications, ``sender`` will be the event or organizer,
depending on context.
"""
register_event_permission_groups = GlobalSignal()
@@ -628,7 +627,7 @@ This signal is sent out to get all known permissions. Receivers should return an
instance of pretix.base.permissions.PermissionGroup or a list of such instances.
"""
notification = EventPluginSignal()
event_notification_sent = EventPluginSignal()
"""
Arguments: ``logentry_id``, ``notification_type``
@@ -637,6 +636,15 @@ This signal is sent out when a notification is sent.
As with all event-plugin signals, the ``sender`` keyword argument will contain the event.
"""
organizer_notification_sent = OrganizerPluginSignal()
"""
Arguments: ``logentry_id``, ``notification_type``
This signal is sent out when a notification is sent.
As with all organizer-plugin signals, the ``sender`` keyword argument will contain the organizer.
"""
register_sales_channel_types = GlobalSignal()
"""
This signal is sent out to get all known sales channels types. Receivers should return an
+9 -4
View File
@@ -1635,10 +1635,15 @@ class MailSettingsForm(FormPlaceholderMixin, SettingsForm):
self._set_field_placeholders(k, v, rich=k.startswith('mail_text_') and k not in self.plain_rendering)
for k, v in list(self.fields.items()):
if k.endswith('_attendee') and not event.settings.attendee_emails_asked:
# If we don't ask for attendee emails, we can't send them anything and we don't need to clutter
# the user interface with it
del self.fields[k]
if k.endswith('_attendee'):
if not event.settings.attendee_emails_asked:
# If we don't ask for attendee emails, we can't send them anything and we don't need to clutter
# the user interface with it
del self.fields[k]
elif 'subject' in k and k.replace("subject", "send") in self.fields:
v.widget.attrs["data-display-dependency"] = f'#id_{k.replace("subject", "send")}'
elif 'text' in k and k.replace("text", "send") in self.fields:
v.widget.attrs["data-display-dependency"] = f'#id_{k.replace("text", "send")}'
class TicketSettingsForm(SettingsForm):
+1
View File
@@ -774,6 +774,7 @@ class CoreUserImpersonatedLogEntryType(UserImpersonatedLogEntryType):
'pretix.user.settings.2fa.disabled': _('Two-factor authentication has been disabled.'),
'pretix.user.settings.2fa.regenemergency': _('Your two-factor emergency codes have been regenerated.'),
'pretix.user.settings.2fa.emergency': _('A two-factor emergency code has been generated.'),
'pretix.user.settings.2fa.resetdrift': _('Drift and throttle values for two-factor devices have been reset.'),
'pretix.user.settings.2fa.device.added': _('A new two-factor authentication device "{name}" has been added to '
'your account.'),
'pretix.user.settings.2fa.device.deleted': _('The two-factor authentication device "{name}" has been removed '
+2 -2
View File
@@ -85,8 +85,8 @@ class PermissionMiddleware:
"user.settings.2fa.enable",
"user.settings.2fa.disable",
"user.settings.2fa.regenemergency",
"user.settings.2fa.confirm.totp",
"user.settings.2fa.confirm.webauthn",
"user.settings.2fa.confirm.otp_totp.totpdevice",
"user.settings.2fa.confirm.pretixbase.webauthndevice",
"user.settings.2fa.delete",
"user.settings.2fa.leaveteams",
"auth.logout",
@@ -15,7 +15,7 @@
<div>
<div class="big-radio radio">
<label>
<input type="radio" required value="totp" name="{{ form.devicetype.html_name }}" {% if form.devicetype.value == "totp" %}checked{% endif %}>
<input type="radio" required value="otp_totp.totpdevice" name="{{ form.devicetype.html_name }}" {% if form.devicetype.value == "otp_totp.totpdevice" %}checked{% endif %}>
<strong>{% trans "Smartphone with Authenticator app" %}</strong><br>
<div class="help-block">
{% blocktrans trimmed %}
@@ -26,7 +26,7 @@
</div>
<div class="big-radio radio">
<label>
<input type="radio" required value="webauthn" name="{{ form.devicetype.html_name }}" {% if form.devicetype.value == "webauthn" %}checked{% endif %}>
<input type="radio" required value="pretixbase.webauthndevice" name="{{ form.devicetype.html_name }}" {% if form.devicetype.value == "pretixbase.webauthndevice" %}checked{% endif %}>
<strong>{% trans "WebAuthn-compatible hardware token" %}</strong><br>
<div class="help-block">
{% blocktrans trimmed %}
@@ -116,14 +116,14 @@
{% for d in devices %}
<li class="list-group-item">
<a class="btn btn-danger btn-xs pull-right flip"
href="{% url "control:user.settings.2fa.delete" devicetype=d.devicetype device=d.pk %}">
href="{% url "control:user.settings.2fa.delete" devicetype=d.model_label device=d.pk %}">
Delete
</a>
{% if d.devicetype == "totp" %}
{% if d.model_label == "otp_totp.totpdevice" %}
<span class="fa fa-mobile"></span>
{% elif d.devicetype == "webauthn" %}
{% elif d.model_label == "pretixbase.webauthndevice" %}
<span class="fa fa-usb"></span>
{% elif d.devicetype == "u2f" %}
{% elif d.model_label == "pretixbase.u2fdevice" %}
<span class="fa fa-usb"></span>
{% endif %}
{{ d.name }}
@@ -1,6 +1,7 @@
{% extends "pretixcontrol/base.html" %}
{% load i18n %}
{% load bootstrap3 %}
{% load icon %}
{% block title %}{% trans "User" %}{% endblock %}
{% block content %}
<h1>{% trans "User" %} {{ user.email }}</h1>
@@ -59,8 +60,83 @@
{% bootstrap_field form.is_verified layout='control' %}
{% endif %}
{% bootstrap_field form.last_login layout='control' %}
{% bootstrap_field form.require_2fa layout='control' %}
{% bootstrap_field form.needs_password_change layout='control' %}
{% bootstrap_field form.require_2fa layout='control' %}
<div class="form-group">
<div class="col-md-9 col-md-offset-3">
<div class="panel panel-default">
<div class="panel-heading">
<button class="btn btn-default btn-xs pull-right" type="submit" form="resetdriftthrottle">
{% trans "Reset drift and throttle" %}
</button>
<h3 class="panel-title">
{% trans "Available two-factor authentication methods" %}
</h3>
</div>
<table class="panel-body table table-hover">
{% for d in devices %}
<tr>
<td>
{% if d.model_label == 'otp_totp.totpdevice' %}
TOTP
{% elif d.model_label == 'pretixbase.u2fdevice' %}
U2F
{% elif d.model_label == 'pretixbase.webauthndevice' %}
WebAuthn
{% elif d.model_label == 'otp_static.staticdevice' %}
{% trans "Emergency tokens" %}
{% endif %}
{% if d.confirmed %}
{% icon "check" %}
{% else %}
{% icon "warning" %}
{% endif %}
</td>
<td>
{{ d.name }}
</td>
<td>
{% if d.throttling_failure_timestamp %}
{% blocktrans trimmed with date=d.throttling_failure_timestamp|date:"SHORT_DATETIME_FORMAT" count cnt=d.throttling_failure_count %}
1 failed attempt since {{ date }}
{% plural %}
{{ cnt }} failed attempts since {{ date }}
{% endblocktrans %}
<br>
{% endif %}
{% if d.throttling_enabled and not d.verify_is_allowed.0 %}
<strong>
{% blocktrans trimmed with date=d.verify_is_allowed.1.locked_until|date:"SHORT_DATETIME_FORMAT" %}
Currently locked until {{ date }}
{% endblocktrans %}
</strong>
<br>
{% endif %}
{% if d.model_label == 'otp_totp.totpdevice' %}
<small>
<code>step = {{ d.step }},
t0 = {{ d.t0 }},
digits = {{ d.digits }},
tolerance = {{ d.tolerance }},
drift = {{ d.drift }},
last_t = {{ d.last_t }}</code>
</small>
{% elif d.model_label == 'pretixbase.u2fdevice' %}
<small>
<code>sign_count = {{ d.sign_count }}</code>
</small>
{% elif d.model_label == 'otp_static.staticdevice' %}
<small>
<code>token_count = {{ d.token_set.count }}</code>
</small>
{% endif %}
</td>
</tr>
{% endfor %}
</table>
</div>
</div>
</div>
</fieldset>
<fieldset>
<legend>{% trans "Team memberships" %}</legend>
@@ -102,4 +178,8 @@
</div>
</div>
</div>
<form action="{% url "control:users.resetdriftthrottle" id=user.pk %}" id="resetdriftthrottle" method="post">
{% csrf_token %}
</form>
{% endblock %}
+3 -2
View File
@@ -78,6 +78,7 @@ urlpatterns = [
re_path(r'^users/(?P<id>\d+)/impersonate$', users.UserImpersonateView.as_view(), name='users.impersonate'),
re_path(r'^users/(?P<id>\d+)/anonymize$', users.UserAnonymizeView.as_view(), name='users.anonymize'),
re_path(r'^users/(?P<id>\d+)/emergencytoken$', users.UserEmergencyTokenView.as_view(), name='users.emergencytoken'),
re_path(r'^users/(?P<id>\d+)/resetdriftthrottle$', users.Reset2FADriftThrottleView.as_view(), name='users.resetdriftthrottle'),
re_path(r'^pdf/editor/webfonts.css', pdf.FontsCSSView.as_view(), name='pdf.css'),
re_path(r'^settings/?$', user.UserSettings.as_view(), name='user.settings'),
re_path(r'^settings/history/$', user.UserHistoryView.as_view(), name='user.settings.history'),
@@ -106,9 +107,9 @@ urlpatterns = [
re_path(r'^settings/2fa/regenemergency', user.User2FARegenerateEmergencyView.as_view(),
name='user.settings.2fa.regenemergency'),
re_path(r'^settings/2fa/totp/(?P<device>[0-9]+)/confirm', user.User2FADeviceConfirmTOTPView.as_view(),
name='user.settings.2fa.confirm.totp'),
name='user.settings.2fa.confirm.otp_totp.totpdevice'),
re_path(r'^settings/2fa/webauthn/(?P<device>[0-9]+)/confirm', user.User2FADeviceConfirmWebAuthnView.as_view(),
name='user.settings.2fa.confirm.webauthn'),
name='user.settings.2fa.confirm.pretixbase.webauthndevice'),
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'),
+15 -24
View File
@@ -59,6 +59,7 @@ from django.utils.translation import gettext_lazy as _
from django.views import View
from django.views.decorators.cache import never_cache
from django.views.generic import FormView, ListView, TemplateView, UpdateView
from django_otp import devices_for_user
from django_otp.plugins.otp_static.models import StaticDevice
from django_otp.plugins.otp_totp.models import TOTPDevice
from django_scopes import scopes_disabled
@@ -85,7 +86,6 @@ from pretix.helpers.ratelimit import rate_limit, rate_limit_reset
from pretix.helpers.security import session_reauth
from pretix.helpers.u2f import websafe_encode
REAL_DEVICE_TYPES = (TOTPDevice, WebAuthnDevice, U2FDevice)
logger = logging.getLogger(__name__)
@@ -313,17 +313,7 @@ class User2FAMainView(RecentAuthenticationRequiredMixin, TemplateView):
except StaticDevice.DoesNotExist:
ctx['static_tokens_device'] = None
ctx['devices'] = []
for dt in REAL_DEVICE_TYPES:
objs = list(dt.objects.filter(user=self.request.user, confirmed=True))
for obj in objs:
if dt == TOTPDevice:
obj.devicetype = 'totp'
elif dt == U2FDevice:
obj.devicetype = 'u2f'
elif dt == WebAuthnDevice:
obj.devicetype = 'webauthn'
ctx['devices'] += objs
ctx['devices'] = [d for d in devices_for_user(self.request.user) if not isinstance(d, StaticDevice)]
ctx['obligatory'] = None
if settings.PRETIX_OBLIGATORY_2FA is True:
@@ -342,9 +332,9 @@ class User2FADeviceAddView(RecentAuthenticationRequiredMixin, FormView):
template_name = 'pretixcontrol/user/2fa_add.html'
def form_valid(self, form):
if form.cleaned_data['devicetype'] == 'totp':
if form.cleaned_data['devicetype'] == 'otp_totp.totpdevice':
dev = TOTPDevice.objects.create(user=self.request.user, confirmed=False, name=form.cleaned_data['name'])
elif form.cleaned_data['devicetype'] == 'webauthn':
elif form.cleaned_data['devicetype'] == 'pretixbase.webauthndevice':
if not self.request.is_secure():
messages.error(self.request,
_('Security devices are only available if pretix is served via HTTPS.'))
@@ -364,11 +354,11 @@ class User2FADeviceDeleteView(RecentAuthenticationRequiredMixin, TemplateView):
@cached_property
def device(self):
if self.kwargs['devicetype'] == 'totp':
if self.kwargs['devicetype'] == 'otp_totp.totpdevice':
return get_object_or_404(TOTPDevice, user=self.request.user, pk=self.kwargs['device'], confirmed=True)
elif self.kwargs['devicetype'] == 'webauthn':
elif self.kwargs['devicetype'] == 'pretixbase.webauthndevice':
return get_object_or_404(WebAuthnDevice, user=self.request.user, pk=self.kwargs['device'], confirmed=True)
elif self.kwargs['devicetype'] == 'u2f':
elif self.kwargs['devicetype'] == 'pretixbase.u2fdevice':
return get_object_or_404(U2FDevice, user=self.request.user, pk=self.kwargs['device'], confirmed=True)
def get_context_data(self, **kwargs):
@@ -386,7 +376,7 @@ class User2FADeviceDeleteView(RecentAuthenticationRequiredMixin, TemplateView):
msgs = [
_('A two-factor authentication device has been removed from your account.')
]
if not any(dt.objects.filter(user=self.request.user, confirmed=True) for dt in REAL_DEVICE_TYPES):
if not any(d.confirmed for d in devices_for_user(self.request.user) if not isinstance(d, StaticDevice)):
self.request.user.require_2fa = False
self.request.user.save()
self.request.user.log_action('pretix.user.settings.2fa.disabled', user=self.request.user)
@@ -461,7 +451,7 @@ class User2FADeviceConfirmWebAuthnView(RecentAuthenticationRequiredMixin, Templa
).first()
if credential_id_exists:
messages.error(request, _('This security device is already registered.'))
return redirect(reverse('control:user.settings.2fa.confirm.webauthn', kwargs={
return redirect(reverse('control:user.settings.2fa.confirm.pretixbase.webauthndevice', kwargs={
'device': self.device.pk
}))
@@ -475,7 +465,7 @@ class User2FADeviceConfirmWebAuthnView(RecentAuthenticationRequiredMixin, Templa
self.device.save()
self.request.user.log_action('pretix.user.settings.2fa.device.added', user=self.request.user, data={
'id': self.device.pk,
'devicetype': 'u2f',
'devicetype': 'pretixbase.webauthndevice',
'name': self.device.name,
})
notices = [
@@ -503,7 +493,7 @@ class User2FADeviceConfirmWebAuthnView(RecentAuthenticationRequiredMixin, Templa
except Exception:
messages.error(request, _('The registration could not be completed. Please try again.'))
logger.exception('WebAuthn registration failed')
return redirect(reverse('control:user.settings.2fa.confirm.webauthn', kwargs={
return redirect(reverse('control:user.settings.2fa.confirm.pretixbase.webauthndevice', kwargs={
'device': self.device.pk
}))
@@ -537,7 +527,7 @@ class User2FADeviceConfirmTOTPView(RecentAuthenticationRequiredMixin, TemplateVi
self.request.user.log_action('pretix.user.settings.2fa.device.added', user=self.request.user, data={
'id': self.device.pk,
'name': self.device.name,
'devicetype': 'totp'
'devicetype': 'otp_totp.totpdevice'
})
notices = [
_('A new two-factor authentication device has been added to your account.')
@@ -563,7 +553,7 @@ class User2FADeviceConfirmTOTPView(RecentAuthenticationRequiredMixin, TemplateVi
else:
messages.error(request, _('The code you entered was not valid. If this problem persists, please check '
'that the date and time of your phone are configured correctly.'))
return redirect(reverse('control:user.settings.2fa.confirm.totp', kwargs={
return redirect(reverse('control:user.settings.2fa.confirm.otp_totp.totpdevice', kwargs={
'device': self.device.pk
}))
@@ -594,7 +584,7 @@ class User2FAEnableView(RecentAuthenticationRequiredMixin, TemplateView):
template_name = 'pretixcontrol/user/2fa_enable.html'
def dispatch(self, request, *args, **kwargs):
if not any(dt.objects.filter(user=self.request.user, confirmed=True) for dt in REAL_DEVICE_TYPES):
if not any(d.confirmed for d in devices_for_user(self.request.user) if not isinstance(d, StaticDevice)):
messages.error(request, _('Please configure at least one device before enabling two-factor '
'authentication.'))
return redirect(reverse('control:user.settings.2fa'))
@@ -771,6 +761,7 @@ class UserNotificationsEditView(TemplateView):
{k: a.get(t) for k, a in self.global_set.items()},
)
for t, tv in self.types.items()
if tv.is_event_level or not self.event
]
ctx['event'] = self.event
if self.event:
+23
View File
@@ -40,6 +40,7 @@ from django.utils.functional import cached_property
from django.utils.translation import gettext_lazy as _
from django.views import View
from django.views.generic import ListView, TemplateView
from django_otp import devices_for_user
from django_otp.plugins.otp_static.models import StaticDevice
from hijack import signals
@@ -107,6 +108,9 @@ class UserEditView(AdministratorPermissionRequiredMixin, RecentAuthenticationReq
ctx['backend'] = (
b[self.object.auth_backend].verbose_name if self.object.auth_backend in b else self.object.auth_backend
)
ctx['devices'] = devices_for_user(self.object)
return ctx
def get_success_url(self):
@@ -183,6 +187,25 @@ class UserEmergencyTokenView(AdministratorPermissionRequiredMixin, RecentAuthent
return reverse('control:users.edit', kwargs=self.kwargs)
class Reset2FADriftThrottleView(AdministratorPermissionRequiredMixin, RecentAuthenticationRequiredMixin, View):
def get(self, request, *args, **kwargs):
return redirect(reverse('control:users.edit', kwargs=self.kwargs))
def post(self, request, *args, **kwargs):
self.object = get_object_or_404(User, pk=self.kwargs.get("id"))
self.object.totpdevice_set.update(drift=0, throttling_failure_timestamp=None, throttling_failure_count=0)
self.object.staticdevice_set.update(throttling_failure_timestamp=None, throttling_failure_count=0)
self.object.log_action('pretix.user.settings.2fa.resetdrift', user=self.request.user)
messages.success(request, _(
'The drift values for TOTP devices have been reset.'
))
return redirect(self.get_success_url())
def get_success_url(self):
return reverse('control:users.edit', kwargs=self.kwargs)
class UserAnonymizeView(AdministratorPermissionRequiredMixin, RecentAuthenticationRequiredMixin, TemplateView):
template_name = "pretixcontrol/users/anonymize.html"
File diff suppressed because it is too large Load Diff
-12
View File
@@ -19,15 +19,3 @@
# 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/>.
#
# 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: Tobias Kunze
#
# 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.
-53
View File
@@ -1,53 +0,0 @@
#
# This file is part of pretix (Community Edition).
#
# Copyright (C) 2014-2020 Raphael Michel and contributors
# Copyright (C) 2020-today pretix GmbH and contributors
#
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
# Public License as published by the Free Software Foundation in version 3 of the License.
#
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
# this file, see <https://pretix.eu/about/en/license>.
#
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# 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 hashlib
import json
from django.core.cache import cache
from paypalrestsdk.api import Api as VendorApi
class Api(VendorApi):
def get_token_hash(self, authorization_code=None, refresh_token=None, headers=None):
if not authorization_code and not refresh_token:
checksum = hashlib.sha256(self.basic_auth().encode()).hexdigest()
cache_key_hash = f'pretix_paypal_token_hash_{checksum}'
cache_key_request_at = f'pretix_paypal_token_request_at_{checksum}'
token_hash = cache.get(cache_key_hash)
if token_hash:
token_request_at = cache.get(cache_key_request_at)
if token_request_at:
self.token_hash = json.loads(token_hash)
self.token_request_at = token_request_at
self.validate_token_hash()
if self.token_hash is not None:
return self.token_hash
t = super().get_token_hash(authorization_code, refresh_token, headers)
if self.token_hash:
cache.set(cache_key_hash, json.dumps(self.token_hash), 3600 * 4)
cache.set(cache_key_request_at, self.token_request_at, 3600 * 4)
return t
return super().get_token_hash(authorization_code, refresh_token, headers)
-69
View File
@@ -1,69 +0,0 @@
#
# This file is part of pretix (Community Edition).
#
# Copyright (C) 2014-2020 Raphael Michel and contributors
# Copyright (C) 2020-today pretix GmbH and contributors
#
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
# Public License as published by the Free Software Foundation in version 3 of the License.
#
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
# this file, see <https://pretix.eu/about/en/license>.
#
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# 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/>.
#
# 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: Tobias Kunze
#
# 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.
from django.apps import AppConfig
from django.utils.functional import cached_property
from django.utils.translation import gettext_lazy as _
from pretix import __version__ as version
class PaypalApp(AppConfig):
name = 'pretix.plugins.paypal'
verbose_name = _("PayPal")
class PretixPluginMeta:
name = _("PayPal")
author = _("the pretix team")
version = version
category = 'PAYMENT'
featured = True
picture = 'pretixplugins/paypal/paypal_logo.svg'
description = _("Accept payments with your PayPal account. PayPal is one of the most popular payment methods "
"world-wide.")
def ready(self):
from . import signals # NOQA
def is_available(self, event):
return 'pretix.plugins.paypal' in event.plugins.split(',')
@cached_property
def compatibility_errors(self):
errs = []
try:
import paypalrestsdk # NOQA
except ImportError:
errs.append("Python package 'paypalrestsdk' is not installed.")
return errs
@@ -0,0 +1,21 @@
# Generated by Django 5.2.17 on 2026-09-08 08:05
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("paypal", "0004_bigint"),
]
operations = [
migrations.SeparateDatabaseAndState(
state_operations=[
migrations.DeleteModel(
name="ReferencedPayPalObject",
),
],
database_operations=[]
)
]
-715
View File
@@ -1,715 +0,0 @@
#
# This file is part of pretix (Community Edition).
#
# Copyright (C) 2014-2020 Raphael Michel and contributors
# Copyright (C) 2020-today pretix GmbH and contributors
#
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
# Public License as published by the Free Software Foundation in version 3 of the License.
#
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
# this file, see <https://pretix.eu/about/en/license>.
#
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# 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/>.
#
# 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: Jakob Schnell, Tobias Kunze
#
# 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 json
import logging
from collections import OrderedDict
from decimal import Decimal
import paypalrestsdk
import paypalrestsdk.exceptions
from django import forms
from django.contrib import messages
from django.http import HttpRequest
from django.template.loader import get_template
from django.urls import reverse
from django.utils.html import format_html
from django.utils.timezone import now
from django.utils.translation import gettext as __, gettext_lazy as _
from i18nfield.strings import LazyI18nString
from paypalrestsdk.exceptions import BadRequest, UnauthorizedAccess
from paypalrestsdk.openid_connect import Tokeninfo
from requests import RequestException
from pretix.base.decimal import round_decimal
from pretix.base.forms import SecretKeySettingsField
from pretix.base.models import Event, Order, OrderPayment, OrderRefund, Quota
from pretix.base.payment import BasePaymentProvider, PaymentException
from pretix.base.settings import SettingsSandbox
from pretix.base.views.redirect import safelink
from pretix.multidomain.urlreverse import eventreverse_absolute
from pretix.plugins.paypal.api import Api
from pretix.plugins.paypal.models import ReferencedPayPalObject
logger = logging.getLogger('pretix.plugins.paypal')
SUPPORTED_CURRENCIES = ['AUD', 'BRL', 'CAD', 'CZK', 'DKK', 'EUR', 'HKD', 'HUF', 'INR', 'ILS', 'JPY', 'MYR', 'MXN',
'TWD', 'NZD', 'NOK', 'PHP', 'PLN', 'GBP', 'RUB', 'SGD', 'SEK', 'CHF', 'THB', 'USD']
LOCAL_ONLY_CURRENCIES = ['INR']
class Paypal(BasePaymentProvider):
identifier = 'paypal'
verbose_name = _('PayPal')
payment_form_fields = OrderedDict([
])
def __init__(self, event: Event):
super().__init__(event)
self.settings = SettingsSandbox('payment', 'paypal', event)
@property
def test_mode_message(self):
if self.settings.connect_client_id and not self.settings.secret:
# in OAuth mode, sandbox mode needs to be set global
is_sandbox = self.settings.connect_endpoint == 'sandbox'
else:
is_sandbox = self.settings.get('endpoint') == 'sandbox'
if is_sandbox:
return _('The PayPal sandbox is being used, you can test without actually sending money but you will need a '
'PayPal sandbox user to log in.')
return None
@property
def settings_form_fields(self):
if self.settings.connect_client_id and not self.settings.secret:
# PayPal connect
if self.settings.connect_user_id:
fields = [
('connect_user_id',
forms.CharField(
label=_('PayPal account'),
disabled=True
)),
]
else:
return {}
else:
fields = [
('client_id',
forms.CharField(
label=_('Client ID'),
min_length=80,
help_text=format_html(
'<a target="_blank" rel="noopener" href="{docs_url}">{text}</a>',
text=_('Click here for a tutorial on how to obtain the required keys'),
docs_url='https://docs.pretix.eu/en/latest/user/payments/paypal.html',
)
)),
('secret',
SecretKeySettingsField(
label=_('Secret'),
max_length=80,
min_length=80,
)),
('endpoint',
forms.ChoiceField(
label=_('Endpoint'),
initial='live',
choices=(
('live', 'Live'),
('sandbox', 'Sandbox'),
),
)),
]
extra_fields = [
('prefix',
forms.CharField(
label=_('Reference prefix'),
help_text=_('Any value entered here will be added in front of the regular booking reference '
'containing the order number.'),
required=False,
)),
('postfix',
forms.CharField(
label=_('Reference postfix'),
help_text=_('Any value entered here will be added behind the regular booking reference '
'containing the order number.'),
required=False,
)),
]
d = OrderedDict(
fields + extra_fields + list(super().settings_form_fields.items())
)
d.move_to_end('prefix')
d.move_to_end('postfix')
d.move_to_end('_enabled', False)
return d
def get_connect_url(self, request):
request.session['payment_paypal_oauth_event'] = request.event.pk
self.init_api()
return Tokeninfo.authorize_url({'scope': 'openid profile email'})
def settings_content_render(self, request):
settings_content = ""
if self.settings.connect_client_id and not self.settings.secret:
# Use PayPal connect
if not self.settings.connect_user_id:
# Migrate User to PayPal v2
self.event.disable_plugin("pretix.plugins.paypal")
self.event.enable_plugin("pretix.plugins.paypal2")
self.event.save()
else:
settings_content = (
"<button formaction='{}' class='btn btn-danger'>{}</button>"
).format(
reverse('plugins:paypal:oauth.disconnect', kwargs={
'organizer': self.event.organizer.slug,
'event': self.event.slug,
}),
_('Disconnect from PayPal')
)
else:
# Migrate User to PayPal v2
self.event.disable_plugin("pretix.plugins.paypal")
self.event.enable_plugin("pretix.plugins.paypal2")
self.event.save()
return settings_content
def is_allowed(self, request: HttpRequest, total: Decimal = None) -> bool:
return super().is_allowed(request, total) and self.event.currency in SUPPORTED_CURRENCIES
def init_api(self):
if self.settings.connect_client_id and not self.settings.secret:
paypalrestsdk.api.__api__ = Api(
mode="sandbox" if "sandbox" in self.settings.connect_endpoint else 'live',
client_id=self.settings.connect_client_id,
client_secret=self.settings.connect_secret_key,
openid_client_id=self.settings.connect_client_id,
openid_client_secret=self.settings.connect_secret_key
)
else:
paypalrestsdk.api.__api__ = Api(
mode="sandbox" if "sandbox" in self.settings.get('endpoint') else 'live',
client_id=self.settings.get('client_id'),
client_secret=self.settings.get('secret')
)
def payment_is_valid_session(self, request):
return (request.session.get('payment_paypal_id', '') != ''
and request.session.get('payment_paypal_payer', '') != '')
def payment_form_render(self, request) -> str:
template = get_template('pretixplugins/paypal/checkout_payment_form.html')
ctx = {'request': request, 'event': self.event, 'settings': self.settings}
return template.render(ctx)
def checkout_prepare(self, request, cart):
self.init_api()
kwargs = {}
if request.resolver_match and 'cart_namespace' in request.resolver_match.kwargs:
kwargs['cart_namespace'] = request.resolver_match.kwargs['cart_namespace']
try:
if self.settings.connect_client_id and not self.settings.secret:
if not request.event.settings.payment_paypal_connect_user_id:
raise PaymentException('Payment method misconfigured')
try:
tokeninfo = Tokeninfo.create_with_refresh_token(request.event.settings.payment_paypal_connect_refresh_token)
except BadRequest as ex:
ex = json.loads(ex.content)
messages.error(request, '{}: {} ({})'.format(
_('We had trouble communicating with PayPal'),
ex['error_description'],
ex['correlation_id'])
)
return
# Even if the token has been refreshed, calling userinfo() can fail. In this case we just don't
# get the userinfo again and use the payment_paypal_connect_user_id that we already have on file
try:
userinfo = tokeninfo.userinfo()
request.event.settings.payment_paypal_connect_user_id = userinfo.email
except UnauthorizedAccess:
pass
payee = {
"email": request.event.settings.payment_paypal_connect_user_id,
# If PayPal ever offers a good way to get the MerchantID via the Identifity API,
# we should use it instead of the merchant's eMail-address
# "merchant_id": request.event.settings.payment_paypal_connect_user_id,
}
else:
payee = {}
payment = paypalrestsdk.Payment({
'header': {'PayPal-Partner-Attribution-Id': 'ramiioSoftwareentwicklung_SP'},
'intent': 'sale',
'payer': {
"payment_method": "paypal",
},
"redirect_urls": {
"return_url": eventreverse_absolute(request.event, 'plugins:paypal:return', kwargs=kwargs),
"cancel_url": eventreverse_absolute(request.event, 'plugins:paypal:abort', kwargs=kwargs),
},
"transactions": [
{
"item_list": {
"items": [
{
"name": '{prefix}{orderstring}{postfix}'.format(
prefix='{} '.format(self.settings.prefix) if self.settings.prefix else '',
orderstring=__('Order for %s') % str(request.event),
postfix=' {}'.format(self.settings.postfix) if self.settings.postfix else ''
),
"quantity": 1,
"price": self.format_price(cart['total']),
"currency": request.event.currency
}
]
},
"amount": {
"currency": request.event.currency,
"total": self.format_price(cart['total'])
},
"description": __('Event tickets for {event}').format(event=request.event.name),
"payee": payee,
"custom": '{prefix}{slug}{postfix}'.format(
prefix='{} '.format(self.settings.prefix) if self.settings.prefix else '',
slug=request.event.slug.upper(),
postfix=' {}'.format(self.settings.postfix) if self.settings.postfix else ''
)
}
]
})
request.session['payment_paypal_payment'] = None
return self._create_payment(request, payment)
except paypalrestsdk.exceptions.ConnectionError as e:
messages.error(request, _('We had trouble communicating with PayPal'))
logger.exception('Error on creating payment: ' + str(e))
def format_price(self, value):
return str(round_decimal(value, self.event.currency, {
# PayPal behaves differently than Stripe in deciding what currencies have decimal places
# Source https://developer.paypal.com/docs/classic/api/currency_codes/
'HUF': 0,
'JPY': 0,
'MYR': 0,
'TWD': 0,
# However, CLPs are not listed there while PayPal requires us not to send decimal places there. WTF.
'CLP': 0,
# Let's just guess that the ones listed here are 0-based as well
# https://developers.braintreepayments.com/reference/general/currencies
'BIF': 0,
'DJF': 0,
'GNF': 0,
'KMF': 0,
'KRW': 0,
'LAK': 0,
'PYG': 0,
'RWF': 0,
'UGX': 0,
'VND': 0,
'VUV': 0,
'XAF': 0,
'XOF': 0,
'XPF': 0,
}))
@property
def abort_pending_allowed(self):
return False
def _create_payment(self, request, payment):
if payment.create():
if payment.state not in ('created', 'approved', 'pending'):
messages.error(request, _('We had trouble communicating with PayPal'))
logger.error('Invalid payment state: ' + str(payment))
return
request.session['payment_paypal_id'] = payment.id
for link in payment.links:
if link.method == "REDIRECT" and link.rel == "approval_url":
if request.session.get('iframe_session', False):
return safelink(link.href, framebreak=True)
else:
return str(link.href)
else:
messages.error(request, _('We had trouble communicating with PayPal'))
logger.error('Error on creating payment: ' + str(payment.error))
def checkout_confirm_render(self, request) -> str:
"""
Returns the HTML that should be displayed when the user selected this provider
on the 'confirm order' page.
"""
template = get_template('pretixplugins/paypal/checkout_payment_confirm.html')
ctx = {'request': request, 'event': self.event, 'settings': self.settings}
return template.render(ctx)
def execute_payment(self, request: HttpRequest, payment: OrderPayment):
if (request.session.get('payment_paypal_id', '') == '' or request.session.get('payment_paypal_payer', '') == ''):
raise PaymentException(_('We were unable to process your payment. See below for details on how to '
'proceed.'))
self.init_api()
pp_payment = paypalrestsdk.Payment.find(request.session.get('payment_paypal_id'))
ReferencedPayPalObject.objects.get_or_create(order=payment.order, payment=payment, reference=pp_payment.id)
if str(pp_payment.transactions[0].amount.total) != str(payment.amount) or pp_payment.transactions[0].amount.currency \
!= self.event.currency:
logger.error('Value mismatch: Payment %s vs paypal trans %s' % (payment.id, str(pp_payment)))
raise PaymentException(_('We were unable to process your payment. See below for details on how to '
'proceed.'))
return self._execute_payment(pp_payment, request, payment)
def _execute_payment(self, payment, request, payment_obj):
if payment.state == 'created':
payment.replace([
{
"op": "replace",
"path": "/transactions/0/item_list",
"value": {
"items": [
{
"name": '{prefix}{orderstring}{postfix}'.format(
prefix='{} '.format(self.settings.prefix) if self.settings.prefix else '',
orderstring=__('Order {slug}-{code}').format(
slug=self.event.slug.upper(),
code=payment_obj.order.code
),
postfix=' {}'.format(self.settings.postfix) if self.settings.postfix else ''
),
"quantity": 1,
"price": self.format_price(payment_obj.amount),
"currency": payment_obj.order.event.currency
}
]
}
},
{
"op": "replace",
"path": "/transactions/0/description",
"value": '{prefix}{orderstring}{postfix}'.format(
prefix='{} '.format(self.settings.prefix) if self.settings.prefix else '',
orderstring=__('Order {order} for {event}').format(
event=request.event.name,
order=payment_obj.order.code
),
postfix=' {}'.format(self.settings.postfix) if self.settings.postfix else ''
),
}
])
try:
payment.execute({"payer_id": request.session.get('payment_paypal_payer')})
except paypalrestsdk.exceptions.ConnectionError as e:
messages.error(request, _('We had trouble communicating with PayPal'))
logger.exception('Error on creating payment: ' + str(e))
except RequestException as e:
messages.error(request, _('We had trouble communicating with PayPal'))
logger.exception('Error on creating payment: ' + str(e))
for trans in payment.transactions:
for rr in trans.related_resources:
if hasattr(rr, 'sale') and rr.sale:
if rr.sale.state == 'pending':
messages.warning(request, _('PayPal has not yet approved the payment. We will inform you as '
'soon as the payment completed.'))
payment_obj.info = json.dumps(payment.to_dict())
payment_obj.state = OrderPayment.PAYMENT_STATE_PENDING
payment_obj.save()
return
payment_obj.refresh_from_db()
if payment.state == 'pending':
messages.warning(request, _('PayPal has not yet approved the payment. We will inform you as soon as the '
'payment completed.'))
payment_obj.info = json.dumps(payment.to_dict())
payment_obj.state = OrderPayment.PAYMENT_STATE_PENDING
payment_obj.save()
return
if payment.state != 'approved':
payment_obj.fail(info=payment.to_dict())
logger.error('Invalid state: %s' % str(payment))
raise PaymentException(_('We were unable to process your payment. See below for details on how to '
'proceed.'))
if payment_obj.state == OrderPayment.PAYMENT_STATE_CONFIRMED:
logger.warning('PayPal success event even though order is already marked as paid')
return
try:
payment_obj.info = json.dumps(payment.to_dict())
payment_obj.save(update_fields=['info'])
payment_obj.confirm()
except Quota.QuotaExceededException as e:
raise PaymentException(str(e))
return None
def payment_pending_render(self, request, payment) -> str:
retry = True
try:
if (
payment.info
and payment.info_data['transactions'][0]['related_resources'][0]['sale']['state'] == 'pending'
):
retry = False
except (KeyError, IndexError):
pass
template = get_template('pretixplugins/paypal/pending.html')
ctx = {'request': request, 'event': self.event, 'settings': self.settings,
'retry': retry, 'order': payment.order}
return template.render(ctx)
def matching_id(self, payment: OrderPayment):
sale_id = None
for trans in payment.info_data.get('transactions', []):
for res in trans.get('related_resources', []):
if 'sale' in res and 'id' in res['sale']:
sale_id = res['sale']['id']
return sale_id or payment.info_data.get('id', None)
def api_payment_details(self, payment: OrderPayment):
sale_id = None
for trans in payment.info_data.get('transactions', []):
for res in trans.get('related_resources', []):
if 'sale' in res and 'id' in res['sale']:
sale_id = res['sale']['id']
return {
"payer_email": payment.info_data.get('payer', {}).get('payer_info', {}).get('email'),
"payer_id": payment.info_data.get('payer', {}).get('payer_info', {}).get('payer_id'),
"cart_id": payment.info_data.get('cart', None),
"payment_id": payment.info_data.get('id', None),
"sale_id": sale_id,
}
def payment_control_render(self, request: HttpRequest, payment: OrderPayment):
template = get_template('pretixplugins/paypal/control.html')
sale_id = None
for trans in payment.info_data.get('transactions', []):
for res in trans.get('related_resources', []):
if 'sale' in res and 'id' in res['sale']:
sale_id = res['sale']['id']
ctx = {'request': request, 'event': self.event, 'settings': self.settings,
'payment_info': payment.info_data, 'order': payment.order, 'sale_id': sale_id}
return template.render(ctx)
def payment_control_render_short(self, payment: OrderPayment) -> str:
return payment.info_data.get('payer', {}).get('payer_info', {}).get('email', '')
def payment_partial_refund_supported(self, payment: OrderPayment):
# Paypal refunds are possible for 180 days after purchase:
# https://www.paypal.com/lc/smarthelp/article/how-do-i-issue-a-refund-faq780#:~:text=Refund%20after%20180%20days%20of,PayPal%20balance%20of%20the%20recipient.
return (now() - payment.payment_date).days <= 180
def payment_refund_supported(self, payment: OrderPayment):
self.payment_partial_refund_supported(payment)
def execute_refund(self, refund: OrderRefund):
self.init_api()
try:
sale = None
for res in refund.payment.info_data['transactions'][0]['related_resources']:
for k, v in res.items():
if k == 'sale':
sale = paypalrestsdk.Sale.find(v['id'])
break
if not sale:
pp_payment = paypalrestsdk.Payment.find(refund.payment.info_data['id'])
for res in pp_payment.transactions[0].related_resources:
for k, v in res.to_dict().items():
if k == 'sale':
sale = paypalrestsdk.Sale.find(v['id'])
break
pp_refund = sale.refund({
"amount": {
"total": self.format_price(refund.amount),
"currency": refund.order.event.currency
}
})
except paypalrestsdk.exceptions.ConnectionError as e:
refund.order.log_action('pretix.event.order.refund.failed', {
'local_id': refund.local_id,
'provider': refund.provider,
'error': str(e)
})
raise PaymentException(_('Refunding the amount via PayPal failed: {}').format(str(e)))
if not pp_refund.success():
refund.order.log_action('pretix.event.order.refund.failed', {
'local_id': refund.local_id,
'provider': refund.provider,
'error': str(pp_refund.error)
})
raise PaymentException(_('Refunding the amount via PayPal failed: {}').format(pp_refund.error))
else:
sale = paypalrestsdk.Payment.find(refund.payment.info_data['id'])
refund.payment.info = json.dumps(sale.to_dict())
refund.info = json.dumps(pp_refund.to_dict())
refund.done()
def payment_prepare(self, request, payment_obj):
self.init_api()
try:
if request.event.settings.payment_paypal_connect_user_id:
try:
tokeninfo = Tokeninfo.create_with_refresh_token(request.event.settings.payment_paypal_connect_refresh_token)
except BadRequest as ex:
ex = json.loads(ex.content)
messages.error(request, '{}: {} ({})'.format(
_('We had trouble communicating with PayPal'),
ex['error_description'],
ex['correlation_id'])
)
return
# Even if the token has been refreshed, calling userinfo() can fail. In this case we just don't
# get the userinfo again and use the payment_paypal_connect_user_id that we already have on file
try:
userinfo = tokeninfo.userinfo()
request.event.settings.payment_paypal_connect_user_id = userinfo.email
except UnauthorizedAccess:
pass
payee = {
"email": request.event.settings.payment_paypal_connect_user_id,
# If PayPal ever offers a good way to get the MerchantID via the Identifity API,
# we should use it instead of the merchant's eMail-address
# "merchant_id": request.event.settings.payment_paypal_connect_user_id,
}
else:
payee = {}
payment = paypalrestsdk.Payment({
'header': {'PayPal-Partner-Attribution-Id': 'ramiioSoftwareentwicklung_SP'},
'intent': 'sale',
'payer': {
"payment_method": "paypal",
},
"redirect_urls": {
"return_url": eventreverse_absolute(request.event, 'plugins:paypal:return'),
"cancel_url": eventreverse_absolute(request.event, 'plugins:paypal:abort'),
},
"transactions": [
{
"item_list": {
"items": [
{
"name": '{prefix}{orderstring}{postfix}'.format(
prefix='{} '.format(self.settings.prefix) if self.settings.prefix else '',
orderstring=__('Order {slug}-{code}').format(
slug=self.event.slug.upper(),
code=payment_obj.order.code
),
postfix=' {}'.format(self.settings.postfix) if self.settings.postfix else ''
),
"quantity": 1,
"price": self.format_price(payment_obj.amount),
"currency": payment_obj.order.event.currency
}
]
},
"amount": {
"currency": request.event.currency,
"total": self.format_price(payment_obj.amount)
},
"description": '{prefix}{orderstring}{postfix}'.format(
prefix='{} '.format(self.settings.prefix) if self.settings.prefix else '',
orderstring=__('Order {order} for {event}').format(
event=request.event.name,
order=payment_obj.order.code
),
postfix=' {}'.format(self.settings.postfix) if self.settings.postfix else ''
),
"payee": payee,
"custom": '{prefix}{slug}-{code}{postfix}'.format(
prefix='{} '.format(self.settings.prefix) if self.settings.prefix else '',
slug=self.event.slug.upper(),
code=payment_obj.order.code,
postfix=' {}'.format(self.settings.postfix) if self.settings.postfix else ''
),
}
]
})
request.session['payment_paypal_payment'] = payment_obj.pk
return self._create_payment(request, payment)
except paypalrestsdk.exceptions.ConnectionError as e:
messages.error(request, _('We had trouble communicating with PayPal'))
logger.exception('Error on creating payment: ' + str(e))
def shred_payment_info(self, obj):
if obj.info:
d = json.loads(obj.info)
new = {
'id': d.get('id'),
'payer': {
'payer_info': {
'email': '█'
}
},
'update_time': d.get('update_time'),
'transactions': [
{
'amount': t.get('amount')
} for t in d.get('transactions', [])
],
'_shredded': True
}
obj.info = json.dumps(new)
obj.save(update_fields=['info'])
for le in obj.order.all_logentries().filter(action_type="pretix.plugins.paypal.event").exclude(data=""):
d = le.parsed_data
if 'resource' in d:
d['resource'] = {
'id': d['resource'].get('id'),
'sale_id': d['resource'].get('sale_id'),
'parent_payment': d['resource'].get('parent_payment'),
}
le.data = json.dumps(d)
le.shredded = True
le.save(update_fields=['data', 'shredded'])
def render_invoice_text(self, order: Order, payment: OrderPayment) -> str:
if order.status == Order.STATUS_PAID:
if payment.info_data.get('id', None):
try:
return '{}\r\n{}: {}\r\n{}: {}'.format(
_('The payment for this invoice has already been received.'),
_('PayPal payment ID'),
payment.info_data['id'],
_('PayPal sale ID'),
payment.info_data['transactions'][0]['related_resources'][0]['sale']['id']
)
except (KeyError, IndexError):
return '{}\r\n{}: {}'.format(
_('The payment for this invoice has already been received.'),
_('PayPal payment ID'),
payment.info_data['id']
)
else:
return super().render_invoice_text(order, payment)
return self.settings.get('_invoice_text', as_type=LazyI18nString, default='')
-31
View File
@@ -1,31 +0,0 @@
#
# This file is part of pretix (Community Edition).
#
# Copyright (C) 2014-2020 Raphael Michel and contributors
# Copyright (C) 2020-today pretix GmbH and contributors
#
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
# Public License as published by the Free Software Foundation in version 3 of the License.
#
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
# this file, see <https://pretix.eu/about/en/license>.
#
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# 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.dispatch import receiver
from pretix.base.signals import register_payment_providers
@receiver(register_payment_providers, dispatch_uid="payment_paypal")
def register_payment_provider(sender, **kwargs):
from .payment import Paypal
return Paypal
@@ -1,15 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Generator: Adobe Illustrator 16.0.4, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" width="124px" height="33px" viewBox="0 0 124 33" enable-background="new 0 0 124 33" xml:space="preserve">
<path fill="#253B80" d="M46.211,6.749h-6.839c-0.468,0-0.866,0.34-0.939,0.802l-2.766,17.537c-0.055,0.346,0.213,0.658,0.564,0.658 h3.265c0.468,0,0.866-0.34,0.939-0.803l0.746-4.73c0.072-0.463,0.471-0.803,0.938-0.803h2.165c4.505,0,7.105-2.18,7.784-6.5 c0.306-1.89,0.013-3.375-0.872-4.415C50.224,7.353,48.5,6.749,46.211,6.749z M47,13.154c-0.374,2.454-2.249,2.454-4.062,2.454 h-1.032l0.724-4.583c0.043-0.277,0.283-0.481,0.563-0.481h0.473c1.235,0,2.4,0,3.002,0.704C47.027,11.668,47.137,12.292,47,13.154z"/>
<path fill="#253B80" d="M66.654,13.075h-3.275c-0.279,0-0.52,0.204-0.563,0.481l-0.145,0.916l-0.229-0.332 c-0.709-1.029-2.29-1.373-3.868-1.373c-3.619,0-6.71,2.741-7.312,6.586c-0.313,1.918,0.132,3.752,1.22,5.031 c0.998,1.176,2.426,1.666,4.125,1.666c2.916,0,4.533-1.875,4.533-1.875l-0.146,0.91c-0.055,0.348,0.213,0.66,0.562,0.66h2.95 c0.469,0,0.865-0.34,0.939-0.803l1.77-11.209C67.271,13.388,67.004,13.075,66.654,13.075z M62.089,19.449 c-0.316,1.871-1.801,3.127-3.695,3.127c-0.951,0-1.711-0.305-2.199-0.883c-0.484-0.574-0.668-1.391-0.514-2.301 c0.295-1.855,1.805-3.152,3.67-3.152c0.93,0,1.686,0.309,2.184,0.892C62.034,17.721,62.232,18.543,62.089,19.449z"/>
<path fill="#253B80" d="M84.096,13.075h-3.291c-0.314,0-0.609,0.156-0.787,0.417l-4.539,6.686l-1.924-6.425 c-0.121-0.402-0.492-0.678-0.912-0.678h-3.234c-0.393,0-0.666,0.384-0.541,0.754l3.625,10.638l-3.408,4.811 c-0.268,0.379,0.002,0.9,0.465,0.9h3.287c0.312,0,0.604-0.152,0.781-0.408L84.564,13.97C84.826,13.592,84.557,13.075,84.096,13.075z "/>
<path fill="#179BD7" d="M94.992,6.749h-6.84c-0.467,0-0.865,0.34-0.938,0.802l-2.766,17.537c-0.055,0.346,0.213,0.658,0.562,0.658 h3.51c0.326,0,0.605-0.238,0.656-0.562l0.785-4.971c0.072-0.463,0.471-0.803,0.938-0.803h2.164c4.506,0,7.105-2.18,7.785-6.5 c0.307-1.89,0.012-3.375-0.873-4.415C99.004,7.353,97.281,6.749,94.992,6.749z M95.781,13.154c-0.373,2.454-2.248,2.454-4.062,2.454 h-1.031l0.725-4.583c0.043-0.277,0.281-0.481,0.562-0.481h0.473c1.234,0,2.4,0,3.002,0.704 C95.809,11.668,95.918,12.292,95.781,13.154z"/>
<path fill="#179BD7" d="M115.434,13.075h-3.273c-0.281,0-0.52,0.204-0.562,0.481l-0.145,0.916l-0.23-0.332 c-0.709-1.029-2.289-1.373-3.867-1.373c-3.619,0-6.709,2.741-7.311,6.586c-0.312,1.918,0.131,3.752,1.219,5.031 c1,1.176,2.426,1.666,4.125,1.666c2.916,0,4.533-1.875,4.533-1.875l-0.146,0.91c-0.055,0.348,0.213,0.66,0.564,0.66h2.949 c0.467,0,0.865-0.34,0.938-0.803l1.771-11.209C116.053,13.388,115.785,13.075,115.434,13.075z M110.869,19.449 c-0.314,1.871-1.801,3.127-3.695,3.127c-0.949,0-1.711-0.305-2.199-0.883c-0.484-0.574-0.666-1.391-0.514-2.301 c0.297-1.855,1.805-3.152,3.67-3.152c0.93,0,1.686,0.309,2.184,0.892C110.816,17.721,111.014,18.543,110.869,19.449z"/>
<path fill="#179BD7" d="M119.295,7.23l-2.807,17.858c-0.055,0.346,0.213,0.658,0.562,0.658h2.822c0.469,0,0.867-0.34,0.939-0.803 l2.768-17.536c0.055-0.346-0.213-0.659-0.562-0.659h-3.16C119.578,6.749,119.338,6.953,119.295,7.23z"/>
<path fill="#253B80" d="M7.266,29.154l0.523-3.322l-1.165-0.027H1.061L4.927,1.292C4.939,1.218,4.978,1.149,5.035,1.1 c0.057-0.049,0.13-0.076,0.206-0.076h9.38c3.114,0,5.263,0.648,6.385,1.927c0.526,0.6,0.861,1.227,1.023,1.917 c0.17,0.724,0.173,1.589,0.007,2.644l-0.012,0.077v0.676l0.526,0.298c0.443,0.235,0.795,0.504,1.065,0.812 c0.45,0.513,0.741,1.165,0.864,1.938c0.127,0.795,0.085,1.741-0.123,2.812c-0.24,1.232-0.628,2.305-1.152,3.183 c-0.482,0.809-1.096,1.48-1.825,2c-0.696,0.494-1.523,0.869-2.458,1.109c-0.906,0.236-1.939,0.355-3.072,0.355h-0.73 c-0.522,0-1.029,0.188-1.427,0.525c-0.399,0.344-0.663,0.814-0.744,1.328l-0.055,0.299l-0.924,5.855l-0.042,0.215 c-0.011,0.068-0.03,0.102-0.058,0.125c-0.025,0.021-0.061,0.035-0.096,0.035H7.266z"/>
<path fill="#179BD7" d="M23.048,7.667L23.048,7.667L23.048,7.667c-0.028,0.179-0.06,0.362-0.096,0.55 c-1.237,6.351-5.469,8.545-10.874,8.545H9.326c-0.661,0-1.218,0.48-1.321,1.132l0,0l0,0L6.596,26.83l-0.399,2.533 c-0.067,0.428,0.263,0.814,0.695,0.814h4.881c0.578,0,1.069-0.42,1.16-0.99l0.048-0.248l0.919-5.832l0.059-0.32 c0.09-0.572,0.582-0.992,1.16-0.992h0.73c4.729,0,8.431-1.92,9.513-7.476c0.452-2.321,0.218-4.259-0.978-5.622 C24.022,8.286,23.573,7.945,23.048,7.667z"/>
<path fill="#222D65" d="M21.754,7.151c-0.189-0.055-0.384-0.105-0.584-0.15c-0.201-0.044-0.407-0.083-0.619-0.117 c-0.742-0.12-1.555-0.177-2.426-0.177h-7.352c-0.181,0-0.353,0.041-0.507,0.115C9.927,6.985,9.675,7.306,9.614,7.699L8.05,17.605 l-0.045,0.289c0.103-0.652,0.66-1.132,1.321-1.132h2.752c5.405,0,9.637-2.195,10.874-8.545c0.037-0.188,0.068-0.371,0.096-0.55 c-0.313-0.166-0.652-0.308-1.017-0.429C21.941,7.208,21.848,7.179,21.754,7.151z"/>
<path fill="#253B80" d="M9.614,7.699c0.061-0.393,0.313-0.714,0.652-0.876c0.155-0.074,0.326-0.115,0.507-0.115h7.352 c0.871,0,1.684,0.057,2.426,0.177c0.212,0.034,0.418,0.073,0.619,0.117c0.2,0.045,0.395,0.095,0.584,0.15 c0.094,0.028,0.187,0.057,0.278,0.086c0.365,0.121,0.704,0.264,1.017,0.429c0.368-2.347-0.003-3.945-1.272-5.392 C20.378,0.682,17.853,0,14.622,0h-9.38c-0.66,0-1.223,0.48-1.325,1.133L0.01,25.898c-0.077,0.49,0.301,0.932,0.795,0.932h5.791 l1.454-9.225L9.614,7.699z"/>
</svg>

Before

Width:  |  Height:  |  Size: 5.4 KiB

@@ -1,6 +0,0 @@
{% load i18n %}
<p>{% blocktrans trimmed %}
The total amount listed above will be withdrawn from your PayPal account after the
confirmation of your purchase.
{% endblocktrans %}</p>
@@ -1,6 +0,0 @@
{% load i18n %}
<p>{% blocktrans trimmed %}
After you clicked continue, we will redirect you to PayPal to fill in your payment
details. You will then be redirected back here to review and confirm your order.
{% endblocktrans %}</p>
@@ -1,18 +0,0 @@
{% load i18n %}
{% if payment_info %}
<dl class="dl-horizontal">
<dt>{% trans "Payment ID" %}</dt>
<dd>{{ payment_info.id }}</dd>
<dt>{% trans "Sale ID" %}</dt>
<dd>{{ sale_id|default_if_none:"?" }}</dd>
<dt>{% trans "Payer" %}</dt>
<dd>{{ payment_info.payer.payer_info.email }}</dd>
<dt>{% trans "Last update" %}</dt>
<dd>{{ payment_info.update_time }}</dd>
<dt>{% trans "Total value" %}</dt>
<dd>{{ payment_info.transactions.0.amount.total }}</dd>
<dt>{% trans "Currency" %}</dt>
<dd>{{ payment_info.transactions.0.amount.currency }}</dd>
</dl>
{% endif %}
@@ -1,12 +0,0 @@
{% load i18n %}
{% if retry %}
<p>{% blocktrans trimmed %}
Our attempt to execute your Payment via PayPal has failed. Please try again or contact us.
{% endblocktrans %}</p>
{% else %}
<p>{% blocktrans trimmed %}
We're waiting for an answer from PayPal regarding your payment. Please contact us, if this
takes more than a few hours.
{% endblocktrans %}</p>
{% endif %}
-39
View File
@@ -1,39 +0,0 @@
#
# This file is part of pretix (Community Edition).
#
# Copyright (C) 2014-2020 Raphael Michel and contributors
# Copyright (C) 2020-today pretix GmbH and contributors
#
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
# Public License as published by the Free Software Foundation in version 3 of the License.
#
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
# this file, see <https://pretix.eu/about/en/license>.
#
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# 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.urls import include, re_path
from .views import abort, oauth_disconnect, success
event_patterns = [
re_path(r'^paypal/', include([
re_path(r'^abort/$', abort, name='abort'),
re_path(r'^return/$', success, name='return'),
re_path(r'w/(?P<cart_namespace>[a-zA-Z0-9]{16})/abort/', abort, name='abort'),
re_path(r'w/(?P<cart_namespace>[a-zA-Z0-9]{16})/return/', success, name='return'),
])),
]
urlpatterns = [
re_path(r'^control/event/(?P<organizer>[^/]+)/(?P<event>[^/]+)/paypal/disconnect/',
oauth_disconnect, name='oauth.disconnect'),
]
-249
View File
@@ -1,249 +0,0 @@
#
# This file is part of pretix (Community Edition).
#
# Copyright (C) 2014-2020 Raphael Michel and contributors
# Copyright (C) 2020-today pretix GmbH and contributors
#
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
# Public License as published by the Free Software Foundation in version 3 of the License.
#
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
# this file, see <https://pretix.eu/about/en/license>.
#
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# 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/>.
#
# 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: Flavia Bastos
#
# 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 json
import logging
from decimal import Decimal
import paypalrestsdk
import paypalrestsdk.exceptions
from django.contrib import messages
from django.db.models import Sum
from django.http import HttpResponse
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST
from django_scopes import scopes_disabled
from pretix.base.models import Order, OrderPayment, OrderRefund, Quota
from pretix.base.payment import PaymentException
from pretix.control.permissions import event_permission_required
from pretix.helpers.http import redirect_to_url
from pretix.multidomain.urlreverse import eventreverse
from pretix.plugins.paypal.models import ReferencedPayPalObject
from pretix.plugins.paypal.payment import Paypal
logger = logging.getLogger('pretix.plugins.paypal')
def success(request, *args, **kwargs):
pid = request.GET.get('paymentId')
token = request.GET.get('token')
payer = request.GET.get('PayerID')
request.session['payment_paypal_token'] = token
request.session['payment_paypal_payer'] = payer
urlkwargs = {}
if 'cart_namespace' in kwargs:
urlkwargs['cart_namespace'] = kwargs['cart_namespace']
if request.session.get('payment_paypal_payment'):
payment = OrderPayment.objects.get(pk=request.session.get('payment_paypal_payment'))
else:
payment = None
if pid == request.session.get('payment_paypal_id', None):
if payment:
prov = Paypal(request.event)
try:
resp = prov.execute_payment(request, payment)
except PaymentException as e:
messages.error(request, str(e))
urlkwargs['step'] = 'payment'
return redirect_to_url(eventreverse(request.event, 'presale:event.checkout', kwargs=urlkwargs))
if resp:
return resp
else:
messages.error(request, _('Invalid response from PayPal received.'))
logger.error('Session did not contain payment_paypal_id')
urlkwargs['step'] = 'payment'
return redirect_to_url(eventreverse(request.event, 'presale:event.checkout', kwargs=urlkwargs))
if payment:
return redirect_to_url(eventreverse(request.event, 'presale:event.order', kwargs={
'order': payment.order.code,
'secret': payment.order.secret
}) + ('?paid=yes' if payment.order.status == Order.STATUS_PAID else ''))
else:
urlkwargs['step'] = 'confirm'
return redirect_to_url(eventreverse(request.event, 'presale:event.checkout', kwargs=urlkwargs))
def abort(request, *args, **kwargs):
messages.error(request, _('It looks like you canceled the PayPal payment'))
if request.session.get('payment_paypal_payment'):
payment = OrderPayment.objects.get(pk=request.session.get('payment_paypal_payment'))
else:
payment = None
if payment:
return redirect_to_url(eventreverse(request.event, 'presale:event.order', kwargs={
'order': payment.order.code,
'secret': payment.order.secret
}) + ('?paid=yes' if payment.order.status == Order.STATUS_PAID else ''))
else:
return redirect_to_url(eventreverse(request.event, 'presale:event.checkout', kwargs={'step': 'payment'}))
@csrf_exempt
@require_POST
@scopes_disabled()
def webhook(request, *args, **kwargs):
event_body = request.body.decode('utf-8').strip()
event_json = json.loads(event_body)
# We do not check the signature, we just use it as a trigger to look the charge up.
if 'resource_type' not in event_json:
return HttpResponse("Invalid body, no resource_type given", status=400)
if event_json['resource_type'] not in ('sale', 'refund'):
return HttpResponse("Not interested in this resource type", status=200)
if event_json['resource_type'] == 'sale':
saleid = event_json['resource']['id']
else:
saleid = event_json['resource']['sale_id']
try:
refs = [saleid]
if event_json['resource'].get('parent_payment'):
refs.append(event_json['resource'].get('parent_payment'))
rso = ReferencedPayPalObject.objects.select_related('order', 'order__event').get(
reference__in=refs
)
event = rso.order.event
except ReferencedPayPalObject.DoesNotExist:
rso = None
if hasattr(request, 'event'):
event = request.event
else:
return HttpResponse("Unable to detect event", status=200)
prov = Paypal(event)
prov.init_api()
try:
sale = paypalrestsdk.Sale.find(saleid)
except paypalrestsdk.exceptions.ConnectionError:
logger.exception('PayPal error on webhook. Event data: %s' % str(event_json))
return HttpResponse('Sale not found', status=500)
if rso and rso.payment:
payment = rso.payment
else:
payments = OrderPayment.objects.filter(order__event=event, provider='paypal',
info__icontains=sale['id'])
payment = None
for p in payments:
payment_info = p.info_data
for res in payment_info['transactions'][0]['related_resources']:
for k, v in res.items():
if k == 'sale' and v['id'] == sale['id']:
payment = p
break
if not payment:
return HttpResponse('Payment not found', status=200)
payment.order.log_action('pretix.plugins.paypal.event', data=event_json)
if payment.state == OrderPayment.PAYMENT_STATE_CONFIRMED and sale['state'] in ('partially_refunded', 'refunded'):
if event_json['resource_type'] == 'refund':
try:
refund = paypalrestsdk.Refund.find(event_json['resource']['id'])
except paypalrestsdk.exceptions.ConnectionError:
logger.exception('PayPal error on webhook. Event data: %s' % str(event_json))
return HttpResponse('Refund not found', status=500)
known_refunds = {r.info_data.get('id'): r for r in payment.refunds.all()}
if refund['id'] not in known_refunds:
payment.create_external_refund(
amount=abs(Decimal(refund['amount']['total'])),
info=json.dumps(refund.to_dict() if not isinstance(refund, dict) else refund)
)
elif known_refunds.get(refund['id']).state in (
OrderRefund.REFUND_STATE_CREATED, OrderRefund.REFUND_STATE_TRANSIT) and refund['state'] == 'completed':
known_refunds.get(refund['id']).done()
if 'total_refunded_amount' in refund:
known_sum = payment.refunds.filter(
state__in=(OrderRefund.REFUND_STATE_DONE, OrderRefund.REFUND_STATE_TRANSIT,
OrderRefund.REFUND_STATE_CREATED, OrderRefund.REFUND_SOURCE_EXTERNAL)
).aggregate(s=Sum('amount'))['s'] or Decimal('0.00')
total_refunded_amount = Decimal(refund['total_refunded_amount']['value'])
if known_sum < total_refunded_amount:
payment.create_external_refund(
amount=total_refunded_amount - known_sum
)
elif sale['state'] == 'refunded':
known_sum = payment.refunds.filter(
state__in=(OrderRefund.REFUND_STATE_DONE, OrderRefund.REFUND_STATE_TRANSIT,
OrderRefund.REFUND_STATE_CREATED, OrderRefund.REFUND_SOURCE_EXTERNAL)
).aggregate(s=Sum('amount'))['s'] or Decimal('0.00')
if known_sum < payment.amount:
payment.create_external_refund(
amount=payment.amount - known_sum
)
elif payment.state in (OrderPayment.PAYMENT_STATE_PENDING, OrderPayment.PAYMENT_STATE_CREATED,
OrderPayment.PAYMENT_STATE_CANCELED, OrderPayment.PAYMENT_STATE_FAILED) and sale['state'] == 'completed':
try:
payment.confirm()
except Quota.QuotaExceededException:
pass
return HttpResponse(status=200)
@event_permission_required('event.settings.general:write')
@require_POST
def oauth_disconnect(request, **kwargs):
del request.event.settings.payment_paypal_connect_refresh_token
del request.event.settings.payment_paypal_connect_user_id
request.event.settings.payment_paypal__enabled = False
messages.success(request, _('Your PayPal account has been disconnected.'))
# Migrate User to PayPal v2
event = request.event
event.disable_plugin("pretix.plugins.paypal")
event.enable_plugin("pretix.plugins.paypal2")
event.save()
return redirect_to_url(reverse('control:event.settings.payment.provider', kwargs={
'organizer': request.event.organizer.slug,
'event': request.event.slug,
'provider': 'paypal_settings'
}))
@@ -0,0 +1,54 @@
# Generated by Django 5.2.17 on 2026-09-08 08:01
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
("pretixbase", "0310_question_valid_string_length_min"),
]
operations = [
migrations.SeparateDatabaseAndState(
state_operations=[
migrations.CreateModel(
name="ReferencedPayPalObject",
fields=[
(
"id",
models.BigAutoField(
auto_created=True, primary_key=True, serialize=False
),
),
(
"reference",
models.CharField(db_index=True, max_length=190, unique=True),
),
(
"order",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to="pretixbase.order",
),
),
(
"payment",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.CASCADE,
to="pretixbase.orderpayment",
),
),
],
options={
"db_table": "paypal_referencedpaypalobject",
},
),
],
database_operations=[]
)
]
@@ -26,3 +26,6 @@ class ReferencedPayPalObject(models.Model):
reference = models.CharField(max_length=190, db_index=True, unique=True)
order = models.ForeignKey('pretixbase.Order', on_delete=models.CASCADE)
payment = models.ForeignKey('pretixbase.OrderPayment', null=True, blank=True, on_delete=models.CASCADE)
class Meta:
db_table = 'paypal_referencedpaypalobject'
+1 -1
View File
@@ -68,7 +68,7 @@ from pretix.plugins.paypal2.client.core.paypal_http_client import (
from pretix.plugins.paypal2.client.customer.partner_referral_create_request import (
PartnerReferralCreateRequest,
)
from pretix.plugins.paypal.models import ReferencedPayPalObject
from pretix.plugins.paypal2.models import ReferencedPayPalObject
logger = logging.getLogger('pretix.plugins.paypal2')
+3 -3
View File
@@ -67,10 +67,10 @@ from pretix.multidomain.urlreverse import eventreverse
from pretix.plugins.paypal2.client.customer.partners_merchantintegrations_get_request import (
PartnersMerchantIntegrationsGetRequest,
)
from pretix.plugins.paypal2.models import ReferencedPayPalObject
from pretix.plugins.paypal2.payment import (
PaypalMethod, PaypalMethod as Paypal, PaypalWallet,
)
from pretix.plugins.paypal.models import ReferencedPayPalObject
from pretix.presale.views import get_cart
from pretix.presale.views.cart import cart_session
@@ -350,8 +350,8 @@ def webhook(request, *args, **kwargs):
return HttpResponse("Invalid body, no event_type given", status=400)
if event_json['event_type'].startswith('PAYMENT.SALE.'):
from pretix.plugins.paypal.views import webhook
return webhook(request, *args, **kwargs)
logger.info(f"Received PPv1 webhook: {json.dumps(event_json)}")
return HttpResponse("PayPal V1 no longer supported", status=400)
# V1/V2 Sorting -- End
# We do not check the signature, we just use it as a trigger to look the charge up.
+3 -2
View File
@@ -247,14 +247,15 @@ def prepare_item_list_for_shop(event, *, channel: SalesChannel, subevent=None, v
continue
if item.hidden_if_item_available:
time_available = item.hidden_if_item_available.is_available()
if item.hidden_if_item_available.has_variations:
item._dependency_available = any(
var.check_quotas(subevent=subevent, _cache=quota_cache, include_bundled=True)[0] == Quota.AVAILABILITY_OK
# is_available on variant is evaluated called by available_variations
for var in item.hidden_if_item_available.available_variations
)
) and time_available
else:
q = item.hidden_if_item_available.check_quotas(subevent=subevent, _cache=quota_cache, include_bundled=True)
time_available = item.hidden_if_item_available.is_available()
item._dependency_available = (q[0] == Quota.AVAILABILITY_OK) and time_available
if item._dependency_available and item.hidden_if_item_available_mode == Item.UNAVAIL_MODE_HIDDEN:
item._remove = True
@@ -139,9 +139,6 @@
{% if event_logo and event_logo_show_title %}
<h2 class="content-header">
{{ event.name }}
{% if request.event.settings.show_dates_on_frontpage %}
<small>{{ event.get_date_range_display_as_html }}</small>
{% endif %}
</h2>
{% endif %}
{% if frontpage_text %}
@@ -401,6 +401,9 @@ let form_handlers = function (el) {
if (tagName !== 'div' && tagName !== 'button') {
$toggling = dependent.closest('.form-group')
}
if ($toggling.find(".has-error").length > 0) {
enabled = true // Don't hide error message that makes form unsubmittable
}
if (ev) {
if (enabled) {
$toggling.stop().slideDown()
+9
View File
@@ -61,6 +61,15 @@ def meta_prop(organizer):
return organizer.meta_properties.create(name="type", default="Concert")
@pytest.fixture
@scopes_disabled()
def meta_prop_choices(organizer):
return organizer.meta_properties.create(name="department", default="A", choices=[
{"key": "A", "label": {"en": "Group A"}},
{"key": "B", "label": {"en": "Group B"}},
])
@pytest.fixture
@scopes_disabled()
def event(organizer, meta_prop):
+66 -1
View File
@@ -703,7 +703,7 @@ def test_event_delete_with_clone(token_client, organizer, event, meta_prop):
@pytest.mark.django_db
def test_event_update(token_client, organizer, event, item, meta_prop):
def test_event_update(token_client, team, organizer, event, item, meta_prop, meta_prop_choices):
resp = token_client.patch(
'/api/v1/organizers/{}/events/{}/'.format(organizer.slug, event.slug),
{
@@ -782,6 +782,26 @@ def test_event_update(token_client, organizer, event, item, meta_prop):
property__name=meta_prop.name, value="Workshop"
).exists()
resp = token_client.patch(
'/api/v1/organizers/{}/events/{}/'.format(organizer.slug, event.slug),
{
"meta_data": {
meta_prop.name: "Workshop",
meta_prop_choices.name: "B"
}
},
format='json'
)
assert resp.status_code == 200
assert resp.data["meta_data"] == {
meta_prop.name: "Workshop",
meta_prop_choices.name: "B"
}
with scopes_disabled():
assert organizer.events.get(slug=resp.data['slug']).meta_values.filter(
property__name=meta_prop_choices.name, value="B"
).exists()
resp = token_client.patch(
'/api/v1/organizers/{}/events/{}/'.format(organizer.slug, event.slug),
{
@@ -796,6 +816,51 @@ def test_event_update(token_client, organizer, event, item, meta_prop):
property__name=meta_prop.name
).exists()
resp = token_client.patch(
'/api/v1/organizers/{}/events/{}/'.format(organizer.slug, event.slug),
{
"meta_data": {
meta_prop_choices.name: "invalid"
}
},
format='json'
)
assert resp.status_code == 400
assert resp.content.decode() == '{"meta_data":["Meta data property \'department\' does not allow value \'invalid\'."]}'
meta_prop_choices.protected = True
meta_prop_choices.save()
team.all_organizer_permissions = False
team.limit_organizer_permissions = {}
team.save()
resp = token_client.patch(
'/api/v1/organizers/{}/events/{}/'.format(organizer.slug, event.slug),
{
"meta_data": {
meta_prop_choices.name: "A"
}
},
format='json'
)
assert resp.status_code == 200 # silently ignored
assert resp.data["meta_data"] == {}
team.all_organizer_permissions = True
team.save()
resp = token_client.patch(
'/api/v1/organizers/{}/events/{}/'.format(organizer.slug, event.slug),
{
"meta_data": {
meta_prop_choices.name: "A"
}
},
format='json'
)
assert resp.status_code == 200
assert resp.data["meta_data"] == {
meta_prop_choices.name: "A"
}
resp = token_client.patch(
'/api/v1/organizers/{}/events/{}/'.format(organizer.slug, event.slug),
{
+9
View File
@@ -115,6 +115,15 @@ def test_voucher_list(token_client, organizer, event, voucher, item, quota, sube
)
assert [] == resp.data['results']
resp = token_client.get(
'/api/v1/organizers/{}/events/{}/vouchers/?search={}'.format(organizer.slug, event.slug, voucher.code[:3])
)
assert [res] == resp.data['results']
resp = token_client.get(
'/api/v1/organizers/{}/events/{}/vouchers/?code=RAvRcnbDehWD59oywV5x'.format(organizer.slug, event.slug)
)
assert [] == resp.data['results']
resp = token_client.get(
'/api/v1/organizers/{}/events/{}/vouchers/?max_usages=1'.format(organizer.slug, event.slug)
)
+1 -1
View File
@@ -43,7 +43,7 @@ def event():
o = Organizer.objects.create(name='Dummy', slug='dummy')
event = Event.objects.create(
organizer=o, name='Dummy', slug='dummy',
date_from=now(), plugins='pretix.plugins.banktransfer,pretix.plugins.paypal'
date_from=now(), plugins='pretix.plugins.banktransfer,pretix.plugins.paypal2'
)
return event
+1 -1
View File
@@ -37,7 +37,7 @@ def event():
o = Organizer.objects.create(name='Dummy', slug='dummy')
event = Event.objects.create(
organizer=o, name='Dummy', slug='dummy',
date_from=now(), plugins='pretix.plugins.banktransfer,pretix.plugins.paypal'
date_from=now(), plugins='pretix.plugins.banktransfer,pretix.plugins.paypal2'
)
return event
+6
View File
@@ -219,8 +219,14 @@ def test_specific_organizer_permission(event, user):
def test_organizer_permissions_multiple_teams(event, user):
team1 = Team.objects.create(organizer=event.organizer, limit_organizer_permissions={"organizer.settings.general:write": True})
team2 = Team.objects.create(organizer=event.organizer, limit_organizer_permissions={"organizer.events:create": True})
assert set(event.organizer.get_users_with_permission('organizer.settings.general:write')) == set()
assert set(event.organizer.get_users_with_permission(None)) == set()
team1.members.add(user)
assert set(event.organizer.get_users_with_permission('organizer.settings.general:write')) == {user}
assert set(event.organizer.get_users_with_permission(None)) == {user}
assert set(event.organizer.get_users_with_permission('organizer.events:create')) == set()
team2.members.add(user)
assert set(event.organizer.get_users_with_permission('organizer.events:create')) == {user}
orga2 = Organizer.objects.create(slug='d2', name='d2')
team3 = Team.objects.create(organizer=orga2, limit_organizer_permissions={"organizer.teams:write": True})
team3.members.add(user)
+1 -1
View File
@@ -32,7 +32,7 @@ def env():
o = Organizer.objects.create(name='Dummy', slug='dummy')
event = Event.objects.create(
organizer=o, name='Dummy', slug='dummy',
date_from=now(), plugins='pretix.plugins.banktransfer,pretix.plugins.paypal'
date_from=now(), plugins='pretix.plugins.banktransfer,pretix.plugins.paypal2'
)
user = User.objects.create_user('dummy@dummy.dummy', 'dummy')
t = Team.objects.create(organizer=event.organizer, all_event_permissions=True)
+13 -13
View File
@@ -349,25 +349,25 @@ class UserSettings2FATest(SoupTest):
def test_delete_u2f(self):
d = U2FDevice.objects.create(user=self.user, name='Test')
self.client.get('/control/settings/2fa/u2f/{}/delete'.format(d.pk))
self.client.post('/control/settings/2fa/u2f/{}/delete'.format(d.pk))
self.client.get('/control/settings/2fa/pretixbase.u2fdevice/{}/delete'.format(d.pk))
self.client.post('/control/settings/2fa/pretixbase.u2fdevice/{}/delete'.format(d.pk))
assert not U2FDevice.objects.exists()
def test_delete_webauthn(self):
d = WebAuthnDevice.objects.create(user=self.user, name='Test')
self.client.get('/control/settings/2fa/webauthn/{}/delete'.format(d.pk))
self.client.post('/control/settings/2fa/webauthn/{}/delete'.format(d.pk))
self.client.get('/control/settings/2fa/pretixbase.webauthndevice/{}/delete'.format(d.pk))
self.client.post('/control/settings/2fa/pretixbase.webauthndevice/{}/delete'.format(d.pk))
assert not WebAuthnDevice.objects.exists()
def test_delete_totp(self):
d = TOTPDevice.objects.create(user=self.user, name='Test')
self.client.get('/control/settings/2fa/totp/{}/delete'.format(d.pk))
self.client.post('/control/settings/2fa/totp/{}/delete'.format(d.pk))
self.client.get('/control/settings/2fa/otp_totp.totpdevice/{}/delete'.format(d.pk))
self.client.post('/control/settings/2fa/otp_totp.totpdevice/{}/delete'.format(d.pk))
assert not TOTPDevice.objects.exists()
def test_create_webauthn_require_https(self):
r = self.client.post('/control/settings/2fa/add', {
'devicetype': 'webauthn',
'devicetype': 'pretixbase.webauthndevice',
'name': 'Foo'
})
assert 'alert-danger' in r.content.decode()
@@ -376,7 +376,7 @@ class UserSettings2FATest(SoupTest):
with mocker_context() as mocker:
mocker.patch('django.http.request.HttpRequest.is_secure')
self.client.post('/control/settings/2fa/add', {
'devicetype': 'webauthn',
'devicetype': 'pretixbase.webauthndevice',
'name': 'Foo'
})
d = WebAuthnDevice.objects.first()
@@ -385,7 +385,7 @@ class UserSettings2FATest(SoupTest):
def test_create_totp(self):
self.client.post('/control/settings/2fa/add', {
'devicetype': 'totp',
'devicetype': 'otp_totp.totpdevice',
'name': 'Foo'
})
d = TOTPDevice.objects.first()
@@ -393,7 +393,7 @@ class UserSettings2FATest(SoupTest):
def test_confirm_totp(self):
self.client.post('/control/settings/2fa/add', {
'devicetype': 'totp',
'devicetype': 'otp_totp.totpdevice',
'name': 'Foo'
}, follow=True)
d = TOTPDevice.objects.first()
@@ -411,7 +411,7 @@ class UserSettings2FATest(SoupTest):
def test_confirm_totp_failed(self):
self.client.post('/control/settings/2fa/add', {
'devicetype': 'totp',
'devicetype': 'otp_totp.totpdevice',
'name': 'Foo'
}, follow=True)
d = TOTPDevice.objects.first()
@@ -428,7 +428,7 @@ class UserSettings2FATest(SoupTest):
with mocker_context() as mocker:
mocker.patch('django.http.request.HttpRequest.is_secure')
self.client.post('/control/settings/2fa/add', {
'devicetype': 'webauthn',
'devicetype': 'pretixbase.webauthndevice',
'name': 'Foo'
}, follow=True)
d = WebAuthnDevice.objects.first()
@@ -443,7 +443,7 @@ class UserSettings2FATest(SoupTest):
with mocker_context() as mocker:
mocker.patch('django.http.request.HttpRequest.is_secure')
self.client.post('/control/settings/2fa/add', {
'devicetype': 'webauthn',
'devicetype': 'pretixbase.webauthndevice',
'name': 'Foo'
}, follow=True)
+6 -6
View File
@@ -37,26 +37,26 @@ def event():
@pytest.mark.django_db
def test_require_plugin(event, client):
event.plugins = 'pretix.plugins.paypal'
event.plugins = 'pretix.plugins.paypal2'
event.live = True
event.save()
r = client.get('/mrmcd/2015/paypal/abort/', follow=False)
r = client.get('/mrmcd/2015/paypal2/abort/', follow=False)
assert r.status_code == 302
event.plugins = ''
event.save()
r = client.get('/mrmcd/2015/paypal/abort/', follow=False)
r = client.get('/mrmcd/2015/paypal2/abort/', follow=False)
assert r.status_code == 404
@pytest.mark.django_db
def test_require_live(event, client):
event.plugins = 'pretix.plugins.paypal'
event.plugins = 'pretix.plugins.paypal2'
event.live = True
event.save()
r = client.get('/mrmcd/2015/paypal/abort/', follow=False)
r = client.get('/mrmcd/2015/paypal2/abort/', follow=False)
assert r.status_code == 302
event.live = False
event.save()
r = client.get('/mrmcd/2015/paypal/abort/', follow=False)
r = client.get('/mrmcd/2015/paypal2/abort/', follow=False)
assert r.status_code == 403
@@ -56,7 +56,7 @@ def env():
o = Organizer.objects.create(name='Dummy', slug='dummy', plugins='pretix.plugins.banktransfer')
event = Event.objects.create(
organizer=o, name='Dummy', slug='dummy',
date_from=now(), plugins='pretix.plugins.banktransfer,pretix.plugins.paypal'
date_from=now(), plugins='pretix.plugins.banktransfer,pretix.plugins.paypal2'
)
event.settings.invoice_numbers_prefix = 'INV-'
event.settings.invoice_numbers_counter_length = 3
@@ -843,7 +843,7 @@ def test_event_name_prefix_contains_dash(env, orga_job):
for slug in slugs:
event = Event.objects.create(
organizer=event.organizer, name=slug.upper(), slug=slug,
date_from=now(), plugins='pretix.plugins.banktransfer,pretix.plugins.paypal'
date_from=now(), plugins='pretix.plugins.banktransfer,pretix.plugins.paypal2'
)
with scopes_disabled():
o1.event = Event.objects.get(slug="dummy2345")
@@ -871,7 +871,7 @@ def test_event_name_prefix_multiple_dashes(env, orga_job):
for slug in slugs:
event = Event.objects.create(
organizer=event.organizer, name=slug.upper(), slug=slug,
date_from=now(), plugins='pretix.plugins.banktransfer,pretix.plugins.paypal'
date_from=now(), plugins='pretix.plugins.banktransfer,pretix.plugins.paypal2'
)
with scopes_disabled():
@@ -37,7 +37,7 @@ def env():
o = Organizer.objects.create(name='Dummy', slug='dummy', plugins='pretix.plugins.banktransfer')
event = Event.objects.create(
organizer=o, name='Dummy', slug='dummy',
date_from=now(), plugins='pretix.plugins.banktransfer,pretix.plugins.paypal'
date_from=now(), plugins='pretix.plugins.banktransfer,pretix.plugins.paypal2'
)
user = User.objects.create_user('dummy@dummy.dummy', 'dummy')
t = Team.objects.create(organizer=event.organizer, all_event_permissions=True)
@@ -38,7 +38,7 @@ def env():
o = Organizer.objects.create(name='Dummy', slug='dummy', plugins='pretix.plugins.banktransfer')
event = Event.objects.create(
organizer=o, name='Dummy', slug='dummy',
date_from=now(), plugins='pretix.plugins.banktransfer,pretix.plugins.paypal'
date_from=now(), plugins='pretix.plugins.banktransfer,pretix.plugins.paypal2'
)
user = User.objects.create_user('dummy@dummy.dummy', 'dummy')
t = Team.objects.create(organizer=event.organizer, all_event_permissions=True)
@@ -69,7 +69,7 @@ def env():
def refund_huf(env):
event = Event.objects.create(
organizer=env[0].organizer, name='Dummy', slug='dummy2', currency='HUF',
date_from=now(), plugins='pretix.plugins.banktransfer,pretix.plugins.paypal'
date_from=now(), plugins='pretix.plugins.banktransfer,pretix.plugins.paypal2'
)
order = Order.objects.create(
code='HUFFY', event=event, email='admin@localhost',
-33
View File
@@ -1,33 +0,0 @@
#
# This file is part of pretix (Community Edition).
#
# Copyright (C) 2014-2020 Raphael Michel and contributors
# Copyright (C) 2020-today pretix GmbH and contributors
#
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
# Public License as published by the Free Software Foundation in version 3 of the License.
#
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
# this file, see <https://pretix.eu/about/en/license>.
#
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# 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/>.
#
# 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: Tobias Kunze
#
# 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.
-93
View File
@@ -1,93 +0,0 @@
#
# This file is part of pretix (Community Edition).
#
# Copyright (C) 2014-2020 Raphael Michel and contributors
# Copyright (C) 2020-today pretix GmbH and contributors
#
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
# Public License as published by the Free Software Foundation in version 3 of the License.
#
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
# this file, see <https://pretix.eu/about/en/license>.
#
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# 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/>.
#
# 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: Tobias Kunze
#
# 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 datetime
import pytest
from django.utils.timezone import now
from pretix.base.models import (
CartPosition, Event, Item, ItemCategory, Organizer, Quota,
)
from pretix.testutils.sessions import add_cart_session, get_cart_session_key
@pytest.fixture
def env(client):
orga = Organizer.objects.create(name='CCC', slug='ccc')
event = Event.objects.create(
organizer=orga, name='30C3', slug='30c3',
date_from=datetime.datetime(now().year + 1, 12, 26, tzinfo=datetime.timezone.utc),
plugins='pretix.plugins.paypal',
live=True
)
category = ItemCategory.objects.create(event=event, name="Everything", position=0)
quota_tickets = Quota.objects.create(event=event, name='Tickets', size=5)
ticket = Item.objects.create(event=event, name='Early-bird ticket',
category=category, default_price=23, admission=True)
quota_tickets.items.add(ticket)
event.settings.set('attendee_names_asked', False)
event.settings.set('payment_paypal__enabled', True)
event.settings.set('payment_paypal__fee_abs', 3)
event.settings.set('payment_paypal_endpoint', 'sandbox')
event.settings.set('payment_paypal_client_id', '12345')
event.settings.set('payment_paypal_secret', '12345')
add_cart_session(client, event, {'email': 'admin@localhost'})
return client, ticket
@pytest.mark.django_db
def test_payment(env, monkeypatch):
def create_payment(self, request, payment):
assert payment['intent'] == 'sale'
assert payment['transactions'][0]['amount']['currency'] == 'EUR'
assert payment['transactions'][0]['amount']['total'] == '26.00'
create_payment.called = True
return 'https://approve.url'
monkeypatch.setattr("pretix.plugins.paypal.payment.Paypal._create_payment", create_payment)
client, ticket = env
session_key = get_cart_session_key(client, ticket.event)
CartPosition.objects.create(
event=ticket.event, cart_id=session_key, item=ticket,
price=23, expires=now() + datetime.timedelta(minutes=10)
)
client.get('/%s/%s/checkout/payment/' % (ticket.event.organizer.slug, ticket.event.slug), follow=True)
client.post('/%s/%s/checkout/questions/' % (ticket.event.organizer.slug, ticket.event.slug), {
'email': 'admin@localhost'
}, follow=True)
response = client.post('/%s/%s/checkout/payment/' % (ticket.event.organizer.slug, ticket.event.slug), {
'payment': 'paypal'
})
assert response['Location'] == 'https://approve.url'
-67
View File
@@ -1,67 +0,0 @@
#
# This file is part of pretix (Community Edition).
#
# Copyright (C) 2014-2020 Raphael Michel and contributors
# Copyright (C) 2020-today pretix GmbH and contributors
#
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
# Public License as published by the Free Software Foundation in version 3 of the License.
#
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
# this file, see <https://pretix.eu/about/en/license>.
#
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# 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/>.
#
# 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: Tobias Kunze
#
# 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 datetime
import pytest
from pretix.base.models import Event, Organizer, Team, User
@pytest.fixture
def env(client):
orga = Organizer.objects.create(name='CCC', slug='ccc')
event = Event.objects.create(
organizer=orga, name='30C3', slug='30c3',
date_from=datetime.datetime(2013, 12, 26, tzinfo=datetime.timezone.utc),
plugins='pretix.plugins.paypal',
live=True
)
event.settings.set('attendee_names_asked', False)
event.settings.set('payment_paypal__enabled', True)
user = User.objects.create_user('dummy@dummy.dummy', 'dummy')
t = Team.objects.create(organizer=event.organizer, all_event_permissions=True)
t.members.add(user)
t.limit_events.add(event)
client.force_login(user)
return client, event
@pytest.mark.django_db
def test_settings(env):
client, event = env
response = client.get('/control/event/%s/%s/settings/payment/paypal' % (event.organizer.slug, event.slug),
follow=True)
assert response.status_code == 200
assert 'paypal__enabled' in response.rendered_content
-391
View File
@@ -1,391 +0,0 @@
#
# This file is part of pretix (Community Edition).
#
# Copyright (C) 2014-2020 Raphael Michel and contributors
# Copyright (C) 2020-today pretix GmbH and contributors
#
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
# Public License as published by the Free Software Foundation in version 3 of the License.
#
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
# this file, see <https://pretix.eu/about/en/license>.
#
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# 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
from datetime import timedelta
from decimal import Decimal
import pytest
from django.utils.timezone import now
from django_scopes import scopes_disabled
from pretix.base.models import (
Event, Order, OrderPayment, OrderRefund, Organizer, Team, User,
)
from pretix.plugins.paypal.models import ReferencedPayPalObject
@pytest.fixture
def env():
user = User.objects.create_user('dummy@dummy.dummy', 'dummy')
o = Organizer.objects.create(name='Dummy', slug='dummy')
event = Event.objects.create(
organizer=o, name='Dummy', slug='dummy', plugins='pretix.plugins.paypal',
date_from=now(), live=True
)
t = Team.objects.create(organizer=event.organizer, all_event_permissions=True)
t.members.add(user)
t.limit_events.add(event)
o1 = Order.objects.create(
code='FOOBAR', event=event, email='dummy@dummy.test',
status=Order.STATUS_PAID,
datetime=now(), expires=now() + timedelta(days=10),
total=Decimal('13.37'),
sales_channel=o.sales_channels.get(identifier="web"),
)
o1.payments.create(
amount=o1.total,
provider='paypal',
state=OrderPayment.PAYMENT_STATE_CONFIRMED,
info=json.dumps({
"id": "PAY-5YK922393D847794YKER7MUI",
"create_time": "2013-02-19T22:01:53Z",
"update_time": "2013-02-19T22:01:55Z",
"state": "approved",
"intent": "sale",
"payer": {
"payment_method": "credit_card",
"funding_instruments": [
{
"credit_card": {
"type": "mastercard",
"number": "xxxxxxxxxxxx5559",
"expire_month": 2,
"expire_year": 2018,
"first_name": "Betsy",
"last_name": "Buyer"
}
}
]
},
"transactions": [
{
"amount": {
"total": "7.47",
"currency": "USD",
"details": {
"subtotal": "7.47"
}
},
"description": "This is the payment transaction description.",
"note_to_payer": "Contact us for any questions on your order.",
"related_resources": [
{
"sale": {
"id": "36C38912MN9658832",
"create_time": "2013-02-19T22:01:53Z",
"update_time": "2013-02-19T22:01:55Z",
"state": "completed",
"amount": {
"total": "7.47",
"currency": "USD"
},
"protection_eligibility": "ELIGIBLE",
"protection_eligibility_type": "ITEM_NOT_RECEIVED_ELIGIBLE",
"transaction_fee": {
"value": "1.75",
"currency": "USD"
},
"parent_payment": "PAY-5YK922393D847794YKER7MUI",
"links": [
{
"href": "https://api.paypal.com/v1/payments/sale/36C38912MN9658832",
"rel": "self",
"method": "GET"
},
{
"href": "https://api.paypal.com/v1/payments/sale/36C38912MN9658832/refund",
"rel": "refund",
"method": "POST"
},
{
"href":
"https://api.paypal.com/v1/payments/payment/PAY-5YK922393D847794YKER7MUI",
"rel": "parent_payment",
"method": "GET"
}
]
}
}
]
}
],
"links": [
{
"href": "https://api.paypal.com/v1/payments/payment/PAY-5YK922393D847794YKER7MUI",
"rel": "self",
"method": "GET"
}
]
})
)
return event, o1
def get_test_charge(order: Order):
return {
"id": "36C38912MN9658832",
"create_time": "2013-02-19T22:01:53Z",
"update_time": "2013-02-19T22:01:55Z",
"state": "completed",
"amount": {
"total": "7.47",
"currency": "USD"
},
"protection_eligibility": "ELIGIBLE",
"protection_eligibility_type": "ITEM_NOT_RECEIVED_ELIGIBLE,UNAUTHORIZED_PAYMENT_ELIGIBLE",
"transaction_fee": {
"value": "1.75",
"currency": "USD"
},
"parent_payment": "PAY-5YK922393D847794YKER7MUI",
"links": [
{
"href": "https://api.paypal.com/v1/payments/sale/36C38912MN9658832",
"rel": "self",
"method": "GET"
},
{
"href": "https://api.paypal.com/v1/payments/sale/36C38912MN9658832/refund",
"rel": "refund",
"method": "POST"
},
{
"href": "https://api.paypal.com/v1/payments/payment/PAY-5YK922393D847794YKER7MUI",
"rel": "parent_payment",
"method": "GET"
}
]
}
def get_test_refund(order: Order):
return {
'refund_from_received_amount': {'value': '13.30', 'currency': 'EUR'},
'amount': {'total': '13.37', 'currency': 'EUR'},
'sale_id': '1G495778AR8401726',
'update_time': '2018-07-24T07:50:07Z',
'total_refunded_amount': {'value': '13.37', 'currency': 'EUR'},
'refund_reason_code': 'REFUND',
'invoice_number': 'Test',
'parent_payment': 'PAY-0UB50445HE155450FLNLNMUY',
'state': 'completed',
'create_time': '2018-07-24T07:50:07Z',
'refund_from_transaction_fee': {'value': '0.07', 'currency': 'EUR'},
'id': '93M41501U3542574L',
'refund_to_payer': {'value': '13.37', 'currency': 'EUR'},
'links': [
{'method': 'GET', 'rel': 'self',
'href': 'https://api.sandbox.paypal.com/v1/payments/refund/93M41501U3542574L'},
{'method': 'GET',
'rel': 'parent_payment',
'href': 'https://api.sandbox.paypal.com/v1/payments/payment/PAY-0UB50445HE155450FLNLNMUY'},
{'method': 'GET', 'rel': 'sale',
'href': 'https://api.sandbox.paypal.com/v1/payments/sale/1G495778AR8401726'}
]
}
@pytest.mark.django_db
def test_webhook_all_good(env, client, monkeypatch):
charge = get_test_charge(env[1])
monkeypatch.setattr("paypalrestsdk.Sale.find", lambda *args: charge)
monkeypatch.setattr("pretix.plugins.paypal.payment.Paypal.init_api", lambda *args: None)
client.post('/dummy/dummy/paypal/webhook/', json.dumps(
{
"id": "WH-2WR32451HC0233532-67976317FL4543714",
"create_time": "2014-10-23T17:23:52Z",
"resource_type": "sale",
"event_type": "PAYMENT.SALE.COMPLETED",
"summary": "A successful sale payment was made for $ 0.48 USD",
"resource": {
"amount": {
"total": "-0.01",
"currency": "USD"
},
"id": "36C38912MN9658832",
"parent_payment": "PAY-5YK922393D847794YKER7MUI",
"update_time": "2014-10-31T15:41:51Z",
"state": "completed",
"create_time": "2014-10-31T15:41:51Z",
"links": [],
"sale_id": "9T0916710M1105906"
},
"links": [],
"event_version": "1.0"
}
), content_type='application_json')
order = env[1]
order.refresh_from_db()
assert order.status == Order.STATUS_PAID
@pytest.mark.django_db
def test_webhook_mark_paid(env, client, monkeypatch):
order = env[1]
order.status = Order.STATUS_PENDING
order.save()
with scopes_disabled():
order.payments.update(state=OrderPayment.PAYMENT_STATE_PENDING)
charge = get_test_charge(env[1])
monkeypatch.setattr("paypalrestsdk.Sale.find", lambda *args: charge)
monkeypatch.setattr("pretix.plugins.paypal.payment.Paypal.init_api", lambda *args: None)
ReferencedPayPalObject.objects.create(order=order, reference="PAY-5YK922393D847794YKER7MUI")
client.post('/_paypal/webhook/', json.dumps(
{
"id": "WH-2WR32451HC0233532-67976317FL4543714",
"create_time": "2014-10-23T17:23:52Z",
"resource_type": "sale",
"event_type": "PAYMENT.SALE.COMPLETED",
"summary": "A successful sale payment was made for $ 0.48 USD",
"resource": {
"amount": {
"total": "-0.01",
"currency": "USD"
},
"id": "36C38912MN9658832",
"parent_payment": "PAY-5YK922393D847794YKER7MUI",
"update_time": "2014-10-31T15:41:51Z",
"state": "completed",
"create_time": "2014-10-31T15:41:51Z",
"links": [],
"sale_id": "9T0916710M1105906"
},
"links": [],
"event_version": "1.0"
}
), content_type='application_json')
order.refresh_from_db()
assert order.status == Order.STATUS_PAID
@pytest.mark.django_db
def test_webhook_refund1(env, client, monkeypatch):
order = env[1]
charge = get_test_charge(env[1])
charge['state'] = 'refunded'
refund = get_test_refund(env[1])
monkeypatch.setattr("paypalrestsdk.Sale.find", lambda *args: charge)
monkeypatch.setattr("paypalrestsdk.Refund.find", lambda *args: refund)
monkeypatch.setattr("pretix.plugins.paypal.payment.Paypal.init_api", lambda *args: None)
ReferencedPayPalObject.objects.create(order=order, reference="PAY-5YK922393D847794YKER7MUI")
client.post('/_paypal/webhook/', json.dumps(
{
# Sample obtained in a sandbox webhook
"id": "WH-9K829080KA1622327-31011919VC6498738",
"create_time": "2017-01-15T20:15:36Z",
"resource_type": "refund",
"event_type": "PAYMENT.SALE.REFUNDED",
"summary": "A EUR 255.41 EUR sale payment was refunded",
"resource": {
"amount": {
"total": "255.41",
"currency": "EUR"
},
"id": "75S46770PP192124D",
"parent_payment": "PAY-5YK922393D847794YKER7MUI",
"update_time": "2017-01-15T20:15:06Z",
"create_time": "2017-01-15T20:14:29Z",
"state": "completed",
"links": [],
"refund_to_payer": {
"value": "255.41",
"currency": "EUR"
},
"invoice_number": "",
"refund_reason_code": "REFUND",
"sale_id": "9T0916710M1105906"
},
"links": [],
"event_version": "1.0"
}
), content_type='application_json')
order = env[1]
order.refresh_from_db()
assert order.status == Order.STATUS_PAID
with scopes_disabled():
r = order.refunds.first()
assert r.provider == 'paypal'
assert r.amount == order.total
assert r.payment == order.payments.first()
assert r.state == OrderRefund.REFUND_STATE_EXTERNAL
assert r.source == OrderRefund.REFUND_SOURCE_EXTERNAL
@pytest.mark.django_db
def test_webhook_refund2(env, client, monkeypatch):
order = env[1]
charge = get_test_charge(env[1])
charge['state'] = 'refunded'
refund = get_test_refund(env[1])
monkeypatch.setattr("paypalrestsdk.Sale.find", lambda *args: charge)
monkeypatch.setattr("paypalrestsdk.Refund.find", lambda *args: refund)
monkeypatch.setattr("pretix.plugins.paypal.payment.Paypal.init_api", lambda *args: None)
ReferencedPayPalObject.objects.create(order=order, reference="PAY-5YK922393D847794YKER7MUI")
client.post('/_paypal/webhook/', json.dumps(
{
# Sample obtained in the webhook simulator
"id": "WH-2N242548W9943490U-1JU23391CS4765624",
"create_time": "2014-10-31T15:42:24Z",
"resource_type": "refund",
"event_type": "PAYMENT.SALE.REFUNDED",
"summary": "A 0.01 USD sale payment was refunded",
"resource": {
"amount": {
"total": "-0.01",
"currency": "USD"
},
"id": "36C38912MN9658832",
"parent_payment": "PAY-5YK922393D847794YKER7MUI",
"update_time": "2014-10-31T15:41:51Z",
"state": "completed",
"create_time": "2014-10-31T15:41:51Z",
"links": [],
"sale_id": "9T0916710M1105906"
},
"links": [],
"event_version": "1.0"
}
), content_type='application_json')
order = env[1]
order.refresh_from_db()
assert order.status == Order.STATUS_PAID
with scopes_disabled():
r = order.refunds.first()
assert r.provider == 'paypal'
assert r.amount == order.total
assert r.payment == order.payments.first()
assert r.state == OrderRefund.REFUND_STATE_EXTERNAL
assert r.source == OrderRefund.REFUND_SOURCE_EXTERNAL
+1 -1
View File
@@ -32,7 +32,7 @@ from paypalhttp.http_response import Result
from pretix.base.models import (
Event, Order, OrderPayment, OrderRefund, Organizer, Team, User,
)
from pretix.plugins.paypal.models import ReferencedPayPalObject
from pretix.plugins.paypal2.models import ReferencedPayPalObject
@pytest.fixture
+29
View File
@@ -744,6 +744,35 @@ class ItemDisplayTest(EventTestMixin, SoupTest):
self.assertNotIn("SOLD OUT", doc.select("section:nth-of-type(1)")[0].text)
self.assertIn("Late-bird", doc.select("section:nth-of-type(1)")[0].text)
def test_hidden_if_item_available_variation_unavailable_by_time(self):
with scopes_disabled():
q = Quota.objects.create(event=self.event, name='Early-bird', size=10)
q2 = Quota.objects.create(event=self.event, name='Late-bird', size=10)
item_with_vars = Item.objects.create(event=self.event, name='Early-bird ticket', default_price=12)
v = item_with_vars.variations.create(
value='Regular', active=True,
)
item2 = Item.objects.create(event=self.event, name='Late-bird ticket', default_price=12,
hidden_if_item_available=item_with_vars)
q.items.add(item_with_vars)
q.variations.add(v)
q2.items.add(item2)
self.event.settings.hide_sold_out = True
doc = self.get_doc('/%s/%s/' % (self.orga.slug, self.event.slug))
self.assertIn("Early-bird", doc.select("section:nth-of-type(1)")[0].text)
self.assertNotIn("SOLD OUT", doc.select("section:nth-of-type(1)")[0].text)
self.assertNotIn("Late-bird", doc.select("section:nth-of-type(1)")[0].text)
item_with_vars.available_until = now() - datetime.timedelta(days=3)
item_with_vars.available_until_mode = "hide"
item_with_vars.save()
doc = self.get_doc('/%s/%s/' % (self.orga.slug, self.event.slug))
self.assertNotIn("Early-bird", doc.select("section:nth-of-type(1)")[0].text)
self.assertNotIn("SOLD OUT", doc.select("section:nth-of-type(1)")[0].text)
self.assertIn("Late-bird", doc.select("section:nth-of-type(1)")[0].text)
def test_bundle_sold_out(self):
with scopes_disabled():
q = Quota.objects.create(event=self.event, name='Quota', size=2)