diff --git a/doc/api/resources/customers.rst b/doc/api/resources/customers.rst new file mode 100644 index 000000000..444542828 --- /dev/null +++ b/doc/api/resources/customers.rst @@ -0,0 +1,238 @@ +.. _`rest-customers`: + +Customers +========= + +Resource description +-------------------- + +The customer resource contains the following public fields: + +.. rst-class:: rest-resource-table + +===================================== ========================== ======================================================= +Field Type Description +===================================== ========================== ======================================================= +identifier string Internal ID of the customer +email string Customer email address +name string Name of this customer (or ``null``) +name_parts object of strings Decomposition of name (i.e. given name, family name) +is_active boolean Whether this account is active +is_verified boolean Whether the email address of this account has been + verified +last_login datetime Date and time of last login +date_joined datetime Date and time of registration +locale string Preferred language of the customer +last_modified datetime Date and time of modification of the record +===================================== ========================== ======================================================= + +.. versionadded:: 4.0 + +Endpoints +--------- + +.. http:get:: /api/v1/organizers/(organizer)/customers/ + + Returns a list of all customers registered with a given organizer. + + **Example request**: + + .. sourcecode:: http + + GET /api/v1/organizers/bigevents/customers/ HTTP/1.1 + Host: pretix.eu + Accept: application/json, text/javascript + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Vary: Accept + Content-Type: application/json + + { + "count": 1, + "next": null, + "previous": null, + "results": [ + { + "identifier": "8WSAJCJ", + "email": "customer@example.org", + "name": "John Doe", + "name_parts": { + "_scheme": "full", + "full_name": "John Doe" + }, + "is_active": true, + "is_verified": false, + "last_login": null, + "date_joined": "2021-04-06T13:44:22.809216Z", + "locale": "de", + "last_modified": "2021-04-06T13:44:22.809377Z" + } + ] + } + + :query integer page: The page number in case of a multi-page result set, default is 1 + :query string email: Only fetch customers with this email address + :param organizer: The ``slug`` field of the organizer to fetch + :statuscode 200: no error + :statuscode 401: Authentication failure + :statuscode 403: The requested organizer does not exist **or** you have no permission to view this resource. + +.. http:get:: /api/v1/organizers/(organizer)/customers/(identifier)/ + + Returns information on one customer, identified by its identifier. + + **Example request**: + + .. sourcecode:: http + + GET /api/v1/organizers/bigevents/customers/8WSAJCJ/ HTTP/1.1 + Host: pretix.eu + Accept: application/json, text/javascript + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Vary: Accept + Content-Type: application/json + + { + "identifier": "8WSAJCJ", + "email": "customer@example.org", + "name": "John Doe", + "name_parts": { + "_scheme": "full", + "full_name": "John Doe" + }, + "is_active": true, + "is_verified": false, + "last_login": null, + "date_joined": "2021-04-06T13:44:22.809216Z", + "locale": "de", + "last_modified": "2021-04-06T13:44:22.809377Z" + } + + :param organizer: The ``slug`` field of the organizer to fetch + :param identifier: The ``identifier`` field of the customer to fetch + :statuscode 200: no error + :statuscode 401: Authentication failure + :statuscode 403: The requested organizer does not exist **or** you have no permission to view this resource. + +.. http:post:: /api/v1/organizers/(organizer)/customers/ + + Creates a new customer + + **Example request**: + + .. sourcecode:: http + + POST /api/v1/organizers/bigevents/customers/ HTTP/1.1 + Host: pretix.eu + Accept: application/json, text/javascript + Content-Type: application/json + + { + "email": "test@example.org" + } + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 201 Created + Vary: Accept + Content-Type: application/json + + { + "identifier": "8WSAJCJ", + "email": "test@example.org", + ... + } + + :param organizer: The ``slug`` field of the organizer to create a customer for + :statuscode 201: no error + :statuscode 400: The customer could not be created due to invalid submitted data. + :statuscode 401: Authentication failure + :statuscode 403: The requested organizer does not exist **or** you have no permission to create this resource. + +.. http:patch:: /api/v1/organizers/(organizer)/customers/(identifier)/ + + Update a customer. You can also use ``PUT`` instead of ``PATCH``. With ``PUT``, you have to provide all fields of + the resource, other fields will be reset to default. With ``PATCH``, you only need to provide the fields that you + want to change. + + You can change all fields of the resource except the ``identifier``, ``last_login``, ``date_joined``, ``name``, + and ``last_modified`` fields. + + **Example request**: + + .. sourcecode:: http + + PATCH /api/v1/organizers/bigevents/customers/8WSAJCJ/ HTTP/1.1 + Host: pretix.eu + Accept: application/json, text/javascript + Content-Type: application/json + Content-Length: 94 + + { + "email": "test@example.org" + } + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Vary: Accept + Content-Type: application/json + + { + "identifier": "8WSAJCJ", + "email": "test@example.org", + … + } + + :param organizer: The ``slug`` field of the organizer to modify + :param identifier: The ``identifier`` field of the customer to modify + :statuscode 200: no error + :statuscode 400: The customer could not be modified due to invalid submitted data + :statuscode 401: Authentication failure + :statuscode 403: The requested organizer does not exist **or** you have no permission to change this resource. + +.. http:post:: /api/v1/organizers/(organizer)/customers/(identifier)/anonymize/ + + Anonymize a customer. Deletes personal data and disconnects from existing orders. + + **Example request**: + + .. sourcecode:: http + + POST /api/v1/organizers/bigevents/customers/8WSAJCJ/anonymize/ HTTP/1.1 + Host: pretix.eu + Accept: application/json, text/javascript + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Vary: Accept + Content-Type: application/json + + { + "identifier": "8WSAJCJ", + "email": null, + … + } + + :param organizer: The ``slug`` field of the organizer to modify + :param identifier: The ``identifier`` field of the customer to modify + :statuscode 200: no error + :statuscode 400: The customer could not be modified due to invalid submitted data + :statuscode 401: Authentication failure + :statuscode 403: The requested organizer does not exist **or** you have no permission to change this resource. diff --git a/doc/api/resources/index.rst b/doc/api/resources/index.rst index 56e3168c7..5a4693083 100644 --- a/doc/api/resources/index.rst +++ b/doc/api/resources/index.rst @@ -21,6 +21,9 @@ Resources and endpoints vouchers checkinlists waitinglist + customers + membershiptypes + memberships giftcards carts teams diff --git a/doc/api/resources/items.rst b/doc/api/resources/items.rst index c9d513d80..59e3eb074 100644 --- a/doc/api/resources/items.rst +++ b/doc/api/resources/items.rst @@ -69,6 +69,16 @@ require_approval boolean If ``true``, or approved by the event organizer before they can be paid. require_bundling boolean If ``true``, this item is only available as part of bundles. +require_membership boolean If ``true``, booking this item requires an active membership. +require_membership_types list of integers Internal IDs of membership types valid if ``require_membership`` is ``true`` +grant_membership_type integer If set to the internal ID of a membership type, purchasing this item will + create a membership of the given type. +grant_membership_duration_like_event boolean If ``true``, the membership created through ``grant_membership_type`` will derive + its term from ``date_from`` to ``date_to`` of the purchased (sub)event. +grant_membership_duration_days integer If ``grant_membership_duration_like_event`` is ``false``, this sets the number of + days for the membership. +grant_membership_duration_months integer If ``grant_membership_duration_like_event`` is ``false``, this sets the number of + calendar months for the membership. generate_tickets boolean If ``false``, tickets are never generated for this product, regardless of other settings. If ``true``, tickets are generated even if this is a @@ -198,6 +208,12 @@ Endpoints "show_quota_left": null, "require_approval": false, "require_bundling": false, + "require_membership": false, + "require_membership_types": [], + "grant_membership_type": null, + "grant_membership_duration_like_event": true, + "grant_membership_duration_days": 0, + "grant_membership_duration_months": 0, "variations": [ { "value": {"en": "Student"}, @@ -294,6 +310,12 @@ Endpoints "has_variations": false, "require_approval": false, "require_bundling": false, + "require_membership": false, + "require_membership_types": [], + "grant_membership_type": null, + "grant_membership_duration_like_event": true, + "grant_membership_duration_days": 0, + "grant_membership_duration_months": 0, "variations": [ { "value": {"en": "Student"}, @@ -370,6 +392,12 @@ Endpoints "checkin_attention": false, "require_approval": false, "require_bundling": false, + "require_membership": false, + "require_membership_types": [], + "grant_membership_type": null, + "grant_membership_duration_like_event": true, + "grant_membership_duration_days": 0, + "grant_membership_duration_months": 0, "variations": [ { "value": {"en": "Student"}, @@ -435,6 +463,12 @@ Endpoints "has_variations": true, "require_approval": false, "require_bundling": false, + "require_membership": false, + "require_membership_types": [], + "grant_membership_type": null, + "grant_membership_duration_like_event": true, + "grant_membership_duration_days": 0, + "grant_membership_duration_months": 0, "variations": [ { "value": {"en": "Student"}, @@ -531,6 +565,12 @@ Endpoints "has_variations": true, "require_approval": false, "require_bundling": false, + "require_membership": false, + "require_membership_types": [], + "grant_membership_type": null, + "grant_membership_duration_like_event": true, + "grant_membership_duration_days": 0, + "grant_membership_duration_months": 0, "variations": [ { "value": {"en": "Student"}, diff --git a/doc/api/resources/memberships.rst b/doc/api/resources/memberships.rst new file mode 100644 index 000000000..07dbe83ce --- /dev/null +++ b/doc/api/resources/memberships.rst @@ -0,0 +1,210 @@ +Memberships +=========== + +Resource description +-------------------- + +The membership resource contains the following public fields: + +.. rst-class:: rest-resource-table + +===================================== ========================== ======================================================= +Field Type Description +===================================== ========================== ======================================================= +id integer Internal ID of the membership +customer string Identifier of the customer associated with this membership (can't be changed) +membership_type integer Internal ID of the membership type +date_start datetime Start of validity +date_end datetime End of validity +attendee_name_parts object JSON representation of components of an attendee name (configuration dependent) +===================================== ========================== ======================================================= + +Endpoints +--------- + +.. http:get:: /api/v1/organizers/(organizer)/memberships/ + + Returns a list of all memberships within a given organizer. + + **Example request**: + + .. sourcecode:: http + + GET /api/v1/organizers/bigevents/memberships/ HTTP/1.1 + Host: pretix.eu + Accept: application/json, text/javascript + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Vary: Accept + Content-Type: application/json + + { + "count": 1, + "next": null, + "previous": null, + "results": [ + { + "id": 2, + "customer": "EGR9SYT", + "membership_type": 1, + "date_start": "2021-04-19T00:00:00+02:00", + "date_end": "2021-04-20T00:00:00+02:00", + "attendee_name_parts": { + "_scheme": "title_given_family", + "family_name": "Doe", + "given_name": "John", + "title": "" + } + } + ] + } + + :query integer page: The page number in case of a multi-page result set, default is 1 + :query string customer: A customer identifier to filter for + :query integer membership_type: A membership type ID to filter for + :param organizer: The ``slug`` field of the organizer to fetch + :statuscode 200: no error + :statuscode 401: Authentication failure + :statuscode 403: The requested organizer does not exist **or** you have no permission to view this resource. + +.. http:get:: /api/v1/organizers/(organizer)/memberships/(id)/ + + Returns information on one membership, identified by its ID. + + **Example request**: + + .. sourcecode:: http + + GET /api/v1/organizers/bigevents/memberships/2/ HTTP/1.1 + Host: pretix.eu + Accept: application/json, text/javascript + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Vary: Accept + Content-Type: application/json + + { + "id": 2, + "customer": "EGR9SYT", + "membership_type": 1, + "date_start": "2021-04-19T00:00:00+02:00", + "date_end": "2021-04-20T00:00:00+02:00", + "attendee_name_parts": { + "_scheme": "title_given_family", + "family_name": "Doe", + "given_name": "John", + "title": "" + } + } + + :param organizer: The ``slug`` field of the organizer to fetch + :param id: The ``id`` field of the membership to fetch + :statuscode 200: no error + :statuscode 401: Authentication failure + :statuscode 403: The requested organizer does not exist **or** you have no permission to view this resource. + +.. http:post:: /api/v1/organizers/(organizer)/memberships/ + + Creates a new membership + + **Example request**: + + .. sourcecode:: http + + POST /api/v1/organizers/bigevents/memberships/ HTTP/1.1 + Host: pretix.eu + Accept: application/json, text/javascript + Content-Type: application/json + + { + "membership_type": 2, + "customer": "EGR9SYT", + "date_start": "2021-04-19T00:00:00+02:00", + "date_end": "2021-04-20T00:00:00+02:00", + "attendee_name_parts": { + "_scheme": "title_given_family", + "family_name": "Doe", + "given_name": "John", + "title": "" + } + } + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 201 Created + Vary: Accept + Content-Type: application/json + + { + "id": 3, + "membership_type": 2, + "customer": "EGR9SYT", + "date_start": "2021-04-19T00:00:00+02:00", + "date_end": "2021-04-20T00:00:00+02:00", + "attendee_name_parts": { + "_scheme": "title_given_family", + "family_name": "Doe", + "given_name": "John", + "title": "" + } + } + + :param organizer: The ``slug`` field of the organizer to create a membership for + :statuscode 201: no error + :statuscode 400: The membership could not be created due to invalid submitted data. + :statuscode 401: Authentication failure + :statuscode 403: The requested organizer does not exist **or** you have no permission to create this resource. + +.. http:patch:: /api/v1/organizers/(organizer)/memberships/(id)/ + + Update a membership. You can also use ``PUT`` instead of ``PATCH``. With ``PUT``, you have to provide all fields of + the resource, other fields will be reset to default. With ``PATCH``, you only need to provide the fields that you + want to change. + + You can change all fields of the resource except the ``id`` and ``customer`` fields. + + **Example request**: + + .. sourcecode:: http + + PATCH /api/v1/organizers/bigevents/memberships/1/ HTTP/1.1 + Host: pretix.eu + Accept: application/json, text/javascript + Content-Type: application/json + Content-Length: 94 + + { + "membership_type": 3 + } + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Vary: Accept + Content-Type: application/json + + { + "id": 1, + "membership_type": 3, + … + } + + :param organizer: The ``slug`` field of the organizer to modify + :param id: The ``id`` field of the membership to modify + :statuscode 200: no error + :statuscode 400: The membership could not be modified due to invalid submitted data + :statuscode 401: Authentication failure + :statuscode 403: The requested organizer does not exist **or** you have no permission to change this resource. + diff --git a/doc/api/resources/membershiptypes.rst b/doc/api/resources/membershiptypes.rst new file mode 100644 index 000000000..7e1f8c783 --- /dev/null +++ b/doc/api/resources/membershiptypes.rst @@ -0,0 +1,227 @@ +Membership types +================ + +Resource description +-------------------- + +The membership type resource contains the following public fields: + +.. rst-class:: rest-resource-table + +===================================== ========================== ======================================================= +Field Type Description +===================================== ========================== ======================================================= +id integer Internal ID of the membership type +name multi-lingual string Human-readable name of the type +transferable boolean Whether a membership of this type can be used by + multiple persons +allow_parallel_usage boolean Whether a membership of this type can be used for + multiple parallel tickets +max_usages integer Maximum number of times a membership of this type can be + used. +===================================== ========================== ======================================================= + +Endpoints +--------- + +.. http:get:: /api/v1/organizers/(organizer)/membershiptypes/ + + Returns a list of all membership types within a given organizer. + + **Example request**: + + .. sourcecode:: http + + GET /api/v1/organizers/bigevents/membershiptypes/ HTTP/1.1 + Host: pretix.eu + Accept: application/json, text/javascript + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Vary: Accept + Content-Type: application/json + + { + "count": 1, + "next": null, + "previous": null, + "results": [ + { + "id": 2, + "name": { + "de": "Wochenkarte", + "en": "Week pass" + }, + "transferable": false, + "allow_parallel_usage": false, + "max_usages": 7 + } + ] + } + + :query integer page: The page number in case of a multi-page result set, default is 1 + :param organizer: The ``slug`` field of the organizer to fetch + :statuscode 200: no error + :statuscode 401: Authentication failure + :statuscode 403: The requested organizer does not exist **or** you have no permission to view this resource. + +.. http:get:: /api/v1/organizers/(organizer)/membershiptypes/(id)/ + + Returns information on one membership type, identified by its ID. + + **Example request**: + + .. sourcecode:: http + + GET /api/v1/organizers/bigevents/membershiptypes/1/ HTTP/1.1 + Host: pretix.eu + Accept: application/json, text/javascript + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Vary: Accept + Content-Type: application/json + + { + "id": 1, + "name": { + "de": "Wochenkarte", + "en": "Week pass" + }, + "transferable": false, + "allow_parallel_usage": false, + "max_usages": 7 + } + + :param organizer: The ``slug`` field of the organizer to fetch + :param id: The ``id`` field of the membership type to fetch + :statuscode 200: no error + :statuscode 401: Authentication failure + :statuscode 403: The requested organizer does not exist **or** you have no permission to view this resource. + +.. http:post:: /api/v1/organizers/(organizer)/membershiptypes/ + + Creates a new membership type + + **Example request**: + + .. sourcecode:: http + + POST /api/v1/organizers/bigevents/membershiptypes/ HTTP/1.1 + Host: pretix.eu + Accept: application/json, text/javascript + Content-Type: application/json + + { + "name": { + "de": "Wochenkarte", + "en": "Week pass" + }, + "transferable": false, + "allow_parallel_usage": false, + "max_usages": 7 + } + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 201 Created + Vary: Accept + Content-Type: application/json + + { + "id": 3, + "name": { + "de": "Wochenkarte", + "en": "Week pass" + }, + "transferable": false, + "allow_parallel_usage": false, + "max_usages": 7 + } + + :param organizer: The ``slug`` field of the organizer to create a membership type for + :statuscode 201: no error + :statuscode 400: The membership type could not be created due to invalid submitted data. + :statuscode 401: Authentication failure + :statuscode 403: The requested organizer does not exist **or** you have no permission to create this resource. + +.. http:patch:: /api/v1/organizers/(organizer)/membershiptypes/(id)/ + + Update a membership type. You can also use ``PUT`` instead of ``PATCH``. With ``PUT``, you have to provide all fields of + the resource, other fields will be reset to default. With ``PATCH``, you only need to provide the fields that you + want to change. + + You can change all fields of the resource except the ``id`` field. + + **Example request**: + + .. sourcecode:: http + + PATCH /api/v1/organizers/bigevents/membershiptypes/2/ HTTP/1.1 + Host: pretix.eu + Accept: application/json, text/javascript + Content-Type: application/json + Content-Length: 94 + + { + "max_usages": 3 + } + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Vary: Accept + Content-Type: application/json + + { + "id": 2, + "name": { + "de": "Wochenkarte", + "en": "Week pass" + }, + "transferable": false, + "allow_parallel_usage": false, + "max_usages": 3 + } + + :param organizer: The ``slug`` field of the organizer to modify + :param id: The ``id`` field of the membership type to modify + :statuscode 200: no error + :statuscode 400: The membership could not be modified due to invalid submitted data + :statuscode 401: Authentication failure + :statuscode 403: The requested organizer does not exist **or** you have no permission to change this resource. + +.. http:delete:: /api/v1/organizers/(organizer)/membershiptypes/(id)/ + + Delete a membership type. You can not delete types which have already been used. + + **Example request**: + + .. sourcecode:: http + + DELETE /api/v1/organizers/bigevents/membershiptype/1/ HTTP/1.1 + Host: pretix.eu + Accept: application/json, text/javascript + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 204 No Content + Vary: Accept + + :param organizer: The ``slug`` field of the organizer to modify + :param id: The ``id`` field of the type to delete + :statuscode 204: no error + :statuscode 401: Authentication failure + :statuscode 403: The requested organizer does not exist **or** you have no permission to delete this resource **or** the membership type is currently in use. diff --git a/doc/api/resources/orders.rst b/doc/api/resources/orders.rst index 5f086fa7e..b035957b5 100644 --- a/doc/api/resources/orders.rst +++ b/doc/api/resources/orders.rst @@ -31,6 +31,7 @@ testmode boolean If ``true``, th secret string The secret contained in the link sent to the customer email string The customer email address phone string The customer phone number +customer string The customer account identifier (or ``null``) locale string The locale used for communication with this customer sales_channel string Channel this sale was created through, such as ``"web"``. @@ -118,6 +119,10 @@ last_modified datetime Last modificati The ``phone`` attribute has been added. +.. versionchanged:: 4.0 + + The ``customer`` attribute has been added. + .. _order-position-resource: @@ -289,6 +294,7 @@ List of all orders "url": "https://test.pretix.eu/dummy/dummy/order/ABC12/k24fiuwvu8kxz3y1/", "email": "tester@example.org", "phone": "+491234567", + "customer": null, "locale": "en", "sales_channel": "web", "datetime": "2017-12-01T10:00:00Z", @@ -457,6 +463,7 @@ Fetching individual orders "url": "https://test.pretix.eu/dummy/dummy/order/ABC12/k24fiuwvu8kxz3y1/", "email": "tester@example.org", "phone": "+491234567", + "customer": null, "locale": "en", "sales_channel": "web", "datetime": "2017-12-01T10:00:00Z", @@ -775,6 +782,8 @@ Creating orders * does not support redeeming gift cards + * does not support or validate memberships + You can supply the following fields of the resource: * ``code`` (optional) @@ -783,6 +792,7 @@ Creating orders or in state ``confirmed``, depending on this value. If you create a paid order, the ``order_paid`` signal will **not** be sent out to plugins and no email will be sent. If you want that behavior, create an unpaid order and then call the ``mark_paid`` API method. + * ``customer`` (optional) – Customer identifier or ``null`` * ``testmode`` (optional) – Defaults to ``false`` * ``consume_carts`` (optional) – A list of cart IDs. All cart positions with these IDs will be deleted if the order creation is successful. Any quotas or seats that become free by this operation will be credited to your order diff --git a/doc/api/resources/teams.rst b/doc/api/resources/teams.rst index d94cd3b39..91ebe6e5b 100644 --- a/doc/api/resources/teams.rst +++ b/doc/api/resources/teams.rst @@ -25,6 +25,7 @@ limit_events list List of event s can_create_events boolean can_change_teams boolean can_change_organizer_settings boolean +can_manage_customers boolean can_manage_gift_cards boolean can_change_event_settings boolean can_change_items boolean diff --git a/doc/user/organizers/teams.rst b/doc/user/organizers/teams.rst index 09b9855cb..115681bb1 100644 --- a/doc/user/organizers/teams.rst +++ b/doc/user/organizers/teams.rst @@ -33,6 +33,8 @@ Permissions separate into two areas: * Can create events – To create a new event under this organizer account, users need to have this permission + * Can manage customer accounts – This permission is required to view and change organizer-level customer accounts. + * Can change teams and permissions – This permission is required to perform the kind of action you are doing right now. Anyone with this permission can assign arbitrary other permissions to themselves, so this is the most powerful permission there is to give. diff --git a/src/pretix/api/auth/devicesecurity.py b/src/pretix/api/auth/devicesecurity.py index 9a9bc2bae..ecc8db347 100644 --- a/src/pretix/api/auth/devicesecurity.py +++ b/src/pretix/api/auth/devicesecurity.py @@ -19,7 +19,8 @@ # You should have received a copy of the GNU Affero General Public License along with this program. If not, see # . # -from django.utils.translation import ugettext_lazy as _ + +from django.utils.translation import gettext_lazy as _ class FullAccessSecurityProfile: diff --git a/src/pretix/api/serializers/item.py b/src/pretix/api/serializers/item.py index fe99b7d75..1b3047b57 100644 --- a/src/pretix/api/serializers/item.py +++ b/src/pretix/api/serializers/item.py @@ -160,9 +160,17 @@ class ItemSerializer(I18nAwareModelSerializer): 'require_voucher', 'hide_without_voucher', 'allow_cancel', 'require_bundling', 'min_per_order', 'max_per_order', 'checkin_attention', 'has_variations', 'variations', 'addons', 'bundles', 'original_price', 'require_approval', 'generate_tickets', - 'show_quota_left', 'hidden_if_available', 'allow_waitinglist', 'issue_giftcard', 'meta_data') + 'show_quota_left', 'hidden_if_available', 'allow_waitinglist', 'issue_giftcard', 'meta_data', + 'require_membership', 'require_membership_types', 'grant_membership_type', + 'grant_membership_duration_like_event', 'grant_membership_duration_days', + 'grant_membership_duration_months') read_only_fields = ('has_variations',) + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields['require_membership_types'].queryset = self.context['event'].organizer.membership_types.all() + self.fields['grant_membership_type'].queryset = self.context['event'].organizer.membership_types.all() + def validate(self, data): data = super().validate(data) if self.instance and ('addons' in data or 'variations' in data or 'bundles' in data): diff --git a/src/pretix/api/serializers/order.py b/src/pretix/api/serializers/order.py index 154f1372d..806e19b87 100644 --- a/src/pretix/api/serializers/order.py +++ b/src/pretix/api/serializers/order.py @@ -44,7 +44,7 @@ from pretix.base.channels import get_all_sales_channels from pretix.base.decimal import round_decimal from pretix.base.i18n import language from pretix.base.models import ( - CachedFile, Checkin, Invoice, InvoiceAddress, InvoiceLine, Item, + CachedFile, Checkin, Customer, Invoice, InvoiceAddress, InvoiceLine, Item, ItemVariation, Order, OrderPosition, Question, QuestionAnswer, Seat, SubEvent, TaxRule, Voucher, ) @@ -537,7 +537,7 @@ class CheckinListOrderPositionSerializer(OrderPositionSerializer): self.fields['subevent'] = SubEventSerializer(read_only=True) if 'item' in self.context['request'].query_params.getlist('expand'): - self.fields['item'] = ItemSerializer(read_only=True) + self.fields['item'] = ItemSerializer(read_only=True, context=self.context) if 'variation' in self.context['request'].query_params.getlist('expand'): self.fields['variation'] = InlineItemVariationSerializer(read_only=True) @@ -624,6 +624,7 @@ class OrderSerializer(I18nAwareModelSerializer): payment_date = OrderPaymentDateField(source='*', read_only=True) payment_provider = OrderPaymentTypeField(source='*', read_only=True) url = OrderURLField(source='*', read_only=True) + customer = serializers.SlugRelatedField(slug_field='identifier', read_only=True) class Meta: model = Order @@ -631,11 +632,11 @@ class OrderSerializer(I18nAwareModelSerializer): 'code', 'status', 'testmode', 'secret', 'email', 'phone', 'locale', 'datetime', 'expires', 'payment_date', 'payment_provider', 'fees', 'total', 'comment', 'invoice_address', 'positions', 'downloads', 'checkin_attention', 'last_modified', 'payments', 'refunds', 'require_approval', 'sales_channel', - 'url' + 'url', 'customer' ) read_only_fields = ( 'code', 'status', 'testmode', 'secret', 'datetime', 'expires', 'payment_date', - 'payment_provider', 'fees', 'total', 'positions', 'downloads', + 'payment_provider', 'fees', 'total', 'positions', 'downloads', 'customer', 'last_modified', 'payments', 'refunds', 'require_approval', 'sales_channel' ) @@ -907,16 +908,18 @@ class OrderCreateSerializer(I18nAwareModelSerializer): payment_date = serializers.DateTimeField(required=False, allow_null=True) send_email = serializers.BooleanField(default=False, required=False) simulate = serializers.BooleanField(default=False, required=False) + customer = serializers.SlugRelatedField(slug_field='identifier', queryset=Customer.objects.none(), required=False) def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.fields['positions'].child.fields['voucher'].queryset = self.context['event'].vouchers.all() + self.fields['customer'].queryset = self.context['event'].organizer.customers.all() class Meta: model = Order fields = ('code', 'status', 'testmode', 'email', 'phone', 'locale', 'payment_provider', 'fees', 'comment', 'sales_channel', 'invoice_address', 'positions', 'checkin_attention', 'payment_info', 'payment_date', 'consume_carts', - 'force', 'send_email', 'simulate') + 'force', 'send_email', 'simulate', 'customer') def validate_payment_provider(self, pp): if pp is None: diff --git a/src/pretix/api/serializers/organizer.py b/src/pretix/api/serializers/organizer.py index fda11cfa6..7c909a0d8 100644 --- a/src/pretix/api/serializers/organizer.py +++ b/src/pretix/api/serializers/organizer.py @@ -34,8 +34,9 @@ from pretix.api.serializers.settings import SettingsSerializer from pretix.base.auth import get_auth_backends from pretix.base.i18n import get_language_without_region from pretix.base.models import ( - Device, GiftCard, GiftCardTransaction, Organizer, SeatingPlan, Team, - TeamAPIToken, TeamInvite, User, + Customer, Device, GiftCard, GiftCardTransaction, Membership, + MembershipType, Organizer, SeatingPlan, Team, TeamAPIToken, TeamInvite, + User, ) from pretix.base.models.seating import SeatingPlanLayoutValidator from pretix.base.services.mail import SendMailException, mail @@ -61,6 +62,43 @@ class SeatingPlanSerializer(I18nAwareModelSerializer): fields = ('id', 'name', 'layout') +class CustomerSerializer(I18nAwareModelSerializer): + identifier = serializers.CharField(read_only=True) + name = serializers.CharField(read_only=True) + last_login = serializers.DateTimeField(read_only=True) + date_joined = serializers.DateTimeField(read_only=True) + last_modified = serializers.DateTimeField(read_only=True) + + class Meta: + model = Customer + fields = ('identifier', 'email', 'name', 'name_parts', 'is_active', 'is_verified', 'last_login', 'date_joined', + 'locale', 'last_modified') + + +class MembershipTypeSerializer(I18nAwareModelSerializer): + + class Meta: + model = MembershipType + fields = ('id', 'name', 'transferable', 'allow_parallel_usage', 'max_usages') + + +class MembershipSerializer(I18nAwareModelSerializer): + customer = serializers.SlugRelatedField(slug_field='identifier', queryset=Customer.objects.none()) + + class Meta: + model = Membership + fields = ('id', 'customer', 'membership_type', 'date_start', 'date_end', 'attendee_name_parts') + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields['customer'].queryset = self.context['organizer'].customers.all() + self.fields['membership_type'].queryset = self.context['organizer'].membership_types.all() + + def update(self, instance, validated_data): + validated_data['customer'] = instance.customer # no modifying + return super().update(instance, validated_data) + + class GiftCardSerializer(I18nAwareModelSerializer): value = serializers.DecimalField(max_digits=10, decimal_places=2, min_value=Decimal('0.00')) @@ -116,7 +154,7 @@ class TeamSerializer(serializers.ModelSerializer): 'id', 'name', 'all_events', 'limit_events', 'can_create_events', 'can_change_teams', 'can_change_organizer_settings', 'can_manage_gift_cards', 'can_change_event_settings', 'can_change_items', 'can_view_orders', 'can_change_orders', 'can_view_vouchers', - 'can_change_vouchers', 'can_checkin_orders' + 'can_change_vouchers', 'can_checkin_orders', 'can_manage_customers' ) def validate(self, data): @@ -234,6 +272,7 @@ class TeamMemberSerializer(serializers.ModelSerializer): class OrganizerSettingsSerializer(SettingsSerializer): default_fields = [ + 'customer_accounts', 'contact_mail', 'imprint_url', 'organizer_info_text', diff --git a/src/pretix/api/urls.py b/src/pretix/api/urls.py index a3e553023..622955562 100644 --- a/src/pretix/api/urls.py +++ b/src/pretix/api/urls.py @@ -54,6 +54,9 @@ orga_router.register(r'subevents', event.SubEventViewSet) orga_router.register(r'webhooks', webhooks.WebHookViewSet) orga_router.register(r'seatingplans', organizer.SeatingPlanViewSet) orga_router.register(r'giftcards', organizer.GiftCardViewSet) +orga_router.register(r'customers', organizer.CustomerViewSet) +orga_router.register(r'memberships', organizer.MembershipViewSet) +orga_router.register(r'membershiptypes', organizer.MembershipTypeViewSet) orga_router.register(r'teams', organizer.TeamViewSet) orga_router.register(r'devices', organizer.DeviceViewSet) orga_router.register(r'exporters', exporters.OrganizerExportersViewSet, basename='exporters') diff --git a/src/pretix/api/views/organizer.py b/src/pretix/api/views/organizer.py index 11199d206..9fd724a42 100644 --- a/src/pretix/api/views/organizer.py +++ b/src/pretix/api/views/organizer.py @@ -38,14 +38,16 @@ from rest_framework.viewsets import GenericViewSet from pretix.api.models import OAuthAccessToken from pretix.api.serializers.organizer import ( - DeviceSerializer, GiftCardSerializer, GiftCardTransactionSerializer, - OrganizerSerializer, OrganizerSettingsSerializer, SeatingPlanSerializer, - TeamAPITokenSerializer, TeamInviteSerializer, TeamMemberSerializer, - TeamSerializer, + CustomerSerializer, DeviceSerializer, GiftCardSerializer, + GiftCardTransactionSerializer, MembershipSerializer, + MembershipTypeSerializer, OrganizerSerializer, OrganizerSettingsSerializer, + SeatingPlanSerializer, TeamAPITokenSerializer, TeamInviteSerializer, + TeamMemberSerializer, TeamSerializer, ) from pretix.base.models import ( - Device, GiftCard, GiftCardTransaction, Organizer, SeatingPlan, Team, - TeamAPIToken, TeamInvite, User, + Customer, Device, GiftCard, GiftCardTransaction, Membership, + MembershipType, Organizer, SeatingPlan, Team, TeamAPIToken, TeamInvite, + User, ) from pretix.base.settings import SETTINGS_AFFECTING_CSS from pretix.helpers.dicts import merge_dicts @@ -480,3 +482,163 @@ class OrganizerSettingsView(views.APIView): 'request': request }) return Response(s.data) + + +with scopes_disabled(): + class CustomerFilter(FilterSet): + email = django_filters.CharFilter(field_name='email', lookup_expr='iexact') + + class Meta: + model = Customer + fields = ['email'] + + +class CustomerViewSet(viewsets.ModelViewSet): + serializer_class = CustomerSerializer + queryset = Customer.objects.none() + permission = 'can_manage_customers' + lookup_field = 'identifier' + filter_backends = (DjangoFilterBackend,) + filterset_class = CustomerFilter + + def get_queryset(self): + qs = self.request.organizer.customers.all() + return qs + + def get_serializer_context(self): + ctx = super().get_serializer_context() + ctx['organizer'] = self.request.organizer + return ctx + + def perform_destroy(self, instance): + raise MethodNotAllowed("Customers cannot be deleted.") + + @transaction.atomic() + def perform_create(self, serializer): + inst = serializer.save(organizer=self.request.organizer) + serializer.instance.log_action( + 'pretix.customer.created', + user=self.request.user, + auth=self.request.auth, + data=self.request.data, + ) + return inst + + @transaction.atomic() + def perform_update(self, serializer): + inst = serializer.save(organizer=self.request.organizer) + serializer.instance.log_action( + 'pretix.customer.changed', + user=self.request.user, + auth=self.request.auth, + data=self.request.data, + ) + return inst + + @action(detail=True, methods=["POST"]) + @transaction.atomic() + def anonymize(self, request, **kwargs): + o = self.get_object() + o.anonymize() + o.log_action('pretix.customer.anonymized', user=self.request.user, auth=self.request.auth) + return Response(CustomerSerializer(o).data, status=status.HTTP_200_OK) + + +class MembershipTypeViewSet(viewsets.ModelViewSet): + serializer_class = MembershipTypeSerializer + queryset = MembershipType.objects.none() + permission = 'can_change_organizer_settings' + + def get_queryset(self): + qs = self.request.organizer.membership_types.all() + return qs + + def get_serializer_context(self): + ctx = super().get_serializer_context() + ctx['organizer'] = self.request.organizer + return ctx + + def perform_destroy(self, instance): + if not instance.allow_delete(): + raise PermissionDenied("Can only be deleted if unused.") + instance.log_action( + 'pretix.membershiptype.deleted', + user=self.request.user, + auth=self.request.auth, + data={'id': instance.pk} + ) + instance.delete() + + @transaction.atomic() + def perform_create(self, serializer): + inst = serializer.save(organizer=self.request.organizer) + serializer.instance.log_action( + 'pretix.membershiptype.created', + user=self.request.user, + auth=self.request.auth, + data=self.request.data, + ) + return inst + + @transaction.atomic() + def perform_update(self, serializer): + inst = serializer.save(organizer=self.request.organizer) + serializer.instance.log_action( + 'pretix.membershiptype.changed', + user=self.request.user, + auth=self.request.auth, + data=self.request.data, + ) + return inst + + +with scopes_disabled(): + class MembershipFilter(FilterSet): + customer = django_filters.CharFilter(field_name='customer__identifier', lookup_expr='iexact') + + class Meta: + model = Membership + fields = ['customer', 'membership_type'] + + +class MembershipViewSet(viewsets.ModelViewSet): + serializer_class = MembershipSerializer + queryset = Membership.objects.none() + permission = 'can_manage_customers' + filter_backends = (DjangoFilterBackend,) + filterset_class = MembershipFilter + + def get_queryset(self): + return Membership.objects.filter( + customer__organizer=self.request.organizer + ) + + def get_serializer_context(self): + ctx = super().get_serializer_context() + ctx['organizer'] = self.request.organizer + return ctx + + def perform_destroy(self, instance): + raise MethodNotAllowed("Memberships cannot be deleted. You can change the date instead.") + + @transaction.atomic() + def perform_create(self, serializer): + inst = serializer.save() + serializer.instance.customer.log_action( + 'pretix.customer.membership.created', + user=self.request.user, + auth=self.request.auth, + data=self.request.data, + ) + return inst + + @transaction.atomic() + def perform_update(self, serializer): + inst = serializer.save() + serializer.instance.customer.log_action( + 'pretix.customer.membership.changed', + user=self.request.user, + auth=self.request.auth, + data=self.request.data, + ) + return inst diff --git a/src/pretix/base/email.py b/src/pretix/base/email.py index 0164a0fff..a473910b2 100644 --- a/src/pretix/base/email.py +++ b/src/pretix/base/email.py @@ -71,8 +71,9 @@ class BaseHTMLMailRenderer: This is the base class for all HTML e-mail renderers. """ - def __init__(self, event: Event): + def __init__(self, event: Event, organizer=None): self.event = event + self.organizer = organizer def __str__(self): return self.identifier @@ -140,6 +141,9 @@ class TemplateBasedMailRenderer(BaseHTMLMailRenderer): 'color': settings.PRETIX_PRIMARY_COLOR, 'rtl': get_language() in settings.LANGUAGES_RTL or get_language().split('-')[0] in settings.LANGUAGES_RTL, } + if self.organizer: + htmlctx['organizer'] = self.organizer + if self.event: htmlctx['event'] = self.event htmlctx['color'] = self.event.settings.primary_color diff --git a/src/pretix/base/forms/questions.py b/src/pretix/base/forms/questions.py index 98e2b0cff..f0bfaa0d7 100644 --- a/src/pretix/base/forms/questions.py +++ b/src/pretix/base/forms/questions.py @@ -234,8 +234,8 @@ class NamePartsFormField(forms.MultiValueField): if self.one_required and (not value or not any(v for v in value.values())): raise forms.ValidationError(self.error_messages['required'], code='required') if self.one_required: - for k, v in value.items(): - if k in REQUIRED_NAME_PARTS and not v: + for k, label, size in self.scheme['fields']: + if k in REQUIRED_NAME_PARTS and not value.get(k): raise forms.ValidationError(self.error_messages['required'], code='required') if self.require_all_fields and not all(v for v in value): raise forms.ValidationError(self.error_messages['incomplete'], code='required') diff --git a/src/pretix/base/middleware.py b/src/pretix/base/middleware.py index 1528d65e1..4c394d330 100644 --- a/src/pretix/base/middleware.py +++ b/src/pretix/base/middleware.py @@ -85,6 +85,8 @@ class LocaleMiddleware(MiddlewareMixin): tzname = None if hasattr(request, 'event'): tzname = request.event.settings.timezone + elif hasattr(request, 'organizer') and 'timezone' in request.organizer.settings._cache(): + tzname = request.organizer.settings.timezone elif request.user.is_authenticated: tzname = request.user.timezone if tzname: @@ -104,6 +106,13 @@ class LocaleMiddleware(MiddlewareMixin): return response +def get_language_from_customer_settings(request: HttpRequest) -> str: + if getattr(request, 'customer', None): + lang_code = request.customer.locale + if lang_code in _supported and lang_code is not None and check_for_language(lang_code): + return lang_code + + def get_language_from_user_settings(request: HttpRequest) -> str: if request.user.is_authenticated: lang_code = request.user.locale @@ -169,6 +178,7 @@ def get_language_from_request(request: HttpRequest) -> str: if request.path.startswith(get_script_prefix() + 'control'): return ( get_language_from_user_settings(request) + or get_language_from_customer_settings(request) or get_language_from_session_or_cookie(request) or get_language_from_browser(request) or get_language_from_event(request) @@ -177,6 +187,7 @@ def get_language_from_request(request: HttpRequest) -> str: else: return ( get_language_from_session_or_cookie(request) + or get_language_from_customer_settings(request) or get_language_from_user_settings(request) or get_language_from_browser(request) or get_language_from_event(request) diff --git a/src/pretix/base/migrations/0184_customer.py b/src/pretix/base/migrations/0184_customer.py new file mode 100644 index 000000000..ad62e0c15 --- /dev/null +++ b/src/pretix/base/migrations/0184_customer.py @@ -0,0 +1,59 @@ +# Generated by Django 3.0.13 on 2021-04-06 07:25 + +import django.db.models.deletion +import jsonfallback.fields +from django.db import migrations, models + +import pretix.base.models.base + + +def set_can_manage_customers(apps, schema_editor): + Team = apps.get_model('pretixbase', 'Team') + Team.objects.filter(can_change_organizer_settings=True).update(can_manage_customers=True) + Team.objects.filter(can_change_orders=True, all_events=True).update(can_manage_customers=True) + + +class Migration(migrations.Migration): + + dependencies = [ + ('pretixbase', '0183_auto_20210423_0829'), + ] + + operations = [ + migrations.CreateModel( + name='Customer', + fields=[ + ('id', models.BigAutoField(primary_key=True, serialize=False)), + ('identifier', models.CharField(db_index=True, max_length=190, unique=True)), + ('email', models.EmailField(db_index=True, max_length=190, null=True)), + ('password', models.CharField(max_length=128)), + ('name_cached', models.CharField(max_length=255)), + ('name_parts', jsonfallback.fields.FallbackJSONField(default=dict)), + ('is_active', models.BooleanField(default=True)), + ('is_verified', models.BooleanField(default=True)), + ('last_login', models.DateTimeField(blank=True, null=True)), + ('date_joined', models.DateTimeField(auto_now_add=True)), + ('locale', models.CharField(default='en', max_length=50)), + ('last_modified', models.DateTimeField(auto_now=True)), + ('organizer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='customers', to='pretixbase.Organizer')), + ], + options={ + 'unique_together': {('organizer', 'email')}, + }, + bases=(models.Model, pretix.base.models.base.LoggingMixin), + ), + migrations.AddField( + model_name='order', + name='customer', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='orders', to='pretixbase.Customer'), + ), + migrations.AddField( + model_name='team', + name='can_manage_customers', + field=models.BooleanField(default=False), + ), + migrations.RunPython( + set_can_manage_customers, + migrations.RunPython.noop, + ), + ] diff --git a/src/pretix/base/migrations/0185_memberships.py b/src/pretix/base/migrations/0185_memberships.py new file mode 100644 index 000000000..70d1cab47 --- /dev/null +++ b/src/pretix/base/migrations/0185_memberships.py @@ -0,0 +1,95 @@ +# Generated by Django 3.0.13 on 2021-04-08 09:59 + +import django.db.models.deletion +import i18nfield.fields +import jsonfallback.fields +from django.db import migrations, models + +import pretix.base.models.base + + +class Migration(migrations.Migration): + + dependencies = [ + ('pretixbase', '0184_customer'), + ] + + operations = [ + migrations.AddField( + model_name='item', + name='grant_membership_duration_days', + field=models.IntegerField(default=0), + ), + migrations.AddField( + model_name='item', + name='grant_membership_duration_like_event', + field=models.BooleanField(default=True), + ), + migrations.AddField( + model_name='item', + name='grant_membership_duration_months', + field=models.IntegerField(default=0), + ), + migrations.AddField( + model_name='item', + name='require_membership', + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name='itemvariation', + name='require_membership', + field=models.BooleanField(default=False), + ), + migrations.CreateModel( + name='MembershipType', + fields=[ + ('id', models.BigAutoField(primary_key=True, serialize=False)), + ('name', i18nfield.fields.I18nCharField()), + ('transferable', models.BooleanField(default=False)), + ('allow_parallel_usage', models.BooleanField(default=False)), + ('max_usages', models.PositiveIntegerField(null=True)), + ('organizer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='membership_types', to='pretixbase.Organizer')), + ], + options={ + 'abstract': False, + }, + bases=(models.Model, pretix.base.models.base.LoggingMixin), + ), + migrations.CreateModel( + name='Membership', + fields=[ + ('id', models.BigAutoField(primary_key=True, serialize=False)), + ('date_start', models.DateTimeField()), + ('date_end', models.DateTimeField()), + ('attendee_name_parts', jsonfallback.fields.FallbackJSONField(default=dict, null=True)), + ('customer', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='memberships', to='pretixbase.Customer')), + ('granted_in', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='granted_memberships', to='pretixbase.OrderPosition', null=True)), + ('membership_type', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='memberships', to='pretixbase.MembershipType')), + ], + ), + migrations.AddField( + model_name='cartposition', + name='used_membership', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to='pretixbase.Membership'), + ), + migrations.AddField( + model_name='item', + name='grant_membership_type', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, related_name='granted_by', to='pretixbase.MembershipType'), + ), + migrations.AddField( + model_name='item', + name='require_membership_types', + field=models.ManyToManyField(to='pretixbase.MembershipType'), + ), + migrations.AddField( + model_name='itemvariation', + name='require_membership_types', + field=models.ManyToManyField(to='pretixbase.MembershipType'), + ), + migrations.AddField( + model_name='orderposition', + name='used_membership', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to='pretixbase.Membership'), + ), + ] diff --git a/src/pretix/base/models/__init__.py b/src/pretix/base/models/__init__.py index 9281b3f59..9f5d805ec 100644 --- a/src/pretix/base/models/__init__.py +++ b/src/pretix/base/models/__init__.py @@ -23,6 +23,7 @@ from ..settings import GlobalSettingsObject_SettingsStore from .auth import U2FDevice, User, WebAuthnDevice from .base import CachedFile, LoggedModel, cachedfile_name from .checkin import Checkin, CheckinList +from .customers import Customer from .devices import Device, Gate from .event import ( Event, Event_SettingsStore, EventLock, EventMetaProperty, EventMetaValue, @@ -36,6 +37,7 @@ from .items import ( SubEventItemVariation, itempicture_upload_to, ) from .log import LogEntry +from .memberships import Membership, MembershipType from .notifications import NotificationSetting from .orders import ( AbstractPosition, CachedCombinedTicket, CachedTicket, CartPosition, diff --git a/src/pretix/base/models/customers.py b/src/pretix/base/models/customers.py new file mode 100644 index 000000000..1c5a2dd12 --- /dev/null +++ b/src/pretix/base/models/customers.py @@ -0,0 +1,173 @@ +# +# This file is part of pretix (Community Edition). +# +# Copyright (C) 2014-2020 Raphael Michel and contributors +# Copyright (C) 2020-2021 rami.io 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 . +# +# 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 +# . +# +from django.conf import settings +from django.contrib.auth.hashers import ( + check_password, is_password_usable, make_password, +) +from django.db import models +from django.utils.crypto import get_random_string, salted_hmac +from django.utils.translation import gettext_lazy as _ +from django_scopes import ScopedManager, scopes_disabled +from jsonfallback.fields import FallbackJSONField + +from pretix.base.banlist import banned +from pretix.base.models.base import LoggedModel +from pretix.base.models.organizer import Organizer +from pretix.base.settings import PERSON_NAME_SCHEMES + + +class Customer(LoggedModel): + """ + Represents a registered customer of an organizer. + """ + id = models.BigAutoField(primary_key=True) + organizer = models.ForeignKey(Organizer, related_name='customers', on_delete=models.CASCADE) + identifier = models.CharField(max_length=190, db_index=True, unique=True) + email = models.EmailField(db_index=True, null=True, blank=False, verbose_name=_('E-mail'), max_length=190) + password = models.CharField(verbose_name=_('Password'), max_length=128) + name_cached = models.CharField(max_length=255, verbose_name=_('Full name'), blank=True) + name_parts = FallbackJSONField(default=dict) + is_active = models.BooleanField(default=True, verbose_name=_('Account active')) + is_verified = models.BooleanField(default=True, verbose_name=_('Verified email address')) + last_login = models.DateTimeField(verbose_name=_('Last login'), blank=True, null=True) + date_joined = models.DateTimeField(auto_now_add=True, verbose_name=_('Registration date')) + locale = models.CharField(max_length=50, + choices=settings.LANGUAGES, + default=settings.LANGUAGE_CODE, + verbose_name=_('Language')) + last_modified = models.DateTimeField(auto_now=True) + + objects = ScopedManager(organizer='organizer') + + class Meta: + unique_together = [['organizer', 'email']] + + def save(self, **kwargs): + if self.email: + self.email = self.email.lower() + if 'update_fields' in kwargs and 'last_modified' not in kwargs['update_fields']: + kwargs['update_fields'] = list(kwargs['update_fields']) + ['last_modified'] + if not self.identifier: + self.assign_identifier() + if self.name_parts: + self.name_cached = self.name + else: + self.name_cached = "" + self.name_parts = {} + super().save(**kwargs) + + def anonymize(self): + self.is_active = False + self.is_verified = False + self.name_parts = {} + self.name_cached = '' + self.email = None + self.save() + self.all_logentries().update(data={}, shredded=True) + self.orders.all().update(customer=None) + self.memberships.all().update(attendee_name_parts=None) + + @scopes_disabled() + def assign_identifier(self): + charset = list('ABCDEFGHJKLMNPQRSTUVWXYZ23456789') + iteration = 0 + length = settings.ENTROPY['customer_identifier'] + while True: + code = get_random_string(length=length, allowed_chars=charset) + iteration += 1 + + if banned(code): + continue + + if not Customer.objects.filter(identifier=code).exists(): + self.identifier = code + return + + if iteration > 20: + # Safeguard: If we don't find an unused and non-banlisted code within 20 iterations, we increase + # the length. + length += 1 + iteration = 0 + + @property + def name(self): + if not self.name_parts: + return "" + if '_legacy' in self.name_parts: + return self.name_parts['_legacy'] + if '_scheme' in self.name_parts: + scheme = PERSON_NAME_SCHEMES[self.name_parts['_scheme']] + else: + raise TypeError("Invalid name given.") + return scheme['concatenation'](self.name_parts).strip() + + def __str__(self): + s = f'#{self.identifier}' + if self.name or self.email: + s += f' – {self.name or self.email}' + if not self.is_active: + s += f' ({_("disabled")})' + return s + + def set_password(self, raw_password): + self.password = make_password(raw_password) + + def check_password(self, raw_password): + """ + Return a boolean of whether the raw_password was correct. Handles + hashing formats behind the scenes. + """ + def setter(raw_password): + self.set_password(raw_password) + self.save(update_fields=["password"]) + return check_password(raw_password, self.password, setter) + + def set_unusable_password(self): + # Set a value that will never be a valid hash + self.password = make_password(None) + + def has_usable_password(self): + """ + Return False if set_unusable_password() has been called for this user. + """ + return is_password_usable(self.password) + + def get_session_auth_hash(self): + """ + Return an HMAC of the password field. + """ + key_salt = "pretix.base.models.customers.Customer.get_session_auth_hash" + payload = self.password + payload += self.email + return salted_hmac(key_salt, payload).hexdigest() + + def get_email_context(self): + ctx = { + 'name': self.name, + 'organizer': self.organizer.name, + } + name_scheme = PERSON_NAME_SCHEMES[self.organizer.settings.name_scheme] + for f, l, w in name_scheme['fields']: + if f == 'full_name': + continue + ctx['name_%s' % f] = self.name_parts.get(f, '') + return ctx diff --git a/src/pretix/base/models/event.py b/src/pretix/base/models/event.py index 3b164fae1..ca260bd9f 100644 --- a/src/pretix/base/models/event.py +++ b/src/pretix/base/models/event.py @@ -670,6 +670,7 @@ class Event(EventMixin, LoggedModel): variation_map = {} for i in Item.objects.filter(event=other).prefetch_related('variations'): vars = list(i.variations.all()) + require_membership_types = list(i.require_membership_types.all()) item_map[i.pk] = i i.pk = None i.event = self @@ -679,8 +680,16 @@ class Event(EventMixin, LoggedModel): i.category = category_map[i.category_id] if i.tax_rule_id: i.tax_rule = tax_map[i.tax_rule_id] + + if i.grant_membership_type and other.organizer_id != self.organizer_id: + i.grant_membership_type = None + i.save() i.log_action('pretix.object.cloned') + + if require_membership_types and other.organizer_id == self.organizer_id: + i.require_membership_types.set(require_membership_types) + for v in vars: variation_map[v.pk] = v v.pk = None diff --git a/src/pretix/base/models/items.py b/src/pretix/base/models/items.py index 3bfac8704..d1480dc41 100644 --- a/src/pretix/base/models/items.py +++ b/src/pretix/base/models/items.py @@ -514,6 +514,34 @@ class Item(LoggedModel): 'product price.'), default=False ) + require_membership = models.BooleanField( + verbose_name=_('Require a valid membership'), + default=False, + ) + require_membership_types = models.ManyToManyField( + 'MembershipType', + verbose_name=_('Allowed membership types'), + blank=True, + ) + grant_membership_type = models.ForeignKey( + 'MembershipType', + null=True, blank=True, + related_name='granted_by', + on_delete=models.PROTECT, + verbose_name=_('This product creates a membership of type'), + ) + grant_membership_duration_like_event = models.BooleanField( + verbose_name=_('The duration of the membership is the same as the duration of the event or event series date'), + default=True, + ) + grant_membership_duration_days = models.IntegerField( + verbose_name=_('Membership duration in days'), + default=0, + ) + grant_membership_duration_months = models.IntegerField( + verbose_name=_('Membership duration in months'), + default=0, + ) # !!! Attention: If you add new fields here, also add them to the copying code in # pretix/control/forms/item.py if applicable. @@ -760,6 +788,15 @@ class ItemVariation(models.Model): help_text=_('If set, this will be displayed next to the current price to show that the current price is a ' 'discounted one. This is just a cosmetic setting and will not actually impact pricing.') ) + require_membership = models.BooleanField( + verbose_name=_('Require a valid membership'), + default=False, + ) + require_membership_types = models.ManyToManyField( + 'MembershipType', + verbose_name=_('Membership types'), + blank=True, + ) objects = ScopedManager(organizer='item__event__organizer') diff --git a/src/pretix/base/models/memberships.py b/src/pretix/base/models/memberships.py new file mode 100644 index 000000000..b47960325 --- /dev/null +++ b/src/pretix/base/models/memberships.py @@ -0,0 +1,168 @@ +# +# This file is part of pretix (Community Edition). +# +# Copyright (C) 2014-2020 Raphael Michel and contributors +# Copyright (C) 2020-2021 rami.io 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 . +# +# 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 +# . +# +from django.db import models +from django.db.models import Count, OuterRef, Subquery, Value +from django.db.models.functions import Coalesce +from django.utils.formats import date_format +from django.utils.timezone import now +from django.utils.translation import gettext_lazy as _ +from django_scopes import ScopedManager, scopes_disabled +from i18nfield.fields import I18nCharField +from jsonfallback.fields import FallbackJSONField + +from pretix.base.models import Customer +from pretix.base.models.base import LoggedModel +from pretix.base.models.organizer import Organizer +from pretix.base.settings import PERSON_NAME_SCHEMES + + +class MembershipType(LoggedModel): + id = models.BigAutoField(primary_key=True) + organizer = models.ForeignKey(Organizer, related_name='membership_types', on_delete=models.CASCADE) + name = I18nCharField( + verbose_name=_('Name'), + ) + transferable = models.BooleanField( + verbose_name=_('Membership is transferable'), + help_text=_('If this is selected, the membership can be used to purchase tickets for multiple persons. If not, ' + 'the attendee name always needs to stay the same.'), + default=False + ) + allow_parallel_usage = models.BooleanField( + verbose_name=_('Parallel usage is allowed'), + help_text=_('If this is selected, the membership can be used to purchase tickets for events happening at the same time. Note ' + 'that this will only check for an identical start time of the events, not for any overlap between events.'), + default=False + ) + max_usages = models.PositiveIntegerField( + verbose_name=_("Maximum usages"), + help_text=_("Number of times this membership can be used in a purchase."), + null=True, blank=True, + ) + + def __str__(self): + return str(self.name) + + def allow_delete(self): + return not self.memberships.exists() and not self.granted_by.exists() + + +class MembershipQuerySet(models.QuerySet): + + @scopes_disabled() # no scoping of subquery + def with_usages(self, ignored_order=None): + from . import Order, OrderPosition + + sq = OrderPosition.all.filter( + used_membership_id=OuterRef('pk'), + canceled=False, + ).exclude( + order__status=Order.STATUS_CANCELED + ) + if ignored_order: + sq = sq.exclude(order__id=ignored_order.pk) + return self.annotate( + usages=Coalesce( + Subquery( + sq.order_by().values('used_membership_id').annotate( + c=Count('*') + ).values('c') + ), + Value('0') + ) + ) + + def active(self, ev): + return self.filter( + date_start__lte=ev.date_from, + date_end__gte=ev.date_from + ) + + +class MembershipQuerySetManager(ScopedManager(organizer='customer__organizer').__class__): + def __init__(self): + super().__init__() + self._queryset_class = MembershipQuerySet + + def with_usages(self, ignored_order=None): + return self.get_queryset().with_usages(ignored_order) + + def active(self, ev): + return self.get_queryset().active(ev) + + +class Membership(models.Model): + id = models.BigAutoField(primary_key=True) + customer = models.ForeignKey( + Customer, + related_name='memberships', + on_delete=models.PROTECT + ) + membership_type = models.ForeignKey( + MembershipType, + verbose_name=_('Membership type'), + related_name='memberships', + on_delete=models.PROTECT + ) + granted_in = models.ForeignKey( + 'OrderPosition', + related_name='granted_memberships', + on_delete=models.PROTECT, + null=True, blank=True, + ) + date_start = models.DateTimeField( + verbose_name=_('Start date') + ) + date_end = models.DateTimeField( + verbose_name=_('End date') + ) + attendee_name_parts = FallbackJSONField(default=dict, null=True) + + objects = MembershipQuerySetManager() + + class Meta: + ordering = "-date_end", "-date_start", "membership_type" + + def __str__(self): + ds = date_format(self.date_start, 'SHORT_DATE_FORMAT') + de = date_format(self.date_end, 'SHORT_DATE_FORMAT') + return f'{self.membership_type.name}: {self.attendee_name} ({ds} – {de})' + + @property + def attendee_name(self): + if not self.attendee_name_parts: + return None + if '_legacy' in self.attendee_name_parts: + return self.attendee_name_parts['_legacy'] + if '_scheme' in self.attendee_name_parts: + scheme = PERSON_NAME_SCHEMES[self.attendee_name_parts['_scheme']] + else: + scheme = PERSON_NAME_SCHEMES[self.customer.organizer.settings.name_scheme] + return scheme['concatenation'](self.attendee_name_parts).strip() + + def is_valid(self, ev=None): + if ev: + dt = ev.date_from + else: + dt = now() + + return dt >= self.date_start and dt <= self.date_end diff --git a/src/pretix/base/models/orders.py b/src/pretix/base/models/orders.py index 6d0ba796e..8c528508c 100644 --- a/src/pretix/base/models/orders.py +++ b/src/pretix/base/models/orders.py @@ -47,6 +47,7 @@ import dateutil import pycountry import pytz from django.conf import settings +from django.core.exceptions import ValidationError from django.db import models, transaction from django.db.models import ( Case, Exists, F, Max, OuterRef, Q, Subquery, Sum, Value, When, @@ -73,7 +74,7 @@ from pretix.base.banlist import banned from pretix.base.decimal import round_decimal from pretix.base.email import get_email_context from pretix.base.i18n import language -from pretix.base.models import User +from pretix.base.models import Customer, User from pretix.base.reldate import RelativeDateWrapper from pretix.base.services.locking import NoLockManager from pretix.base.settings import PERSON_NAME_SCHEMES @@ -119,6 +120,8 @@ class Order(LockModel, LoggedModel): :param event: The event this order belongs to :type event: Event + :param customer: The customer this order belongs to + :type customer: Customer :param email: The email of the person who ordered this :type email: str :param phone: The phone number of the person who ordered this @@ -177,6 +180,13 @@ class Order(LockModel, LoggedModel): related_name="orders", on_delete=models.CASCADE ) + customer = models.ForeignKey( + Customer, + verbose_name=_("Customer"), + related_name="orders", + null=True, blank=True, + on_delete=models.SET_NULL + ) email = models.EmailField( null=True, blank=True, verbose_name=_('E-mail') @@ -822,7 +832,11 @@ class Order(LockModel, LoggedModel): return self._is_still_available(count_waitinglist=count_waitinglist, force=force) def _is_still_available(self, now_dt: datetime=None, count_waitinglist=True, force=False, - check_voucher_usage=False) -> Union[bool, str]: + check_voucher_usage=False, check_memberships=False) -> Union[bool, str]: + from pretix.base.services.memberships import ( + validate_memberships_in_order, + ) + error_messages = { 'unavailable': _('The ordered product "{item}" is no longer available.'), 'seat_unavailable': _('The seat "{seat}" is no longer available.'), @@ -830,11 +844,17 @@ class Order(LockModel, LoggedModel): 'voucher_usages': _('The voucher "{voucher}" has been used in the meantime.'), } now_dt = now_dt or now() - positions = self.positions.all().select_related('item', 'variation', 'seat', 'voucher') + positions = list(self.positions.all().select_related('item', 'variation', 'seat', 'voucher')) quota_cache = {} v_budget = {} v_usage = Counter() try: + if check_memberships: + try: + validate_memberships_in_order(self.customer, positions, self.event, lock=False) + except ValidationError as e: + raise Quota.QuotaExceededException(e.message) + for i, op in enumerate(positions): if op.seat: if not op.seat.is_available(ignore_orderpos=op): @@ -1181,6 +1201,9 @@ class AbstractPosition(models.Model): voucher = models.ForeignKey( 'Voucher', null=True, blank=True, on_delete=models.PROTECT ) + used_membership = models.ForeignKey( + 'Membership', null=True, blank=True, on_delete=models.PROTECT + ) addon_to = models.ForeignKey( 'self', null=True, blank=True, on_delete=models.PROTECT, related_name='addons' ) diff --git a/src/pretix/base/models/organizer.py b/src/pretix/base/models/organizer.py index f2f3a642b..b5c72ba62 100644 --- a/src/pretix/base/models/organizer.py +++ b/src/pretix/base/models/organizer.py @@ -35,6 +35,8 @@ import string from datetime import date, datetime, time +import pytz +from django.core.mail import get_connection from django.core.validators import MinLengthValidator, RegexValidator from django.db import models from django.db.models import Exists, OuterRef, Q @@ -123,6 +125,10 @@ class Organizer(LoggedModel): return ObjectRelatedCache(self) + @property + def timezone(self): + return pytz.timezone(self.settings.timezone) + @cached_property def all_logentries_link(self): return reverse( @@ -173,6 +179,24 @@ class Organizer(LoggedModel): e.delete() self.teams.all().delete() + def get_mail_backend(self, timeout=None, force_custom=False): + """ + Returns an email server connection, either by using the system-wide connection + or by returning a custom one based on the organizer's settings. + """ + from pretix.base.email import CustomSMTPBackend + + if self.settings.smtp_use_custom or force_custom: + return CustomSMTPBackend(host=self.settings.smtp_host, + port=self.settings.smtp_port, + username=self.settings.smtp_username, + password=self.settings.smtp_password, + use_tls=self.settings.smtp_use_tls, + use_ssl=self.settings.smtp_use_ssl, + fail_silently=False, timeout=timeout) + else: + return get_connection(fail_silently=False) + def generate_invite_token(): return get_random_string(length=32, allowed_chars=string.ascii_lowercase + string.digits) @@ -198,6 +222,8 @@ class Team(LoggedModel): :type can_create_events: bool :param can_change_teams: If ``True``, the members can change the teams of this organizer account. :type can_change_teams: bool + :param can_manage_customers: If ``True``, the members can view and change organizer-level customer accounts. + :type can_manage_customers: bool :param can_change_organizer_settings: If ``True``, the members can change the settings of this organizer account. :type can_change_organizer_settings: bool :param can_change_event_settings: If ``True``, the members can change the settings of the associated events. @@ -235,11 +261,14 @@ class Team(LoggedModel): help_text=_('Someone with this setting can get access to most data of all of your events, i.e. via privacy ' 'reports, so be careful who you add to this team!') ) + can_manage_customers = models.BooleanField( + default=False, + verbose_name=_("Can manage customer accounts") + ) can_manage_gift_cards = models.BooleanField( default=False, verbose_name=_("Can manage gift cards") ) - can_change_event_settings = models.BooleanField( default=False, verbose_name=_("Can change event settings") diff --git a/src/pretix/base/secrets.py b/src/pretix/base/secrets.py index 9c2f36015..c3fbae09a 100644 --- a/src/pretix/base/secrets.py +++ b/src/pretix/base/secrets.py @@ -32,7 +32,7 @@ from cryptography.hazmat.primitives.serialization import ( from django.conf import settings from django.dispatch import receiver from django.utils.crypto import get_random_string -from django.utils.translation import ugettext_lazy as _ +from django.utils.translation import gettext_lazy as _ from pretix.base.models import Item, ItemVariation, SubEvent from pretix.base.secretgenerators import pretix_sig1_pb2 diff --git a/src/pretix/base/services/mail.py b/src/pretix/base/services/mail.py index 7f5313376..0a3d07487 100644 --- a/src/pretix/base/services/mail.py +++ b/src/pretix/base/services/mail.py @@ -66,7 +66,8 @@ from i18nfield.strings import LazyI18nString from pretix.base.email import ClassicMailRenderer from pretix.base.i18n import language from pretix.base.models import ( - CachedFile, Event, Invoice, InvoiceAddress, Order, OrderPosition, User, + CachedFile, Customer, Event, Invoice, InvoiceAddress, Order, OrderPosition, + Organizer, User, ) from pretix.base.services.invoices import invoice_pdf_task from pretix.base.services.tasks import TransactionAwareTask @@ -92,10 +93,10 @@ class SendMailException(Exception): def mail(email: Union[str, Sequence[str]], subject: str, template: Union[str, LazyI18nString], - context: Dict[str, Any] = None, event: Event = None, locale: str = None, - order: Order = None, position: OrderPosition = None, headers: dict = None, sender: str = None, - invoices: Sequence = None, attach_tickets=False, auto_email=True, user=None, attach_ical=False, - attach_cached_files: Sequence = None): + context: Dict[str, Any] = None, event: Event = None, locale: str = None, order: Order = None, + position: OrderPosition = None, *, headers: dict = None, sender: str = None, organizer: Organizer = None, + customer: Customer = None, invoices: Sequence = None, attach_tickets=False, auto_email=True, user=None, + attach_ical=False, attach_cached_files: Sequence = None): """ Sends out an email to a user. The mail will be sent synchronously or asynchronously depending on the installation. @@ -113,6 +114,9 @@ def mail(email: Union[str, Sequence[str]], subject: str, template: Union[str, La :param event: The event this email is related to (optional). If set, this will be used to determine the sender, a possible prefix for the subject and the SMTP server that should be used to send this email. + :param organizer: The event this organizer is related to (optional). If set, this will be used to determine the sender, + a possible prefix for the subject and the SMTP server that should be used to send this email. + :param order: The order this email is related to (optional). If set, this will be used to include a link to the order below the email. @@ -136,6 +140,8 @@ def mail(email: Union[str, Sequence[str]], subject: str, template: Union[str, La :param user: The user this email is sent to + :param customer: The user this email is sent to + :param attach_cached_files: A list of cached file to attach to this email. :raises MailOrderException: on obvious, immediate failures. Not raising an exception does not necessarily mean @@ -165,15 +171,24 @@ def mail(email: Union[str, Sequence[str]], subject: str, template: Union[str, La 'invoice_name': '', 'invoice_company': '' }) - renderer = ClassicMailRenderer(None) + renderer = ClassicMailRenderer(None, organizer) content_plain = body_plain = render_mail(template, context) subject = str(subject).format_map(TolerantDict(context)) - sender = sender or (event.settings.get('mail_from') if event else settings.MAIL_FROM) or settings.MAIL_FROM + sender = ( + sender or + (event.settings.get('mail_from') if event else settings.MAIL_FROM) or + (organizer.settings.get('mail_from') if organizer else settings.MAIL_FROM) or + settings.MAIL_FROM + ) if event: - sender_name = str(event.name) + sender_name = event.settings.mail_from_name or str(event.name) + if len(sender_name) > 75: + sender_name = sender_name[:75] + "..." + sender = formataddr((sender_name, sender)) + elif organizer: + sender_name = organizer.settings.mail_from_name or str(organizer.name) if len(sender_name) > 75: sender_name = sender_name[:75] + "..." - sender_name = event.settings.mail_from_name or sender_name sender = formataddr((sender_name, sender)) else: sender = formataddr((settings.PRETIX_INSTANCE_NAME, sender)) @@ -182,17 +197,27 @@ def mail(email: Union[str, Sequence[str]], subject: str, template: Union[str, La signature = "" bcc = [] + + settings_holder = event or organizer + if event: timezone = event.timezone - renderer = event.get_html_mail_renderer() - if event.settings.mail_bcc: - for bcc_mail in event.settings.mail_bcc.split(','): + elif user: + timezone = pytz.timezone(user.timezone) + elif organizer: + timezone = organizer.timezone + else: + timezone = pytz.timezone(settings.TIME_ZONE) + + if settings_holder: + if settings_holder.settings.mail_bcc: + for bcc_mail in set.settings.mail_bcc.split(','): bcc.append(bcc_mail.strip()) - if event.settings.mail_from == settings.DEFAULT_FROM_EMAIL and event.settings.contact_mail and not headers.get('Reply-To'): - headers['Reply-To'] = event.settings.contact_mail + if settings_holder.settings.mail_from == settings.DEFAULT_FROM_EMAIL and settings_holder.settings.contact_mail and not headers.get('Reply-To'): + headers['Reply-To'] = settings_holder.settings.contact_mail - prefix = event.settings.get('mail_prefix') + prefix = settings_holder.settings.get('mail_prefix') if prefix and prefix.startswith('[') and prefix.endswith(']'): prefix = prefix[1:-1] if prefix: @@ -200,12 +225,15 @@ def mail(email: Union[str, Sequence[str]], subject: str, template: Union[str, La body_plain += "\r\n\r\n-- \r\n" - signature = str(event.settings.get('mail_text_signature')) + signature = str(settings_holder.settings.get('mail_text_signature')) if signature: - signature = signature.format(event=event.name) + signature = signature.format(event=event.name if event else '') body_plain += signature body_plain += "\r\n\r\n-- \r\n" + if event: + renderer = event.get_html_mail_renderer() + if order and order.testmode: subject = "[TESTMODE] " + subject @@ -242,10 +270,6 @@ def mail(email: Union[str, Sequence[str]], subject: str, template: Union[str, La ) ) body_plain += "\r\n" - elif user: - timezone = pytz.timezone(user.timezone) - else: - timezone = pytz.timezone(settings.TIME_ZONE) with override(timezone): try: @@ -276,6 +300,8 @@ def mail(email: Union[str, Sequence[str]], subject: str, template: Union[str, La attach_tickets=attach_tickets, attach_ical=attach_ical, user=user.pk if user else None, + organizer=organizer.pk if organizer else None, + customer=customer.pk if customer else None, attach_cached_files=[(cf.id if isinstance(cf, CachedFile) else cf) for cf in attach_cached_files] if attach_cached_files else [], ) @@ -314,7 +340,7 @@ class CustomEmail(EmailMultiAlternatives): def mail_send_task(self, *args, to: List[str], subject: str, body: str, html: str, sender: str, event: int = None, position: int = None, headers: dict = None, bcc: List[str] = None, invoices: List[int] = None, order: int = None, attach_tickets=False, user=None, - attach_ical=False, attach_cached_files: List[int] = None) -> bool: + organizer=None, customer=None, attach_ical=False, attach_cached_files: List[int] = None) -> bool: email = CustomEmail(subject, body, sender, to=to, bcc=bcc, headers=headers) if html is not None: html_message = SafeMIMEMultipart(_subtype='related', encoding=settings.DEFAULT_CHARSET) @@ -326,15 +352,25 @@ def mail_send_task(self, *args, to: List[str], subject: str, body: str, html: st if user: user = User.objects.get(pk=user) + if customer: + customer = Customer.objects.get(pk=customer) + if event: with scopes_disabled(): event = Event.objects.get(id=event) backend = event.get_mail_backend() cm = lambda: scope(organizer=event.organizer) # noqa + elif organizer: + with scopes_disabled(): + organizer = Organizer.objects.get(id=organizer) + backend = organizer.get_mail_backend() + cm = lambda: scope(organizer=organizer) # noqa else: backend = get_connection(fail_silently=False) cm = lambda: scopes_disabled() # noqa + log_target = order or user or customer + with cm(): if event: if order: @@ -432,7 +468,8 @@ def mail_send_task(self, *args, to: List[str], subject: str, body: str, html: st logger.exception('Could not attach file to email') pass - email = global_email_filter.send_chained(event, 'message', message=email, user=user, order=order) + email = global_email_filter.send_chained(event, 'message', message=email, user=user, order=order, + organizer=organizer, customer=customer) try: backend.send_messages([email]) @@ -442,9 +479,9 @@ def mail_send_task(self, *args, to: List[str], subject: str, body: str, html: st try: self.retry(max_retries=5, countdown=2 ** (self.request.retries * 3)) # max is 2 ** (4*3) = 4096 seconds = 68 minutes except MaxRetriesExceededError: - if order: - order.log_action( - 'pretix.event.order.email.error', + if log_target: + log_target.log_action( + 'pretix.email.error', data={ 'subject': 'SMTP code {}, max retries exceeded'.format(e.smtp_code), 'message': e.smtp_error.decode() if isinstance(e.smtp_error, bytes) else str(e.smtp_error), @@ -455,9 +492,9 @@ def mail_send_task(self, *args, to: List[str], subject: str, body: str, html: st raise e logger.exception('Error sending email') - if order: - order.log_action( - 'pretix.event.order.email.error', + if log_target: + log_target.log_action( + 'pretix.email.error', data={ 'subject': 'SMTP code {}'.format(e.smtp_code), 'message': e.smtp_error.decode() if isinstance(e.smtp_error, bytes) else str(e.smtp_error), @@ -479,13 +516,13 @@ def mail_send_task(self, *args, to: List[str], subject: str, body: str, html: st pass logger.exception('Error sending email') - if order: + if log_target: message = [] for e, val in e.recipients.items(): message.append(f'{e}: {val[0]} {val[1].decode()}') - order.log_action( - 'pretix.event.order.email.error', + logger.log_action( + 'pretix.email.error', data={ 'subject': 'SMTP error', 'message': '\n'.join(message), @@ -500,9 +537,9 @@ def mail_send_task(self, *args, to: List[str], subject: str, body: str, html: st try: self.retry(max_retries=5, countdown=2 ** (self.request.retries * 3)) # max is 2 ** (4*3) = 4096 seconds = 68 minutes except MaxRetriesExceededError: - if order: - order.log_action( - 'pretix.event.order.email.error', + if log_target: + log_target.log_action( + 'pretix.email.error', data={ 'subject': 'Internal error', 'message': 'Max retries exceeded', @@ -511,9 +548,9 @@ def mail_send_task(self, *args, to: List[str], subject: str, body: str, html: st } ) raise e - if order: - order.log_action( - 'pretix.event.order.email.error', + if logger: + log_target.log_action( + 'pretix.email.error', data={ 'subject': 'Internal error', 'message': str(e), diff --git a/src/pretix/base/services/memberships.py b/src/pretix/base/services/memberships.py new file mode 100644 index 000000000..1f18bb8f9 --- /dev/null +++ b/src/pretix/base/services/memberships.py @@ -0,0 +1,187 @@ +# +# This file is part of pretix (Community Edition). +# +# Copyright (C) 2014-2020 Raphael Michel and contributors +# Copyright (C) 2020-2021 rami.io 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 . +# +# 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 +# . +# +from datetime import timedelta +from typing import List, Optional + +from dateutil.relativedelta import relativedelta +from django.core.exceptions import ValidationError +from django.utils.formats import date_format +from django.utils.timezone import now +from django.utils.translation import gettext_lazy as _ + +from pretix.base.models import ( + AbstractPosition, Customer, Event, Item, Membership, Order, OrderPosition, + SubEvent, +) + + +def membership_validity(item: Item, subevent: Optional[SubEvent], event: Event): + tz = event.timezone + if item.grant_membership_duration_like_event: + ev = subevent or event + date_start = ev.date_from + date_end = ev.date_to + + if not date_end: + # Use end of day, if event end date is not set + date_end = date_start.astimezone(tz).replace(hour=23, minute=59, second=59, microsecond=999999) + + else: + # Always start at start of day + date_start = now().astimezone(tz).replace(hour=0, minute=0, second=0, microsecond=0) + date_end = date_start + + if item.grant_membership_duration_months: + date_end -= timedelta(days=1) # start on 25th gives end on 26th + date_end += relativedelta(months=item.grant_membership_duration_months) # start on 31th may give end on 28th + + if item.grant_membership_duration_days: + date_end += timedelta(days=item.grant_membership_duration_days) + if not item.grant_membership_duration_months: + # Correct off-by-one due to first day + date_end -= timedelta(days=1) + + # Always end at end of day + date_end = date_end.astimezone(tz).replace(hour=23, minute=59, second=59, microsecond=999999) + + return date_start, date_end + + +def create_membership(customer: Customer, position: OrderPosition): + item = position.item + + date_start, date_end = membership_validity(item, position.subevent, position.order.event) + + customer.memberships.create( + membership_type=position.item.grant_membership_type, + granted_in=position, + date_start=date_start, + date_end=date_end, + attendee_name_parts=position.attendee_name_parts + ) + + +def validate_memberships_in_order(customer: Customer, positions: List[AbstractPosition], event: Event, lock=False, ignored_order: Order = None): + """ + Validate that a set of cart or order positions. This currently does not validate + + :param customer: Customer to validate for + :param positions: List of order or cart positions + :param event: Event this all is computed in + :param lock: Whether to place a SELECT FOR UPDATE lock on the selected memberships + :param ignored_order: An order that should be ignored for usage counting + """ + tz = event.timezone + applicable_positions = [ + p for p in positions + if p.item.require_membership or (p.variation and p.variation.require_membership) + ] + + for p in positions: + if p not in applicable_positions and p.used_membership_id: + raise ValidationError( + _('You selected a membership for the product "{product}" which does not require a membership.').format( + product=str(p.item.name) + (' – ' + str(p.variation.value) if p.variation else '') + ) + ) + + for p in applicable_positions: + if not p.used_membership_id: + raise ValidationError( + _('You selected the product "{product}" which requires an active membership to ' + 'be selected.').format( + product=str(p.item.name) + (' – ' + str(p.variation.value) if p.variation else '') + ) + ) + + base_qs = Membership.objects.with_usages(ignored_order=ignored_order) + + if lock: + base_qs = base_qs.select_for_update() + + membership_cache = base_qs\ + .select_related('membership_type')\ + .prefetch_related('orderposition_set', 'orderposition_set__order', 'orderposition_set__order__event', 'orderposition_set__subevent')\ + .in_bulk([p.used_membership_id for p in applicable_positions]) + + for m in membership_cache.values(): + qs = m.orderposition_set.filter(canceled=False).exclude(order__status=Order.STATUS_CANCELED) + if ignored_order: + qs = qs.exclude(order_id=ignored_order.pk) + m._used_at_dates = [ + (op.subevent or op.order.event).date_from + for op in qs + ] + + for p in applicable_positions: + m = membership_cache[p.used_membership_id] + if not customer or m.customer_id != customer.pk: + raise ValidationError( + _('You selected a membership that is connected to a different customer account.') + ) + + ev = p.subevent or event + + if not m.is_valid(ev): + raise ValidationError( + _('You selected a membership that is valid from {start} to {end}, but selected an event ' + 'taking place at {date}.').format( + start=date_format(m.date_start.astimezone(tz), 'SHORT_DATETIME_FORMAT'), + end=date_format(m.date_end.astimezone(tz), 'SHORT_DATETIME_FORMAT'), + date=date_format(ev.date_from.astimezone(tz), 'SHORT_DATETIME_FORMAT'), + ) + ) + + if p.variation and p.variation.require_membership: + types = p.variation.require_membership_types.all() + else: + types = p.item.require_membership_types.all() + + if not types.filter(pk=m.membership_type_id).exists(): + raise ValidationError( + _('You selected a membership of type "{type}", which is not allowed for the product "{product}".').format( + product=str(p.item.name) + (' – ' + str(p.variation.value) if p.variation else ''), + type=m.membership_type.name + ) + ) + + if m.membership_type.max_usages is not None: + if m.usages >= m.membership_type.max_usages: + raise ValidationError( + _('You are trying to use a membership of type "{type}" more than {number} times, which is the maximum amount.').format( + type=m.membership_type.name, + number=m.usages, + ) + ) + m.usages += 1 + + if not m.membership_type.allow_parallel_usage: + df = ev.date_from + if any(abs(df - d) < timedelta(minutes=1) for d in m._used_at_dates): + raise ValidationError( + _('You are trying to use a membership of type "{type}" for an event taking place at {date}, ' + 'however you already used the same membership for a different ticket at the same time.').format( + type=m.membership_type.name, + date=date_format(ev.date_from.astimezone(tz), 'SHORT_DATETIME_FORMAT'), + ) + ) + m._used_at_dates.append(ev.date_from) diff --git a/src/pretix/base/services/orders.py b/src/pretix/base/services/orders.py index ed28cbbbe..4fce257c8 100644 --- a/src/pretix/base/services/orders.py +++ b/src/pretix/base/services/orders.py @@ -43,6 +43,7 @@ from typing import List, Optional from celery.exceptions import MaxRetriesExceededError from django.conf import settings from django.core.cache import cache +from django.core.exceptions import ValidationError from django.db import transaction from django.db.models import ( Exists, F, IntegerField, Max, Min, OuterRef, Q, Sum, Value, @@ -62,8 +63,8 @@ from pretix.base.i18n import ( LazyLocaleException, get_language_without_region, language, ) from pretix.base.models import ( - CartPosition, Device, Event, GiftCard, Item, ItemVariation, Order, - OrderPayment, OrderPosition, Quota, Seat, SeatCategoryMapping, User, + CartPosition, Device, Event, GiftCard, Item, ItemVariation, Membership, + Order, OrderPayment, OrderPosition, Quota, Seat, SeatCategoryMapping, User, Voucher, ) from pretix.base.models.event import SubEvent @@ -82,6 +83,9 @@ from pretix.base.services.invoices import ( ) from pretix.base.services.locking import LockTimeoutException, NoLockManager from pretix.base.services.mail import SendMailException +from pretix.base.services.memberships import ( + create_membership, validate_memberships_in_order, +) from pretix.base.services.pricing import get_price from pretix.base.services.quotas import QuotaAvailability from pretix.base.services.tasks import ProfiledEventTask, ProfiledTask @@ -145,7 +149,8 @@ def reactivate_order(order: Order, force: bool=False, user: User=None, auth=None raise OrderError('The order was not canceled.') with order.event.lock() as now_dt: - is_available = force or order._is_still_available(now_dt, count_waitinglist=False, check_voucher_usage=True) + is_available = force or order._is_still_available(now_dt, count_waitinglist=False, check_voucher_usage=True, + check_memberships=True) if is_available is True: if order.payment_refund_sum >= order.total: order.status = Order.STATUS_PAID @@ -531,7 +536,7 @@ def _check_date(event: Event, now_dt: datetime): def _check_positions(event: Event, now_dt: datetime, positions: List[CartPosition], address: InvoiceAddress=None, - sales_channel='web'): + sales_channel='web', customer=None): err = None errargs = None _check_date(event, now_dt) @@ -553,7 +558,8 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio deleted_positions.add(cp.pk) cp.delete() - for i, cp in enumerate(sorted(positions, key=lambda s: -int(s.is_bundled))): + sorted_positions = sorted(positions, key=lambda s: -int(s.is_bundled)) + for i, cp in enumerate(sorted_positions): if cp.pk in deleted_positions: continue @@ -748,6 +754,13 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio else: # Sorry, can't let you keep that! delete(cp) + + if not err: + try: + validate_memberships_in_order(customer, [p for p in sorted_positions if p.pk not in deleted_positions], event, lock=True) + except ValidationError as e: + raise OrderError(e.message) + if err: raise OrderError(err, errargs) @@ -786,8 +799,8 @@ def _get_fees(positions: List[CartPosition], payment_provider: BasePaymentProvid def _create_order(event: Event, email: str, positions: List[CartPosition], now_dt: datetime, payment_provider: BasePaymentProvider, locale: str=None, address: InvoiceAddress=None, - meta_info: dict=None, sales_channel: str='web', gift_cards: list=None, - shown_total=None): + meta_info: dict=None, sales_channel: str='web', gift_cards: list=None, shown_total=None, + customer=None): p = None sales_channel = get_all_sales_channels()[sales_channel] @@ -822,8 +835,11 @@ def _create_order(event: Event, email: str, positions: List[CartPosition], now_d testmode=True if sales_channel.testmode_supported and event.testmode else False, meta_info=json.dumps(meta_info or {}), require_approval=any(p.item.require_approval for p in positions), - sales_channel=sales_channel.identifier + sales_channel=sales_channel.identifier, + customer=customer, ) + if customer: + order.email_known_to_work = customer.is_verified order.set_expires(now_dt, event.subevents.filter(id__in=[p.subevent_id for p in positions])) order.save() @@ -927,7 +943,7 @@ def _order_placed_email_attendee(event: Event, order: Order, position: OrderPosi def _perform_order(event: Event, payment_provider: str, position_ids: List[str], email: str, locale: str, address: int, meta_info: dict=None, sales_channel: str='web', - gift_cards: list=None, shown_total=None): + gift_cards: list=None, shown_total=None, customer=None): if payment_provider: pprov = event.get_payment_providers().get(payment_provider) if not pprov: @@ -935,6 +951,9 @@ def _perform_order(event: Event, payment_provider: str, position_ids: List[str], else: pprov = None + if customer: + customer = event.organizer.customers.get(pk=customer) + if email == settings.PRETIX_EMAIL_NONE_VALUE: email = None @@ -960,8 +979,8 @@ def _perform_order(event: Event, payment_provider: str, position_ids: List[str], id__in=position_ids, event=event ) - validate_order.send(event, payment_provider=pprov, email=email, positions=positions, - locale=locale, invoice_address=addr, meta_info=meta_info) + validate_order.send(event, payment_provider=pprov, email=email, positions=positions, locale=locale, + invoice_address=addr, meta_info=meta_info, customer=customer) lockfn = NoLockManager locked = False @@ -980,10 +999,10 @@ def _perform_order(event: Event, payment_provider: str, position_ids: List[str], raise OrderError(error_messages['empty']) if len(position_ids) != len(positions): raise OrderError(error_messages['internal']) - _check_positions(event, now_dt, positions, address=addr, sales_channel=sales_channel) + _check_positions(event, now_dt, positions, address=addr, sales_channel=sales_channel, customer=customer) order, payment = _create_order(event, email, positions, now_dt, pprov, locale=locale, address=addr, meta_info=meta_info, sales_channel=sales_channel, - gift_cards=gift_cards, shown_total=shown_total) + gift_cards=gift_cards, shown_total=shown_total, customer=customer) free_order_flow = payment and payment_provider == 'free' and order.pending_sum == Decimal('0.00') and not order.require_approval if free_order_flow: @@ -1228,8 +1247,9 @@ class OrderChangeManager: SeatOperation = namedtuple('SubeventOperation', ('position', 'seat')) PriceOperation = namedtuple('PriceOperation', ('position', 'price')) TaxRuleOperation = namedtuple('TaxRuleOperation', ('position', 'tax_rule')) + MembershipOperation = namedtuple('MembershipOperation', ('position', 'membership')) CancelOperation = namedtuple('CancelOperation', ('position',)) - AddOperation = namedtuple('AddOperation', ('item', 'variation', 'price', 'addon_to', 'subevent', 'seat')) + AddOperation = namedtuple('AddOperation', ('item', 'variation', 'price', 'addon_to', 'subevent', 'seat', 'membership')) SplitOperation = namedtuple('SplitOperation', ('position',)) FeeValueOperation = namedtuple('FeeValueOperation', ('fee', 'value')) AddFeeOperation = namedtuple('AddFeeOperation', ('fee',)) @@ -1287,6 +1307,9 @@ class OrderChangeManager: self._seatdiff.update([seat]) self._operations.append(self.SeatOperation(position, seat)) + def change_membership(self, position: OrderPosition, membership: Membership): + self._operations.append(self.MembershipOperation(position, membership)) + def change_subevent(self, position: OrderPosition, subevent: SubEvent): try: price = get_price(position.item, position.variation, voucher=position.voucher, subevent=subevent, @@ -1416,7 +1439,7 @@ class OrderChangeManager: self._invoice_dirty = True def add_position(self, item: Item, variation: ItemVariation, price: Decimal, addon_to: Order = None, - subevent: SubEvent = None, seat: Seat = None): + subevent: SubEvent = None, seat: Seat = None, membership: Membership = None): if isinstance(seat, str): if not seat: seat = None @@ -1468,7 +1491,7 @@ class OrderChangeManager: self._quotadiff.update(new_quotas) if seat: self._seatdiff.update([seat]) - self._operations.append(self.AddOperation(item, variation, price, addon_to, subevent, seat)) + self._operations.append(self.AddOperation(item, variation, price, addon_to, subevent, seat, membership)) def split(self, position: OrderPosition): if self.order.event.settings.invoice_include_free or position.price != Decimal('0.00'): @@ -1633,6 +1656,15 @@ class OrderChangeManager: event=self.event, position=op.position, force_invalidate=False, save=False ) op.position.save() + elif isinstance(op, self.MembershipOperation): + self.order.log_action('pretix.event.order.changed.membership', user=self.user, auth=self.auth, data={ + 'position': op.position.pk, + 'positionid': op.position.positionid, + 'old_membership_id': op.position.used_membership_id, + 'new_membership_id': op.membership.pk if op.membership else None, + }) + op.position.used_membership = op.membership + op.position.save() elif isinstance(op, self.SeatOperation): self.order.log_action('pretix.event.order.changed.seat', user=self.user, auth=self.auth, data={ 'position': op.position.pk, @@ -1773,7 +1805,8 @@ class OrderChangeManager: item=op.item, variation=op.variation, addon_to=op.addon_to, price=op.price.gross, order=self.order, tax_rate=op.price.rate, tax_value=op.price.tax, tax_rule=op.item.tax_rule, - positionid=nextposid, subevent=op.subevent, seat=op.seat + positionid=nextposid, subevent=op.subevent, seat=op.seat, + used_membership=op.membership, ) nextposid += 1 self.order.log_action('pretix.event.order.changed.add', user=self.user, auth=self.auth, data={ @@ -1783,6 +1816,7 @@ class OrderChangeManager: 'addon_to': op.addon_to.pk if op.addon_to else None, 'price': op.price.gross, 'positionid': pos.positionid, + 'membership': pos.used_membership_id, 'subevent': op.subevent.pk if op.subevent else None, 'seat': op.seat.pk if op.seat else None, }) @@ -1990,6 +2024,50 @@ class OrderChangeManager: except InvoiceAddress.DoesNotExist: return None + def _check_and_lock_memberships(self): + # To avoid duplicating all the logic around memberships, we simulate an application of all relevant + # operations in a non-existing cart and then pass that to our cart checker. + fake_cart = [] + positions_to_fake_cart = {} + + for p in self.order.positions.all(): + cp = CartPosition( + item=p.item, + variation=p.variation, + attendee_name_parts=p.attendee_name_parts, + used_membership=p.used_membership, + subevent=p.subevent, + seat=p.seat, + ) + fake_cart.append(cp) + positions_to_fake_cart[p] = cp + + for op in self._operations: + if isinstance(op, self.ItemOperation): + positions_to_fake_cart[op.position].item = op.item + positions_to_fake_cart[op.position].variation = op.variation + elif isinstance(op, self.SubeventOperation): + positions_to_fake_cart[op.position].subevent = op.subevent + elif isinstance(op, self.SeatOperation): + positions_to_fake_cart[op.position].seat = op.seat + elif isinstance(op, self.MembershipOperation): + positions_to_fake_cart[op.position].used_membership = op.membership + elif isinstance(op, self.CancelOperation): + fake_cart.remove(positions_to_fake_cart[op.position]) + elif isinstance(op, self.AddOperation): + cp = CartPosition( + item=op.item, + variation=op.variation, + used_membership=op.membership, + subevent=op.subevent, + seat=op.seat, + ) + fake_cart.append(cp) + try: + validate_memberships_in_order(self.order.customer, fake_cart, self.event, lock=True, ignored_order=self.order) + except ValidationError as e: + raise OrderError(e.message) + def commit(self, check_quotas=True): if self._committed: # an order change can only be committed once @@ -2011,6 +2089,7 @@ class OrderChangeManager: self._check_quotas() self._check_seats() self._check_complete_cancel() + self._check_and_lock_memberships() try: self._perform_operations() except TaxRule.SaleNotAllowed: @@ -2055,12 +2134,12 @@ class OrderChangeManager: @app.task(base=ProfiledEventTask, bind=True, max_retries=5, default_retry_delay=1, throws=(OrderError,)) def perform_order(self, event: Event, payment_provider: str, positions: List[str], email: str=None, locale: str=None, address: int=None, meta_info: dict=None, - sales_channel: str='web', gift_cards: list=None, shown_total=None): + sales_channel: str='web', gift_cards: list=None, shown_total=None, customer=None): with language(locale): try: try: return _perform_order(event, payment_provider, positions, email, locale, address, meta_info, - sales_channel, gift_cards, shown_total) + sales_channel, gift_cards, shown_total, customer) except LockTimeoutException: self.retry() except (MaxRetriesExceededError, LockTimeoutException): @@ -2321,3 +2400,14 @@ def signal_listener_issue_giftcards(sender: Event, order: Order, **kwargs): if any_giftcards: tickets.invalidate_cache.apply_async(kwargs={'event': sender.pk, 'order': order.pk}) + + +@receiver(order_paid, dispatch_uid="pretixbase_order_paid_memberships") +@receiver(order_changed, dispatch_uid="pretixbase_order_changed_memberships") +@transaction.atomic() +def signal_listener_issue_memberships(sender: Event, order: Order, **kwargs): + if order.status != Order.STATUS_PAID or not order.customer: + return + for p in order.positions.all(): + if p.item.grant_membership_type_id: + create_membership(order.customer, p) diff --git a/src/pretix/base/settings.py b/src/pretix/base/settings.py index d6856a671..c8dfb26ab 100644 --- a/src/pretix/base/settings.py +++ b/src/pretix/base/settings.py @@ -107,6 +107,17 @@ class LazyI18nStringList(UserList): DEFAULTS = { + 'customer_accounts': { + 'default': 'False', + 'type': bool, + 'form_class': forms.BooleanField, + 'serializer_class': serializers.BooleanField, + 'form_kwargs': dict( + label=_("Allow customers to create accounts"), + help_text=_("This will allow customers to sign up for an account on your ticket shop. This is a prerequesite for some " + "advanced features like memberships.") + ) + }, 'max_items_per_order': { 'default': '10', 'type': int, @@ -1753,13 +1764,13 @@ Your {event} team""")) 'type': LazyI18nString, 'default': LazyI18nString.from_gettext(gettext_noop("""Hello {attendee_name}, -you are registered for {event}. + you are registered for {event}. -If you did not do so already, you can download your ticket here: -{url} + If you did not do so already, you can download your ticket here: + {url} -Best regards, -Your {event} team""")) + Best regards, + Your {event} team""")) }, 'mail_text_download_reminder': { 'type': LazyI18nString, @@ -1772,6 +1783,60 @@ If you did not do so already, you can download your ticket here: Best regards, Your {event} team""")) + }, + 'mail_text_customer_registration': { + 'type': LazyI18nString, + 'default': LazyI18nString.from_gettext(gettext_noop("""Hello {name}, + +thank you for signing up for an account at {organizer}! + +To activate your account and set a password, please click here: + +{url} + +This link is valid for one day. + +If you did not sign up yourself, please ignore this email. + +Best regards, + +Your {organizer} team""")) + }, + 'mail_text_customer_email_change': { + 'type': LazyI18nString, + 'default': LazyI18nString.from_gettext(gettext_noop("""Hello {name}, + +you requested to change the email address of your account at {organizer}! + +To confirm the change, please click here: + +{url} + +This link is valid for one day. + +If you did not request this, please ignore this email. + +Best regards, + +Your {organizer} team""")) + }, + 'mail_text_customer_reset': { + 'type': LazyI18nString, + 'default': LazyI18nString.from_gettext(gettext_noop("""Hello {name}, + +you requested a new password for your account at {organizer}! + +To set a new password, please click here: + +{url} + +This link is valid for one day. + +If you did not request a new password, please ignore this email. + +Best regards, + +Your {organizer} team""")) }, 'smtp_use_custom': { 'default': 'False', diff --git a/src/pretix/base/shredder.py b/src/pretix/base/shredder.py index dcc424046..819d93c2f 100644 --- a/src/pretix/base/shredder.py +++ b/src/pretix/base/shredder.py @@ -194,7 +194,7 @@ class EmailAddressShredder(BaseDataShredder): verbose_name = _('E-mails') identifier = 'order_emails' description = _('This will remove all e-mail addresses from orders and attendees, as well as logged email ' - 'contents.') + 'contents. This will also remove the association to customer accounts.') def generate_files(self) -> List[Tuple[str, str, str]]: yield 'emails-by-order.json', 'application/json', json.dumps({ @@ -211,12 +211,13 @@ class EmailAddressShredder(BaseDataShredder): for o in self.event.orders.all(): o.email = None + o.customer = None d = o.meta_info_data if d: if 'contact_form_data' in d and 'email' in d['contact_form_data']: del d['contact_form_data']['email'] o.meta_info = json.dumps(d) - o.save(update_fields=['meta_info', 'email']) + o.save(update_fields=['meta_info', 'email', 'customer']) for le in self.event.logentry_set.filter(action_type__contains="order.email"): shred_log_fields(le, banlist=['recipient', 'message', 'subject']) diff --git a/src/pretix/base/signals.py b/src/pretix/base/signals.py index acb59e21f..4c0de5616 100644 --- a/src/pretix/base/signals.py +++ b/src/pretix/base/signals.py @@ -324,7 +324,7 @@ The ``sender`` keyword argument will contain an organizer. validate_order = EventPluginSignal( providing_args=["payment_provider", "positions", "email", "locale", "invoice_address", - "meta_info"] + "meta_info", "customer"] ) """ This signal is sent out when the user tries to confirm the order, before we actually create @@ -633,7 +633,7 @@ well, otherwise it will be ``None``. """ global_email_filter = GlobalSignal( - providing_args=['message', 'order', 'user'] + providing_args=['message', 'order', 'user', 'customer', 'organizer'] ) """ This signal allows you to implement a middleware-style filter on all outgoing emails. You are expected to diff --git a/src/pretix/base/templates/pretixbase/email/base.html b/src/pretix/base/templates/pretixbase/email/base.html index 4f622691c..b1b337a47 100644 --- a/src/pretix/base/templates/pretixbase/email/base.html +++ b/src/pretix/base/templates/pretixbase/email/base.html @@ -202,6 +202,9 @@ {% if event %}

{{ event.name }}

+ {% elif organizer %} +

{{ organizer.name }} +

{% else %}

{{ site }}

{% endif %} diff --git a/src/pretix/base/templates/pretixbase/email/simple_logo.html b/src/pretix/base/templates/pretixbase/email/simple_logo.html index 2bebf180b..c907c1ba5 100644 --- a/src/pretix/base/templates/pretixbase/email/simple_logo.html +++ b/src/pretix/base/templates/pretixbase/email/simple_logo.html @@ -220,6 +220,9 @@ {% if event %}

{{ event.name }}

+ {% elif organizer %} +

{{ organizer.name }} +

{% else %}

{{ site }}

{% endif %} diff --git a/src/pretix/base/validators.py b/src/pretix/base/validators.py index 95540c1da..1ccd6078a 100644 --- a/src/pretix/base/validators.py +++ b/src/pretix/base/validators.py @@ -69,6 +69,8 @@ class EventSlugBanlistValidator(BanlistValidator): 'events', 'csp_report', 'widget', + 'customer', + 'account', ] diff --git a/src/pretix/control/forms/__init__.py b/src/pretix/control/forms/__init__.py index 3786b3532..8793da50a 100644 --- a/src/pretix/control/forms/__init__.py +++ b/src/pretix/control/forms/__init__.py @@ -45,7 +45,7 @@ from django.urls import reverse from django.utils.timezone import now from django.utils.translation import gettext_lazy as _ -from ...base.forms import I18nModelForm +from ...base.forms import I18nModelForm, SecretKeySettingsField # Import for backwards compatibility with okd import paths from ...base.forms.widgets import ( # noqa @@ -368,3 +368,46 @@ class SplitDateTimeField(forms.SplitDateTimeField): class FontSelect(forms.RadioSelect): option_template_name = 'pretixcontrol/font_option.html' + + +class SMTPSettingsMixin(forms.Form): + smtp_use_custom = forms.BooleanField( + label=_("Use custom SMTP server"), + help_text=_("All mail related to your event will be sent over the smtp server specified by you."), + required=False + ) + smtp_host = forms.CharField( + label=_("Hostname"), + required=False, + widget=forms.TextInput(attrs={'placeholder': 'mail.example.org'}) + ) + smtp_port = forms.IntegerField( + label=_("Port"), + required=False, + widget=forms.TextInput(attrs={'placeholder': 'e.g. 587, 465, 25, ...'}) + ) + smtp_username = forms.CharField( + label=_("Username"), + widget=forms.TextInput(attrs={'placeholder': 'myuser@example.org'}), + required=False + ) + smtp_password = SecretKeySettingsField( + label=_("Password"), + required=False, + ) + smtp_use_tls = forms.BooleanField( + label=_("Use STARTTLS"), + help_text=_("Commonly enabled on port 587."), + required=False + ) + smtp_use_ssl = forms.BooleanField( + label=_("Use SSL"), + help_text=_("Commonly enabled on port 465."), + required=False + ) + + def clean(self): + data = super().clean() + if data.get('smtp_use_tls') and data.get('smtp_use_ssl'): + raise ValidationError(_('You can activate either SSL or STARTTLS security, but not both at the same time.')) + return data diff --git a/src/pretix/control/forms/event.py b/src/pretix/control/forms/event.py index c4ca399f9..c3ac6ba17 100644 --- a/src/pretix/control/forms/event.py +++ b/src/pretix/control/forms/event.py @@ -63,7 +63,7 @@ from pretix.base.settings import ( PERSON_NAME_SCHEMES, PERSON_NAME_TITLE_GROUPS, validate_event_settings, ) from pretix.control.forms import ( - MultipleLanguagesWidget, SlugWidget, SplitDateTimeField, + MultipleLanguagesWidget, SlugWidget, SMTPSettingsMixin, SplitDateTimeField, SplitDateTimePickerWidget, ) from pretix.control.forms.widgets import Select2 @@ -825,7 +825,7 @@ def contains_web_channel_validate(val): raise ValidationError(_("The online shop must be selected to receive these emails.")) -class MailSettingsForm(SettingsForm): +class MailSettingsForm(SMTPSettingsMixin, SettingsForm): auto_fields = [ 'mail_prefix', 'mail_from', @@ -1020,43 +1020,6 @@ class MailSettingsForm(SettingsForm): required=False, widget=I18nTextarea, ) - smtp_use_custom = forms.BooleanField( - label=_("Use custom SMTP server"), - help_text=_("All mail related to your event will be sent over the smtp server specified by you."), - required=False - ) - smtp_host = forms.CharField( - label=_("Hostname"), - required=False, - widget=forms.TextInput(attrs={'placeholder': 'mail.example.org'}) - ) - smtp_port = forms.IntegerField( - label=_("Port"), - required=False, - widget=forms.TextInput(attrs={'placeholder': 'e.g. 587, 465, 25, ...'}) - ) - smtp_username = forms.CharField( - label=_("Username"), - widget=forms.TextInput(attrs={'placeholder': 'myuser@example.org'}), - required=False - ) - smtp_password = forms.CharField( - label=_("Password"), - required=False, - widget=forms.PasswordInput(attrs={ - 'autocomplete': 'new-password' # see https://bugs.chromium.org/p/chromium/issues/detail?id=370363#c7 - }), - ) - smtp_use_tls = forms.BooleanField( - label=_("Use STARTTLS"), - help_text=_("Commonly enabled on port 587."), - required=False - ) - smtp_use_ssl = forms.BooleanField( - label=_("Use SSL"), - help_text=_("Commonly enabled on port 465."), - required=False - ) base_context = { 'mail_text_order_placed': ['event', 'order', 'payment'], 'mail_text_order_placed_attendee': ['event', 'order', 'position'], @@ -1110,17 +1073,6 @@ class MailSettingsForm(SettingsForm): # the user interface with it del self.fields[k] - def clean(self): - data = self.cleaned_data - if not data.get('smtp_password') and data.get('smtp_username'): - # Leave password unchanged if the username is set and the password field is empty. - # This makes it impossible to set an empty password as long as a username is set, but - # Python's smtplib does not support password-less schemes anyway. - data['smtp_password'] = self.initial.get('smtp_password') - - if data.get('smtp_use_tls') and data.get('smtp_use_ssl'): - raise ValidationError(_('You can activate either SSL or STARTTLS security, but not both at the same time.')) - class TicketSettingsForm(SettingsForm): auto_fields = [ diff --git a/src/pretix/control/forms/filter.py b/src/pretix/control/forms/filter.py index 652a33d26..111f91848 100644 --- a/src/pretix/control/forms/filter.py +++ b/src/pretix/control/forms/filter.py @@ -1022,6 +1022,44 @@ class GiftCardFilterForm(FilterForm): return qs.distinct() +class CustomerFilterForm(FilterForm): + orders = { + 'email': 'email', + 'identifier': 'identifier', + 'name_cached': 'name_cached', + } + query = forms.CharField( + label=_('Search query'), + widget=forms.TextInput(attrs={ + 'placeholder': _('Search query'), + 'autofocus': 'autofocus' + }), + required=False + ) + + def __init__(self, *args, **kwargs): + kwargs.pop('request') + super().__init__(*args, **kwargs) + + def filter_qs(self, qs): + fdata = self.cleaned_data + + if fdata.get('query'): + query = fdata.get('query') + qs = qs.filter( + Q(email__icontains=query) + | Q(name_cached__icontains=query) + | Q(identifier__istartswith=query) + ) + + if fdata.get('ordering'): + qs = qs.order_by(self.get_order_by()) + else: + qs = qs.order_by('-email') + + return qs + + class TeamFilterForm(FilterForm): orders = { 'name': 'name', diff --git a/src/pretix/control/forms/item.py b/src/pretix/control/forms/item.py index 4a328600c..7ab4e7a5f 100644 --- a/src/pretix/control/forms/item.py +++ b/src/pretix/control/forms/item.py @@ -368,6 +368,11 @@ class ItemCreateForm(I18nModelForm): 'hidden_if_available', 'require_bundling', 'checkin_attention', + 'require_membership', + 'grant_membership_type', + 'grant_membership_duration_like_event', + 'grant_membership_duration_days', + 'grant_membership_duration_months', ) for f in fields: setattr(self.instance, f, getattr(self.cleaned_data['copy_from'], f)) @@ -399,6 +404,10 @@ class ItemCreateForm(I18nModelForm): 'items': [self.instance.pk] }) + if self.cleaned_data.get('copy_from'): + self.instance.require_membership_types.set( + self.cleaned_data['copy_from'].require_membership_types.all() + ) if self.cleaned_data.get('has_variations'): if self.cleaned_data.get('copy_from') and self.cleaned_data.get('copy_from').has_variations: for variation in self.cleaned_data['copy_from'].variations.all(): @@ -523,6 +532,19 @@ class ItemUpdateForm(I18nModelForm): ) self.fields['category'].widget.choices = self.fields['category'].choices + qs = self.event.organizer.membership_types.all() + if qs: + self.fields['require_membership_types'].queryset = qs + self.fields['grant_membership_type'].queryset = qs + self.fields['grant_membership_type'].empty_label = _('No membership granted') + else: + del self.fields['require_membership'] + del self.fields['require_membership_types'] + del self.fields['grant_membership_type'] + del self.fields['grant_membership_duration_like_event'] + del self.fields['grant_membership_duration_days'] + del self.fields['grant_membership_duration_months'] + def clean(self): d = super().clean() if d['issue_giftcard']: @@ -571,15 +593,26 @@ class ItemUpdateForm(I18nModelForm): 'show_quota_left', 'hidden_if_available', 'issue_giftcard', + 'require_membership', + 'require_membership_types', + 'grant_membership_type', + 'grant_membership_duration_like_event', + 'grant_membership_duration_days', + 'grant_membership_duration_months', ] field_classes = { 'available_from': SplitDateTimeField, 'available_until': SplitDateTimeField, 'hidden_if_available': SafeModelChoiceField, + 'grant_membership_type': SafeModelChoiceField, + 'require_membership_types': SafeModelMultipleChoiceField, } widgets = { 'available_from': SplitDateTimePickerWidget(), 'available_until': SplitDateTimePickerWidget(attrs={'data-date-after': '#id_available_from_0'}), + 'require_membership_types': forms.CheckboxSelectMultiple(attrs={ + 'class': 'scrolling-multiple-choice' + }), 'generate_tickets': TicketNullBooleanSelect(), 'show_quota_left': ShowQuotaNullBooleanSelect() } @@ -632,6 +665,13 @@ class ItemVariationForm(I18nModelForm): super().__init__(*args, **kwargs) change_decimal_field(self.fields['default_price'], self.event.currency) + qs = self.event.organizer.membership_types.all() + if qs: + self.fields['require_membership_types'].queryset = qs + else: + del self.fields['require_membership'] + del self.fields['require_membership_types'] + class Meta: model = ItemVariation localized_fields = '__all__' @@ -641,7 +681,14 @@ class ItemVariationForm(I18nModelForm): 'default_price', 'original_price', 'description', + 'require_membership', + 'require_membership_types' ] + widgets = { + 'require_membership_types': forms.CheckboxSelectMultiple(attrs={ + 'class': 'scrolling-multiple-choice' + }), + } class ItemAddOnsFormSet(I18nFormSet): diff --git a/src/pretix/control/forms/orders.py b/src/pretix/control/forms/orders.py index db3ec2b1b..9d07dc070 100644 --- a/src/pretix/control/forms/orders.py +++ b/src/pretix/control/forms/orders.py @@ -46,6 +46,7 @@ from django.utils.timezone import make_aware, now from django.utils.translation import ( gettext_lazy as _, gettext_noop, pgettext_lazy, ) +from django_scopes.forms import SafeModelChoiceField from i18nfield.forms import I18nFormField, I18nTextarea, I18nTextInput from i18nfield.strings import LazyI18nString @@ -290,6 +291,10 @@ class OrderPositionAddForm(forms.Form): widget=forms.TextInput(attrs={'placeholder': _('General admission'), 'data-seat-guid-field': 'true'}), label=_('Seat') ) + used_membership = forms.ChoiceField( + label=_('Membership'), + required=False, + ) price = forms.DecimalField( required=False, max_digits=10, decimal_places=2, @@ -360,6 +365,23 @@ class OrderPositionAddForm(forms.Form): del self.fields['subevent'] change_decimal_field(self.fields['price'], order.event.currency) + choices = [ + ('', ''), + ] + if order.customer: + self.memberships = list(order.customer.memberships.all()) + for m in self.memberships: + choices.append((str(m.pk), str(m))) + self.fields['used_membership'].choices = choices + + def clean(self): + d = super().clean() + if d['used_membership']: + d['used_membership'] = [m for m in self.memberships if str(m.pk) == d['used_membership']][0] + else: + d['used_membership'] = None + return d + class OrderPositionAddFormset(forms.BaseFormSet): def __init__(self, *args, **kwargs): @@ -405,6 +427,9 @@ class OrderPositionChangeForm(forms.Form): localize=True, label=_('New price (gross)') ) + used_membership = forms.ChoiceField( + required=False, + ) tax_rule = forms.ModelChoiceField( TaxRule.objects.none(), required=False, @@ -478,6 +503,24 @@ class OrderPositionChangeForm(forms.Form): self.fields['itemvar'].choices = choices change_decimal_field(self.fields['price'], instance.order.event.currency) + choices = [ + ('', _('(Unchanged)')), + ('CLEAR', _('(No membership)')), + ] + if instance.order.customer: + self.memberships = list(instance.order.customer.memberships.all()) + for m in self.memberships: + choices.append((str(m.pk), str(m))) + self.fields['used_membership'].choices = choices + + def clean(self): + d = super().clean() + if d['used_membership'] and d['used_membership'] != 'CLEAR': + d['used_membership'] = [m for m in self.memberships if str(m.pk) == d['used_membership']][0] + elif not d['used_membership']: + d['used_membership'] = None + return d + class OrderFeeChangeForm(forms.Form): value = forms.DecimalField( @@ -516,16 +559,36 @@ class OrderContactForm(forms.ModelForm): class Meta: model = Order - fields = ['email', 'email_known_to_work', 'phone'] + fields = ['customer', 'email', 'email_known_to_work', 'phone'] widgets = { - 'phone': WrappedPhoneNumberPrefixWidget() + 'phone': WrappedPhoneNumberPrefixWidget(), + } + field_classes = { + 'customer': SafeModelChoiceField, } def __init__(self, *args, **kwargs): + customers = kwargs.pop('customers') super().__init__(*args, **kwargs) if not self.instance.event.settings.order_phone_asked and not self.instance.phone: del self.fields['phone'] + if customers: + self.fields['customer'].queryset = self.instance.event.organizer.customers.all() + self.fields['customer'].widget = Select2( + attrs={ + 'data-model-select2': 'generic', + 'data-select2-url': reverse('control:organizer.customers.select2', kwargs={ + 'organizer': self.instance.event.organizer.slug, + }), + 'data-placeholder': _('Customer') + } + ) + self.fields['customer'].widget.choices = self.fields['customer'].choices + self.fields['customer'].required = False + else: + del self.fields['customer'] + class OrderLocaleForm(forms.ModelForm): locale = forms.ChoiceField() diff --git a/src/pretix/control/forms/organizer.py b/src/pretix/control/forms/organizer.py index f1a7388e8..0f3191177 100644 --- a/src/pretix/control/forms/organizer.py +++ b/src/pretix/control/forms/organizer.py @@ -39,20 +39,31 @@ from django import forms from django.conf import settings from django.core.exceptions import ValidationError from django.db.models import Q +from django.utils.crypto import get_random_string from django.utils.safestring import mark_safe from django.utils.translation import gettext_lazy as _, pgettext_lazy from django_scopes.forms import SafeModelMultipleChoiceField +from i18nfield.forms import I18nFormField, I18nTextarea +from pytz import common_timezones from pretix.api.models import WebHook from pretix.api.webhooks import get_all_webhook_events -from pretix.base.forms import I18nModelForm, SettingsForm +from pretix.base.forms import I18nModelForm, PlaceholderValidator, SettingsForm +from pretix.base.forms.questions import NamePartsFormField from pretix.base.forms.widgets import SplitDateTimePickerWidget from pretix.base.models import ( - Device, EventMetaProperty, Gate, GiftCard, Organizer, Team, + Customer, Device, EventMetaProperty, Gate, GiftCard, Membership, + MembershipType, Organizer, Team, +) +from pretix.base.settings import PERSON_NAME_SCHEMES, PERSON_NAME_TITLE_GROUPS +from pretix.control.forms import ( + ExtFileField, SMTPSettingsMixin, SplitDateTimeField, +) +from pretix.control.forms.event import ( + SafeEventMultipleChoiceField, multimail_validate, ) -from pretix.control.forms import ExtFileField, SplitDateTimeField -from pretix.control.forms.event import SafeEventMultipleChoiceField from pretix.multidomain.models import KnownDomain +from pretix.multidomain.urlreverse import build_absolute_uri class OrganizerForm(I18nModelForm): @@ -168,6 +179,12 @@ class EventMetaPropertyForm(forms.ModelForm): } +class MembershipTypeForm(I18nModelForm): + class Meta: + model = MembershipType + fields = ['name', 'transferable', 'allow_parallel_usage', 'max_usages'] + + class TeamForm(forms.ModelForm): def __init__(self, *args, **kwargs): @@ -181,7 +198,7 @@ class TeamForm(forms.ModelForm): model = Team fields = ['name', 'all_events', 'limit_events', 'can_create_events', 'can_change_teams', 'can_change_organizer_settings', - 'can_manage_gift_cards', + 'can_manage_gift_cards', 'can_manage_customers', 'can_change_event_settings', 'can_change_items', 'can_view_orders', 'can_change_orders', 'can_checkin_orders', 'can_view_vouchers', 'can_change_vouchers'] @@ -250,7 +267,24 @@ class DeviceForm(forms.ModelForm): class OrganizerSettingsForm(SettingsForm): + timezone = forms.ChoiceField( + choices=((a, a) for a in common_timezones), + label=_("Default timezone"), + ) + name_scheme = forms.ChoiceField( + label=_("Name format"), + help_text=_("This defines how pretix will ask for human names. Changing this after you already received " + "orders might lead to unexpected behavior when sorting or changing names."), + required=True, + ) + name_scheme_titles = forms.ChoiceField( + label=_("Allowed titles"), + help_text=_("If the naming scheme you defined above allows users to input a title, you can use this to " + "restrict the set of selectable titles."), + required=False, + ) auto_fields = [ + 'customer_accounts', 'contact_mail', 'imprint_url', 'organizer_info_text', @@ -292,6 +326,115 @@ class OrganizerSettingsForm(SettingsForm): 'We recommend a size of at least 200x200px to accommodate most devices.') ) + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields['name_scheme'].choices = ( + (k, _('Ask for {fields}, display like {example}').format( + fields=' + '.join(str(vv[1]) for vv in v['fields']), + example=v['concatenation'](v['sample']) + )) + for k, v in PERSON_NAME_SCHEMES.items() + ) + self.fields['name_scheme_titles'].choices = [('', _('Free text input'))] + [ + (k, '{scheme}: {samples}'.format( + scheme=v[0], + samples=', '.join(v[1]) + )) + for k, v in PERSON_NAME_TITLE_GROUPS.items() + ] + + +class MailSettingsForm(SMTPSettingsMixin, SettingsForm): + auto_fields = [ + 'mail_from', + 'mail_from_name', + ] + + mail_bcc = forms.CharField( + label=_("Bcc address"), + help_text=_("All emails will be sent to this address as a Bcc copy"), + validators=[multimail_validate], + required=False, + max_length=255 + ) + mail_text_signature = I18nFormField( + label=_("Signature"), + required=False, + widget=I18nTextarea, + help_text=_("This will be attached to every email."), + validators=[PlaceholderValidator([])], + widget_kwargs={'attrs': { + 'rows': '4', + 'placeholder': _( + 'e.g. your contact details' + ) + }} + ) + + mail_text_customer_registration = I18nFormField( + label=_("Text"), + required=False, + widget=I18nTextarea, + ) + mail_text_customer_email_change = I18nFormField( + label=_("Text"), + required=False, + widget=I18nTextarea, + ) + mail_text_customer_reset = I18nFormField( + label=_("Text"), + required=False, + widget=I18nTextarea, + ) + + base_context = { + 'mail_text_customer_registration': ['customer', 'url'], + 'mail_text_customer_email_change': ['customer', 'url'], + 'mail_text_customer_reset': ['customer', 'url'], + } + + def _get_sample_context(self, base_parameters): + placeholders = { + 'organizer': self.organizer.name + } + + if 'url' in base_parameters: + placeholders['url'] = build_absolute_uri( + self.organizer, + 'presale:organizer.customer.activate' + ) + '?token=' + get_random_string(30) + + if 'customer' in base_parameters: + placeholders['name'] = pgettext_lazy('person_name_sample', 'John Doe') + name_scheme = PERSON_NAME_SCHEMES[self.organizer.settings.name_scheme] + for f, l, w in name_scheme['fields']: + if f == 'full_name': + continue + placeholders['name_%s' % f] = name_scheme['sample'][f] + return placeholders + + def _set_field_placeholders(self, fn, base_parameters): + phs = [ + '{%s}' % p + for p in sorted(self._get_sample_context(base_parameters).keys()) + ] + ht = _('Available placeholders: {list}').format( + list=', '.join(phs) + ) + if self.fields[fn].help_text: + self.fields[fn].help_text += ' ' + str(ht) + else: + self.fields[fn].help_text = ht + self.fields[fn].validators.append( + PlaceholderValidator(phs) + ) + + def __init__(self, *args, **kwargs): + self.organizer = kwargs.get('obj') + super().__init__(*args, **kwargs) + for k, v in self.base_context.items(): + self._set_field_placeholders(k, v) + class WebHookForm(forms.ModelForm): events = forms.MultipleChoiceField( @@ -373,3 +516,67 @@ class GiftCardUpdateForm(forms.ModelForm): 'expires': SplitDateTimePickerWidget, 'conditions': forms.Textarea(attrs={"rows": 2}) } + + +class CustomerUpdateForm(forms.ModelForm): + error_messages = { + 'duplicate': _("An account with this email address is already registered."), + } + + class Meta: + model = Customer + fields = ['is_active', 'name_parts', 'email', 'is_verified', 'locale'] + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.fields['name_parts'] = NamePartsFormField( + max_length=255, + required=False, + scheme=self.instance.organizer.settings.name_scheme, + titles=self.instance.organizer.settings.name_scheme_titles, + label=_('Name'), + ) + + def clean(self): + email = self.cleaned_data.get('email') + + if email is not None: + try: + self.instance.organizer.customers.exclude(pk=self.instance.pk).get(email=email) + except Customer.DoesNotExist: + pass + else: + raise forms.ValidationError( + self.error_messages['duplicate'], + code='duplicate', + ) + + return self.cleaned_data + + +class MembershipUpdateForm(forms.ModelForm): + + class Meta: + model = Membership + fields = ['membership_type', 'date_start', 'date_end', 'attendee_name_parts'] + field_classes = { + 'date_start': SplitDateTimeField, + 'date_end': SplitDateTimeField, + } + widgets = { + 'date_start': SplitDateTimePickerWidget(), + 'date_end': SplitDateTimePickerWidget(attrs={'data-date-after': '#id_date_Start'}), + } + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.fields['membership_type'].queryset = self.instance.customer.organizer.membership_types.all() + self.fields['attendee_name_parts'] = NamePartsFormField( + max_length=255, + required=False, + scheme=self.instance.customer.organizer.settings.name_scheme, + titles=self.instance.customer.organizer.settings.name_scheme_titles, + label=_('Attendee name'), + ) diff --git a/src/pretix/control/logdisplay.py b/src/pretix/control/logdisplay.py index b67c650a6..96886de2b 100644 --- a/src/pretix/control/logdisplay.py +++ b/src/pretix/control/logdisplay.py @@ -78,6 +78,10 @@ def _display_order_changed(event: Event, logentry: LogEntry): old_price=money_filter(Decimal(data['old_price']), event.currency), new_price=money_filter(Decimal(data['new_price']), event.currency), ) + elif logentry.action_type == 'pretix.event.order.changed.membership': + return text + ' ' + _('Position #{posid}: Used membership changed.').format( + posid=data.get('positionid', '?'), + ) elif logentry.action_type == 'pretix.event.order.changed.seat': return text + ' ' + _('Position #{posid}: Seat "{old_seat}" changed ' 'to "{new_seat}".').format( @@ -314,6 +318,17 @@ def pretixcontrol_logentry_display(sender: Event, logentry: LogEntry, **kwargs): 'pretix.giftcards.acceptance.removed': _('Gift card acceptance for another organizer has been removed.'), 'pretix.webhook.created': _('The webhook has been created.'), 'pretix.webhook.changed': _('The webhook has been changed.'), + 'pretix.membershiptype.created': _('The membership type has been created.'), + 'pretix.membershiptype.changed': _('The membership type has been changed.'), + 'pretix.membershiptype.deleted': _('The membership type has been deleted.'), + 'pretix.customer.created': _('The account has been created.'), + 'pretix.customer.changed': _('The account has been changed.'), + 'pretix.customer.membership.created': _('A membership for this account has been added.'), + 'pretix.customer.membership.changed': _('A membership of this account has been changed.'), + 'pretix.customer.anonymized': _('The account has been disabled and anonymized.'), + 'pretix.customer.password.resetrequested': _('A new password has been requested.'), + 'pretix.customer.password.set': _('A new password has been set.'), + 'pretix.email.error': _('Sending of an email has failed.'), 'pretix.event.comment': _('The event\'s internal comment has been updated.'), 'pretix.event.canceled': _('The event has been canceled.'), 'pretix.event.deleted': _('An event has been deleted.'), @@ -338,6 +353,7 @@ def pretixcontrol_logentry_display(sender: Event, logentry: LogEntry, **kwargs): 'in the email for the first time).'), 'pretix.event.order.phone.changed': _('The phone number has been changed from "{old_phone}" ' 'to "{new_phone}".'), + 'pretix.event.order.customer.changed': _('The customer account has been changed.'), 'pretix.event.order.locale.changed': _('The order locale has been changed.'), 'pretix.event.order.invoice.generated': _('The invoice has been generated.'), 'pretix.event.order.invoice.regenerated': _('The invoice has been regenerated.'), diff --git a/src/pretix/control/navigation.py b/src/pretix/control/navigation.py index cc7a0fdae..2137324de 100644 --- a/src/pretix/control/navigation.py +++ b/src/pretix/control/navigation.py @@ -460,6 +460,13 @@ def get_organizer_navigation(request): }), 'active': url.url_name.startswith('organizer.propert'), }, + { + 'label': _('E-mail'), + 'url': reverse('control:organizer.settings.mail', kwargs={ + 'organizer': request.organizer.slug, + }), + 'active': url.url_name == 'organizer.settings.mail', + }, { 'label': _('Webhooks'), 'url': reverse('control:organizer.webhooks', kwargs={ @@ -467,9 +474,10 @@ def get_organizer_navigation(request): }), 'active': 'organizer.webhook' in url.url_name, 'icon': 'bolt', - } + }, ] }) + if 'can_change_teams' in request.orgapermset: nav.append({ 'label': _('Teams'), @@ -490,6 +498,38 @@ def get_organizer_navigation(request): 'icon': 'credit-card', }) + if request.organizer.settings.customer_accounts: + children = [] + if 'can_manage_customers' in request.orgapermset: + children.append( + { + 'label': _('Customers'), + 'url': reverse('control:organizer.customers', kwargs={ + 'organizer': request.organizer.slug + }), + 'active': 'organizer.customer' in url.url_name, + } + ) + if 'can_change_organizer_settings' in request.orgapermset: + children.append( + { + 'label': _('Membership types'), + 'url': reverse('control:organizer.membershiptypes', kwargs={ + 'organizer': request.organizer.slug + }), + 'active': 'organizer.membershiptype' in url.url_name, + } + ) + if children: + nav.append({ + 'label': _('Customer accounts'), + 'url': reverse('control:organizer.customers', kwargs={ + 'organizer': request.organizer.slug + }), + 'icon': 'user', + 'children': children, + }) + if 'can_change_organizer_settings' in request.orgapermset: nav.append({ 'label': _('Devices'), diff --git a/src/pretix/control/templates/pretixcontrol/event/mail.html b/src/pretix/control/templates/pretixcontrol/event/mail.html index 22caefe91..008656f57 100644 --- a/src/pretix/control/templates/pretixcontrol/event/mail.html +++ b/src/pretix/control/templates/pretixcontrol/event/mail.html @@ -1,6 +1,7 @@ {% extends "pretixcontrol/event/settings_base.html" %} {% load i18n %} {% load bootstrap3 %} +{% load hierarkey_form %} {% load static %} {% block inside %}

{% trans "E-mail settings" %}

@@ -11,11 +12,14 @@
{% trans "General" %} + {% url "control:organizer.settings.mail" organizer=request.organizer.slug as org_url %} + {% propagated request.event org_url "mail_from" "mail_from_name" "mail_text_signature" "mail_bcc" %} + {% bootstrap_field form.mail_from layout="control" %} + {% bootstrap_field form.mail_from_name layout="control" %} + {% bootstrap_field form.mail_text_signature layout="control" %} + {% bootstrap_field form.mail_bcc layout="control" %} + {% endpropagated %} {% bootstrap_field form.mail_prefix layout="control" %} - {% bootstrap_field form.mail_from layout="control" %} - {% bootstrap_field form.mail_from_name layout="control" %} - {% bootstrap_field form.mail_text_signature layout="control" %} - {% bootstrap_field form.mail_bcc layout="control" %} {% bootstrap_field form.mail_attach_tickets layout="control" %} {% bootstrap_field form.mail_attach_ical layout="control" %} {% bootstrap_field form.mail_sales_channel_placed_paid layout="control" %} @@ -80,13 +84,15 @@
{% trans "SMTP settings" %} - {% bootstrap_field form.smtp_use_custom layout="control" %} - {% bootstrap_field form.smtp_host layout="control" %} - {% bootstrap_field form.smtp_port layout="control" %} - {% bootstrap_field form.smtp_username layout="control" %} - {% bootstrap_field form.smtp_password layout="control" %} - {% bootstrap_field form.smtp_use_tls layout="control" %} - {% bootstrap_field form.smtp_use_ssl layout="control" %} + {% propagated request.event org_url "smtp_use_custom" "smtp_host" "smtp_port" "smtp_username" "smtp_password" "smtp_use_tls" "smtp_use_ssl" %} + {% bootstrap_field form.smtp_use_custom layout="control" %} + {% bootstrap_field form.smtp_host layout="control" %} + {% bootstrap_field form.smtp_port layout="control" %} + {% bootstrap_field form.smtp_username layout="control" %} + {% bootstrap_field form.smtp_password layout="control" %} + {% bootstrap_field form.smtp_use_tls layout="control" %} + {% bootstrap_field form.smtp_use_ssl layout="control" %} + {% endpropagated %}
diff --git a/src/pretix/control/templates/pretixcontrol/event/mail_settings_fragment.html b/src/pretix/control/templates/pretixcontrol/event/mail_settings_fragment.html index a816fa691..fc8d4604c 100644 --- a/src/pretix/control/templates/pretixcontrol/event/mail_settings_fragment.html +++ b/src/pretix/control/templates/pretixcontrol/event/mail_settings_fragment.html @@ -35,9 +35,15 @@ {% bootstrap_field field show_label=False form_group_class="" %}
- {% for l in request.event.settings.locales %} -
- {% endfor %} + {% if request.event %} + {% for l in request.event.settings.locales %} +
+ {% endfor %} + {% else %} + {% for l in request.organizer.settings.locales %} +
+ {% endfor %} + {% endif %}
diff --git a/src/pretix/control/templates/pretixcontrol/event/settings.html b/src/pretix/control/templates/pretixcontrol/event/settings.html index 6e55f7801..6a2f6bbf0 100644 --- a/src/pretix/control/templates/pretixcontrol/event/settings.html +++ b/src/pretix/control/templates/pretixcontrol/event/settings.html @@ -4,6 +4,7 @@ {% load static %} {% load hierarkey_form %} {% load formset_tags %} +{% block title %}{% trans "General settings" %}{% endblock %} {% block custom_header %} {{ block.super }} @@ -203,7 +204,7 @@ {% bootstrap_field sform.logo_show_title layout="control" %} {% bootstrap_field sform.og_image layout="control" %} {% url "control:organizer.edit" organizer=request.organizer.slug as org_url %} - {% propagated request.event org_url "primary_color" "primary_font" "theme_color_success" "theme_color_danger" %} + {% propagated request.event org_url "primary_color" "primary_font" "theme_color_success" "theme_color_danger" "theme_round_borders" %} {% bootstrap_field sform.primary_color layout="control" %} {% bootstrap_field sform.theme_color_success layout="control" %} {% bootstrap_field sform.theme_color_danger layout="control" %} diff --git a/src/pretix/control/templates/pretixcontrol/item/include_variations.html b/src/pretix/control/templates/pretixcontrol/item/include_variations.html index 584afd90c..6fafe9cff 100644 --- a/src/pretix/control/templates/pretixcontrol/item/include_variations.html +++ b/src/pretix/control/templates/pretixcontrol/item/include_variations.html @@ -46,6 +46,12 @@ {% bootstrap_field form.default_price addon_after=request.event.currency layout="control" %} {% bootstrap_field form.original_price addon_after=request.event.currency layout="control" %} {% bootstrap_field form.description layout="control" %} + {% if form.require_membership %} + {% bootstrap_field form.require_membership layout="control" %} +
+ {% bootstrap_field form.require_membership_types layout="control" %} +
+ {% endif %} {% endfor %} @@ -80,6 +86,12 @@ {% bootstrap_field formset.empty_form.default_price addon_after=request.event.currency layout="control" %} {% bootstrap_field formset.empty_form.original_price addon_after=request.event.currency layout="control" %} {% bootstrap_field formset.empty_form.description layout="control" %} + {% if formset.empty_form.require_membership %} + {% bootstrap_field formset.empty_form.require_membership layout="control" %} +
+ {% bootstrap_field formset.empty_form.require_membership_types layout="control" %} +
+ {% endif %} {% endescapescript %} diff --git a/src/pretix/control/templates/pretixcontrol/item/index.html b/src/pretix/control/templates/pretixcontrol/item/index.html index 453a2a645..e651f4578 100644 --- a/src/pretix/control/templates/pretixcontrol/item/index.html +++ b/src/pretix/control/templates/pretixcontrol/item/index.html @@ -101,6 +101,12 @@ {% bootstrap_field form.require_voucher layout="control" %} {% bootstrap_field form.hide_without_voucher layout="control" %} {% bootstrap_field form.require_bundling layout="control" %} + {% if form.require_membership %} + {% bootstrap_field form.require_membership layout="control" %} +
+ {% bootstrap_field form.require_membership_types layout="control" %} +
+ {% endif %} {% bootstrap_field form.allow_cancel layout="control" %} {% bootstrap_field form.allow_waitinglist layout="control" %} {% bootstrap_field form.hidden_if_available layout="control" %} @@ -120,6 +126,28 @@ {% trans "Additional settings" %} {% bootstrap_field form.issue_giftcard layout="control" %} {% bootstrap_field form.show_quota_left layout="control" %} + {% if form.grant_membership_type %} + {% bootstrap_field form.grant_membership_type layout="control" %} +
+ {% bootstrap_field form.grant_membership_duration_like_event layout="control" %} +
+ {% blocktrans asvar days %}days{% endblocktrans %} + {% blocktrans asvar months %}months{% endblocktrans %} + +
+ {% bootstrap_field form.grant_membership_duration_months layout="" addon_after=months label_class="sr-only" form_group_class="" %} +
+ +
+ {% bootstrap_field form.grant_membership_duration_days layout="" addon_after=days label_class="sr-only" form_group_class="" %} +
+
+
+ {% endif %} {% for f in plugin_forms %} {% bootstrap_form f layout="control" %} {% endfor %} diff --git a/src/pretix/control/templates/pretixcontrol/order/change.html b/src/pretix/control/templates/pretixcontrol/order/change.html index 6cb851e52..9a6efac18 100644 --- a/src/pretix/control/templates/pretixcontrol/order/change.html +++ b/src/pretix/control/templates/pretixcontrol/order/change.html @@ -152,6 +152,18 @@ +
+
+ {% trans "Membership" %} +
+
+ {{ position.used_membership|default:"–" }} +
+
+ {% bootstrap_field position.form.used_membership layout='inline' %} +
+
+
{% trans "Price" %} @@ -232,6 +244,9 @@ {% if add_form.subevent %} {% bootstrap_field add_form.subevent layout="control" %} {% endif %} + {% if add_form.used_membership %} + {% bootstrap_field add_form.used_membership layout="control" %} + {% endif %} {% bootstrap_field add_form.seat layout="control" %}
@@ -264,6 +279,9 @@ {% if add_formset.empty_form.subevent %} {% bootstrap_field add_formset.empty_form.subevent layout="control" %} {% endif %} + {% if add_formset.empty_form.used_membership %} + {% bootstrap_field add_formset.empty_form.used_membership layout="control" %} + {% endif %} {% bootstrap_field add_formset.empty_form.seat layout="control" %} diff --git a/src/pretix/control/templates/pretixcontrol/order/index.html b/src/pretix/control/templates/pretixcontrol/order/index.html index 5aef10a8d..141b8217f 100644 --- a/src/pretix/control/templates/pretixcontrol/order/index.html +++ b/src/pretix/control/templates/pretixcontrol/order/index.html @@ -182,7 +182,20 @@
{% trans "Expiry date" %}
{{ order.expires|date:"SHORT_DATETIME_FORMAT" }}
{% endif %} -
{% trans "User" %}
+ {% if request.organizer.settings.customer_accounts %} +
{% trans "Customer account" %}
+
+ {% if order.customer %} + + {{ order.customer.identifier }} – {{ order.customer.email }} + + {% endif %} + + + +
+ {% endif %} +
{% trans "Contact email" %}
{{ order.email|default_if_none:"" }} {% if order.email and order.email_known_to_work %} @@ -197,7 +210,7 @@ {% if order.status != "c" %}
+ action="{% url "control:event.order.resendlink" event=request.event.slug organizer=request.event.organizer.slug code=order.code %}"> {% csrf_token %} + +
+{% endblock %} diff --git a/src/pretix/control/templates/pretixcontrol/organizers/customer_edit.html b/src/pretix/control/templates/pretixcontrol/organizers/customer_edit.html new file mode 100644 index 000000000..9267d34e0 --- /dev/null +++ b/src/pretix/control/templates/pretixcontrol/organizers/customer_edit.html @@ -0,0 +1,24 @@ +{% extends "pretixcontrol/organizers/base.html" %} +{% load i18n %} +{% load bootstrap3 %} +{% block title %} + {% blocktrans trimmed with id=customer.identifier %} + Customer #{{ id }} + {% endblocktrans %} +{% endblock %} +{% block inner %} +

+ {% blocktrans trimmed with id=customer.identifier %} + Customer #{{ id }} + {% endblocktrans %} +

+
+ {% csrf_token %} + {% bootstrap_form form layout="control" %} +
+ +
+
+{% endblock %} diff --git a/src/pretix/control/templates/pretixcontrol/organizers/customer_membership.html b/src/pretix/control/templates/pretixcontrol/organizers/customer_membership.html new file mode 100644 index 000000000..ab4c90857 --- /dev/null +++ b/src/pretix/control/templates/pretixcontrol/organizers/customer_membership.html @@ -0,0 +1,88 @@ +{% extends "pretixcontrol/organizers/base.html" %} +{% load i18n %} +{% load bootstrap3 %} +{% block title %} + {% trans "Membership" %} +{% endblock %} +{% block inner %} +

+ {% trans "Membership" %} +

+
+
+

+ {% trans "Details" %} +

+
+
+
+ {% csrf_token %} + {% bootstrap_form form layout="control" %} +
+
+ +
+
+
+
+
+
+
+

+ {% trans "Usages" %} +

+
+ + + + + + + + + + + + + {% for op in usages %} + + + + + + + + + {% endfor %} + +
{% trans "Order code" %}{% trans "Event" %}{% trans "Date" context "subevent" %}{% trans "Product" %}{% trans "Order date" %}{% trans "Status" %}
+ + + {{ op.order.code }}-{{ op.positionid }} + + {% if op.order.testmode %} + {% trans "TEST MODE" %} + {% endif %} + + {{ op.order.event }} + + {{ op.subevent|default:"" }} + + {{ op.item }} + {% if op.variation %}– {{ op.variation }}{% endif %} + + {{ op.order.datetime|date:"SHORT_DATETIME_FORMAT" }} + + {% if op.canceled %} + + + {% trans "Canceled" %} + + {% else %} + {% include "pretixcontrol/orders/fragment_order_status.html" with order=op.order %} + {% endif %} +
+
+{% endblock %} diff --git a/src/pretix/control/templates/pretixcontrol/organizers/customers.html b/src/pretix/control/templates/pretixcontrol/organizers/customers.html new file mode 100644 index 000000000..25ab062a6 --- /dev/null +++ b/src/pretix/control/templates/pretixcontrol/organizers/customers.html @@ -0,0 +1,80 @@ +{% extends "pretixcontrol/organizers/base.html" %} +{% load i18n %} +{% load urlreplace %} +{% load bootstrap3 %} +{% load money %} +{% block title %}{% trans "Customers" %}{% endblock %} +{% block inner %} +

+ {% trans "Customers" %} +

+ {% if customers|length == 0 and not filter_form.filtered %} +
+

+ {% blocktrans trimmed %} + No customer accounts have been created yet. + {% endblocktrans %} +

+
+ {% else %} +
+
+ {% bootstrap_field filter_form.query layout='inline' %} +
+
+ +
+
+
+ + + + + + + + + + + {% for c in customers %} + + + + + + + {% endfor %} + +
{% trans "Customer ID" %} + + + {% trans "Email" %} + + {% trans "Name" %} + +
+ + {% if not c.is_active %}{% endif %} + #{{ c.identifier }} + {% if not c.is_active %}{% endif %} + + + {% if not c.is_verified %}{% endif %} + {{ c.email|default_if_none:"" }} + {% if not c.is_verified %}{% endif %} + {{ c.name }} + + + +
+
+ {% include "pretixcontrol/pagination.html" %} + {% endif %} +{% endblock %} diff --git a/src/pretix/control/templates/pretixcontrol/organizers/edit.html b/src/pretix/control/templates/pretixcontrol/organizers/edit.html index 1d33e9dc9..3d97a1989 100644 --- a/src/pretix/control/templates/pretixcontrol/organizers/edit.html +++ b/src/pretix/control/templates/pretixcontrol/organizers/edit.html @@ -50,6 +50,13 @@ {% trans "Localization" %} {% bootstrap_field sform.locales layout="control" %} {% bootstrap_field sform.region layout="control" %} + {% bootstrap_field sform.timezone layout="control" %} + +
+ {% trans "Customer accounts" %} + {% bootstrap_field sform.customer_accounts layout="control" %} + {% bootstrap_field sform.name_scheme layout="control" %} + {% bootstrap_field sform.name_scheme_titles layout="control" %}
{% trans "Shop design" %} diff --git a/src/pretix/control/templates/pretixcontrol/organizers/mail.html b/src/pretix/control/templates/pretixcontrol/organizers/mail.html new file mode 100644 index 000000000..3b1968a37 --- /dev/null +++ b/src/pretix/control/templates/pretixcontrol/organizers/mail.html @@ -0,0 +1,58 @@ +{% extends "pretixcontrol/base.html" %} +{% load i18n %} +{% load bootstrap3 %} +{% load formset_tags %} +{% block custom_header %} + {{ block.super }} + +{% endblock %} +{% block title %}{% trans "Organizer" %}{% endblock %} +{% block content %} +

{% trans "E-mail settings" %}

+ +
+ {% csrf_token %} + {% bootstrap_form_errors form %} +
+
+ {% trans "General" %} + {% bootstrap_field form.mail_from layout="control" %} + {% bootstrap_field form.mail_from_name layout="control" %} + {% bootstrap_field form.mail_text_signature layout="control" %} + {% bootstrap_field form.mail_bcc layout="control" %} +
+
+ {% trans "E-mail content" %} +
+ {% blocktrans asvar title_customer_registration %}Customer account registration{% endblocktrans %} + {% include "pretixcontrol/event/mail_settings_fragment.html" with pid="customer_registration" title=title_customer_registration items="mail_text_customer_registration" %} + + {% blocktrans asvar title_email_change %}Customer account email change{% endblocktrans %} + {% include "pretixcontrol/event/mail_settings_fragment.html" with pid="email_change" title=title_email_change items="mail_text_customer_email_change" %} + + {% blocktrans asvar title_reset %}Customer account password reset{% endblocktrans %} + {% include "pretixcontrol/event/mail_settings_fragment.html" with pid="reset" title=title_reset items="mail_text_customer_reset" %} +
+
+
+ {% trans "SMTP settings" %} + {% bootstrap_field form.smtp_use_custom layout="control" %} + {% bootstrap_field form.smtp_host layout="control" %} + {% bootstrap_field form.smtp_port layout="control" %} + {% bootstrap_field form.smtp_username layout="control" %} + {% bootstrap_field form.smtp_password layout="control" %} + {% bootstrap_field form.smtp_use_tls layout="control" %} + {% bootstrap_field form.smtp_use_ssl layout="control" %} +
+
+
+ + +
+
+{% endblock %} diff --git a/src/pretix/control/templates/pretixcontrol/organizers/membershiptype_delete.html b/src/pretix/control/templates/pretixcontrol/organizers/membershiptype_delete.html new file mode 100644 index 000000000..443f173df --- /dev/null +++ b/src/pretix/control/templates/pretixcontrol/organizers/membershiptype_delete.html @@ -0,0 +1,25 @@ +{% extends "pretixcontrol/organizers/base.html" %} +{% load i18n %} +{% load bootstrap3 %} +{% block inner %} +

{% trans "Delete membership type:" %} {{ type.name }}

+
+ {% csrf_token %} + {% if is_allowed %} +

{% blocktrans %}Are you sure you want to delete this membership type?{% endblocktrans %} + {% else %} +

{% blocktrans %}This membership type cannot be deleted since it has already been used.{% endblocktrans %} + {% endif %} +

+
+ + {% trans "Cancel" %} + + {% if is_allowed %} + + {% endif %} +
+
+{% endblock %} diff --git a/src/pretix/control/templates/pretixcontrol/organizers/membershiptype_edit.html b/src/pretix/control/templates/pretixcontrol/organizers/membershiptype_edit.html new file mode 100644 index 000000000..46705f58f --- /dev/null +++ b/src/pretix/control/templates/pretixcontrol/organizers/membershiptype_edit.html @@ -0,0 +1,20 @@ +{% extends "pretixcontrol/organizers/base.html" %} +{% load i18n %} +{% load bootstrap3 %} +{% block inner %} + {% if type %} +

{% trans "Membership type:" %} {{ type.name }}

+ {% else %} +

{% trans "Create a new membership type" %}

+ {% endif %} +
+ {% csrf_token %} + {% bootstrap_form form layout="control" %} +
+ +
+ +
+{% endblock %} diff --git a/src/pretix/control/templates/pretixcontrol/organizers/membershiptypes.html b/src/pretix/control/templates/pretixcontrol/organizers/membershiptypes.html new file mode 100644 index 000000000..c82843fbf --- /dev/null +++ b/src/pretix/control/templates/pretixcontrol/organizers/membershiptypes.html @@ -0,0 +1,48 @@ +{% extends "pretixcontrol/organizers/base.html" %} +{% load i18n %} +{% load bootstrap3 %} +{% block title %}{% trans "Membership types" %}{% endblock %} +{% block inner %} +

{% trans "Membership types" %}

+

+ {% blocktrans trimmed %} + You can define membership types. These allow you to link products from different events + together. You can sell a membership as part of a a product in one event, and require valid + memberships to allow purchases in another event. + {% endblocktrans %} +

+

+ {% blocktrans trimmed %} + This can be used to enable products like year passes, tickets of ten, etc. + {% endblocktrans %} +

+ + + {% trans "Create a new membership type" %} + + + + + + + + + + {% for t in types %} + + + + + {% endfor %} + +
{% trans "Name" %}
+ + {{ t.name }} + + + + +
+{% endblock %} diff --git a/src/pretix/control/templates/pretixcontrol/organizers/property_delete.html b/src/pretix/control/templates/pretixcontrol/organizers/property_delete.html index 2c6e697c5..073c38016 100644 --- a/src/pretix/control/templates/pretixcontrol/organizers/property_delete.html +++ b/src/pretix/control/templates/pretixcontrol/organizers/property_delete.html @@ -2,7 +2,7 @@ {% load i18n %} {% load bootstrap3 %} {% block inner %} -

{% trans "Delete property:" %} {{ gate.name }}

+

{% trans "Delete property:" %} {{ type.name }}

{% csrf_token %}

{% blocktrans %}Are you sure you want to delete the property?{% endblocktrans %} diff --git a/src/pretix/control/templates/pretixcontrol/organizers/property_edit.html b/src/pretix/control/templates/pretixcontrol/organizers/property_edit.html index f4f2ffc84..e3d93db05 100644 --- a/src/pretix/control/templates/pretixcontrol/organizers/property_edit.html +++ b/src/pretix/control/templates/pretixcontrol/organizers/property_edit.html @@ -2,7 +2,7 @@ {% load i18n %} {% load bootstrap3 %} {% block inner %} - {% if gate %} + {% if property %}

{% trans "Property:" %} {{ property.name }}

{% else %}

{% trans "Create a new property" %}

diff --git a/src/pretix/control/templates/pretixcontrol/organizers/team_edit.html b/src/pretix/control/templates/pretixcontrol/organizers/team_edit.html index bd6d25c4b..601735c9e 100644 --- a/src/pretix/control/templates/pretixcontrol/organizers/team_edit.html +++ b/src/pretix/control/templates/pretixcontrol/organizers/team_edit.html @@ -23,6 +23,7 @@ {% trans "Organizer permissions" %} {% bootstrap_field form.can_create_events layout="control" %} {% bootstrap_field form.can_manage_gift_cards layout="control" %} + {% bootstrap_field form.can_manage_customers layout="control" %} {% bootstrap_field form.can_change_teams layout="control" %} {% bootstrap_field form.can_change_organizer_settings layout="control" %}
diff --git a/src/pretix/control/templatetags/hierarkey_form.py b/src/pretix/control/templatetags/hierarkey_form.py index 8451ab33f..9f8796fb2 100644 --- a/src/pretix/control/templatetags/hierarkey_form.py +++ b/src/pretix/control/templatetags/hierarkey_form.py @@ -42,39 +42,41 @@ class PropagatedNode(Node): if all([fn not in event.settings._cache() for fn in self.field_names]): body = """ -
- -
- {body} -
-
-

{text_inh}

-

- {text_expl} -

- - - {text_orga} +

+ {text_inh} +

+
+ +
+ {body} +
""".format( body=body, - text_inh=_("Organizer-level settings") if isinstance(event, Event) else _('Site-level settings'), + text_inh=_("Currently set on organizer level") if isinstance(event, Event) else _('Currently set on global level'), fnames=','.join(self.field_names), text_expl=_( 'These settings are currently set on organizer level. This way, you can easily change them for ' - 'all of your events at the same time. You can either go to the organizer settings to change them ' - 'or decouple them from the organizer account to change them for this event individually.' + 'all of your events at the same time. You can either go to the organizer settings to change them for all your events ' + 'or you can unlock them to change them for this event individually.' ) if isinstance(event, Event) else _( 'These settings are currently set on global level. This way, you can easily change them for ' - 'all organizers at the same time. You can either go to the global settings to change them ' - 'or decouple them from the global settings to change them for this event individually.' + 'all organizers at the same time. You can either go to the global settings to change them for all your organizers ' + 'or you can unlock them to change them for this event individually.' ), - text_unlink=_('Change only for this event') if isinstance(event, Event) else _('Change only for this organizer'), - text_orga=_('Change for all events') if isinstance(event, Event) else _('Change for all organizers'), + text_unlink=_('Unlock'), + text_orga=_('Go to organizer settings') if isinstance(event, Event) else _('Go to global settings'), url=url ) diff --git a/src/pretix/control/urls.py b/src/pretix/control/urls.py index e47c2090f..ca08c6517 100644 --- a/src/pretix/control/urls.py +++ b/src/pretix/control/urls.py @@ -110,6 +110,10 @@ urlpatterns = [ url(r'^organizers/select2$', typeahead.organizer_select2, name='organizers.select2'), url(r'^organizer/(?P[^/]+)/$', organizer.OrganizerDetail.as_view(), name='organizer'), url(r'^organizer/(?P[^/]+)/edit$', organizer.OrganizerUpdate.as_view(), name='organizer.edit'), + url(r'^organizer/(?P[^/]+)/settings/email$', + organizer.OrganizerMailSettings.as_view(), name='organizer.settings.mail'), + url(r'^organizer/(?P[^/]+)/settings/email/preview$', + organizer.MailSettingsPreview.as_view(), name='organizer.settings.mail.preview'), url(r'^organizer/(?P[^/]+)/delete$', organizer.OrganizerDelete.as_view(), name='organizer.delete'), url(r'^organizer/(?P[^/]+)/settings/display$', organizer.OrganizerDisplaySettings.as_view(), name='organizer.display'), @@ -120,6 +124,25 @@ urlpatterns = [ name='organizer.property.edit'), url(r'^organizer/(?P[^/]+)/property/(?P[^/]+)/delete$', organizer.EventMetaPropertyDeleteView.as_view(), name='organizer.property.delete'), + url(r'^organizer/(?P[^/]+)/membershiptypes$', organizer.MembershipTypeListView.as_view(), name='organizer.membershiptypes'), + url(r'^organizer/(?P[^/]+)/membershiptype/add$', organizer.MembershipTypeCreateView.as_view(), + name='organizer.membershiptype.add'), + url(r'^organizer/(?P[^/]+)/membershiptype/(?P[^/]+)/edit$', organizer.MembershipTypeUpdateView.as_view(), + name='organizer.membershiptype.edit'), + url(r'^organizer/(?P[^/]+)/membershiptype/(?P[^/]+)/delete$', organizer.MembershipTypeDeleteView.as_view(), + name='organizer.membershiptype.delete'), + url(r'^organizer/(?P[^/]+)/customers$', organizer.CustomerListView.as_view(), name='organizer.customers'), + url(r'^organizer/(?P[^/]+)/customers/select2$', typeahead.customer_select2, name='organizer.customers.select2'), + url(r'^organizer/(?P[^/]+)/customer/(?P[^/]+)/$', + organizer.CustomerDetailView.as_view(), name='organizer.customer'), + url(r'^organizer/(?P[^/]+)/customer/(?P[^/]+)/edit$', + organizer.CustomerUpdateView.as_view(), name='organizer.customer.edit'), + url(r'^organizer/(?P[^/]+)/customer/(?P[^/]+)/membership/add$', + organizer.MembershipCreateView.as_view(), name='organizer.customer.membership.add'), + url(r'^organizer/(?P[^/]+)/customer/(?P[^/]+)/membership/(?P[^/]+)/edit$', + organizer.MembershipUpdateView.as_view(), name='organizer.customer.membership.edit'), + url(r'^organizer/(?P[^/]+)/customer/(?P[^/]+)/anonymize$', + organizer.CustomerAnonymizeView.as_view(), name='organizer.customer.anonymize'), url(r'^organizer/(?P[^/]+)/giftcards$', organizer.GiftCardListView.as_view(), name='organizer.giftcards'), url(r'^organizer/(?P[^/]+)/giftcard/add$', organizer.GiftCardCreateView.as_view(), name='organizer.giftcard.add'), url(r'^organizer/(?P[^/]+)/giftcard/(?P[^/]+)/$', organizer.GiftCardDetailView.as_view(), name='organizer.giftcard'), diff --git a/src/pretix/control/views/auth.py b/src/pretix/control/views/auth.py index a20e4b12e..c882b30c0 100644 --- a/src/pretix/control/views/auth.py +++ b/src/pretix/control/views/auth.py @@ -277,7 +277,7 @@ class Forgot(TemplateView): rc.setex('pretix_pwreset_%s' % (user.id), 3600 * 24, '1') except User.DoesNotExist: - logger.warning('Password reset for unregistered e-mail \"' + email + '\"requested.') + logger.warning('Password reset for unregistered e-mail \"' + email + '\" requested.') except SendMailException: logger.exception('Sending password reset e-mail to \"' + email + '\" failed.') diff --git a/src/pretix/control/views/orders.py b/src/pretix/control/views/orders.py index 65499763a..924dc0920 100644 --- a/src/pretix/control/views/orders.py +++ b/src/pretix/control/views/orders.py @@ -336,7 +336,7 @@ class OrderDetail(OrderView): cartpos = queryset.order_by( 'item', 'variation' ).select_related( - 'item', 'variation', 'addon_to', 'tax_rule' + 'item', 'variation', 'addon_to', 'tax_rule', 'used_membership', 'used_membership__membership_type' ).prefetch_related( 'item__questions', 'issued_gift_cards', Prefetch('answers', queryset=QuestionAnswer.objects.prefetch_related('options').select_related('question')), @@ -1568,7 +1568,10 @@ class OrderChange(OrderView): @cached_property def positions(self): - positions = list(self.order.positions.select_related('item', 'item__tax_rule')) + positions = list(self.order.positions.select_related( + 'item', 'item__tax_rule', 'used_membership', 'used_membership__membership_type', 'tax_rule', + 'seat', 'subevent', + )) for p in positions: p.form = OrderPositionChangeForm(prefix='op-{}'.format(p.pk), instance=p, items=self.items, initial={'seat': p.seat.seat_guid if p.seat else None}, @@ -1616,7 +1619,8 @@ class OrderChange(OrderView): f.cleaned_data['price'], f.cleaned_data.get('addon_to'), f.cleaned_data.get('subevent'), - f.cleaned_data.get('seat')) + f.cleaned_data.get('seat'), + f.cleaned_data.get('used_membership')) except OrderError as e: f.custom_error = str(e) return False @@ -1685,6 +1689,12 @@ class OrderChange(OrderView): if p.form.cleaned_data['price'] is not None and p.form.cleaned_data['price'] != p.price: ocm.change_price(p, p.form.cleaned_data['price']) + if p.form.cleaned_data['used_membership'] is not None and p.form.cleaned_data['used_membership'] != (p.used_membership or 'CLEAR'): + if p.form.cleaned_data['used_membership'] == 'CLEAR': + ocm.change_membership(p, None) + else: + ocm.change_membership(p, p.form.cleaned_data['used_membership']) + if p.form.cleaned_data['tax_rule'] and p.form.cleaned_data['tax_rule'] != p.tax_rule: ocm.change_tax_rule(p, p.form.cleaned_data['tax_rule']) @@ -1792,12 +1802,18 @@ class OrderContactChange(OrderView): def form(self): return OrderContactForm( instance=self.order, - data=self.request.POST if self.request.method == "POST" else None + data=self.request.POST if self.request.method == "POST" else None, + customers=self.request.organizer.settings.customer_accounts and ( + self.request.user.has_organizer_permission( + self.request.organizer, 'can_manage_customers', request=self.request + ) + ) ) def post(self, *args, **kwargs): old_email = self.order.email old_phone = self.order.phone + old_customer = self.order.customer changed = False if self.form.is_valid(): new_email = self.form.cleaned_data['email'] @@ -1824,6 +1840,18 @@ class OrderContactChange(OrderView): user=self.request.user, ) + new_customer = self.form.cleaned_data.get('customer') + if new_customer != old_customer: + changed = True + self.order.log_action( + 'pretix.event.order.customer.changed', + data={ + 'old_customer': old_customer, + 'new_customer': self.form.cleaned_data['customer'], + }, + user=self.request.user, + ) + if self.form.cleaned_data['regenerate_secrets']: changed = True self.order.secret = generate_secret() diff --git a/src/pretix/control/views/organizer.py b/src/pretix/control/views/organizer.py index 4b331fd85..95fc5cae9 100644 --- a/src/pretix/control/views/organizer.py +++ b/src/pretix/control/views/organizer.py @@ -33,6 +33,7 @@ # License for the specific language governing permissions and limitations under the License. import json +import re from datetime import timedelta from decimal import Decimal @@ -43,11 +44,12 @@ from django.core.exceptions import PermissionDenied, ValidationError from django.core.files import File from django.db import transaction from django.db.models import ( - Count, Max, Min, OuterRef, Prefetch, ProtectedError, Subquery, Sum, + Count, Exists, IntegerField, Max, Min, OuterRef, Prefetch, ProtectedError, + Q, Subquery, Sum, ) from django.db.models.functions import Coalesce, Greatest from django.forms import DecimalField -from django.http import JsonResponse +from django.http import HttpResponseBadRequest, JsonResponse from django.shortcuts import get_object_or_404, redirect from django.urls import reverse from django.utils.functional import cached_property @@ -61,29 +63,37 @@ from django.views.generic import ( from pretix.api.models import WebHook from pretix.base.auth import get_auth_backends +from pretix.base.channels import get_all_sales_channels +from pretix.base.i18n import language from pretix.base.models import ( - CachedFile, Device, Gate, GiftCard, LogEntry, OrderPayment, Organizer, + CachedFile, Customer, Device, Gate, GiftCard, Invoice, LogEntry, + Membership, MembershipType, Order, OrderPayment, OrderPosition, Organizer, Team, TeamInvite, User, ) from pretix.base.models.event import Event, EventMetaProperty, EventMetaValue from pretix.base.models.giftcards import ( GiftCardTransaction, gen_giftcard_secret, ) +from pretix.base.models.orders import CancellationRequest from pretix.base.models.organizer import TeamAPIToken from pretix.base.payment import PaymentException from pretix.base.services.export import multiexport from pretix.base.services.mail import SendMailException, mail from pretix.base.settings import SETTINGS_AFFECTING_CSS from pretix.base.signals import register_multievent_data_exporters +from pretix.base.templatetags.rich_text import markdown_compile_email from pretix.base.views.tasks import AsyncAction from pretix.control.forms.filter import ( - EventFilterForm, GiftCardFilterForm, OrganizerFilterForm, TeamFilterForm, + CustomerFilterForm, EventFilterForm, GiftCardFilterForm, + OrganizerFilterForm, TeamFilterForm, ) from pretix.control.forms.orders import ExporterForm from pretix.control.forms.organizer import ( - DeviceForm, EventMetaPropertyForm, GateForm, GiftCardCreateForm, - GiftCardUpdateForm, OrganizerDeleteForm, OrganizerForm, - OrganizerSettingsForm, OrganizerUpdateForm, TeamForm, WebHookForm, + CustomerUpdateForm, DeviceForm, EventMetaPropertyForm, GateForm, + GiftCardCreateForm, GiftCardUpdateForm, MailSettingsForm, + MembershipTypeForm, MembershipUpdateForm, OrganizerDeleteForm, + OrganizerForm, OrganizerSettingsForm, OrganizerUpdateForm, TeamForm, + WebHookForm, ) from pretix.control.logdisplay import OVERVIEW_BANLIST from pretix.control.permissions import ( @@ -228,6 +238,104 @@ class OrganizerSettingsFormView(OrganizerDetailViewMixin, OrganizerPermissionReq return self.get(request) +class OrganizerMailSettings(OrganizerSettingsFormView): + form_class = MailSettingsForm + template_name = 'pretixcontrol/organizers/mail.html' + permission = 'can_change_organizer_settings' + + def get_success_url(self): + return reverse('control:organizer.settings.mail', kwargs={ + 'organizer': self.request.organizer.slug, + }) + + @transaction.atomic + def post(self, request, *args, **kwargs): + form = self.get_form() + if form.is_valid(): + form.save() + if form.has_changed(): + self.request.organizer.log_action( + 'pretix.organizer.settings', user=self.request.user, data={ + k: form.cleaned_data.get(k) for k in form.changed_data + } + ) + + if request.POST.get('test', '0').strip() == '1': + backend = self.request.organizer.get_mail_backend(force_custom=True, timeout=10) + try: + backend.test(self.request.organizer.settings.mail_from) + except Exception as e: + messages.warning(self.request, _('An error occurred while contacting the SMTP server: %s') % str(e)) + else: + if form.cleaned_data.get('smtp_use_custom'): + messages.success(self.request, _('Your changes have been saved and the connection attempt to ' + 'your SMTP server was successful.')) + else: + messages.success(self.request, _('We\'ve been able to contact the SMTP server you configured. ' + 'Remember to check the "use custom SMTP server" checkbox, ' + 'otherwise your SMTP server will not be used.')) + else: + messages.success(self.request, _('Your changes have been saved.')) + return redirect(self.get_success_url()) + else: + messages.error(self.request, _('We could not save your changes. See below for details.')) + return self.get(request) + + +class MailSettingsPreview(OrganizerPermissionRequiredMixin, View): + permission = 'can_change_organizer_settings' + + # return the origin text if key is missing in dict + class SafeDict(dict): + def __missing__(self, key): + return '{' + key + '}' + + # create index-language mapping + @cached_property + def supported_locale(self): + locales = {} + for idx, val in enumerate(settings.LANGUAGES): + if val[0] in self.request.organizer.settings.locales: + locales[str(idx)] = val[0] + return locales + + # get all supported placeholders with dummy values + def placeholders(self, item): + ctx = {} + for p, s in MailSettingsForm(obj=self.request.organizer)._get_sample_context(MailSettingsForm.base_context[item]).items(): + if s.strip().startswith('*'): + ctx[p] = s + else: + ctx[p] = '{}'.format( + _('This value will be replaced based on dynamic parameters.'), + s + ) + return self.SafeDict(ctx) + + def post(self, request, *args, **kwargs): + preview_item = request.POST.get('item', '') + if preview_item not in MailSettingsForm.base_context: + return HttpResponseBadRequest(_('invalid item')) + + regex = r"^" + re.escape(preview_item) + r"_(?P[\d+])$" + msgs = {} + for k, v in request.POST.items(): + # only accept allowed fields + matched = re.search(regex, k) + if matched is not None: + idx = matched.group('idx') + if idx in self.supported_locale: + with language(self.supported_locale[idx], self.request.organizer.settings.region): + msgs[self.supported_locale[idx]] = markdown_compile_email( + v.format_map(self.placeholders(preview_item)) + ) + + return JsonResponse({ + 'item': preview_item, + 'msgs': msgs + }) + + class OrganizerDisplaySettings(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin, View): permission = None @@ -1502,3 +1610,339 @@ class LogView(OrganizerPermissionRequiredMixin, PaginationMixin, ListView): def get_context_data(self, **kwargs): ctx = super().get_context_data() return ctx + + +class MembershipTypeListView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin, ListView): + model = MembershipType + template_name = 'pretixcontrol/organizers/membershiptypes.html' + permission = 'can_change_organizer_settings' + context_object_name = 'types' + + def get_queryset(self): + return self.request.organizer.membership_types.all() + + +class MembershipTypeCreateView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin, CreateView): + model = MembershipType + template_name = 'pretixcontrol/organizers/membershiptype_edit.html' + permission = 'can_change_organizer_settings' + form_class = MembershipTypeForm + + def get_object(self, queryset=None): + return get_object_or_404(MembershipType, organizer=self.request.organizer, pk=self.kwargs.get('type')) + + def get_success_url(self): + return reverse('control:organizer.membershiptypes', kwargs={ + 'organizer': self.request.organizer.slug, + }) + + def get_form_kwargs(self): + kwargs = super().get_form_kwargs() + kwargs['event'] = self.request.organizer + return kwargs + + def form_valid(self, form): + messages.success(self.request, _('The membership type has been created.')) + form.instance.organizer = self.request.organizer + ret = super().form_valid(form) + form.instance.log_action('pretix.membershiptype.created', user=self.request.user, data={ + k: getattr(self.object, k) for k in form.changed_data + }) + return ret + + def form_invalid(self, form): + messages.error(self.request, _('Your changes could not be saved.')) + return super().form_invalid(form) + + +class MembershipTypeUpdateView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin, UpdateView): + model = MembershipType + template_name = 'pretixcontrol/organizers/membershiptype_edit.html' + permission = 'can_change_organizer_settings' + context_object_name = 'type' + form_class = MembershipTypeForm + + def get_object(self, queryset=None): + return get_object_or_404(MembershipType, organizer=self.request.organizer, pk=self.kwargs.get('type')) + + def get_success_url(self): + return reverse('control:organizer.membershiptypes', kwargs={ + 'organizer': self.request.organizer.slug, + }) + + def get_form_kwargs(self): + kwargs = super().get_form_kwargs() + kwargs['event'] = self.request.organizer + return kwargs + + def form_valid(self, form): + if form.has_changed(): + self.object.log_action('pretix.membershiptype.changed', user=self.request.user, data={ + k: getattr(self.object, k) + for k in form.changed_data + }) + messages.success(self.request, _('Your changes have been saved.')) + return super().form_valid(form) + + def form_invalid(self, form): + messages.error(self.request, _('Your changes could not be saved.')) + return super().form_invalid(form) + + +class MembershipTypeDeleteView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin, DeleteView): + model = MembershipType + template_name = 'pretixcontrol/organizers/membershiptype_delete.html' + permission = 'can_change_organizer_settings' + context_object_name = 'type' + + def get_object(self, queryset=None): + return get_object_or_404(MembershipType, organizer=self.request.organizer, pk=self.kwargs.get('type')) + + def get_context_data(self, **kwargs): + ctx = super().get_context_data(**kwargs) + ctx['is_allowed'] = self.object.allow_delete() + return ctx + + def get_success_url(self): + return reverse('control:organizer.membershiptypes', kwargs={ + 'organizer': self.request.organizer.slug, + }) + + @transaction.atomic + def delete(self, request, *args, **kwargs): + success_url = self.get_success_url() + self.object = self.get_object() + if self.object.allow_delete(): + self.object.log_action('pretix.membershiptype.deleted', user=self.request.user) + self.object.delete() + messages.success(request, _('The selected object has been deleted.')) + return redirect(success_url) + + +class CustomerListView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin, PaginationMixin, ListView): + model = Customer + template_name = 'pretixcontrol/organizers/customers.html' + permission = 'can_manage_customers' + context_object_name = 'customers' + + def get_queryset(self): + qs = self.request.organizer.customers.all() + if self.filter_form.is_valid(): + qs = self.filter_form.filter_qs(qs) + return qs + + def get_context_data(self, **kwargs): + ctx = super().get_context_data(**kwargs) + ctx['filter_form'] = self.filter_form + return ctx + + @cached_property + def filter_form(self): + return CustomerFilterForm(data=self.request.GET, request=self.request) + + +class CustomerDetailView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin, PaginationMixin, ListView): + template_name = 'pretixcontrol/organizers/customer.html' + permission = 'can_manage_customers' + context_object_name = 'orders' + + def get_queryset(self): + qs = Order.objects.filter( + Q(customer=self.customer) + | Q(email__iexact=self.customer.email) + ).select_related('event').order_by('-datetime') + return qs + + @cached_property + def customer(self): + return get_object_or_404( + self.request.organizer.customers, + identifier=self.kwargs.get('customer') + ) + + def get_context_data(self, **kwargs): + ctx = super().get_context_data(**kwargs) + ctx['customer'] = self.customer + ctx['display_locale'] = dict(settings.LANGUAGES)[self.customer.locale or self.request.organizer.settings.locale] + + ctx['memberships'] = self.customer.memberships.with_usages().select_related( + 'membership_type', 'granted_in', 'granted_in__order', 'granted_in__order__event' + ) + + for m in ctx['memberships']: + if m.membership_type.max_usages: + m.percent = int(m.usages / m.membership_type.max_usages * 100) + else: + m.percent = 0 + + # Only compute this annotations for this page (query optimization) + s = OrderPosition.objects.filter( + order=OuterRef('pk') + ).order_by().values('order').annotate(k=Count('id')).values('k') + i = Invoice.objects.filter( + order=OuterRef('pk'), + is_cancellation=False, + refered__isnull=True, + ).order_by().values('order').annotate(k=Count('id')).values('k') + annotated = { + o['pk']: o + for o in + Order.annotate_overpayments(Order.objects, sums=True).filter( + pk__in=[o.pk for o in ctx['orders']] + ).annotate( + pcnt=Subquery(s, output_field=IntegerField()), + icnt=Subquery(i, output_field=IntegerField()), + has_cancellation_request=Exists(CancellationRequest.objects.filter(order=OuterRef('pk'))) + ).values( + 'pk', 'pcnt', 'is_overpaid', 'is_underpaid', 'is_pending_with_full_payment', 'has_external_refund', + 'has_pending_refund', 'has_cancellation_request', 'computed_payment_refund_sum', 'icnt' + ) + } + + scs = get_all_sales_channels() + for o in ctx['orders']: + if o.pk not in annotated: + continue + o.pcnt = annotated.get(o.pk)['pcnt'] + o.is_overpaid = annotated.get(o.pk)['is_overpaid'] + o.is_underpaid = annotated.get(o.pk)['is_underpaid'] + o.is_pending_with_full_payment = annotated.get(o.pk)['is_pending_with_full_payment'] + o.has_external_refund = annotated.get(o.pk)['has_external_refund'] + o.has_pending_refund = annotated.get(o.pk)['has_pending_refund'] + o.has_cancellation_request = annotated.get(o.pk)['has_cancellation_request'] + o.computed_payment_refund_sum = annotated.get(o.pk)['computed_payment_refund_sum'] + o.icnt = annotated.get(o.pk)['icnt'] + o.sales_channel_obj = scs[o.sales_channel] + + return ctx + + +class CustomerUpdateView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin, UpdateView): + template_name = 'pretixcontrol/organizers/customer_edit.html' + permission = 'can_manage_customers' + context_object_name = 'customer' + form_class = CustomerUpdateForm + + def get_object(self, queryset=None): + return get_object_or_404( + self.request.organizer.customers, + identifier=self.kwargs.get('customer') + ) + + def form_valid(self, form): + if form.has_changed(): + self.object.log_action('pretix.customer.changed', user=self.request.user, data={ + k: getattr(self.object, k) + for k in form.changed_data + }) + messages.success(self.request, _('Your changes have been saved.')) + return super().form_valid(form) + + def get_success_url(self): + return reverse('control:organizer.customer', kwargs={ + 'organizer': self.request.organizer.slug, + 'customer': self.object.identifier, + }) + + +class MembershipUpdateView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin, UpdateView): + template_name = 'pretixcontrol/organizers/customer_membership.html' + permission = 'can_manage_customers' + context_object_name = 'membership' + form_class = MembershipUpdateForm + + def get_object(self, queryset=None): + return get_object_or_404( + Membership, + customer__organizer=self.request.organizer, + customer__identifier=self.kwargs.get('customer'), + pk=self.kwargs.get('id') + ) + + def get_context_data(self, **kwargs): + ctx = super().get_context_data(**kwargs) + ctx['usages'] = self.object.orderposition_set.select_related( + 'order', 'order__event', 'subevent', 'item', 'variation', + ) + return ctx + + def form_valid(self, form): + if form.has_changed(): + d = { + k: getattr(self.object, k) + for k in form.changed_data + } + d['id'] = self.object.pk + self.object.customer.log_action('pretix.customer.membership.changed', user=self.request.user, data=d) + messages.success(self.request, _('Your changes have been saved.')) + return super().form_valid(form) + + def get_success_url(self): + return reverse('control:organizer.customer', kwargs={ + 'organizer': self.request.organizer.slug, + 'customer': self.object.customer.identifier, + }) + + +class MembershipCreateView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin, CreateView): + template_name = 'pretixcontrol/organizers/customer_membership.html' + permission = 'can_manage_customers' + context_object_name = 'membership' + form_class = MembershipUpdateForm + + @cached_property + def customer(self): + return get_object_or_404( + self.request.organizer.customers, + identifier=self.kwargs.get('customer') + ) + + def get_form_kwargs(self): + kwargs = super().get_form_kwargs() + kwargs['instance'] = Membership( + customer=self.customer, + ) + return kwargs + + def form_valid(self, form): + r = super().form_valid(form) + d = { + k: getattr(self.object, k) + for k in form.changed_data + } + d['id'] = self.object.pk + self.customer.log_action('pretix.customer.membership.created', user=self.request.user, data=d) + messages.success(self.request, _('Your changes have been saved.')) + return r + + def get_success_url(self): + return reverse('control:organizer.customer', kwargs={ + 'organizer': self.request.organizer.slug, + 'customer': self.object.customer.identifier, + }) + + +class CustomerAnonymizeView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin, DetailView): + template_name = 'pretixcontrol/organizers/customer_anonymize.html' + permission = 'can_manage_customers' + context_object_name = 'customer' + + def get_object(self, queryset=None): + return get_object_or_404( + self.request.organizer.customers, + identifier=self.kwargs.get('customer') + ) + + def post(self, request, *args, **kwargs): + self.object = self.get_object() + with transaction.atomic(): + self.object.anonymize() + self.object.log_action('pretix.customer.anonymized', user=self.request.user) + messages.success(self.request, _('The customer account has been anonymized.')) + return redirect(self.get_success_url()) + + def get_success_url(self): + return reverse('control:organizer.customer', kwargs={ + 'organizer': self.request.organizer.slug, + 'customer': self.object.identifier, + }) diff --git a/src/pretix/control/views/typeahead.py b/src/pretix/control/views/typeahead.py index 467f3c06c..3f4280ff7 100644 --- a/src/pretix/control/views/typeahead.py +++ b/src/pretix/control/views/typeahead.py @@ -51,7 +51,9 @@ from pretix.base.models import ( ItemVariation, Order, Organizer, User, Voucher, ) from pretix.control.forms.event import EventWizardCopyForm -from pretix.control.permissions import event_permission_required +from pretix.control.permissions import ( + event_permission_required, organizer_permission_required, +) from pretix.helpers.daterange import daterange from pretix.helpers.i18n import i18ncomp @@ -169,6 +171,36 @@ def event_list(request): return JsonResponse(doc) +@organizer_permission_required("can_manage_customers") +def customer_select2(request, **kwargs): + query = request.GET.get('query', '') + try: + page = int(request.GET.get('page', '1')) + except ValueError: + page = 1 + + qs = request.organizer.customers.filter( + Q(email__icontains=query) | Q(name_cached__icontains=query) | Q(identifier__istartswith=query) + ).order_by('name_cached') + + total = qs.count() + pagesize = 20 + offset = (page - 1) * pagesize + doc = { + 'results': [ + { + 'id': e.pk, + 'text': str(e), + } + for e in qs[offset:offset + pagesize] + ], + 'pagination': { + "more": total >= (offset + pagesize) + } + } + return JsonResponse(doc) + + def nav_context_list(request): query = request.GET.get('query', '').strip() organizer = request.GET.get('organizer', None) diff --git a/src/pretix/presale/checkoutflow.py b/src/pretix/presale/checkoutflow.py index c8f0a13c7..bc4008776 100644 --- a/src/pretix/presale/checkoutflow.py +++ b/src/pretix/presale/checkoutflow.py @@ -40,6 +40,7 @@ from django.conf import settings from django.contrib import messages from django.core.exceptions import ValidationError from django.core.validators import EmailValidator +from django.db.models import F, Q from django.http import HttpResponseNotAllowed, JsonResponse from django.shortcuts import redirect from django.utils import translation @@ -50,26 +51,29 @@ from django.utils.translation import ( from django.views.generic.base import TemplateResponseMixin from django_scopes import scopes_disabled -from pretix.base.models import Order +from pretix.base.models import Customer, Order from pretix.base.models.orders import InvoiceAddress, OrderPayment from pretix.base.models.tax import TaxedPrice, TaxRule from pretix.base.services.cart import ( CartError, error_messages, get_fees, set_cart_addons, update_tax_rates, ) +from pretix.base.services.memberships import validate_memberships_in_order from pretix.base.services.orders import perform_order from pretix.base.signals import validate_cart_addons from pretix.base.templatetags.rich_text import rich_text_snippet from pretix.base.views.tasks import AsyncAction from pretix.multidomain.urlreverse import eventreverse from pretix.presale.forms.checkout import ( - ContactForm, InvoiceAddressForm, InvoiceNameForm, + ContactForm, InvoiceAddressForm, InvoiceNameForm, MembershipForm, ) +from pretix.presale.forms.customer import AuthenticationForm, RegistrationForm from pretix.presale.signals import ( checkout_all_optional, checkout_confirm_messages, checkout_flow_steps, contact_form_fields, contact_form_fields_overrides, order_meta_from_request, question_form_fields, question_form_fields_overrides, ) +from pretix.presale.utils import customer_login from pretix.presale.views import ( CartMixin, get_cart, get_cart_is_free, get_cart_total, ) @@ -222,6 +226,200 @@ class TemplateFlowStep(TemplateResponseMixin, BaseCheckoutFlowStep): raise NotImplementedError() +class CustomerStep(QuestionsViewMixin, CartMixin, TemplateFlowStep): + priority = 45 + identifier = "customer" + template_name = "pretixpresale/event/checkout_customer.html" + label = pgettext_lazy('checkoutflow', 'Customer account') + icon = 'user' + + def is_applicable(self, request): + return request.organizer.settings.customer_accounts + + @cached_property + def login_form(self): + f = AuthenticationForm( + data=( + self.request.POST + if self.request.method == "POST" and self.request.POST.get('customer_mode') == 'login' + else None + ), + prefix='login', + request=self.request.event, + ) + for field in f.fields.values(): + field._show_required = field.required + field.required = False + field.widget.is_required = False + return f + + @cached_property + def guest_allowed(self): + return not any( + p.item.require_membership or + (p.variation and p.variation.require_membership) or + p.item.grant_membership_type_id + for p in self.positions + ) + + @cached_property + def register_form(self): + f = RegistrationForm( + data=( + self.request.POST + if self.request.method == "POST" and self.request.POST.get('customer_mode') == 'register' + else None + ), + prefix='register', + request=self.request, + ) + for field in f.fields.values(): + field._show_required = field.required + field.required = False + field.widget.is_required = False + return f + + def post(self, request): + self.request = request + + if request.POST.get("customer_mode") == 'login': + if 'customer' in self.cart_session: + return redirect(self.get_next_url(request)) + elif request.customer: + self.cart_session['customer_mode'] = 'login' + self.cart_session['customer'] = request.customer.pk + return redirect(self.get_next_url(request)) + elif self.login_form.is_valid(): + customer_login(self.request, self.login_form.get_customer()) + self.cart_session['customer_mode'] = 'login' + self.cart_session['customer'] = self.login_form.get_customer().pk + return redirect(self.get_next_url(request)) + else: + return self.render() + elif request.POST.get("customer_mode") == 'register': + if self.register_form.is_valid(): + customer = self.register_form.create() + self.cart_session['customer_mode'] = 'login' + self.cart_session['customer'] = customer.pk + return redirect(self.get_next_url(request)) + else: + return self.render() + elif request.POST.get("customer_mode") == 'guest' and self.guest_allowed: + self.cart_session['customer'] = None + self.cart_session['customer_mode'] = 'guest' + return redirect(self.get_next_url(request)) + else: + return self.render() + + def is_completed(self, request, warn=False): + self.request = request + if self.guest_allowed: + return 'customer_mode' in self.cart_session + else: + return self.cart_session.get('customer_mode') == 'login' + + def get_context_data(self, **kwargs): + ctx = super().get_context_data(**kwargs) + ctx['cart'] = self.get_cart() + ctx['cart_session'] = self.cart_session + ctx['login_form'] = self.login_form + ctx['register_form'] = self.register_form + ctx['selected'] = self.request.POST.get( + 'customer_mode', + self.cart_session.get('customer_mode', 'login' if self.request.customer else '') + ) + ctx['guest_allowed'] = self.guest_allowed + + if 'customer' in self.cart_session: + try: + ctx['customer'] = self.request.organizer.customers.get(pk=self.cart_session.get('customer', -1)) + except Customer.DoesNotExist: + self.cart_session['customer'] = None + self.cart_session['customer_mode'] = None + elif self.request.customer: + ctx['customer'] = self.request.customer + + return ctx + + +class MembershipStep(QuestionsViewMixin, CartMixin, TemplateFlowStep): + priority = 47 + identifier = "membership" + template_name = "pretixpresale/event/checkout_membership.html" + label = pgettext_lazy('checkoutflow', 'Membership') + icon = 'id-card' + + def is_applicable(self, request): + self.request = request + return bool(self.applicable_positions) + + @cached_property + def applicable_positions(self): + return [ + p for p in self.positions + if p.item.require_membership or (p.variation and p.variation.require_membership) + ] + + @cached_property + def forms(self): + forms = [] + + memberships = list(self.cart_customer.memberships.with_usages().filter( + Q(Q(membership_type__max_usages__isnull=True) | Q(usages__lt=F('membership_type__max_usages'))), + ).select_related('membership_type')) + + for p in self.applicable_positions: + form = MembershipForm( + event=self.request.event, + memberships=memberships, + position=p, + prefix=f"membership-{p.id}", + initial={ + 'membership': str(p.used_membership_id) + }, + data=self.request.POST if self.request.method == "POST" else None, + ) + forms.append(form) + + return forms + + def post(self, request): + self.request = request + + for f in self.forms: + if not f.is_valid(): + messages.error(request, _('Your cart includes a product that requires an active membership to be selected.')) + return self.render() + + f.position.used_membership = f.cleaned_data['membership'] + + try: + validate_memberships_in_order(self.cart_customer, self.positions, self.request.event, lock=False) + except ValidationError as e: + messages.error(self.request, e.message) + self.render() + else: + for f in self.forms: + f.position.save(update_fields=['used_membership']) + + return redirect(self.get_next_url(request)) + + def is_completed(self, request, warn=False): + self.request = request + ok = all([p.used_membership_id for p in self.applicable_positions]) + if not ok and warn: + messages.error(request, _('Your cart includes a product that requires an active membership to be selected.')) + return ok + + def get_context_data(self, **kwargs): + ctx = super().get_context_data(**kwargs) + ctx['cart'] = self.get_cart() + ctx['cart_session'] = self.cart_session + ctx['forms'] = self.forms + + return ctx + + class AddOnsStep(CartMixin, AsyncAction, TemplateFlowStep): priority = 40 identifier = "addons" @@ -486,12 +684,14 @@ class QuestionsStep(QuestionsViewMixin, CartMixin, TemplateFlowStep): initial.update({ k: v['initial'] for k, v in overrides.items() if 'initial' in v }) + if self.cart_customer: + initial['email'] = self.cart_customer.email f = ContactForm(data=self.request.POST if self.request.method == "POST" else None, event=self.request.event, request=self.request, initial=initial, all_optional=self.all_optional) - if wd.get('email', '') and wd.get('fix', '') == "true": + if wd.get('email', '') and wd.get('fix', '') == "true" or self.cart_customer: f.fields['email'].disabled = True for overrides in override_sets: @@ -504,13 +704,31 @@ class QuestionsStep(QuestionsViewMixin, CartMixin, TemplateFlowStep): return f def get_question_override_sets(self, cart_position): - return [ + o = [] + if self.cart_customer: + o.append({ + 'attendee_name_parts': { + 'initial': self.cart_customer.name_parts + } + }) + o += [ resp for recv, resp in question_form_fields_overrides.send( self.request.event, position=cart_position, request=self.request ) ] + if cart_position.used_membership: + d = { + 'initial': cart_position.used_membership.attendee_name_parts + } + if not cart_position.used_membership.membership_type.transferable: + d['disabled'] = True + o.append({ + 'attendee_name_parts': d + }) + + return o @cached_property def eu_reverse_charge_relevant(self): @@ -538,6 +756,11 @@ class QuestionsStep(QuestionsViewMixin, CartMixin, TemplateFlowStep): wd_initial = {} initial = dict(wd_initial) + if self.cart_customer: + initial.update({ + 'name_parts': self.cart_customer.name_parts + }) + override_sets = self._contact_override_sets for overrides in override_sets: initial.update({ @@ -852,6 +1075,7 @@ class ConfirmStep(CartMixin, AsyncAction, TemplateFlowStep): ctx['confirm_messages'] = self.confirm_messages ctx['cart_session'] = self.cart_session ctx['invoice_address_asked'] = self.address_asked + ctx['customer'] = self.cart_customer self.cart_session['shown_total'] = str(ctx['cart']['total']) @@ -928,11 +1152,19 @@ class ConfirmStep(CartMixin, AsyncAction, TemplateFlowStep): for receiver, response in order_meta_from_request.send(sender=request.event, request=request): meta_info.update(response) - return self.do(self.request.event.id, self.payment_provider.identifier if self.payment_provider else None, - [p.id for p in self.positions], self.cart_session.get('email'), - translation.get_language(), self.invoice_address.pk, meta_info, - request.sales_channel.identifier, self.cart_session.get('gift_cards'), - self.cart_session.get('shown_total')) + return self.do( + self.request.event.id, + payment_provider=self.payment_provider.identifier if self.payment_provider else None, + positions=[p.id for p in self.positions], + email=self.cart_session.get('email'), + locale=translation.get_language(), + address=self.invoice_address.pk, + meta_info=meta_info, + sales_channel=request.sales_channel.identifier, + gift_cards=self.cart_session.get('gift_cards'), + shown_total=self.cart_session.get('shown_total'), + customer=self.cart_session.get('customer'), + ) def get_success_message(self, value): create_empty_cart_id(self.request) @@ -966,6 +1198,8 @@ class ConfirmStep(CartMixin, AsyncAction, TemplateFlowStep): DEFAULT_FLOW = ( AddOnsStep, + CustomerStep, + MembershipStep, QuestionsStep, PaymentStep, ConfirmStep diff --git a/src/pretix/presale/forms/checkout.py b/src/pretix/presale/forms/checkout.py index def8a7dc5..b0113e368 100644 --- a/src/pretix/presale/forms/checkout.py +++ b/src/pretix/presale/forms/checkout.py @@ -37,6 +37,9 @@ from itertools import chain from django import forms from django.core.exceptions import ValidationError from django.utils.encoding import force_str +from django.utils.formats import date_format +from django.utils.html import escape +from django.utils.safestring import mark_safe from django.utils.translation import gettext_lazy as _ from phonenumber_field.formfields import PhoneNumberField from phonenumber_field.phonenumber import PhoneNumber @@ -178,3 +181,60 @@ class AddOnVariationField(forms.ChoiceField): if value == k or text_value == force_str(k): return True return False + + +class MembershipForm(forms.Form): + required_css_class = 'required' + + def __init__(self, *args, **kwargs): + self.memberships = kwargs.pop('memberships') + event = kwargs.pop('event') + self.position = kwargs.pop('position') + + super().__init__(*args, **kwargs) + + ev = self.position.subevent or event + if self.position.variation and self.position.variation.require_membership: + types = self.position.variation.require_membership_types.all() + else: + types = self.position.item.require_membership_types.all() + + initial = None + + memberships = [ + m for m in self.memberships + if m.is_valid(ev) and m.membership_type in types + ] + + if len(memberships) == 1: + initial = str(memberships[0].pk) + + self.fields['membership'] = forms.ChoiceField( + label=_('Membership'), + choices=[ + (str(m.pk), self._label_from_instance(m)) + for m in memberships + ], + initial=initial, + widget=forms.RadioSelect, + ) + self.is_empty = not memberships + + def _label_from_instance(self, obj): + ds = date_format(obj.date_start, 'SHORT_DATE_FORMAT') + de = date_format(obj.date_end, 'SHORT_DATE_FORMAT') + if obj.membership_type.max_usages is not None: + usages = f'({obj.usages} / {obj.membership_type.max_usages})' + else: + usages = '' + return mark_safe( + f'{escape(obj.membership_type)} {usages}
' + f'{escape(obj.attendee_name)}
' + f'{ds} – {de}' + ) + + def clean(self): + d = super().clean() + if d.get('membership'): + d['membership'] = [m for m in self.memberships if str(m.pk) == d['membership']][0] + return d diff --git a/src/pretix/presale/forms/customer.py b/src/pretix/presale/forms/customer.py new file mode 100644 index 000000000..2bee0e9bd --- /dev/null +++ b/src/pretix/presale/forms/customer.py @@ -0,0 +1,456 @@ +# +# This file is part of pretix (Community Edition). +# +# Copyright (C) 2014-2020 Raphael Michel and contributors +# Copyright (C) 2020-2021 rami.io 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 . +# +# 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 +# . +# +import hashlib +import ipaddress + +from django import forms +from django.conf import settings +from django.contrib.auth.hashers import check_password +from django.contrib.auth.password_validation import ( + password_validators_help_texts, validate_password, +) +from django.contrib.auth.tokens import PasswordResetTokenGenerator +from django.utils.functional import cached_property +from django.utils.translation import gettext_lazy as _ + +from pretix.base.forms.questions import NamePartsFormField +from pretix.base.i18n import get_language_without_region +from pretix.base.models import Customer +from pretix.base.services.mail import mail +from pretix.helpers.http import get_client_ip +from pretix.multidomain.urlreverse import build_absolute_uri + + +class TokenGenerator(PasswordResetTokenGenerator): + key_salt = "pretix.presale.forms.customer.TokenGenerator" + + +class AuthenticationForm(forms.Form): + required_css_class = 'required' + email = forms.EmailField( + label=_("E-mail"), + widget=forms.EmailInput(attrs={'autofocus': True}) + ) + password = forms.CharField( + label=_("Password"), + strip=False, + widget=forms.PasswordInput(attrs={'autocomplete': 'current-password'}), + ) + + error_messages = { + 'incomplete': _('You need to fill out all fields.'), + 'invalid_login': _( + "We have not found an account with this email address and password." + ), + 'inactive': _("This account is disabled."), + 'unverified': _("You have not yet activated your account and set a password. Please click the link in the " + "email we sent you. Click \"Reset password\" to receive a new email in case you cannot find " + "it again."), + } + + def __init__(self, request=None, *args, **kwargs): + self.request = request + self.customer_cache = None + super().__init__(*args, **kwargs) + + def clean(self): + email = self.cleaned_data.get('email') + password = self.cleaned_data.get('password') + + if email is not None and password: + try: + u = self.request.organizer.customers.get(email=email) + except Customer.DoesNotExist: + # Run the default password hasher once to reduce the timing + # difference between an existing and a nonexistent user (django #20760). + Customer().set_password(password) + else: + if u.check_password(password): + self.customer_cache = u + if self.customer_cache is None: + raise forms.ValidationError( + self.error_messages['invalid_login'], + code='invalid_login', + ) + else: + self.confirm_login_allowed(self.customer_cache) + else: + raise forms.ValidationError( + self.error_messages['incomplete'], + code='incomplete' + ) + + return self.cleaned_data + + def confirm_login_allowed(self, user): + if not user.is_active: + raise forms.ValidationError( + self.error_messages['inactive'], + code='inactive', + ) + if not user.is_verified: + raise forms.ValidationError( + self.error_messages['unverified'], + code='unverified', + ) + + def get_customer(self): + return self.customer_cache + + +class RegistrationForm(forms.Form): + required_css_class = 'required' + name_parts = forms.CharField() + email = forms.EmailField( + label=_("E-mail"), + ) + + error_messages = { + 'rate_limit': _("We've received a lot of registration requests from you, please wait 10 minutes before you try again."), + 'duplicate': _( + "An account with this email address is already registered. Please try to log in or reset your password " + "instead." + ), + 'required': _('This field is required.'), + } + + def __init__(self, request=None, *args, **kwargs): + self.request = request + super().__init__(*args, **kwargs) + + self.fields['name_parts'] = NamePartsFormField( + max_length=255, + required=True, + scheme=request.organizer.settings.name_scheme, + titles=request.organizer.settings.name_scheme_titles, + label=_('Name'), + ) + + @cached_property + def ratelimit_key(self): + if not settings.HAS_REDIS: + return None + client_ip = get_client_ip(self.request) + if not client_ip: + return None + try: + client_ip = ipaddress.ip_address(client_ip) + except ValueError: + # Web server not set up correctly + return None + if client_ip.is_private: + # This is the private IP of the server, web server not set up correctly + return None + return 'pretix_customer_registration_{}'.format(hashlib.sha1(str(client_ip).encode()).hexdigest()) + + def clean(self): + email = self.cleaned_data.get('email') + + if email is not None: + try: + self.request.organizer.customers.get(email=email) + except Customer.DoesNotExist: + pass + else: + raise forms.ValidationError( + {'email': self.error_messages['duplicate']}, + code='duplicate', + ) + + if not self.cleaned_data.get('email'): + raise forms.ValidationError( + {'email': self.error_messages['required']}, + code='incomplete' + ) + else: + if self.ratelimit_key: + from django_redis import get_redis_connection + + rc = get_redis_connection("redis") + cnt = rc.incr(self.ratelimit_key) + rc.expire(self.ratelimit_key, 600) + if cnt > 10: + raise forms.ValidationError( + self.error_messages['rate_limit'], + code='rate_limit', + ) + return self.cleaned_data + + def create(self): + customer = self.request.organizer.customers.create( + email=self.cleaned_data['email'], + name_parts=self.cleaned_data['name_parts'], + is_active=True, + is_verified=False, + locale=get_language_without_region(), + ) + customer.set_unusable_password() + customer.save() + customer.log_action('pretix.customer.created', {}) + ctx = customer.get_email_context() + token = TokenGenerator().make_token(customer) + ctx['url'] = build_absolute_uri(self.request.organizer, + 'presale:organizer.customer.activate') + '?id=' + customer.identifier + '&token=' + token + mail( + customer.email, + _('Activate your account at {organizer}').format(organizer=self.request.organizer.name), + self.request.organizer.settings.mail_text_customer_registration, + ctx, + locale=customer.locale, + customer=customer, + organizer=self.request.organizer, + ) + return customer + + +class SetPasswordForm(forms.Form): + required_css_class = 'required' + error_messages = { + 'pw_mismatch': _("Please enter the same password twice"), + } + email = forms.EmailField( + label=_('E-mail'), + disabled=True + ) + password = forms.CharField( + label=_('Password'), + widget=forms.PasswordInput(attrs={'minlength': '8', 'autocomplete': 'new-password'}), + required=True + ) + password_repeat = forms.CharField( + label=_('Repeat password'), + widget=forms.PasswordInput(attrs={'minlength': '8', 'autocomplete': 'new-password'}), + ) + + def __init__(self, customer=None, *args, **kwargs): + self.customer = customer + kwargs.setdefault('initial', {}) + kwargs['initial']['email'] = self.customer.email + super().__init__(*args, **kwargs) + + def clean(self): + password1 = self.cleaned_data.get('password', '') + password2 = self.cleaned_data.get('password_repeat') + + if password1 and password1 != password2: + raise forms.ValidationError({ + 'password_repeat': self.error_messages['pw_mismatch'], + }, code='pw_mismatch') + + return self.cleaned_data + + def clean_password(self): + password1 = self.cleaned_data.get('password', '') + if validate_password(password1, user=self.customer) is not None: + raise forms.ValidationError(_(password_validators_help_texts()), code='pw_invalid') + return password1 + + +class ResetPasswordForm(forms.Form): + required_css_class = 'required' + error_messages = { + 'rate_limit': _("For security reasons, please wait 10 minutes before you try again."), + 'unknown': _("A user with this email address is not known in our system."), + } + email = forms.EmailField( + label=_('E-mail'), + ) + + def __init__(self, request=None, *args, **kwargs): + self.request = request + super().__init__(*args, **kwargs) + + def clean_email(self): + if 'email' not in self.cleaned_data: + return + try: + self.customer = self.request.organizer.customers.get(email=self.cleaned_data['email']) + return self.customer.email + except Customer.DoesNotExist: + # Yup, this is an information leak. But it prevents dozens of support requests – and even if we didn't + # have it, there'd be an info leak in the registration flow (trying to sign up for an account, which fails + # if the email address already exists). + raise forms.ValidationError(self.error_messages['unknown'], code='unknown') + + def clean(self): + d = super().clean() + if d.get('email') and settings.HAS_REDIS: + from django_redis import get_redis_connection + + rc = get_redis_connection("redis") + cnt = rc.incr('pretix_pwreset_customer_%s' % self.customer.pk) + rc.expire('pretix_pwreset_customer_%s' % self.customer.pk, 600) + if cnt > 2: + raise forms.ValidationError( + self.error_messages['rate_limit'], + code='rate_limit', + ) + return d + + +class ChangePasswordForm(forms.Form): + required_css_class = 'required' + error_messages = { + 'pw_current_wrong': _("The current password you entered was not correct."), + 'pw_mismatch': _("Please enter the same password twice"), + 'rate_limit': _("For security reasons, please wait 5 minutes before you try again."), + } + email = forms.EmailField( + label=_('E-mail'), + disabled=True + ) + password_current = forms.CharField( + label=_('Your current password'), + widget=forms.PasswordInput, + required=True + ) + password = forms.CharField( + label=_('New password'), + widget=forms.PasswordInput, + required=True + ) + password_repeat = forms.CharField( + label=_('Repeat password'), + widget=forms.PasswordInput(attrs={'minlength': '8', 'autocomplete': 'new-password'}), + ) + + def __init__(self, customer, *args, **kwargs): + self.customer = customer + kwargs.setdefault('initial', {}) + kwargs['initial']['email'] = self.customer.email + super().__init__(*args, **kwargs) + + def clean(self): + password1 = self.cleaned_data.get('password', '') + password2 = self.cleaned_data.get('password_repeat') + + if password1 and password1 != password2: + raise forms.ValidationError({ + 'password_repeat': self.error_messages['pw_mismatch'], + }, code='pw_mismatch') + + return self.cleaned_data + + def clean_password(self): + password1 = self.cleaned_data.get('password', '') + if validate_password(password1, user=self.customer) is not None: + raise forms.ValidationError(_(password_validators_help_texts()), code='pw_invalid') + return password1 + + def clean_password_current(self): + old_pw = self.cleaned_data.get('password_current') + + if old_pw and settings.HAS_REDIS: + from django_redis import get_redis_connection + + rc = get_redis_connection("redis") + cnt = rc.incr('pretix_pwchange_customer_%s' % self.customer.pk) + rc.expire('pretix_pwchange_customer_%s' % self.customer.pk, 300) + if cnt > 10: + raise forms.ValidationError( + self.error_messages['rate_limit'], + code='rate_limit', + ) + + if old_pw and not check_password(old_pw, self.customer.password): + raise forms.ValidationError( + self.error_messages['pw_current_wrong'], + code='pw_current_wrong', + ) + + +class ChangeInfoForm(forms.ModelForm): + required_css_class = 'required' + error_messages = { + 'pw_current_wrong': _("The current password you entered was not correct."), + 'rate_limit': _("For security reasons, please wait 5 minutes before you try again."), + 'duplicate': _("An account with this email address is already registered."), + } + password_current = forms.CharField( + label=_('Your current password'), + widget=forms.PasswordInput, + help_text=_('Only required if you change your email address'), + required=False + ) + + class Meta: + model = Customer + fields = ('name_parts', 'email') + + def __init__(self, request=None, *args, **kwargs): + self.request = request + super().__init__(*args, **kwargs) + + self.fields['name_parts'] = NamePartsFormField( + max_length=255, + required=True, + scheme=request.organizer.settings.name_scheme, + titles=request.organizer.settings.name_scheme_titles, + label=_('Name'), + ) + + def clean_password_current(self): + old_pw = self.cleaned_data.get('password_current') + + if old_pw: + if settings.HAS_REDIS: + from django_redis import get_redis_connection + + rc = get_redis_connection("redis") + cnt = rc.incr('pretix_pwchange_customer_%s' % self.instance.pk) + rc.expire('pretix_pwchange_customer_%s' % self.instance.pk, 300) + if cnt > 10: + raise forms.ValidationError( + self.error_messages['rate_limit'], + code='rate_limit', + ) + + if not check_password(old_pw, self.instance.password): + raise forms.ValidationError( + self.error_messages['pw_current_wrong'], + code='pw_current_wrong', + ) + + return "***valid***" + + def clean(self): + email = self.cleaned_data.get('email') + password_current = self.cleaned_data.get('password_current') + + if email != self.instance.email and not password_current: + raise forms.ValidationError( + self.error_messages['pw_current_wrong'], + code='pw_current_wrong', + ) + + if email is not None: + try: + self.request.organizer.customers.exclude(pk=self.instance.pk).get(email=email) + except Customer.DoesNotExist: + pass + else: + raise forms.ValidationError( + self.error_messages['duplicate'], + code='duplicate', + ) + + return self.cleaned_data diff --git a/src/pretix/presale/forms/renderers.py b/src/pretix/presale/forms/renderers.py index 7f68efa11..d97a1c49f 100644 --- a/src/pretix/presale/forms/renderers.py +++ b/src/pretix/presale/forms/renderers.py @@ -120,7 +120,10 @@ class CheckoutFieldRenderer(FieldRenderer): def add_label(self, html): label = self.get_label() - if hasattr(self.field.field, '_required'): + if hasattr(self.field.field, '_show_required'): + # e.g. payment settings forms where a field is only required if the payment provider is active + required = self.field.field._show_required + elif hasattr(self.field.field, '_required'): # e.g. payment settings forms where a field is only required if the payment provider is active required = self.field.field._required else: diff --git a/src/pretix/presale/templates/pretixpresale/event/base.html b/src/pretix/presale/templates/pretixpresale/event/base.html index f3882f02e..e189d4ac7 100644 --- a/src/pretix/presale/templates/pretixpresale/event/base.html +++ b/src/pretix/presale/templates/pretixpresale/event/base.html @@ -34,21 +34,24 @@ {% endif %}
+ {% if customer %} +
+
{% trans "Customer account" %}
+
{{ customer.email }}
{{ customer.name }}
#{{ customer.identifier }}
+
+ {% endif %} {% if not asked and event.settings.invoice_name_required %}
{% trans "Name" %}
diff --git a/src/pretix/presale/templates/pretixpresale/event/checkout_customer.html b/src/pretix/presale/templates/pretixpresale/event/checkout_customer.html new file mode 100644 index 000000000..0b9e7bbef --- /dev/null +++ b/src/pretix/presale/templates/pretixpresale/event/checkout_customer.html @@ -0,0 +1,141 @@ +{% extends "pretixpresale/event/checkout_base.html" %} +{% load i18n %} +{% load money %} +{% load bootstrap3 %} +{% load eventurl %} +{% load rich_text %} +{% block inner %} + + {% csrf_token %} +
+
+ + +
+
+ + +
+ {% if guest_allowed %} +
+ + +
+ {% endif %} +
+
+ +
+ +
+
+
+ +{% endblock %} diff --git a/src/pretix/presale/templates/pretixpresale/event/checkout_membership.html b/src/pretix/presale/templates/pretixpresale/event/checkout_membership.html new file mode 100644 index 000000000..5c7dafc68 --- /dev/null +++ b/src/pretix/presale/templates/pretixpresale/event/checkout_membership.html @@ -0,0 +1,97 @@ +{% extends "pretixpresale/event/checkout_base.html" %} +{% load i18n %} +{% load bootstrap3 %} +{% load rich_text %} +{% block inner %} +

{% trans "Some of the products in your cart can only be purchased if there is an active membership on your account." %}

+
+ {% csrf_token %} + {% for form in forms %} +
+ +

+ {{ form.position.item.name }}{% if form.position.variation %} + – {{ form.position.variation }} + {% endif %} + +

+
+
+
+ {% if form.position.seat %} +
+ +
+ + + + {{ form.position.seat }} +
+
+ {% endif %} + {% if form.position.addons.all %} +
+ +
+
    + {% for a in form.position.addons.all %} +
  • {{ a.item.name }}{% if a.variation %} – {{ a.variation.value }}{% endif %}
  • + {% endfor %} +
+
+
+ {% endif %} + {% if form.position.subevent %} +
+ +
+
    + {{ form.position.subevent.name }} · {{ form.position.subevent.get_date_range_display }} + {% if form.position.event.settings.show_times %} + + + {{ form.position.subevent.date_from|date:"TIME_FORMAT" }} + + {% endif %} +
+
+
+ {% endif %} + {% if form.is_empty %} +
+ {% trans "Your account does not include an active membership that allows you to buy this product." %} + {% trans "You will not be able to continue." %} +
+
+ {% bootstrap_form form layout="checkout" %} +
+ {% else %} + {% bootstrap_form form layout="checkout" %} + {% endif %} +
+
+
+ {% endfor %} +
+ +
+ +
+
+
+
+{% endblock %} diff --git a/src/pretix/presale/templates/pretixpresale/event/fragment_cart.html b/src/pretix/presale/templates/pretixpresale/event/fragment_cart.html index 8fec58412..02f29e47e 100644 --- a/src/pretix/presale/templates/pretixpresale/event/fragment_cart.html +++ b/src/pretix/presale/templates/pretixpresale/event/fragment_cart.html @@ -34,6 +34,9 @@ {% endif %} {% endif %} + {% if line.used_membership %} +
{{ line.used_membership }} + {% endif %} {% if line.issued_gift_cards %}
diff --git a/src/pretix/presale/templates/pretixpresale/fragment_login_status.html b/src/pretix/presale/templates/pretixpresale/fragment_login_status.html new file mode 100644 index 000000000..f8a520b5a --- /dev/null +++ b/src/pretix/presale/templates/pretixpresale/fragment_login_status.html @@ -0,0 +1,23 @@ +{% load i18n %} +{% load eventurl %} + +{% if request.organizer.settings.customer_accounts %} + +{% endif %} diff --git a/src/pretix/presale/templates/pretixpresale/organizers/base.html b/src/pretix/presale/templates/pretixpresale/organizers/base.html index d5f7fb650..4619cdc4b 100644 --- a/src/pretix/presale/templates/pretixpresale/organizers/base.html +++ b/src/pretix/presale/templates/pretixpresale/organizers/base.html @@ -8,16 +8,19 @@ {% block title %}{% endblock %}{% if url_name != "organizer.index" %} :: {% endif %}{{ organizer.name }} {% endblock %} {% block above %} - {% if organizer.settings.locales|length > 1 %} + {% if organizer.settings.locales|length > 1 or request.organizer.settings.customer_accounts %} {% if organizer.settings.theme_color_background|upper != "#FFFFFF" or organizer.settings.organizer_logo_image_large %} {% endif %} @@ -40,15 +43,18 @@

{{ organizer.name }}

{% endif %}
- {% if organizer.settings.locales|length > 1 %} + {% if organizer.settings.locales|length > 1 or request.organizer.settings.customer_accounts %} {% if organizer.settings.theme_color_background|upper == "#FFFFFF" and not organizer.settings.organizer_logo_image_large %} {% endif %} {% endif %} diff --git a/src/pretix/presale/templates/pretixpresale/organizers/customer_info.html b/src/pretix/presale/templates/pretixpresale/organizers/customer_info.html new file mode 100644 index 000000000..090baf9d1 --- /dev/null +++ b/src/pretix/presale/templates/pretixpresale/organizers/customer_info.html @@ -0,0 +1,27 @@ +{% extends "pretixpresale/organizers/base.html" %} +{% load i18n %} +{% load eventurl %} +{% load urlreplace %} +{% load bootstrap3 %} +{% block title %}{% trans "Account information" %}{% endblock %} +{% block content %} +
+
+

+ {% blocktrans trimmed %} + Update your account information + {% endblocktrans %} +

+
+ {% csrf_token %} + {% bootstrap_form form %} +
+ +
+
+
+
+

 

+{% endblock %} diff --git a/src/pretix/presale/templates/pretixpresale/organizers/customer_login.html b/src/pretix/presale/templates/pretixpresale/organizers/customer_login.html new file mode 100644 index 000000000..9ca8b7fad --- /dev/null +++ b/src/pretix/presale/templates/pretixpresale/organizers/customer_login.html @@ -0,0 +1,39 @@ +{% extends "pretixpresale/organizers/base.html" %} +{% load i18n %} +{% load eventurl %} +{% load urlreplace %} +{% load bootstrap3 %} +{% block title %}{% trans "Log in" %}{% endblock %} +{% block content %} +
+
+

+ {% blocktrans trimmed with org=request.organizer.name %} + Sign in to your account at {{ org }} + {% endblocktrans %} +

+
+ {% csrf_token %} + {% bootstrap_form form %} +
+ +
+ +
+
+
+

 

+{% endblock %} diff --git a/src/pretix/presale/templates/pretixpresale/organizers/customer_membership.html b/src/pretix/presale/templates/pretixpresale/organizers/customer_membership.html new file mode 100644 index 000000000..019012e40 --- /dev/null +++ b/src/pretix/presale/templates/pretixpresale/organizers/customer_membership.html @@ -0,0 +1,98 @@ +{% extends "pretixpresale/organizers/base.html" %} +{% load i18n %} +{% load eventurl %} +{% load urlreplace %} +{% load money %} +{% load bootstrap3 %} +{% block title %}{% trans "Your membership" %}{% endblock %} +{% block content %} +

+ {% trans "Your membership" %} +

+
+
+

+ {% trans "Details" %} +

+
+
+
+
{% trans "Membership type" %}
+
{{ membership.membership_type.name }}
+
{% trans "Valid from" %}
+
{{ membership.date_start|date:"SHORT_DATETIME_FORMAT" }} +
{% trans "Valid until" %}
+
{{ membership.date_end|date:"SHORT_DATETIME_FORMAT" }} +
{% trans "Attendee name" %}
+
{{ membership.attendee_name }} +
{% trans "Maximum usages" %}
+
{{ membership.membership_type.max_usages|default_if_none:"–" }}
+
+
+
+
+
+

+ {% trans "Usages" %} +

+
+ + + + + + + + + + + + + {% for op in usages %} + + + + + + + + + {% endfor %} + +
{% trans "Order code" %}{% trans "Event" %}{% trans "Product" %}{% trans "Order date" %}{% trans "Status" %}
+ + {{ op.order.code }}-{{ op.positionid }} + + {% if op.order.testmode %} + {% trans "TEST MODE" %} + {% endif %} + + {{ op.order.event }} + {% if op.subevent %} +
+ {{ op.subevent|default:"" }} + {% endif %} +
+ {{ op.item.name }} + {% if op.variation %}– {{ op.variation }}{% endif %} + + {{ op.order.datetime|date:"SHORT_DATETIME_FORMAT" }} + + {% if op.canceled %} + + + {% trans "Canceled" %} + + {% else %} + {% include "pretixcontrol/orders/fragment_order_status.html" with order=op.order %} + {% endif %} + + + {% trans "Details" %} + +
+ {% include "pretixcontrol/pagination.html" %} +
+{% endblock %} diff --git a/src/pretix/presale/templates/pretixpresale/organizers/customer_password.html b/src/pretix/presale/templates/pretixpresale/organizers/customer_password.html new file mode 100644 index 000000000..4eb6eb868 --- /dev/null +++ b/src/pretix/presale/templates/pretixpresale/organizers/customer_password.html @@ -0,0 +1,27 @@ +{% extends "pretixpresale/organizers/base.html" %} +{% load i18n %} +{% load eventurl %} +{% load urlreplace %} +{% load bootstrap3 %} +{% block title %}{% trans "Password reset" %}{% endblock %} +{% block content %} +
+
+

+ {% blocktrans trimmed %} + Set a new password for your account + {% endblocktrans %} +

+
+ {% csrf_token %} + {% bootstrap_form form %} +
+ +
+
+
+
+

 

+{% endblock %} diff --git a/src/pretix/presale/templates/pretixpresale/organizers/customer_profile.html b/src/pretix/presale/templates/pretixpresale/organizers/customer_profile.html new file mode 100644 index 000000000..d1b7b29b8 --- /dev/null +++ b/src/pretix/presale/templates/pretixpresale/organizers/customer_profile.html @@ -0,0 +1,158 @@ +{% extends "pretixpresale/organizers/base.html" %} +{% load i18n %} +{% load eventurl %} +{% load urlreplace %} +{% load money %} +{% load bootstrap3 %} +{% block title %}{% trans "Your account" %}{% endblock %} +{% block content %} +

+ {% trans "Your account" %} +

+
+
+

+ {% trans "Account information" %} +

+
+
+
+
{% trans "Customer ID" %}
+
#{{ customer.identifier }}
+
{% trans "E-mail" %}
+
{{ customer.email }} +
+
{% trans "Name" %}
+
{{ customer.name }}
+
+ +
+
+
+
+

+ {% trans "Memberships" %} +

+
+ + + + + + + + + + + + + {% for m in memberships %} + + + + + + + + + {% endfor %} + +
{% trans "Membership type" %}{% trans "Valid from" %}{% trans "Valid until" %}{% trans "Attendee name" %}{% trans "Usages" %}
+ {{ m.membership_type.name }} + + {{ m.date_start|date:"SHORT_DATETIME_FORMAT" }} + + {{ m.date_end|date:"SHORT_DATETIME_FORMAT" }} + + {{ m.attendee_name }} + +
+
+
+
+
+
+ {{ m.usages }} / + {{ m.membership_type.max_usages|default_if_none:"∞" }} +
+
+
+ + + +
+
+
+
+

+ {% trans "Orders" %} +

+
+ + + + + + + + + + + + + + {% for o in orders %} + + + + + + + + + + {% endfor %} + +
{% trans "Order code" %}{% trans "Event" %}{% trans "Order date" %}{% trans "Order total" %}{% trans "Positions" %}{% trans "Status" %}
+ + + {{ o.code }} + + + {% if o.testmode %} + {% trans "TEST MODE" %} + {% endif %} + + {{ o.event }} + + {{ o.datetime|date:"SHORT_DATETIME_FORMAT" }} + {% if o.customer_id != customer.pk %} + + {% endif %} + + {{ o.total|money:o.event.currency }} + {{ o.count_positions|default_if_none:"0" }}{% include "pretixpresale/event/fragment_order_status.html" with order=o event=o.event %} + + {% trans "Details" %} + +
+ {% include "pretixcontrol/pagination.html" %} +
+{% endblock %} diff --git a/src/pretix/presale/templates/pretixpresale/organizers/customer_registration.html b/src/pretix/presale/templates/pretixpresale/organizers/customer_registration.html new file mode 100644 index 000000000..efaf01621 --- /dev/null +++ b/src/pretix/presale/templates/pretixpresale/organizers/customer_registration.html @@ -0,0 +1,30 @@ +{% extends "pretixpresale/organizers/base.html" %} +{% load i18n %} +{% load eventurl %} +{% load urlreplace %} +{% load bootstrap3 %} +{% block title %}{% trans "Registration" %}{% endblock %} +{% block content %} +
+
+

+ {% blocktrans trimmed with org=request.organizer.name %} + Create a new account at {{ org }} + {% endblocktrans %} +

+
+ {% csrf_token %} + {% bootstrap_form form %} +
+ +
+ + {% trans "Log in to an existing account" %} + +
+
+
+

 

+{% endblock %} diff --git a/src/pretix/presale/templates/pretixpresale/organizers/customer_resetpw.html b/src/pretix/presale/templates/pretixpresale/organizers/customer_resetpw.html new file mode 100644 index 000000000..02a705cce --- /dev/null +++ b/src/pretix/presale/templates/pretixpresale/organizers/customer_resetpw.html @@ -0,0 +1,27 @@ +{% extends "pretixpresale/organizers/base.html" %} +{% load i18n %} +{% load eventurl %} +{% load urlreplace %} +{% load bootstrap3 %} +{% block title %}{% trans "Password reset" %}{% endblock %} +{% block content %} +
+
+

+ {% blocktrans trimmed %} + Password reset + {% endblocktrans %} +

+
+ {% csrf_token %} + {% bootstrap_form form %} +
+ +
+
+
+
+

 

+{% endblock %} diff --git a/src/pretix/presale/templates/pretixpresale/organizers/customer_setpassword.html b/src/pretix/presale/templates/pretixpresale/organizers/customer_setpassword.html new file mode 100644 index 000000000..4eb6eb868 --- /dev/null +++ b/src/pretix/presale/templates/pretixpresale/organizers/customer_setpassword.html @@ -0,0 +1,27 @@ +{% extends "pretixpresale/organizers/base.html" %} +{% load i18n %} +{% load eventurl %} +{% load urlreplace %} +{% load bootstrap3 %} +{% block title %}{% trans "Password reset" %}{% endblock %} +{% block content %} +
+
+

+ {% blocktrans trimmed %} + Set a new password for your account + {% endblocktrans %} +

+
+ {% csrf_token %} + {% bootstrap_form form %} +
+ +
+
+
+
+

 

+{% endblock %} diff --git a/src/pretix/presale/urls.py b/src/pretix/presale/urls.py index 797553704..6cfb53bc7 100644 --- a/src/pretix/presale/urls.py +++ b/src/pretix/presale/urls.py @@ -37,6 +37,7 @@ from django.views.decorators.csrf import csrf_exempt import pretix.presale.views.cart import pretix.presale.views.checkout +import pretix.presale.views.customer import pretix.presale.views.event import pretix.presale.views.locale import pretix.presale.views.order @@ -165,6 +166,17 @@ organizer_patterns = [ url(r'^widget/product_list$', pretix.presale.views.widget.WidgetAPIProductList.as_view(), name='organizer.widget.productlist'), url(r'^widget/v1.css$', pretix.presale.views.widget.widget_css, name='organizer.widget.css'), + url(r'^account/login$', pretix.presale.views.customer.LoginView.as_view(), name='organizer.customer.login'), + url(r'^account/logout$', pretix.presale.views.customer.LogoutView.as_view(), name='organizer.customer.logout'), + url(r'^account/register$', pretix.presale.views.customer.RegistrationView.as_view(), name='organizer.customer.register'), + url(r'^account/pwreset$', pretix.presale.views.customer.ResetPasswordView.as_view(), name='organizer.customer.resetpw'), + url(r'^account/pwrecover$', pretix.presale.views.customer.SetPasswordView.as_view(), name='organizer.customer.recoverpw'), + url(r'^account/activate$', pretix.presale.views.customer.SetPasswordView.as_view(), name='organizer.customer.activate'), + url(r'^account/password$', pretix.presale.views.customer.ChangePasswordView.as_view(), name='organizer.customer.password'), + url(r'^account/change$', pretix.presale.views.customer.ChangeInformationView.as_view(), name='organizer.customer.change'), + url(r'^account/confirmchange$', pretix.presale.views.customer.ConfirmChangeView.as_view(), name='organizer.customer.change.confirm'), + url(r'^account/membership/(?P\d+)/$', pretix.presale.views.customer.MembershipUsageView.as_view(), name='organizer.customer.membership'), + url(r'^account/$', pretix.presale.views.customer.ProfileView.as_view(), name='organizer.customer.profile'), ] locale_patterns = [ diff --git a/src/pretix/presale/utils.py b/src/pretix/presale/utils.py index cf7dff81b..ffbdef482 100644 --- a/src/pretix/presale/utils.py +++ b/src/pretix/presale/utils.py @@ -39,15 +39,19 @@ from urllib.parse import urljoin from django.conf import settings from django.core.exceptions import PermissionDenied from django.http import Http404 +from django.middleware.csrf import rotate_token from django.shortcuts import redirect from django.template.response import TemplateResponse from django.urls import resolve +from django.utils.crypto import constant_time_compare +from django.utils.functional import SimpleLazyObject +from django.utils.timezone import now from django.utils.translation import gettext_lazy as _ from django.views.defaults import permission_denied from django_scopes import scope from pretix.base.middleware import LocaleMiddleware -from pretix.base.models import Event, Organizer +from pretix.base.models import Customer, Event, Organizer from pretix.multidomain.urlreverse import ( get_event_domain, get_organizer_domain, ) @@ -56,8 +60,96 @@ from pretix.presale.signals import process_request, process_response SessionStore = import_module(settings.SESSION_ENGINE).SessionStore +def get_customer(request): + if not hasattr(request, '_cached_customer'): + session_key = f'customer_auth_id:{request.organizer.pk}' + hash_session_key = f'customer_auth_hash:{request.organizer.pk}' + + with scope(organizer=request.organizer): + try: + customer = request.organizer.customers.get( + is_active=True, is_verified=True, + pk=request.session[session_key] + ) + except (Customer.DoesNotExist, KeyError): + request._cached_customer = None + else: + session_hash = request.session.get(hash_session_key) + session_hash_verified = session_hash and constant_time_compare( + session_hash, + customer.get_session_auth_hash() + ) + if session_hash_verified: + request._cached_customer = customer + else: + request.session.flush() + request._cached_customer = None + + return request._cached_customer + + +def update_customer_session_auth_hash(request, customer): + hash_session_key = f'customer_auth_hash:{request.organizer.pk}' + session_auth_hash = customer.get_session_auth_hash() + request.session.cycle_key() + request.session[hash_session_key] = session_auth_hash + + +def add_customer_to_request(request): + request.customer = SimpleLazyObject(lambda: get_customer(request)) + + +def customer_login(request, customer): + session_key = f'customer_auth_id:{request.organizer.pk}' + hash_session_key = f'customer_auth_hash:{request.organizer.pk}' + session_auth_hash = customer.get_session_auth_hash() + + if session_key in request.session: + if request.session[session_key] != customer.pk or ( + not constant_time_compare(request.session.get(hash_session_key, ''), session_auth_hash)): + # To avoid reusing another user's session, create a new, empty + # session if the existing session corresponds to a different + # authenticated user. + request.session.flush() + else: + request.session.cycle_key() + + request.session[session_key] = customer.pk + request.session[hash_session_key] = session_auth_hash + request.customer = customer + + customer.last_login = now() + customer.save(update_fields=['last_login']) + + rotate_token(request) + + +def customer_logout(request): + session_key = f'customer_auth_id:{request.organizer.pk}' + hash_session_key = f'customer_auth_hash:{request.organizer.pk}' + + # Remove user session + customer_id = request.session.pop(session_key, None) + request.session.pop(hash_session_key, None) + + # Remove carts tied to this user + carts = request.session.get('carts', {}) + for k, v in list(carts.items()): + if v.get('customer') == customer_id: + carts.pop(k) + request.session['carts'] = carts + + # Cycle session key and CSRF token + request.session.cycle_key() + rotate_token(request) + + request.customer = None + request._cached_customer = None + + @scope(organizer=None) def _detect_event(request, require_live=True, require_plugin=None): + if hasattr(request, '_event_detected'): return @@ -132,6 +224,9 @@ def _detect_event(request, require_live=True, require_plugin=None): r['Access-Control-Allow-Origin'] = '*' return r + if not hasattr(request, 'customer'): + add_customer_to_request(request) + if hasattr(request, 'event'): # Restrict locales to the ones available for this event LocaleMiddleware().process_request(request) diff --git a/src/pretix/presale/views/__init__.py b/src/pretix/presale/views/__init__.py index 9526afe02..c0c451b8f 100644 --- a/src/pretix/presale/views/__init__.py +++ b/src/pretix/presale/views/__init__.py @@ -46,7 +46,7 @@ from django_scopes import scopes_disabled from pretix.base.i18n import language from pretix.base.models import ( - CartPosition, InvoiceAddress, ItemAddOn, OrderPosition, Question, + CartPosition, Customer, InvoiceAddress, ItemAddOn, OrderPosition, Question, QuestionAnswer, QuestionOption, ) from pretix.base.services.cart import get_fees @@ -91,6 +91,14 @@ class CartMixin: from pretix.presale.views.cart import cart_session return cart_session(self.request) + @cached_property + def cart_customer(self): + if self.cart_session.get('customer_mode', 'guest') == 'login': + try: + return self.request.organizer.customers.get(pk=self.cart_session.get('customer', -1)) + except Customer.DoesNotExist: + return + @cached_property def invoice_address(self): return cached_invoice_address(self.request) @@ -273,7 +281,7 @@ def get_cart(request): 'item__category__position', 'item__category_id', 'item__position', 'item__name', 'variation__value' ).select_related( 'item', 'variation', 'subevent', 'subevent__event', 'subevent__event__organizer', - 'item__tax_rule', 'addon_to' + 'item__tax_rule', 'addon_to', 'used_membership', 'used_membership__membership_type' ).select_related( 'addon_to' ).prefetch_related( diff --git a/src/pretix/presale/views/customer.py b/src/pretix/presale/views/customer.py new file mode 100644 index 000000000..52a90cdc7 --- /dev/null +++ b/src/pretix/presale/views/customer.py @@ -0,0 +1,472 @@ +# +# This file is part of pretix (Community Edition). +# +# Copyright (C) 2014-2020 Raphael Michel and contributors +# Copyright (C) 2020-2021 rami.io 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 . +# +# 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 +# . +# +from urllib.parse import quote + +from django.contrib import messages +from django.core.signing import BadSignature, dumps, loads +from django.db import transaction +from django.db.models import Count, IntegerField, OuterRef, Q, Subquery +from django.http import Http404, HttpResponseRedirect +from django.shortcuts import get_object_or_404, redirect +from django.utils.decorators import method_decorator +from django.utils.functional import cached_property +from django.utils.http import url_has_allowed_host_and_scheme +from django.utils.translation import gettext_lazy as _ +from django.views.decorators.cache import never_cache +from django.views.decorators.csrf import csrf_protect +from django.views.decorators.debug import sensitive_post_parameters +from django.views.generic import FormView, ListView, View + +from pretix.base.models import Customer, Order, OrderPosition +from pretix.base.services.mail import mail +from pretix.multidomain.urlreverse import build_absolute_uri, eventreverse +from pretix.presale.forms.customer import ( + AuthenticationForm, ChangeInfoForm, ChangePasswordForm, RegistrationForm, + ResetPasswordForm, SetPasswordForm, TokenGenerator, +) +from pretix.presale.utils import ( + customer_login, customer_logout, update_customer_session_auth_hash, +) + + +class RedirectBackMixin: + redirect_field_name = 'next' + + def get_redirect_url(self): + """Return the user-originating redirect URL if it's safe.""" + redirect_to = self.request.POST.get( + self.redirect_field_name, + self.request.GET.get(self.redirect_field_name, '') + ) + url_is_safe = url_has_allowed_host_and_scheme( + url=redirect_to, + allowed_hosts=None, + require_https=self.request.is_secure(), + ) + return redirect_to if url_is_safe else '' + + +class LoginView(RedirectBackMixin, FormView): + """ + Display the login form and handle the login action. + """ + form_class = AuthenticationForm + template_name = 'pretixpresale/organizers/customer_login.html' + redirect_authenticated_user = True + + @method_decorator(sensitive_post_parameters()) + @method_decorator(csrf_protect) + @method_decorator(never_cache) + def dispatch(self, request, *args, **kwargs): + if not request.organizer.settings.customer_accounts: + raise Http404('Feature not enabled') + if self.redirect_authenticated_user and self.request.customer: + redirect_to = self.get_success_url() + if redirect_to == self.request.path: + raise ValueError( + "Redirection loop for authenticated user detected. Check that " + "your LOGIN_REDIRECT_URL doesn't point to a login page." + ) + return HttpResponseRedirect(redirect_to) + return super().dispatch(request, *args, **kwargs) + + def get_form_kwargs(self): + kwargs = super().get_form_kwargs() + kwargs['request'] = self.request + return kwargs + + def get_success_url(self): + url = self.get_redirect_url() + return url or eventreverse(self.request.organizer, 'presale:organizer.customer.profile', kwargs={}) + + def form_valid(self, form): + """Security check complete. Log the user in.""" + customer_login(self.request, form.get_customer()) + return HttpResponseRedirect(self.get_success_url()) + + +class LogoutView(View): + redirect_field_name = 'next' + + @method_decorator(never_cache) + def dispatch(self, request, *args, **kwargs): + customer_logout(request) + next_page = self.get_next_page() + return HttpResponseRedirect(next_page) + + def get_next_page(self): + next_page = eventreverse(self.request.organizer, 'presale:organizer.index', kwargs={}) + + if (self.redirect_field_name in self.request.POST or + self.redirect_field_name in self.request.GET): + next_page = self.request.POST.get( + self.redirect_field_name, + self.request.GET.get(self.redirect_field_name) + ) + url_is_safe = url_has_allowed_host_and_scheme( + url=next_page, + allowed_hosts=None, + require_https=self.request.is_secure(), + ) + # Security check -- Ensure the user-originating redirection URL is + # safe. + if not url_is_safe: + next_page = self.request.path + return next_page + + +class RegistrationView(RedirectBackMixin, FormView): + form_class = RegistrationForm + template_name = 'pretixpresale/organizers/customer_registration.html' + redirect_authenticated_user = True + + @method_decorator(sensitive_post_parameters()) + @method_decorator(csrf_protect) + @method_decorator(never_cache) + def dispatch(self, request, *args, **kwargs): + if not request.organizer.settings.customer_accounts: + raise Http404('Feature not enabled') + if self.redirect_authenticated_user and self.request.customer: + redirect_to = self.get_success_url() + if redirect_to == self.request.path: + raise ValueError( + "Redirection loop for authenticated user detected. Check that " + "your LOGIN_REDIRECT_URL doesn't point to a login page." + ) + return HttpResponseRedirect(redirect_to) + return super().dispatch(request, *args, **kwargs) + + def get_form_kwargs(self): + kwargs = super().get_form_kwargs() + kwargs['request'] = self.request + return kwargs + + def get_success_url(self): + url = self.get_redirect_url() + return url or eventreverse(self.request.organizer, 'presale:organizer.customer.login', kwargs={}) + + def form_valid(self, form): + with transaction.atomic(): + form.create() + messages.success( + self.request, + _('Your account has been created. Please follow the link in the email we sent you to activate your ' + 'account and choose a password.') + ) + return HttpResponseRedirect(self.get_success_url()) + + +class SetPasswordView(FormView): + form_class = SetPasswordForm + template_name = 'pretixpresale/organizers/customer_setpassword.html' + + @method_decorator(sensitive_post_parameters()) + @method_decorator(csrf_protect) + @method_decorator(never_cache) + def dispatch(self, request, *args, **kwargs): + if not request.organizer.settings.customer_accounts: + raise Http404('Feature not enabled') + try: + self.customer = request.organizer.customers.get(identifier=self.request.GET.get('id')) + except Customer.DoesNotExist: + messages.error(request, _('You clicked an invalid link.')) + return HttpResponseRedirect(self.get_success_url()) + if not TokenGenerator().check_token(self.customer, self.request.GET.get('token', '')): + messages.error(request, _('You clicked an invalid link.')) + return HttpResponseRedirect(self.get_success_url()) + return super().dispatch(request, *args, **kwargs) + + def get_form_kwargs(self): + kwargs = super().get_form_kwargs() + kwargs['customer'] = self.customer + return kwargs + + def get_success_url(self): + return eventreverse(self.request.organizer, 'presale:organizer.customer.login', kwargs={}) + + def form_valid(self, form): + with transaction.atomic(): + self.customer.set_password(form.cleaned_data['password']) + self.customer.is_verified = True + self.customer.save() + self.customer.log_action('pretix.customer.password.set', {}) + messages.success( + self.request, + _('Your new password has been set! You can now use it to log in.'), + ) + return HttpResponseRedirect(self.get_success_url()) + + +class ResetPasswordView(FormView): + form_class = ResetPasswordForm + template_name = 'pretixpresale/organizers/customer_resetpw.html' + + @method_decorator(sensitive_post_parameters()) + @method_decorator(csrf_protect) + @method_decorator(never_cache) + def dispatch(self, request, *args, **kwargs): + if not request.organizer.settings.customer_accounts: + raise Http404('Feature not enabled') + return super().dispatch(request, *args, **kwargs) + + def get_success_url(self): + return eventreverse(self.request.organizer, 'presale:organizer.customer.login', kwargs={}) + + def form_valid(self, form): + customer = form.customer + customer.log_action('pretix.customer.password.resetrequested', {}) + ctx = customer.get_email_context() + token = TokenGenerator().make_token(customer) + ctx['url'] = build_absolute_uri(self.request.organizer, + 'presale:organizer.customer.recoverpw') + '?id=' + customer.identifier + '&token=' + token + mail( + customer.email, + _('Set a new password for your account at {organizer}').format(organizer=self.request.organizer.name), + self.request.organizer.settings.mail_text_customer_reset, + ctx, + locale=customer.locale, + customer=customer, + organizer=self.request.organizer, + ) + messages.success( + self.request, + _('We\'ve sent you an email with further instructions on resetting your password.') + ) + return HttpResponseRedirect(self.get_success_url()) + + def get_form_kwargs(self): + kwargs = super().get_form_kwargs() + kwargs['request'] = self.request + return kwargs + + +class CustomerRequiredMixin: + def dispatch(self, request, *args, **kwargs): + if not request.organizer.settings.customer_accounts: + raise Http404('Feature not enabled') + if not getattr(request, 'customer', None): + return redirect( + eventreverse(self.request.organizer, 'presale:organizer.customer.login', kwargs={}) + + '?next=' + quote(self.request.path_info + '?' + self.request.GET.urlencode()) + ) + return super().dispatch(request, *args, **kwargs) + + +class ProfileView(CustomerRequiredMixin, ListView): + template_name = 'pretixpresale/organizers/customer_profile.html' + context_object_name = 'orders' + paginate_by = 20 + + def get_queryset(self): + qs = Order.objects.filter( + Q(customer=self.request.customer) + | Q(email__iexact=self.request.customer.email) + # This is safe because we only let customers with verified emails log in + ).select_related('event').order_by('-datetime') + return qs + + def get_context_data(self, **kwargs): + ctx = super().get_context_data(**kwargs) + ctx['customer'] = self.request.customer + ctx['memberships'] = self.request.customer.memberships.with_usages().select_related( + 'membership_type', 'granted_in', 'granted_in__order', 'granted_in__order__event' + ) + ctx['is_paginated'] = True + + for m in ctx['memberships']: + if m.membership_type.max_usages: + m.percent = int(m.usages / m.membership_type.max_usages * 100) + else: + m.percent = 0 + + s = OrderPosition.objects.filter( + order=OuterRef('pk') + ).order_by().values('order').annotate(k=Count('id')).values('k') + annotated = { + o['pk']: o + for o in + Order.annotate_overpayments(Order.objects, sums=True).filter( + pk__in=[o.pk for o in ctx['orders']] + ).annotate( + pcnt=Subquery(s, output_field=IntegerField()), + ).values( + 'pk', 'pcnt', + ) + } + + for o in ctx['orders']: + if o.pk not in annotated: + continue + o.count_positions = annotated.get(o.pk)['pcnt'] + return ctx + + +class MembershipUsageView(CustomerRequiredMixin, ListView): + template_name = 'pretixpresale/organizers/customer_membership.html' + context_object_name = 'usages' + paginate_by = 20 + + @cached_property + def membership(self): + return get_object_or_404( + self.request.customer.memberships, + pk=self.kwargs.get('id') + ) + + def get_queryset(self): + return self.membership.orderposition_set.select_related( + 'order', 'order__event', 'subevent', 'item', 'variation', + ) + + def get_context_data(self, **kwargs): + ctx = super().get_context_data(**kwargs) + ctx['membership'] = self.membership + ctx['is_paginated'] = True + return ctx + + +class ChangePasswordView(CustomerRequiredMixin, FormView): + template_name = 'pretixpresale/organizers/customer_password.html' + form_class = ChangePasswordForm + + @method_decorator(sensitive_post_parameters()) + @method_decorator(csrf_protect) + @method_decorator(never_cache) + def dispatch(self, request, *args, **kwargs): + if not request.organizer.settings.customer_accounts: + raise Http404('Feature not enabled') + return super().dispatch(request, *args, **kwargs) + + def get_success_url(self): + return eventreverse(self.request.organizer, 'presale:organizer.customer.profile', kwargs={}) + + @transaction.atomic() + def form_valid(self, form): + customer = form.customer + customer.log_action('pretix.customer.password.set', {}) + customer.set_password(form.cleaned_data['password']) + customer.save() + messages.success(self.request, _('Your changes have been saved.')) + update_customer_session_auth_hash(self.request, customer) + return HttpResponseRedirect(self.get_success_url()) + + def get_form_kwargs(self): + kwargs = super().get_form_kwargs() + kwargs['customer'] = self.request.customer + return kwargs + + +class ChangeInformationView(CustomerRequiredMixin, FormView): + template_name = 'pretixpresale/organizers/customer_info.html' + form_class = ChangeInfoForm + + @method_decorator(sensitive_post_parameters()) + @method_decorator(csrf_protect) + @method_decorator(never_cache) + def dispatch(self, request, *args, **kwargs): + if not request.organizer.settings.customer_accounts: + raise Http404('Feature not enabled') + if self.request.customer: + self.initial_email = self.request.customer.email + return super().dispatch(request, *args, **kwargs) + + def get_success_url(self): + return eventreverse(self.request.organizer, 'presale:organizer.customer.profile', kwargs={}) + + def form_valid(self, form): + if form.cleaned_data['email'] != self.initial_email: + new_email = form.cleaned_data['email'] + form.cleaned_data['email'] = form.instance.email = self.initial_email + ctx = form.instance.get_email_context() + ctx['url'] = build_absolute_uri( + self.request.organizer, + 'presale:organizer.customer.change.confirm' + ) + '?token=' + dumps({ + 'customer': form.instance.pk, + 'email': new_email + }, salt='pretix.presale.views.customer.ChangeInformationView') + mail( + new_email, + _('Confirm email address for your account at {organizer}').format(organizer=self.request.organizer.name), + self.request.organizer.settings.mail_text_customer_email_change, + ctx, + locale=form.instance.locale, + customer=form.instance, + organizer=self.request.organizer, + ) + messages.success(self.request, _('Your changes have been saved. We\'ve sent you an email with a link to update your ' + 'email address. The email address of your account will be changed as soon as you ' + 'click that link.')) + else: + messages.success(self.request, _('Your changes have been saved.')) + + with transaction.atomic(): + form.save() + d = dict(form.cleaned_data) + del d['email'] + self.request.customer.log_action('pretix.customer.changed', d) + + update_customer_session_auth_hash(self.request, form.instance) + return HttpResponseRedirect(self.get_success_url()) + + def get_form_kwargs(self): + kwargs = super().get_form_kwargs() + kwargs['request'] = self.request + kwargs['instance'] = self.request.customer + return kwargs + + +class ConfirmChangeView(View): + template_name = 'pretixpresale/organizers/customer_info.html' + + def get(self, request, *args, **kwargs): + if not request.organizer.settings.customer_accounts: + raise Http404('Feature not enabled') + + try: + data = loads(request.GET.get('token', ''), salt='pretix.presale.views.customer.ChangeInformationView', max_age=3600 * 24) + except BadSignature: + messages.error(request, _('You clicked an invalid link.')) + return HttpResponseRedirect(self.get_success_url()) + + try: + customer = request.organizer.customers.get(pk=data.get('customer')) + except Customer.DoesNotExist: + messages.error(request, _('You clicked an invalid link.')) + return HttpResponseRedirect(self.get_success_url()) + + with transaction.atomic(): + customer.email = data['email'] + customer.save() + customer.log_action('pretix.customer.changed', { + 'email': data['email'] + }) + + messages.success(request, _('Your email address has been updated.')) + + if customer == request.customer: + update_customer_session_auth_hash(self.request, customer) + + return HttpResponseRedirect(self.get_success_url()) + + def get_success_url(self): + return eventreverse(self.request.organizer, 'presale:organizer.customer.profile', kwargs={}) diff --git a/src/pretix/presale/views/order.py b/src/pretix/presale/views/order.py index 9dd4f447e..02f0787fe 100644 --- a/src/pretix/presale/views/order.py +++ b/src/pretix/presale/views/order.py @@ -731,6 +731,14 @@ class OrderModify(EventViewMixin, OrderDetailMixin, OrderQuestionsViewMixin, Tem for k in override: # We don't want initial values to be modified, they should come from the order directly override[k].pop('initial', None) + + if order_position.used_membership and not order_position.used_membership.membership_type.transferable: + override_sets.append({ + 'attendee_name_parts': { + 'disabled': True + } + }) + return override_sets def post(self, request, *args, **kwargs): diff --git a/src/pretix/settings.py b/src/pretix/settings.py index 43802d6ad..454ca17b7 100644 --- a/src/pretix/settings.py +++ b/src/pretix/settings.py @@ -287,6 +287,7 @@ CACHE_TICKETS_HOURS = config.getint('cache', 'tickets', fallback=24 * 3) ENTROPY = { 'order_code': config.getint('entropy', 'order_code', fallback=5), + 'customer_identifier': config.getint('entropy', 'customer_identifier', fallback=7), 'ticket_secret': config.getint('entropy', 'ticket_secret', fallback=32), 'voucher_code': config.getint('entropy', 'voucher_code', fallback=16), 'giftcard_secret': config.getint('entropy', 'giftcard_secret', fallback=12), diff --git a/src/pretix/static/pretixcontrol/js/ui/main.js b/src/pretix/static/pretixcontrol/js/ui/main.js index 447ff056c..9cf694e75 100644 --- a/src/pretix/static/pretixcontrol/js/ui/main.js +++ b/src/pretix/static/pretixcontrol/js/ui/main.js @@ -289,6 +289,9 @@ var form_handlers = function (el) { dependency = $($(this).attr("data-display-dependency")), update = function (ev) { var enabled = (dependency.attr("type") === 'checkbox' || dependency.attr("type") === 'radio') ? dependency.prop('checked') : !!dependency.val(); + if (dependent.is("[data-inverse]")) { + enabled = !enabled; + } var $toggling = dependent; if (dependent.get(0).tagName.toLowerCase() !== "div") { $toggling = dependent.closest('.form-group'); @@ -304,8 +307,8 @@ var form_handlers = function (el) { } }; update(); - dependency.closest('.form-group').find('input[name=' + dependency.attr("name") + ']').on("change", update); - dependency.closest('.form-group').find('input[name=' + dependency.attr("name") + ']').on("dp.change", update); + dependency.closest('.form-group').find('[name=' + dependency.attr("name") + ']').on("change", update); + dependency.closest('.form-group').find('[name=' + dependency.attr("name") + ']').on("dp.change", update); }); el.find("input[data-required-if], select[data-required-if], textarea[data-required-if]").each(function () { @@ -702,11 +705,13 @@ $(function () { ); }); + $(".propagated-settings-box").find("input, textarea, select").not("[disabled]") + .attr("data-propagated-locked", "true").prop("disabled", true); + $(".propagated-settings-box button[data-action=unlink]").click(function (ev) { var $box = $(this).closest(".propagated-settings-box"); - $box.find(".propagated-settings-overlay").fadeOut(); - $box.find("input[name=_settings_ignore]").attr("name", "decouple"); - $box.find(".propagated-settings-form").removeClass("blurred"); + $box.find("[data-propagated-locked]").prop("disabled", false); + $box.removeClass("locked").addClass("unlocked"); ev.preventDefault(); return true; }); diff --git a/src/pretix/static/pretixcontrol/scss/_flags.scss b/src/pretix/static/pretixcontrol/scss/_flags.scss index d21670013..641942ed7 100644 --- a/src/pretix/static/pretixcontrol/scss/_flags.scss +++ b/src/pretix/static/pretixcontrol/scss/_flags.scss @@ -1,10 +1,16 @@ input[lang] { background: no-repeat 10px center; padding-left: 34px; + &[disabled] { + background-color: $input-bg-disabled; + } } textarea[lang] { background: no-repeat 10px 10px; padding-left: 34px; + &[disabled] { + background-color: $input-bg-disabled; + } } pre[lang] { background: no-repeat 10px 10px; @@ -23,6 +29,10 @@ div[lang] { input[lang], textarea[lang], div[lang], pre[lang] { background: none; padding-left: 12px; + + &[disabled] { + background-color: $input-bg-disabled; + } } } diff --git a/src/pretix/static/pretixcontrol/scss/_forms.scss b/src/pretix/static/pretixcontrol/scss/_forms.scss index 71acf4192..1bde313c0 100644 --- a/src/pretix/static/pretixcontrol/scss/_forms.scss +++ b/src/pretix/static/pretixcontrol/scss/_forms.scss @@ -333,25 +333,27 @@ input[type=number].short { } } -.propagated-settings-box { - position: relative; - - .propagated-settings-overlay { - background: rgba(255, 255, 255, 0.7); - position: absolute; - top: 0; - left: 0; - width: 100%; - height: 100%; - text-align: center; +.propagated-settings-box.locked { + .propagated-settings-form { + opacity: 0.7; } - - .propagated-settings-form.blurred { - -webkit-filter: blur(2px); - -moz-filter: blur(2px); - -ms-filter: blur(2px); - -o-filter: blur(2px); - filter: blur(2px); + .panel-body.help-text { + border-bottom: 1px solid $panel-default-heading-bg; + } +} +.propagated-settings-box.unlocked { + border: 0; + transition: border-width 0.5s linear; + .panel-heading, .panel-body.help-text { + height: 0; + padding: 0; + border: 0; + overflow: hidden; + transition: height 0.5s linear, padding 0.5s linear, border-width 0.5s linear; + } + .panel-body { + padding: 0; + transition: padding 0.5s linear; } } @media (max-width: $screen-sm-max) { diff --git a/src/pretix/static/pretixpresale/scss/main.scss b/src/pretix/static/pretixpresale/scss/main.scss index 1acd5f6cf..04a1a5a77 100644 --- a/src/pretix/static/pretixpresale/scss/main.scss +++ b/src/pretix/static/pretixpresale/scss/main.scss @@ -30,7 +30,7 @@ footer nav { .js-only { display: none; } -.locales { +.locales, .loginstatus { display: inline; a { text-decoration: none; @@ -45,6 +45,9 @@ footer nav { vertical-align: baseline; } } +.loginstatus a { + margin-left: 10px; +} .huge { font-size: 40px; } @@ -300,6 +303,29 @@ h2 .label { } } +.quotabox { + display: inline-block; + vertical-align: top; + width: 50px; + .progress { + height: 7px; + margin-bottom: 2px; + } + .numbers { + font-size: 10px; + color: $text-muted; + display: block; + text-align: center; + } + &.availability .progress-bar-success { + background: lighten($brand-success, 20%); + } +} + +@for $i from 0 through 100 { + .progress-bar-#{$i} { width: 1% * $i; } +} + @import "_iframe.scss"; @import "_a11y.scss"; @import "_print.scss"; diff --git a/src/tests/api/conftest.py b/src/tests/api/conftest.py index e901ae3bd..7468411e0 100644 --- a/src/tests/api/conftest.py +++ b/src/tests/api/conftest.py @@ -115,6 +115,7 @@ def team(organizer): can_change_vouchers=True, can_view_vouchers=True, can_change_orders=True, + can_manage_customers=True, can_change_organizer_settings=True ) diff --git a/src/tests/api/test_customers.py b/src/tests/api/test_customers.py new file mode 100644 index 000000000..96c973fb9 --- /dev/null +++ b/src/tests/api/test_customers.py @@ -0,0 +1,130 @@ +# +# This file is part of pretix (Community Edition). +# +# Copyright (C) 2014-2020 Raphael Michel and contributors +# Copyright (C) 2020-2021 rami.io 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 . +# +# 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 +# . +# +import pytest +from django_scopes import scopes_disabled + + +@pytest.fixture +def customer(organizer, event): + return organizer.customers.create( + identifier="8WSAJCJ", + email="foo@example.org", + name_parts={"_legacy": "Foo"}, + name_cached="Foo", + is_verified=False, + ) + + +TEST_CUSTOMER_RES = { + "identifier": "8WSAJCJ", + "email": "foo@example.org", + "name": "Foo", + "name_parts": { + "_legacy": "Foo", + }, + "is_active": True, + "is_verified": False, + "last_login": None, + "date_joined": "2021-04-06T13:44:22.809216Z", + "locale": "en", + "last_modified": "2021-04-06T13:44:22.809377Z" +} + + +@pytest.mark.django_db +def test_customer_list(token_client, organizer, customer): + res = dict(TEST_CUSTOMER_RES) + res["date_joined"] = customer.date_joined.isoformat().replace('+00:00', 'Z') + res["last_modified"] = customer.last_modified.isoformat().replace('+00:00', 'Z') + + resp = token_client.get('/api/v1/organizers/{}/customers/'.format(organizer.slug)) + assert resp.status_code == 200 + assert [res] == resp.data['results'] + + +@pytest.mark.django_db +def test_customer_detail(token_client, organizer, customer): + res = dict(TEST_CUSTOMER_RES) + res["date_joined"] = customer.date_joined.isoformat().replace('+00:00', 'Z') + res["last_modified"] = customer.last_modified.isoformat().replace('+00:00', 'Z') + resp = token_client.get('/api/v1/organizers/{}/customers/{}/'.format(organizer.slug, customer.identifier)) + assert resp.status_code == 200 + assert res == resp.data + + +@pytest.mark.django_db +def test_customer_create(token_client, organizer): + resp = token_client.post( + '/api/v1/organizers/{}/customers/'.format(organizer.slug), + format='json', + data={ + 'identifier': 'IGNORED', + 'email': 'bar@example.com', + 'name_parts': { + "_scheme": "given_family", + 'given_name': 'John', + 'family_name': 'Doe', + }, + 'is_active': True, + 'is_verified': True, + } + ) + assert resp.status_code == 201 + with scopes_disabled(): + customer = organizer.customers.get(identifier=resp.data['identifier']) + assert customer.identifier != 'IGNORED' + assert customer.email == 'bar@example.com' + assert customer.is_active + assert customer.name == 'John Doe' + assert customer.is_verified + + +@pytest.mark.django_db +def test_customer_patch(token_client, organizer, customer): + resp = token_client.patch( + '/api/v1/organizers/{}/customers/{}/'.format(organizer.slug, customer.identifier), + format='json', + data={ + 'email': 'blubb@example.org', + } + ) + assert resp.status_code == 200 + customer.refresh_from_db() + assert customer.email == 'blubb@example.org' + + +@pytest.mark.django_db +def test_customer_anonymize(token_client, organizer, customer): + resp = token_client.post( + '/api/v1/organizers/{}/customers/{}/anonymize/'.format(organizer.slug, customer.identifier), + ) + assert resp.status_code == 200 + customer.refresh_from_db() + assert customer.email is None + + +@pytest.mark.django_db +def test_customer_delete(token_client, organizer, customer): + resp = token_client.delete( + '/api/v1/organizers/{}/customers/{}/'.format(organizer.slug, customer.identifier), + ) + assert resp.status_code == 405 diff --git a/src/tests/api/test_items.py b/src/tests/api/test_items.py index 48342e594..746593501 100644 --- a/src/tests/api/test_items.py +++ b/src/tests/api/test_items.py @@ -283,7 +283,13 @@ TEST_ITEM_RES = { "original_price": None, "meta_data": { "day": "Tuesday" - } + }, + "require_membership": False, + "require_membership_types": [], + "grant_membership_type": None, + "grant_membership_duration_like_event": True, + "grant_membership_duration_days": 0, + "grant_membership_duration_months": 0, } diff --git a/src/tests/api/test_membership.py b/src/tests/api/test_membership.py new file mode 100644 index 000000000..faf511534 --- /dev/null +++ b/src/tests/api/test_membership.py @@ -0,0 +1,151 @@ +# +# This file is part of pretix (Community Edition). +# +# Copyright (C) 2014-2020 Raphael Michel and contributors +# Copyright (C) 2020-2021 rami.io 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 . +# +# 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 +# . +# +from datetime import datetime + +import pytest +import pytz +from django_scopes import scopes_disabled +from i18nfield.strings import LazyI18nString + +from pretix.base.models import Membership + + +@pytest.fixture +def membershiptype(organizer): + return organizer.membership_types.create( + name=LazyI18nString({"en": "Week pass"}), + transferable=True, + allow_parallel_usage=False, + max_usages=15, + ) + + +@pytest.fixture +def customer(organizer): + return organizer.customers.create( + identifier="8WSAJCJ", + email="foo@example.org", + name_parts={"_legacy": "Foo"}, + name_cached="Foo", + is_verified=False, + ) + + +@pytest.fixture +def membership(organizer, customer, membershiptype): + return customer.memberships.create( + membership_type=membershiptype, + date_start=datetime(2021, 4, 1, 0, 0, 0, 0, tzinfo=pytz.UTC), + date_end=datetime(2021, 4, 8, 23, 59, 59, 999999, tzinfo=pytz.UTC), + attendee_name_parts={ + "_scheme": "given_family", + 'given_name': 'John', + 'family_name': 'Doe', + } + ) + + +TEST_MEMBERSHIP_RES = { + "customer": "8WSAJCJ", + "date_start": "2021-04-01T00:00:00Z", + "date_end": "2021-04-08T23:59:59.999999Z", + "attendee_name_parts": { + "_scheme": "given_family", + 'given_name': 'John', + 'family_name': 'Doe', + } +} + + +@pytest.mark.django_db +def test_membership_list(token_client, organizer, membershiptype, membership): + res = dict(TEST_MEMBERSHIP_RES) + res['membership_type'] = membershiptype.pk + res['id'] = membership.pk + + resp = token_client.get('/api/v1/organizers/{}/memberships/'.format(organizer.slug)) + assert resp.status_code == 200 + assert [res] == resp.data['results'] + + +@pytest.mark.django_db +def test_membership_detail(token_client, organizer, membershiptype, membership): + res = dict(TEST_MEMBERSHIP_RES) + res['membership_type'] = membershiptype.pk + res['id'] = membership.pk + resp = token_client.get('/api/v1/organizers/{}/memberships/{}/'.format(organizer.slug, membershiptype.pk)) + assert resp.status_code == 200 + assert res == resp.data + + +@pytest.mark.django_db +def test_membership_create(token_client, organizer, membershiptype, customer): + resp = token_client.post( + '/api/v1/organizers/{}/memberships/'.format(organizer.slug), + format='json', + data={ + "customer": customer.identifier, + "membership_type": membershiptype.pk, + "date_start": "2021-04-01T00:00:00.000Z", + "date_end": "2021-04-08T23:59:59.999999Z", + } + ) + assert resp.status_code == 201 + with scopes_disabled(): + membership = Membership.objects.get(id=resp.data['id']) + assert membership.customer == customer + assert membership.membership_type == membershiptype + + +@pytest.mark.django_db +def test_membership_patch(token_client, organizer, customer, membership): + resp = token_client.patch( + '/api/v1/organizers/{}/memberships/{}/'.format(organizer.slug, membership.pk), + format='json', + data={ + "date_end": "2021-04-03T23:59:59.999999Z", + } + ) + assert resp.status_code == 200 + membership.refresh_from_db() + assert membership.date_end.isoformat() == "2021-04-03T23:59:59.999999+00:00" + + with scopes_disabled(): + other_customer = organizer.customers.create() + resp = token_client.patch( + '/api/v1/organizers/{}/memberships/{}/'.format(organizer.slug, membership.pk), + format='json', + data={ + "customer": other_customer.identifier, + } + ) + assert resp.status_code == 200 + membership.refresh_from_db() + assert membership.customer == customer # change is ignored + + +@pytest.mark.django_db +def test_membership_delete(token_client, organizer, membership): + resp = token_client.delete( + '/api/v1/organizers/{}/memberships/{}/'.format(organizer.slug, membership.pk), + ) + assert resp.status_code == 405 diff --git a/src/tests/api/test_membershiptypes.py b/src/tests/api/test_membershiptypes.py new file mode 100644 index 000000000..aa89815e9 --- /dev/null +++ b/src/tests/api/test_membershiptypes.py @@ -0,0 +1,108 @@ +# +# This file is part of pretix (Community Edition). +# +# Copyright (C) 2014-2020 Raphael Michel and contributors +# Copyright (C) 2020-2021 rami.io 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 . +# +# 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 +# . +# +import pytest +from django_scopes import scopes_disabled +from i18nfield.strings import LazyI18nString + + +@pytest.fixture +def membershiptype(organizer, event): + return organizer.membership_types.create( + name=LazyI18nString({"en": "Week pass"}), + transferable=True, + allow_parallel_usage=False, + max_usages=15, + ) + + +TEST_TYPE_RES = { + "name": { + "en": "Week pass" + }, + "transferable": True, + "allow_parallel_usage": False, + "max_usages": 15, +} + + +@pytest.mark.django_db +def test_membershiptype_list(token_client, organizer, membershiptype): + res = dict(TEST_TYPE_RES) + res["id"] = membershiptype.pk + + resp = token_client.get('/api/v1/organizers/{}/membershiptypes/'.format(organizer.slug)) + assert resp.status_code == 200 + assert [res] == resp.data['results'] + + +@pytest.mark.django_db +def test_membershiptype_detail(token_client, organizer, membershiptype): + res = dict(TEST_TYPE_RES) + res["id"] = membershiptype.pk + resp = token_client.get('/api/v1/organizers/{}/membershiptypes/{}/'.format(organizer.slug, membershiptype.pk)) + assert resp.status_code == 200 + assert res == resp.data + + +@pytest.mark.django_db +def test_membershiptype_create(token_client, organizer): + resp = token_client.post( + '/api/v1/organizers/{}/membershiptypes/'.format(organizer.slug), + format='json', + data={ + "name": { + "en": "Week pass" + }, + "transferable": True, + "allow_parallel_usage": False, + "max_usages": 15, + } + ) + assert resp.status_code == 201 + with scopes_disabled(): + membershiptype = organizer.membership_types.get(id=resp.data['id']) + assert str(membershiptype.name) == "Week pass" + assert membershiptype.transferable + assert not membershiptype.allow_parallel_usage + + +@pytest.mark.django_db +def test_membershiptype_patch(token_client, organizer, membershiptype): + resp = token_client.patch( + '/api/v1/organizers/{}/membershiptypes/{}/'.format(organizer.slug, membershiptype.pk), + format='json', + data={ + 'transferable': False, + } + ) + assert resp.status_code == 200 + membershiptype.refresh_from_db() + assert not membershiptype.transferable + + +@pytest.mark.django_db +def test_membershiptype_delete(token_client, organizer, membershiptype): + resp = token_client.delete( + '/api/v1/organizers/{}/membershiptypes/{}/'.format(organizer.slug, membershiptype.pk), + ) + assert resp.status_code == 204 + assert not organizer.membership_types.exists() diff --git a/src/tests/api/test_orders.py b/src/tests/api/test_orders.py index ed68f9774..48071d354 100644 --- a/src/tests/api/test_orders.py +++ b/src/tests/api/test_orders.py @@ -236,6 +236,7 @@ TEST_ORDER_RES = { "email": "dummy@dummy.test", "phone": None, "locale": "en", + "customer": None, "datetime": "2017-12-01T10:00:00Z", "expires": "2017-12-10T10:00:00Z", "payment_date": "2017-12-01", @@ -1633,6 +1634,9 @@ def test_order_create(token_client, organizer, event, item, quota, question): res = copy.deepcopy(ORDER_CREATE_PAYLOAD) res['positions'][0]['item'] = item.pk res['positions'][0]['answers'][0]['question'] = question.pk + with scopes_disabled(): + customer = organizer.customers.create() + res['customer'] = customer.identifier resp = token_client.post( '/api/v1/organizers/{}/events/{}/orders/'.format( organizer.slug, event.slug @@ -1641,6 +1645,7 @@ def test_order_create(token_client, organizer, event, item, quota, question): assert resp.status_code == 201 with scopes_disabled(): o = Order.objects.get(code=resp.data['code']) + assert o.customer == customer assert o.email == "dummy@dummy.test" assert o.phone == "+49622112345" assert o.locale == "en" @@ -1712,6 +1717,7 @@ def test_order_create_simulate(token_client, organizer, event, item, quota, ques 'testmode': False, 'email': 'dummy@dummy.test', 'phone': '+49622112345', + 'customer': None, 'locale': 'en', 'datetime': None, 'payment_date': None, diff --git a/src/tests/api/test_permissions.py b/src/tests/api/test_permissions.py index 555466574..8523cdc65 100644 --- a/src/tests/api/test_permissions.py +++ b/src/tests/api/test_permissions.py @@ -179,6 +179,25 @@ org_permission_sub_urls = [ ('put', 'can_change_organizer_settings', 'webhooks/1/', 404), ('patch', 'can_change_organizer_settings', 'webhooks/1/', 404), ('delete', 'can_change_organizer_settings', 'webhooks/1/', 404), + ('get', 'can_manage_customers', 'customers/', 200), + ('post', 'can_manage_customers', 'customers/', 201), + ('get', 'can_manage_customers', 'customers/1/', 404), + ('patch', 'can_manage_customers', 'customers/1/', 404), + ('post', 'can_manage_customers', 'customers/1/anonymize/', 404), + ('put', 'can_manage_customers', 'customers/1/', 404), + ('delete', 'can_manage_customers', 'customers/1/', 404), + ('get', 'can_manage_customers', 'memberships/', 200), + ('post', 'can_manage_customers', 'memberships/', 400), + ('get', 'can_manage_customers', 'memberships/1/', 404), + ('patch', 'can_manage_customers', 'memberships/1/', 404), + ('put', 'can_manage_customers', 'memberships/1/', 404), + ('delete', 'can_manage_customers', 'memberships/1/', 404), + ('get', 'can_change_organizer_settings', 'membershiptypes/', 200), + ('post', 'can_change_organizer_settings', 'membershiptypes/', 400), + ('get', 'can_change_organizer_settings', 'membershiptypes/1/', 404), + ('patch', 'can_change_organizer_settings', 'membershiptypes/1/', 404), + ('put', 'can_change_organizer_settings', 'membershiptypes/1/', 404), + ('delete', 'can_change_organizer_settings', 'membershiptypes/1/', 404), ('get', 'can_manage_gift_cards', 'giftcards/', 200), ('post', 'can_manage_gift_cards', 'giftcards/', 400), ('get', 'can_manage_gift_cards', 'giftcards/1/', 404), diff --git a/src/tests/api/test_teams.py b/src/tests/api/test_teams.py index be48398d2..4bb3beaf7 100644 --- a/src/tests/api/test_teams.py +++ b/src/tests/api/test_teams.py @@ -39,6 +39,7 @@ def second_team(organizer, event): TEST_TEAM_RES = { 'id': 1, 'name': 'Test-Team', 'all_events': True, 'limit_events': [], 'can_create_events': True, 'can_change_teams': True, 'can_change_organizer_settings': True, 'can_manage_gift_cards': True, + 'can_manage_customers': True, 'can_change_event_settings': True, 'can_change_items': True, 'can_view_orders': True, 'can_change_orders': True, 'can_view_vouchers': True, 'can_change_vouchers': True, 'can_checkin_orders': False } @@ -46,6 +47,7 @@ TEST_TEAM_RES = { SECOND_TEAM_RES = { 'id': 1, 'name': 'User team', 'all_events': False, 'limit_events': ['dummy'], 'can_create_events': False, + 'can_manage_customers': False, 'can_change_teams': False, 'can_change_organizer_settings': False, 'can_manage_gift_cards': False, 'can_change_event_settings': False, 'can_change_items': False, 'can_view_orders': False, 'can_change_orders': False, 'can_view_vouchers': False, 'can_change_vouchers': False, 'can_checkin_orders': False diff --git a/src/tests/base/test_memberships.py b/src/tests/base/test_memberships.py new file mode 100644 index 000000000..7f454512b --- /dev/null +++ b/src/tests/base/test_memberships.py @@ -0,0 +1,501 @@ +# +# This file is part of pretix (Community Edition). +# +# Copyright (C) 2014-2020 Raphael Michel and contributors +# Copyright (C) 2020-2021 rami.io 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 . +# +# 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 +# . +# +from datetime import datetime, timedelta +from decimal import Decimal + +import pytest +import pytz +from django.conf import settings +from django.core.exceptions import ValidationError +from django.utils.timezone import now +from django_scopes import scope +from freezegun import freeze_time + +from pretix.base.models import ( + CartPosition, Event, Item, Order, OrderPosition, Organizer, +) +from pretix.base.services.memberships import ( + membership_validity, validate_memberships_in_order, +) +from pretix.base.services.orders import ( + OrderError, _create_order, _perform_order, +) +from pretix.plugins.banktransfer.payment import BankTransfer + +TZ = pytz.timezone('Europe/Berlin') + + +@pytest.fixture(scope='function') +def event(): + o = Organizer.objects.create(name='Dummy', slug='dummy') + o.settings.customer_accounts = True + event = Event.objects.create( + organizer=o, name='Dummy', slug='dummy', + date_from=TZ.localize(datetime(2021, 4, 27, 10, 0, 0, 0)), + date_to=TZ.localize(datetime(2021, 4, 28, 10, 0, 0, 0)), + presale_end=TZ.localize(datetime(2221, 4, 28, 10, 0, 0, 0)), + plugins='pretix.plugins.banktransfer' + ) + event.settings.timezone = 'Europe/Berlin' + with scope(organizer=o): + yield event + + +@pytest.fixture +def customer(event): + return event.organizer.customers.create(email="john@example.org") + + +@pytest.fixture +def membership_type(event): + return event.organizer.membership_types.create(name="Full pass") + + +@pytest.fixture +def membership(event, membership_type, customer): + return customer.memberships.create( + membership_type=membership_type, + date_start=TZ.localize(datetime(2021, 4, 1, 0, 0, 0, 0)), + date_end=TZ.localize(datetime(2021, 4, 30, 23, 59, 59, 999999)), + ) + + +@pytest.fixture +def granting_ticket(event, membership_type): + return Item.objects.create( + event=event, name='Full pass', + default_price=Decimal('23.00'), + admission=True, + grant_membership_type=membership_type, + ) + + +@pytest.fixture +def requiring_ticket(event, membership_type): + i = Item.objects.create( + event=event, name='Day ticket', + default_price=Decimal('23.00'), + admission=True, + require_membership=True, + ) + i.require_membership_types.add(membership_type) + return i + + +@pytest.fixture +def subevent(event): + event.has_subevents = True + return event.subevents.create( + name='Foo', + date_from=TZ.localize(datetime(2021, 4, 29, 10, 0, 0, 0)), + ) + + +@pytest.mark.django_db +def test_validity_membership_duration_like_event(event, granting_ticket, membership_type): + granting_ticket.grant_membership_duration_like_event = True + assert membership_validity(granting_ticket, None, event) == ( + TZ.localize(datetime(2021, 4, 27, 10, 0, 0, 0)), + TZ.localize(datetime(2021, 4, 28, 10, 0, 0, 0)), + ) + + +@pytest.mark.django_db +def test_validity_membership_duration_like_subevent_without_end(event, granting_ticket, subevent, membership_type): + granting_ticket.grant_membership_duration_like_event = True + assert membership_validity(granting_ticket, subevent, event) == ( + TZ.localize(datetime(2021, 4, 29, 10, 0, 0, 0)), + TZ.localize(datetime(2021, 4, 29, 23, 59, 59, 999999)), + ) + + +@pytest.mark.django_db +def test_validity_membership_duration_days(event, granting_ticket, membership_type): + granting_ticket.grant_membership_duration_like_event = False + granting_ticket.grant_membership_duration_days = 3 + with freeze_time("2021-04-10T11:00:00+02:00"): + assert membership_validity(granting_ticket, subevent, event) == ( + TZ.localize(datetime(2021, 4, 10, 0, 0, 0, 0)), + TZ.localize(datetime(2021, 4, 12, 23, 59, 59, 999999)), + ) + + +@pytest.mark.django_db +def test_validity_membership_duration_months(event, granting_ticket, membership_type): + granting_ticket.grant_membership_duration_like_event = False + granting_ticket.grant_membership_duration_months = 1 + with freeze_time("2021-02-01T11:00:00+01:00"): + assert membership_validity(granting_ticket, subevent, event) == ( + TZ.localize(datetime(2021, 2, 1, 0, 0, 0, 0)), + TZ.localize(datetime(2021, 2, 28, 23, 59, 59, 999999)), + ) + with freeze_time("2021-02-28T11:00:00+01:00"): + assert membership_validity(granting_ticket, subevent, event) == ( + TZ.localize(datetime(2021, 2, 28, 0, 0, 0, 0)), + TZ.localize(datetime(2021, 3, 27, 23, 59, 59, 999999)), + ) + + +@pytest.mark.django_db +def test_validity_membership_duration_months_plus_days(event, granting_ticket, membership_type): + granting_ticket.grant_membership_duration_like_event = False + granting_ticket.grant_membership_duration_months = 1 + granting_ticket.grant_membership_duration_days = 2 + with freeze_time("2021-02-01T11:00:00+01:00"): + assert membership_validity(granting_ticket, subevent, event) == ( + TZ.localize(datetime(2021, 2, 1, 0, 0, 0, 0)), + TZ.localize(datetime(2021, 3, 2, 23, 59, 59, 999999)), + ) + with freeze_time("2021-02-28T11:00:00+01:00"): + assert membership_validity(granting_ticket, subevent, event) == ( + TZ.localize(datetime(2021, 2, 28, 0, 0, 0, 0)), + TZ.localize(datetime(2021, 3, 29, 23, 59, 59, 999999)), + ) + + +@pytest.mark.django_db +def test_validate_membership_not_required(event, customer, membership, granting_ticket, membership_type): + with pytest.raises(ValidationError) as excinfo: + validate_memberships_in_order( + customer, + [ + CartPosition( + item=granting_ticket, + used_membership=membership, + ) + ], + event, + lock=False, + ignored_order=None + ) + assert "does not require" in str(excinfo.value) + + +@pytest.mark.django_db +def test_validate_membership_required(event, customer, membership, requiring_ticket, membership_type): + with pytest.raises(ValidationError) as excinfo: + validate_memberships_in_order( + customer, + [ + CartPosition( + item=requiring_ticket, + ) + ], + event, + lock=False, + ignored_order=None + ) + assert "requires an active" in str(excinfo.value) + + +@pytest.mark.django_db +def test_validate_membership_ensure_locking(event, customer, membership, requiring_ticket, membership_type, django_assert_num_queries): + with django_assert_num_queries(4) as captured: + validate_memberships_in_order( + customer, + [ + CartPosition( + item=requiring_ticket, + used_membership=membership, + ) + ], + event, + lock=True, + ignored_order=None + ) + if 'sqlite' not in settings.DATABASES['default']['ENGINE']: + assert any('FOR UPDATE' in s['sql'] for s in captured) + + +@pytest.mark.django_db +def test_validate_membership_wrong_customer(event, customer, membership, requiring_ticket, membership_type): + customer2 = event.organizer.customers.create(email="doe@example.org") + with pytest.raises(ValidationError) as excinfo: + validate_memberships_in_order( + customer2, + [ + CartPosition( + item=requiring_ticket, + used_membership=membership + ) + ], + event, + lock=False, + ignored_order=None + ) + assert "different customer" in str(excinfo.value) + + +@pytest.mark.django_db +def test_validate_membership_wrong_date(event, customer, membership, requiring_ticket, membership_type): + membership.date_start -= timedelta(days=100) + membership.date_end -= timedelta(days=100) + membership.save() + with pytest.raises(ValidationError) as excinfo: + validate_memberships_in_order( + customer, + [ + CartPosition( + item=requiring_ticket, + used_membership=membership + ) + ], + event, + lock=False, + ignored_order=None + ) + assert "taking place at" in str(excinfo.value) + + +@pytest.mark.django_db +def test_validate_membership_wrong_type(event, customer, membership, requiring_ticket, membership_type): + requiring_ticket.require_membership_types.clear() + with pytest.raises(ValidationError) as excinfo: + validate_memberships_in_order( + customer, + [ + CartPosition( + item=requiring_ticket, + used_membership=membership + ) + ], + event, + lock=False, + ignored_order=None + ) + assert "not allowed for the product" in str(excinfo.value) + + +@pytest.mark.django_db +def test_validate_membership_max_usages(event, customer, membership, requiring_ticket, membership_type): + membership_type.max_usages = 1 + membership_type.allow_parallel_usage = True + membership_type.save() + o1 = Order.objects.create( + status=Order.STATUS_PENDING, + event=event, + email='admin@localhost', + datetime=now() - timedelta(days=3), + expires=now() + timedelta(days=11), + total=Decimal("23"), + ) + OrderPosition.objects.create( + order=o1, + item=requiring_ticket, + used_membership=membership, + variation=None, + price=Decimal("23"), + attendee_name_parts={'full_name': "Peter"} + ) + + with pytest.raises(ValidationError) as excinfo: + validate_memberships_in_order( + customer, + [ + CartPosition( + item=requiring_ticket, + used_membership=membership + ) + ], + event, + lock=False, + ignored_order=None + ) + assert "more than 1 time" in str(excinfo.value) + membership_type.max_usages = 2 + membership_type.save() + validate_memberships_in_order( + customer, + [ + CartPosition( + item=requiring_ticket, + used_membership=membership + ) + ], + event, + lock=False, + ignored_order=None + ) + + with pytest.raises(ValidationError) as excinfo: + validate_memberships_in_order( + customer, + [ + CartPosition( + item=requiring_ticket, + used_membership=membership + ), + CartPosition( + item=requiring_ticket, + used_membership=membership + ), + ], + event, + lock=False, + ignored_order=None + ) + assert "more than 2 times" in str(excinfo.value) + + +@pytest.mark.django_db +def test_validate_membership_parallel(event, customer, membership, subevent, requiring_ticket, membership_type): + se2 = event.subevents.create( + name='Foo', + date_from=TZ.localize(datetime(2021, 4, 28, 10, 0, 0, 0)), + ) + + membership_type.allow_parallel_usage = False + membership_type.save() + + o1 = Order.objects.create( + status=Order.STATUS_PENDING, + event=event, + email='admin@localhost', + datetime=now() - timedelta(days=3), + expires=now() + timedelta(days=11), + total=Decimal("23"), + ) + OrderPosition.objects.create( + order=o1, + item=requiring_ticket, + used_membership=membership, + variation=None, + subevent=subevent, + price=Decimal("23"), + attendee_name_parts={'full_name': "Peter"} + ) + + with pytest.raises(ValidationError) as excinfo: + validate_memberships_in_order( + customer, + [ + CartPosition( + item=requiring_ticket, + used_membership=membership, + subevent=subevent + ) + ], + event, + lock=False, + ignored_order=None + ) + assert "different ticket at the same time" in str(excinfo.value) + + validate_memberships_in_order( + customer, + [ + CartPosition( + item=requiring_ticket, + used_membership=membership, + subevent=se2 + ) + ], + event, + lock=False, + ignored_order=None + ) + + with pytest.raises(ValidationError) as excinfo: + validate_memberships_in_order( + customer, + [ + CartPosition( + item=requiring_ticket, + used_membership=membership, + subevent=se2 + ), + CartPosition( + item=requiring_ticket, + used_membership=membership, + subevent=se2 + ) + ], + event, + lock=False, + ignored_order=None + ) + assert "different ticket at the same time" in str(excinfo.value) + + membership_type.allow_parallel_usage = True + membership_type.save() + validate_memberships_in_order( + customer, + [ + CartPosition( + item=requiring_ticket, + used_membership=membership, + subevent=subevent + ) + ], + event, + lock=False, + ignored_order=None + ) + + +@pytest.mark.django_db +def test_use_membership(event, customer, membership, requiring_ticket): + cp1 = CartPosition.objects.create( + item=requiring_ticket, price=23, expires=now() + timedelta(days=1), event=event, cart_id="123", + used_membership=membership + ) + order = _create_order(event, email='dummy@example.org', positions=[cp1], + now_dt=now(), payment_provider=BankTransfer(event), + locale='de', customer=customer)[0] + assert order.positions.first().used_membership == membership + + +@pytest.mark.django_db +def test_use_membership_invalid(event, customer, membership, requiring_ticket): + membership.date_start -= timedelta(days=100) + membership.date_end -= timedelta(days=100) + membership.save() + cp1 = CartPosition.objects.create( + item=requiring_ticket, price=23, expires=now() + timedelta(days=1), event=event, cart_id="123", + used_membership=membership + ) + with pytest.raises(OrderError) as excinfo: + _perform_order(event, email='dummy@example.org', position_ids=[cp1.pk], + payment_provider='banktransfer', address=None, + locale='de', customer=customer.pk)[0] + assert 'membership' in str(excinfo.value) + + +@pytest.mark.django_db +def test_grant_when_paid_and_changed(event, customer, granting_ticket): + cp1 = CartPosition.objects.create( + item=granting_ticket, price=0, expires=now() + timedelta(days=1), event=event, cart_id="123", + ) + q = event.quotas.create(size=None, name="foo") + q.items.add(granting_ticket) + order = _create_order(event, email='dummy@example.org', positions=[cp1], + now_dt=now(), payment_provider=BankTransfer(event), + locale='de', customer=customer)[0] + assert not customer.memberships.exists() + + order.payments.first().confirm() + + m = customer.memberships.get() + assert m.granted_in == order.positions.first() + assert m.membership_type == granting_ticket.grant_membership_type + assert m.date_start == TZ.localize(datetime(2021, 4, 27, 10, 0, 0, 0)) + assert m.date_end == TZ.localize(datetime(2021, 4, 28, 10, 0, 0, 0)) diff --git a/src/tests/base/test_orders.py b/src/tests/base/test_orders.py index 0cafd9c25..ce93a369d 100644 --- a/src/tests/base/test_orders.py +++ b/src/tests/base/test_orders.py @@ -826,6 +826,14 @@ class OrderChangeManagerTests(TestCase): self.quota.items.add(self.ticket2) self.quota.items.add(self.shirt) + self.mtype = self.o.membership_types.create(name="Week pass") + self.vip = Item.objects.create(event=self.event, name='VIP', tax_rule=self.tr7, + default_price=Decimal('23.00'), admission=True, + require_membership=True) + self.vip.require_membership_types.add(self.mtype) + self.quota.items.add(self.vip) + self.stalls = Item.objects.create(event=self.event, name='Stalls', tax_rule=self.tr7, + default_price=Decimal('23.00'), admission=True) self.stalls = Item.objects.create(event=self.event, name='Stalls', tax_rule=self.tr7, default_price=Decimal('23.00'), admission=True) self.plan = SeatingPlan.objects.create( @@ -2558,6 +2566,115 @@ class OrderChangeManagerTests(TestCase): assert nop.tax_rate == Decimal('19.00') assert nop.tax_value == Decimal('3.67') + @classscope(attr='o') + def test_add_with_membership_required(self): + with self.assertRaises(OrderError): + self.ocm.add_position(self.vip, None, price=Decimal('13.00')) + self.ocm.commit() + + @classscope(attr='o') + def test_add_with_membership_forbidden(self): + self.order.customer = self.o.customers.create(email="foo@bar.com") + m = self.order.customer.memberships.create( + membership_type=self.mtype, + date_start=self.event.date_from - timedelta(days=1), + date_end=self.event.date_from + timedelta(days=1), + attendee_name_parts={'_scheme': 'full', 'full_name': 'John Doe'}, + ) + with self.assertRaises(OrderError): + self.ocm.add_position(self.ticket, None, price=Decimal('13.00'), membership=m) + self.ocm.commit() + + @classscope(attr='o') + def test_add_with_membership(self): + self.order.customer = self.o.customers.create(email="foo@bar.com") + m = self.order.customer.memberships.create( + membership_type=self.mtype, + date_start=self.event.date_from - timedelta(days=1), + date_end=self.event.date_from + timedelta(days=1), + attendee_name_parts={'_scheme': 'full', 'full_name': 'John Doe'}, + ) + self.ocm.add_position(self.vip, None, price=Decimal('13.00'), membership=m) + self.ocm.commit() + assert self.order.positions.last().used_membership == m + + @classscope(attr='o') + def test_change_membership(self): + self.order.customer = self.o.customers.create(email="foo@bar.com") + m = self.order.customer.memberships.create( + membership_type=self.mtype, + date_start=self.event.date_from - timedelta(days=1), + date_end=self.event.date_from + timedelta(days=1), + attendee_name_parts={'_scheme': 'full', 'full_name': 'John Doe'}, + ) + m2 = self.order.customer.memberships.create( + membership_type=self.mtype, + date_start=self.event.date_from - timedelta(days=1), + date_end=self.event.date_from + timedelta(days=1), + attendee_name_parts={'_scheme': 'full', 'full_name': 'John Doe'}, + ) + self.op1.item = self.vip + self.op1.used_membership = m + self.op1.save() + self.ocm.change_membership(self.op1, membership=m2) + self.ocm.commit() + self.op1.refresh_from_db() + assert self.op1.used_membership == m2 + + @classscope(attr='o') + def test_change_to_invalid_membership(self): + self.order.customer = self.o.customers.create(email="foo@bar.com") + m = self.order.customer.memberships.create( + membership_type=self.mtype, + date_start=self.event.date_from - timedelta(days=1), + date_end=self.event.date_from + timedelta(days=1), + attendee_name_parts={'_scheme': 'full', 'full_name': 'John Doe'}, + ) + m2 = self.order.customer.memberships.create( + membership_type=self.mtype, + date_start=self.event.date_from - timedelta(days=5), + date_end=self.event.date_from - timedelta(days=1), + attendee_name_parts={'_scheme': 'full', 'full_name': 'John Doe'}, + ) + self.op1.item = self.vip + self.op1.used_membership = m + self.op1.save() + self.ocm.change_membership(self.op1, membership=m2) + with self.assertRaises(OrderError): + self.ocm.commit() + + @classscope(attr='o') + def test_change_item_to_required_membership(self): + self.order.customer = self.o.customers.create(email="foo@bar.com") + self.ocm.change_item(self.op1, self.vip, None) + with self.assertRaises(OrderError): + self.ocm.commit() + + @classscope(attr='o') + def test_change_membership_to_none(self): + self.order.customer = self.o.customers.create(email="foo@bar.com") + m = self.order.customer.memberships.create( + membership_type=self.mtype, + date_start=self.event.date_from - timedelta(days=1), + date_end=self.event.date_from + timedelta(days=1), + attendee_name_parts={'_scheme': 'full', 'full_name': 'John Doe'}, + ) + self.order.customer.memberships.create( + membership_type=self.mtype, + date_start=self.event.date_from - timedelta(days=1), + date_end=self.event.date_from + timedelta(days=1), + attendee_name_parts={'_scheme': 'full', 'full_name': 'John Doe'}, + ) + self.op1.item = self.vip + self.op1.used_membership = m + self.op1.save() + self.ocm.change_item(self.op1, self.ticket, None) + self.ocm.change_membership(self.op1, membership=None) + self.ocm.commit() + self.op1.refresh_from_db() + assert self.op1.used_membership is None + assert self.op1.item == self.ticket + @pytest.mark.django_db def test_autocheckin(clist_autocheckin, event): diff --git a/src/tests/control/test_customer.py b/src/tests/control/test_customer.py new file mode 100644 index 000000000..6db491eea --- /dev/null +++ b/src/tests/control/test_customer.py @@ -0,0 +1,235 @@ +# +# This file is part of pretix (Community Edition). +# +# Copyright (C) 2014-2020 Raphael Michel and contributors +# Copyright (C) 2020-2021 rami.io 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 . +# +# 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 +# . +# +from datetime import timedelta +from decimal import Decimal + +import pytest +from bs4 import BeautifulSoup +from django.utils.timezone import now +from django_scopes import scopes_disabled +from tests.base import extract_form_fields + +from pretix.base.models import ( + Item, Order, OrderPosition, Organizer, Team, User, +) + + +@pytest.fixture +def organizer(): + return Organizer.objects.create(name='Dummy', slug='dummy') + + +@pytest.fixture +def customer(organizer): + return organizer.customers.create(email="john@example.org") + + +@pytest.fixture +def membership_type(organizer): + return organizer.membership_types.create(name="Week pass") + + +@pytest.fixture +def event(organizer): + return organizer.events.create( + name='Conference', slug='conf', + date_from=now() + timedelta(days=10), + live=True, is_public=False + ) + + +@pytest.fixture +def order(event, customer): + ticket = Item.objects.create(event=event, name='Early-bird ticket', default_price=23, admission=True) + o1 = Order.objects.create( + status=Order.STATUS_PENDING, + event=event, + customer=customer, + email='admin@localhost', + datetime=now() - timedelta(days=3), + expires=now() + timedelta(days=11), + total=Decimal("23"), + ) + OrderPosition.objects.create( + order=o1, + item=ticket, + variation=None, + price=Decimal("23"), + attendee_name_parts={'full_name': "Peter"} + ) + return o1 + + +@pytest.fixture +def admin_user(organizer): + u = User.objects.create_user('dummy@dummy.dummy', 'dummy') + admin_team = Team.objects.create( + organizer=organizer, can_manage_customers=True, can_change_organizer_settings=True, + name='Admin team' + ) + admin_team.members.add(u) + return u + + +@pytest.mark.django_db +def test_list_of_customers(organizer, admin_user, client, customer): + client.login(email='dummy@dummy.dummy', password='dummy') + resp = client.get('/control/organizer/dummy/customers') + assert customer.identifier in resp.content.decode() + resp = client.get('/control/organizer/dummy/customers?query=john@example.org') + assert customer.identifier in resp.content.decode() + resp = client.get('/control/organizer/dummy/customers?query=1234_FOO') + assert customer.identifier not in resp.content.decode() + + +@pytest.mark.django_db +def test_customer_detail_view(organizer, admin_user, customer, client, order): + client.login(email='dummy@dummy.dummy', password='dummy') + resp = client.get('/control/organizer/dummy/customer/{}/'.format(customer.identifier)) + c = resp.content.decode() + assert customer.email in c + assert order.code in c + + +@pytest.mark.django_db +def test_customer_update(organizer, admin_user, customer, client): + client.login(email='dummy@dummy.dummy', password='dummy') + resp = client.get('/control/organizer/dummy/customer/{}/edit'.format(customer.identifier)) + doc = BeautifulSoup(resp.content, "lxml") + d = extract_form_fields(doc) + d['name_parts_0'] = 'John Doe' + d['is_verified'] = 'on' + resp = client.post('/control/organizer/dummy/customer/{}/edit'.format(customer.identifier), d) + assert resp.status_code == 302 + customer.refresh_from_db() + assert customer.name == 'John Doe' + assert customer.is_verified + + +@pytest.mark.django_db +def test_customer_anonymize(organizer, admin_user, customer, client, order): + customer.is_active = True + customer.name_parts = {'_legacy': 'Foo'} + customer.save() + client.login(email='dummy@dummy.dummy', password='dummy') + client.post('/control/organizer/dummy/customer/{}/anonymize'.format(customer.identifier)) + customer.refresh_from_db() + order.refresh_from_db() + assert not customer.name_parts + assert not customer.name_cached + assert not customer.email + assert not customer.is_active + assert not customer.is_verified + assert not order.customer + + +@pytest.mark.django_db +def test_list_of_membership_types(organizer, admin_user, client, customer, membership_type): + client.login(email='dummy@dummy.dummy', password='dummy') + resp = client.get('/control/organizer/dummy/membershiptypes'.format(customer.identifier)) + c = resp.content.decode() + assert 'Week pass' in c + + +@pytest.mark.django_db +def test_update_membership_type(organizer, admin_user, customer, client, membership_type): + client.login(email='dummy@dummy.dummy', password='dummy') + resp = client.get('/control/organizer/dummy/membershiptype/{}/edit'.format(membership_type.pk)) + doc = BeautifulSoup(resp.content, "lxml") + d = extract_form_fields(doc) + d['transferable'] = 'on' + resp = client.post('/control/organizer/dummy/membershiptype/{}/edit'.format(membership_type.pk), d) + assert resp.status_code == 302 + membership_type.refresh_from_db() + assert membership_type.transferable + + +@pytest.mark.django_db +def test_add_membership_type(organizer, admin_user, customer, client): + client.login(email='dummy@dummy.dummy', password='dummy') + resp = client.post('/control/organizer/dummy/membershiptype/add', { + 'name_0': 'Year pass', + 'max_usages': '3' + }) + assert resp.status_code == 302 + with scopes_disabled(): + mt = organizer.membership_types.get() + assert str(mt.name) == 'Year pass' + assert mt.max_usages == 3 + + +@pytest.mark.django_db +def test_delete_membership_type(organizer, admin_user, customer, client, membership_type): + client.login(email='dummy@dummy.dummy', password='dummy') + resp = client.post('/control/organizer/dummy/membershiptype/{}/delete'.format(membership_type.pk)) + assert resp.status_code == 302 + with scopes_disabled(): + assert not organizer.membership_types.exists() + + +@pytest.mark.django_db +def test_delete_membership_type_forbidden(organizer, admin_user, customer, client, membership_type): + client.login(email='dummy@dummy.dummy', password='dummy') + with scopes_disabled(): + customer.memberships.create(customer=customer, date_start=now(), date_end=now(), membership_type=membership_type) + resp = client.post('/control/organizer/dummy/membershiptype/{}/delete'.format(membership_type.pk)) + assert resp.status_code == 302 + with scopes_disabled(): + assert organizer.membership_types.exists() + + +@pytest.mark.django_db +def test_customer_add_and_change_membership(organizer, admin_user, customer, client, membership_type): + client.login(email='dummy@dummy.dummy', password='dummy') + r = client.post('/control/organizer/dummy/customer/{}/membership/add'.format(customer.identifier), { + 'membership_type': membership_type.pk, + 'date_start_0': '2021-01-01', + 'date_start_1': '00:00:00', + 'date_end_0': '2021-01-08', + 'date_end_1': '23:59:59', + 'attendee_name_parts_0': 'John Doe', + }) + assert r.status_code == 302 + customer.refresh_from_db() + with scopes_disabled(): + m = customer.memberships.get() + assert m.membership_type == membership_type + assert m.date_start.isoformat().startswith('2021-01-01') + assert m.date_end.isoformat().startswith('2021-01-08') + assert m.attendee_name == 'John Doe' + + r = client.post('/control/organizer/dummy/customer/{}/membership/{}/edit'.format(customer.identifier, m.pk), { + 'membership_type': membership_type.pk, + 'date_start_0': '2021-01-02', + 'date_start_1': '00:00:00', + 'date_end_0': '2021-01-09', + 'date_end_1': '23:59:59', + 'attendee_name_parts_0': 'Maria Doe', + }) + assert r.status_code == 302 + customer.refresh_from_db() + with scopes_disabled(): + m = customer.memberships.get() + assert m.membership_type == membership_type + assert m.date_start.isoformat().startswith('2021-01-02') + assert m.date_end.isoformat().startswith('2021-01-09') + assert m.attendee_name == 'Maria Doe' diff --git a/src/tests/control/test_orders.py b/src/tests/control/test_orders.py index 71ef4b0fd..d937a9eac 100644 --- a/src/tests/control/test_orders.py +++ b/src/tests/control/test_orders.py @@ -65,7 +65,7 @@ def env(): ) event.settings.set('ticketoutput_testdummy__enabled', True) user = User.objects.create_user('dummy@dummy.dummy', 'dummy') - t = Team.objects.create(organizer=o, can_view_orders=True, can_change_orders=True) + t = Team.objects.create(organizer=o, can_view_orders=True, can_change_orders=True, can_manage_customers=True) t.members.add(user) t.limit_events.add(event) o = Order.objects.create( @@ -208,6 +208,22 @@ def test_order_set_contact(client, env): assert o.email == 'admin@rami.io' +@pytest.mark.django_db +def test_order_set_customer(client, env): + with scopes_disabled(): + org = env[0].organizer + c = org.customers.create(email='foo@example.org') + org.settings.customer_accounts = True + client.login(email='dummy@dummy.dummy', password='dummy') + client.post('/control/event/dummy/dummy/orders/FOO/contact', { + 'email': 'admin@rami.io', + 'customer': c.pk + }, follow=True) + env[2].refresh_from_db() + assert env[2].email == 'admin@rami.io' + assert env[2].customer == c + + @pytest.mark.django_db def test_order_set_locale(client, env): with scopes_disabled(): @@ -1332,6 +1348,39 @@ class OrderChangeTests(SoupTest): assert self.op1.subevent == se2 assert self.op2.subevent == se1 + def test_change_membership_success(self): + self.event.organizer.settings.customer_accounts = True + with scopes_disabled(): + mtype = self.event.organizer.membership_types.create(name='Week pass', transferable=True, allow_parallel_usage=True) + self.ticket.require_membership = True + self.ticket.require_membership_types.add(mtype) + self.ticket.admission = True + self.ticket.save() + customer = self.event.organizer.customers.create(email='john@example.org', is_verified=True) + self.order.customer = customer + self.order.save() + m_correct1 = customer.memberships.create( + membership_type=mtype, + date_start=self.event.date_from - timedelta(days=1), + date_end=self.event.date_from + timedelta(days=1), + attendee_name_parts={'_scheme': 'full', 'full_name': 'John Doe'}, + ) + r = self.client.post('/control/event/{}/{}/orders/{}/change'.format( + self.event.organizer.slug, self.event.slug, self.order.code + ), { + 'add-TOTAL_FORMS': '0', + 'add-INITIAL_FORMS': '0', + 'add-MIN_NUM_FORMS': '0', + 'add-MAX_NUM_FORMS': '100', + 'op-{}-used_membership'.format(self.op1.pk): str(m_correct1.pk), + 'op-{}-used_membership'.format(self.op2.pk): str(m_correct1.pk), + 'op-{}-used_membership'.format(self.op3.pk): str(m_correct1.pk), + }, follow=True) + print(r.content) + self.op1.refresh_from_db() + self.order.refresh_from_db() + assert self.op1.used_membership == m_correct1 + def test_change_price_success(self): self.client.post('/control/event/{}/{}/orders/{}/change'.format( self.event.organizer.slug, self.event.slug, self.order.code diff --git a/src/tests/control/test_permissions.py b/src/tests/control/test_permissions.py index b2d284f47..c560e85dc 100644 --- a/src/tests/control/test_permissions.py +++ b/src/tests/control/test_permissions.py @@ -191,6 +191,8 @@ organizer_urls = [ 'organizer/abc/webhook/add', 'organizer/abc/webhook/1/edit', 'organizer/abc/webhook/1/logs', + 'organizer/abc/customers', + 'organizer/abc/customer/1/', 'organizer/abc/giftcards', 'organizer/abc/giftcard/add', 'organizer/abc/giftcard/1/', @@ -471,6 +473,16 @@ organizer_permission_urls = [ ("can_change_organizer_settings", "organizer/dummy/property/add", 200), ("can_change_organizer_settings", "organizer/dummy/property/1/edit", 404), ("can_change_organizer_settings", "organizer/dummy/property/1/delete", 404), + ("can_change_organizer_settings", "organizer/dummy/membershiptypes", 200), + ("can_change_organizer_settings", "organizer/dummy/membershiptype/add", 200), + ("can_change_organizer_settings", "organizer/dummy/membershiptype/1/edit", 404), + ("can_change_organizer_settings", "organizer/dummy/membershiptype/1/delete", 404), + ("can_manage_customers", "organizer/dummy/customers", 200), + ("can_manage_customers", "organizer/dummy/customer/ABC/edit", 404), + ("can_manage_customers", "organizer/dummy/customer/ABC/anonymize", 404), + ("can_manage_customers", "organizer/dummy/customer/ABC/membership/add", 404), + ("can_manage_customers", "organizer/dummy/customer/ABC/membership/1/edit", 404), + ("can_manage_customers", "organizer/dummy/customer/ABC/", 404), ("can_manage_gift_cards", "organizer/dummy/giftcards", 200), ("can_manage_gift_cards", "organizer/dummy/giftcard/add", 200), ("can_manage_gift_cards", "organizer/dummy/giftcard/1/", 404), diff --git a/src/tests/presale/test_checkout.py b/src/tests/presale/test_checkout.py index 2b516b10c..d78342037 100644 --- a/src/tests/presale/test_checkout.py +++ b/src/tests/presale/test_checkout.py @@ -3690,3 +3690,253 @@ class CheckoutVoucherBudgetTest(BaseCheckoutTestCase, TestCase): 'web') self.cp2.refresh_from_db() assert self.cp2.price == Decimal('23.00') + + +class CustomerCheckoutTestCase(BaseCheckoutTestCase, TestCase): + + @scopes_disabled() + def setUp(self): + super().setUp() + self.orga.settings.customer_accounts = True + self.event.settings.set('payment_stripe__enabled', True) + self.event.settings.set('payment_banktransfer__enabled', True) + with scopes_disabled(): + CartPosition.objects.create( + event=self.event, cart_id=self.session_key, item=self.ticket, + price=23, expires=now() + timedelta(minutes=10) + ) + self.customer = self.orga.customers.create(email='john@example.org', is_verified=True) + self.customer.set_password('foo') + self.customer.save() + + def _finish(self): + self._set_session('payment', 'banktransfer') + self.client.post('/%s/%s/checkout/confirm/' % (self.orga.slug, self.event.slug), follow=True) + with scopes_disabled(): + return Order.objects.last() + + def test_guest(self): + response = self.client.get('/%s/%s/checkout/start' % (self.orga.slug, self.event.slug), follow=True) + self.assertRedirects(response, '/%s/%s/checkout/customer/' % (self.orga.slug, self.event.slug), + target_status_code=200) + + response = self.client.post('/%s/%s/checkout/customer/' % (self.orga.slug, self.event.slug), { + 'customer_mode': 'guest' + }, follow=True) + self.assertRedirects(response, '/%s/%s/checkout/questions/' % (self.orga.slug, self.event.slug), + target_status_code=200) + + order = self._finish() + assert order.email == 'admin@localhost' + assert not order.customer + + def test_guest_even_if_logged_in(self): + self.client.post('/%s/account/login' % self.orga.slug, { + 'email': 'john@example.org', + 'password': 'foo', + }) + + response = self.client.get('/%s/%s/checkout/start' % (self.orga.slug, self.event.slug), follow=True) + self.assertRedirects(response, '/%s/%s/checkout/customer/' % (self.orga.slug, self.event.slug), + target_status_code=200) + assert 'john@example.org' in response.content.decode() + + response = self.client.post('/%s/%s/checkout/customer/' % (self.orga.slug, self.event.slug), { + 'customer_mode': 'guest' + }, follow=True) + self.assertRedirects(response, '/%s/%s/checkout/questions/' % (self.orga.slug, self.event.slug), + target_status_code=200) + + order = self._finish() + assert order.email == 'admin@localhost' + assert not order.customer + + def test_login_already_logged_in_and_forced_email(self): + self.client.post('/%s/account/login' % self.orga.slug, { + 'email': 'john@example.org', + 'password': 'foo', + }) + + response = self.client.get('/%s/%s/checkout/start' % (self.orga.slug, self.event.slug), follow=True) + self.assertRedirects(response, '/%s/%s/checkout/customer/' % (self.orga.slug, self.event.slug), + target_status_code=200) + assert 'john@example.org' in response.content.decode() + + response = self.client.post('/%s/%s/checkout/customer/' % (self.orga.slug, self.event.slug), { + 'customer_mode': 'login' + }, follow=True) + self.assertRedirects(response, '/%s/%s/checkout/questions/' % (self.orga.slug, self.event.slug), + target_status_code=200) + response = self.client.post('/%s/%s/checkout/questions/' % (self.orga.slug, self.event.slug), { + 'email': 'will-be-ignored' + }, follow=True) + self.assertRedirects(response, '/%s/%s/checkout/payment/' % (self.orga.slug, self.event.slug), + target_status_code=200) + + order = self._finish() + assert order.email == 'john@example.org' + assert order.customer == self.customer + + def test_login_valid(self): + response = self.client.get('/%s/%s/checkout/start' % (self.orga.slug, self.event.slug), follow=True) + self.assertRedirects(response, '/%s/%s/checkout/customer/' % (self.orga.slug, self.event.slug), + target_status_code=200) + + response = self.client.post('/%s/%s/checkout/customer/' % (self.orga.slug, self.event.slug), { + 'customer_mode': 'login', + 'login-email': 'john@example.org', + 'login-password': 'foo', + }, follow=True) + self.assertRedirects(response, '/%s/%s/checkout/questions/' % (self.orga.slug, self.event.slug), + target_status_code=200) + order = self._finish() + assert order.customer == self.customer + + def test_login_invalid(self): + response = self.client.get('/%s/%s/checkout/start' % (self.orga.slug, self.event.slug), follow=True) + self.assertRedirects(response, '/%s/%s/checkout/customer/' % (self.orga.slug, self.event.slug), + target_status_code=200) + + response = self.client.post('/%s/%s/checkout/customer/' % (self.orga.slug, self.event.slug), { + 'customer_mode': 'login', + 'login-email': 'john@example.org', + 'login-password': 'bar', + }, follow=False) + assert response.status_code == 200 + assert b'alert-danger' in response.content + + def test_register_valid(self): + response = self.client.get('/%s/%s/checkout/start' % (self.orga.slug, self.event.slug), follow=True) + self.assertRedirects(response, '/%s/%s/checkout/customer/' % (self.orga.slug, self.event.slug), + target_status_code=200) + + response = self.client.post('/%s/%s/checkout/customer/' % (self.orga.slug, self.event.slug), { + 'customer_mode': 'register', + 'register-email': 'foo@example.com', + 'register-name_parts_0': 'John Doe', + }, follow=False) + self.assertRedirects(response, '/%s/%s/checkout/questions/' % (self.orga.slug, self.event.slug), + target_status_code=200) + assert len(djmail.outbox) == 1 + + # After a valid registration form, we apply a kind of soft login. Since the email address hasn't yet been + # verified, we do not do a proper login, since that would cause security problems. However, if the customer + # goes back to this step manually, they can re-use the account. + response = self.client.get('/%s/%s/checkout/customer/' % (self.orga.slug, self.event.slug)) + assert response.content.decode().count('foo@example.com') == 1 + + response = self.client.post('/%s/%s/checkout/customer/' % (self.orga.slug, self.event.slug), { + 'customer_mode': 'login', + }, follow=False) + self.assertRedirects(response, '/%s/%s/checkout/questions/' % (self.orga.slug, self.event.slug), + target_status_code=200) + + response = self.client.post('/%s/%s/checkout/questions/' % (self.orga.slug, self.event.slug), { + 'email': 'will-be-ignored' + }, follow=True) + self.assertRedirects(response, '/%s/%s/checkout/payment/' % (self.orga.slug, self.event.slug), + target_status_code=200) + order = self._finish() + assert order.customer != self.customer + assert order.customer.email == 'foo@example.com' + assert order.email == 'foo@example.com' + assert not order.customer.is_verified + + def test_register_invalid(self): + response = self.client.get('/%s/%s/checkout/start' % (self.orga.slug, self.event.slug), follow=True) + self.assertRedirects(response, '/%s/%s/checkout/customer/' % (self.orga.slug, self.event.slug), + target_status_code=200) + + response = self.client.post('/%s/%s/checkout/customer/' % (self.orga.slug, self.event.slug), { + 'customer_mode': 'register', + 'register-email': 'john@example.org', + 'register-name_parts_0': 'John Doe', + }, follow=False) + assert response.status_code == 200 + assert b'has-error' in response.content + + def test_guest_not_allowed_if_granting_membership(self): + self.ticket.grant_membership_type = self.orga.membership_types.create( + name='Week pass' + ) + self.ticket.save() + response = self.client.post('/%s/%s/checkout/customer/' % (self.orga.slug, self.event.slug), { + 'customer_mode': 'guest' + }, follow=False) + assert response.status_code == 200 + + def test_guest_not_allowed_if_requiring_membership(self): + self.ticket.require_membership = True + self.ticket.save() + response = self.client.post('/%s/%s/checkout/customer/' % (self.orga.slug, self.event.slug), { + 'customer_mode': 'guest' + }, follow=False) + assert response.status_code == 200 + + def test_select_membership(self): + mtype = self.orga.membership_types.create(name='Week pass', transferable=False) + mtype2 = self.orga.membership_types.create(name='Invalid pass') + self.ticket.require_membership = True + self.ticket.require_membership_types.add(mtype) + self.ticket.admission = True + self.ticket.save() + self.event.settings.attendee_names_asked = True + + with scopes_disabled(): + cp = CartPosition.objects.get() + m_correct1 = self.customer.memberships.create( + membership_type=mtype, + date_start=self.event.date_from - datetime.timedelta(days=1), + date_end=self.event.date_from + datetime.timedelta(days=1), + attendee_name_parts={'_scheme': 'full', 'full_name': 'John Doe'}, + ) + self.customer.memberships.create( + membership_type=mtype, + date_start=self.event.date_from - datetime.timedelta(days=1), + date_end=self.event.date_from + datetime.timedelta(days=1), + attendee_name_parts={'_scheme': 'full', 'full_name': 'Mark Fisher'}, + ) + self.customer.memberships.create( + membership_type=mtype, + date_start=self.event.date_from - datetime.timedelta(days=5), + date_end=self.event.date_from - datetime.timedelta(days=1), + attendee_name_parts={'_scheme': 'full', 'full_name': 'Sue Fisher'}, + ) + self.customer.memberships.create( + membership_type=mtype2, + date_start=self.event.date_from - datetime.timedelta(days=5), + date_end=self.event.date_from + datetime.timedelta(days=1), + attendee_name_parts={'_scheme': 'full', 'full_name': 'Mike Miller'}, + ) + + response = self.client.post('/%s/%s/checkout/customer/' % (self.orga.slug, self.event.slug), { + 'customer_mode': 'login', + 'login-email': 'john@example.org', + 'login-password': 'foo', + }, follow=True) + self.assertRedirects(response, '/%s/%s/checkout/membership/' % (self.orga.slug, self.event.slug), + target_status_code=200) + assert b'John Doe' in response.content + assert b'Mark Fisher' in response.content + assert b'Sue Fisher' not in response.content + assert b'Mike Miller' not in response.content + + response = self.client.post('/%s/%s/checkout/membership/' % (self.orga.slug, self.event.slug), { + f'membership-{cp.pk}-membership': m_correct1.pk, + }, follow=True) + self.assertRedirects(response, '/%s/%s/checkout/questions/' % (self.orga.slug, self.event.slug), + target_status_code=200) + assert b'John Doe' in response.content + assert b'Mark Fisher' not in response.content + response = self.client.post('/%s/%s/checkout/questions/' % (self.orga.slug, self.event.slug), { + 'email': 'will-be-ignored', + f'{cp.pk}-attendee_name_parts_0': 'will-be-ignored' + }, follow=True) + self.assertRedirects(response, '/%s/%s/checkout/payment/' % (self.orga.slug, self.event.slug), + target_status_code=200) + order = self._finish() + assert order.customer == self.customer + assert order.customer.email == order.email + with scopes_disabled(): + assert order.positions.first().used_membership == m_correct1 + assert order.positions.first().attendee_name == 'John Doe' diff --git a/src/tests/presale/test_checkoutflow.py b/src/tests/presale/test_checkoutflow.py index 1f2de96a4..95baec386 100644 --- a/src/tests/presale/test_checkoutflow.py +++ b/src/tests/presale/test_checkoutflow.py @@ -100,10 +100,12 @@ def test_plugin_in_order(event, mocker): flow = with_mocked_step(mocker, MockingStep, event) assert isinstance(flow[0], checkoutflow.AddOnsStep) - assert isinstance(flow[1], checkoutflow.QuestionsStep) - assert isinstance(flow[2], MockingStep) - assert isinstance(flow[3], checkoutflow.PaymentStep) - assert isinstance(flow[4], checkoutflow.ConfirmStep) + assert isinstance(flow[1], checkoutflow.CustomerStep) + assert isinstance(flow[2], checkoutflow.MembershipStep) + assert isinstance(flow[3], checkoutflow.QuestionsStep) + assert isinstance(flow[4], MockingStep) + assert isinstance(flow[5], checkoutflow.PaymentStep) + assert isinstance(flow[6], checkoutflow.ConfirmStep) @pytest.mark.django_db @@ -117,9 +119,9 @@ def test_step_ignored(event, mocker, req_with_session): flow = with_mocked_step(mocker, MockingStep, event) req_with_session.event = event - assert flow[1].get_next_applicable(req_with_session) is flow[4] + assert flow[3].get_next_applicable(req_with_session) is flow[6] # flow[3] is also skipped because no payment is required if there is no cart - assert flow[1] is flow[4].get_prev_applicable(req_with_session) + assert flow[3] is flow[6].get_prev_applicable(req_with_session) @pytest.mark.django_db diff --git a/src/tests/presale/test_customer.py b/src/tests/presale/test_customer.py new file mode 100644 index 000000000..00ec3b4ee --- /dev/null +++ b/src/tests/presale/test_customer.py @@ -0,0 +1,405 @@ +# +# This file is part of pretix (Community Edition). +# +# Copyright (C) 2014-2020 Raphael Michel and contributors +# Copyright (C) 2020-2021 rami.io 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 . +# +# 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 +# . +# +import datetime +from datetime import timedelta +from decimal import Decimal + +import pytest +from django.core import mail as djmail +from django.core.signing import dumps +from django.utils.timezone import now +from django_scopes import scopes_disabled + +from pretix.base.models import Event, Item, Order, OrderPosition, Organizer +from pretix.presale.forms.customer import TokenGenerator + + +@pytest.fixture +def env(): + o = Organizer.objects.create(name='Big Events LLC', slug='bigevents') + o.settings.customer_accounts = True + event = Event.objects.create( + organizer=o, name='Conference', slug='conf', + date_from=now() + timedelta(days=10), + live=True, is_public=False + ) + return o, event + + +@pytest.mark.django_db +def test_disabled(env, client): + env[0].settings.customer_accounts = False + r = client.get('/bigevents/account/register') + assert r.status_code == 404 + r = client.get('/bigevents/account/login') + assert r.status_code == 404 + r = client.get('/bigevents/account/pwreset') + assert r.status_code == 404 + r = client.get('/bigevents/account/pwrecover') + assert r.status_code == 404 + r = client.get('/bigevents/account/activate') + assert r.status_code == 404 + r = client.get('/bigevents/account/change') + assert r.status_code == 404 + r = client.get('/bigevents/account/confirmchange') + assert r.status_code == 404 + r = client.get('/bigevents/account/') + assert r.status_code == 404 + + +@pytest.mark.django_db +def test_org_register(env, client): + r = client.post('/bigevents/account/register', { + 'email': 'john@example.org', + 'name_parts_0': 'John Doe', + }) + assert r.status_code == 302 + assert len(djmail.outbox) == 1 + with scopes_disabled(): + customer = env[0].customers.get(email='john@example.org') + assert not customer.is_verified + assert customer.is_active + + r = client.post( + f'/bigevents/account/activate?id={customer.identifier}&token={TokenGenerator().make_token(customer)}', { + 'password': 'PANioMR62', + 'password_repeat': 'PANioMR62', + }) + assert r.status_code == 302 + + customer.refresh_from_db() + assert customer.check_password('PANioMR62') + assert customer.is_verified + + +@pytest.mark.django_db +def test_org_register_duplicate_email(env, client): + with scopes_disabled(): + env[0].customers.create(email='john@example.org') + r = client.post('/bigevents/account/register', { + 'email': 'john@example.org', + 'name_parts_0': 'John Doe', + }) + assert b'already registered' in r.content + assert r.status_code == 200 + + +@pytest.mark.django_db +def test_org_resetpw(env, client): + with scopes_disabled(): + customer = env[0].customers.create(email='john@example.org', is_verified=False) + + r = client.post('/bigevents/account/pwreset', { + 'email': 'john@example.org', + }) + assert r.status_code == 302 + assert len(djmail.outbox) == 1 + + r = client.post( + f'/bigevents/account/pwrecover?id={customer.identifier}&token={TokenGenerator().make_token(customer)}', { + 'password': 'PANioMR62', + 'password_repeat': 'PANioMR62', + }) + assert r.status_code == 302 + + customer.refresh_from_db() + assert customer.check_password('PANioMR62') + assert customer.is_verified + + +@pytest.mark.django_db +def test_org_activate_invalid_token(env, client): + with scopes_disabled(): + customer = env[0].customers.create(email='john@example.org', is_verified=False) + r = client.get( + f'/bigevents/account/activate?id={customer.identifier}&token=.invalid.{TokenGenerator().make_token(customer)}') + assert r.status_code == 302 + + +@pytest.mark.django_db +def test_org_login_logout(env, client): + with scopes_disabled(): + customer = env[0].customers.create(email='john@example.org', is_verified=True) + customer.set_password('foo') + customer.save() + + r = client.post('/bigevents/account/login', { + 'email': 'john@example.org', + 'password': 'foo', + }) + assert r.status_code == 302 + + r = client.get(f'/bigevents/account/') + assert r.status_code == 200 + + r = client.get('/bigevents/account/logout') + assert r.status_code == 302 + + r = client.get(f'/bigevents/account/') + assert r.status_code == 302 + + +@pytest.mark.django_db +def test_org_login_invalid_password(env, client): + with scopes_disabled(): + customer = env[0].customers.create(email='john@example.org', is_verified=True) + customer.set_password('foo') + customer.save() + + r = client.post('/bigevents/account/login', { + 'email': 'john@example.org', + 'password': 'invalid', + }) + assert r.status_code == 200 + assert b'alert-danger' in r.content + + +@pytest.mark.django_db +def test_org_login_not_verified(env, client): + with scopes_disabled(): + customer = env[0].customers.create(email='john@example.org', is_verified=False) + customer.set_password('foo') + customer.save() + + r = client.post('/bigevents/account/login', { + 'email': 'john@example.org', + 'password': 'foo', + }) + assert r.status_code == 200 + assert b'alert-danger' in r.content + + +@pytest.mark.django_db +def test_org_login_not_active(env, client): + with scopes_disabled(): + customer = env[0].customers.create(email='john@example.org', is_verified=True, is_active=False) + customer.set_password('foo') + customer.save() + + r = client.post('/bigevents/account/login', { + 'email': 'john@example.org', + 'password': 'foo', + }) + assert r.status_code == 200 + assert b'alert-danger' in r.content + + +@pytest.mark.django_db +@pytest.mark.parametrize("url", [ + "account/change", + "account/membership/1/", + "account/", +]) +def test_login_required(client, env, url): + with scopes_disabled(): + customer = env[0].customers.create(email='john@example.org', is_verified=True) + customer.set_password('foo') + customer.save() + + assert client.get('/bigevents/' + url).status_code == 302 + + client.post('/bigevents/account/login', { + 'email': 'john@example.org', + 'password': 'foo', + }) + assert client.get('/bigevents/' + url).status_code in (200, 404) + + +@pytest.mark.django_db +def test_org_order_list(env, client): + with scopes_disabled(): + customer = env[0].customers.create(email='john@example.org', is_verified=True) + customer.set_password('foo') + customer.save() + event = env[1] + ticket = Item.objects.create(event=event, name='Early-bird ticket', default_price=23, admission=True) + o1 = Order.objects.create( + status=Order.STATUS_PENDING, + event=event, + email='admin@localhost', + datetime=now() - datetime.timedelta(days=3), + expires=now() + datetime.timedelta(days=11), + total=Decimal("23"), + ) + OrderPosition.objects.create( + order=o1, + item=ticket, + variation=None, + price=Decimal("23"), + attendee_name_parts={'full_name': "Peter"} + ) + o2 = Order.objects.create( + status=Order.STATUS_PENDING, + event=event, + email='john@example.org', + datetime=now() - datetime.timedelta(days=3), + expires=now() + datetime.timedelta(days=11), + total=Decimal("23"), + ) + OrderPosition.objects.create( + order=o2, + item=ticket, + variation=None, + price=Decimal("23"), + attendee_name_parts={'full_name': "Peter"} + ) + o3 = Order.objects.create( + status=Order.STATUS_PENDING, + event=event, + email='admin@localhost', + customer=customer, + datetime=now() - datetime.timedelta(days=3), + expires=now() + datetime.timedelta(days=11), + total=Decimal("23"), + ) + OrderPosition.objects.create( + order=o3, + item=ticket, + variation=None, + price=Decimal("23"), + attendee_name_parts={'full_name': "Peter"} + ) + + r = client.post('/bigevents/account/login', { + 'email': 'john@example.org', + 'password': 'foo', + }) + assert r.status_code == 302 + + r = client.get(f'/bigevents/account/') + assert r.status_code == 200 + content = r.content.decode() + assert o1.code not in content + assert o2.code in content + assert o3.code in content + + +@pytest.mark.django_db +def test_change_name(env, client): + with scopes_disabled(): + customer = env[0].customers.create(email='john@example.org', is_verified=True) + customer.set_password('foo') + customer.save() + + r = client.post('/bigevents/account/login', { + 'email': 'john@example.org', + 'password': 'foo', + }) + assert r.status_code == 302 + + r = client.post(f'/bigevents/account/change', { + 'name_parts_0': 'John Doe', + 'email': 'john@example.org', + }) + assert r.status_code == 302 + customer.refresh_from_db() + assert customer.name == 'John Doe' + + +@pytest.mark.django_db +def test_change_email(env, client): + with scopes_disabled(): + customer = env[0].customers.create(email='john@example.org', is_verified=True) + customer.set_password('foo') + customer.save() + + r = client.post('/bigevents/account/login', { + 'email': 'john@example.org', + 'password': 'foo', + }) + assert r.status_code == 302 + + r = client.post(f'/bigevents/account/change', { + 'name_parts_0': 'John Doe', + 'email': 'john@example.com' + }) + assert r.status_code == 200 + customer.refresh_from_db() + assert customer.email == 'john@example.org' + + r = client.post(f'/bigevents/account/change', { + 'name_parts_0': 'John Doe', + 'email': 'john@example.com', + 'password_current': 'foo', + }) + assert r.status_code == 302 + customer.refresh_from_db() + assert customer.email == 'john@example.org' + assert len(djmail.outbox) == 1 + + token = dumps({ + 'customer': customer.pk, + 'email': 'john@example.com' + }, salt='pretix.presale.views.customer.ChangeInformationView') + r = client.get(f'/bigevents/account/confirmchange?token={token}') + assert r.status_code == 302 + customer.refresh_from_db() + assert customer.email == 'john@example.com' + + +@pytest.mark.django_db +def test_change_pw(env, client): + with scopes_disabled(): + customer = env[0].customers.create(email='john@example.org', is_verified=True) + customer.set_password('foo') + customer.save() + + r = client.post('/bigevents/account/login', { + 'email': 'john@example.org', + 'password': 'foo', + }) + assert r.status_code == 302 + + r = client.post(f'/bigevents/account/password', { + 'password_current': 'invalid', + 'password': 'aYLBRNg4', + 'password_repeat': 'aYLBRNg4', + }) + assert r.status_code == 200 + customer.refresh_from_db() + assert customer.check_password('foo') + + r = client.post(f'/bigevents/account/password', { + 'password_current': 'foo', + 'password': 'aYLBRNg4', + 'password_repeat': 'aYLBRNg4', + }) + assert r.status_code == 302 + customer.refresh_from_db() + assert customer.check_password('aYLBRNg4') + + +@pytest.mark.django_db +def test_login_per_org(env, client): + with scopes_disabled(): + o2 = Organizer.objects.create(name='Demo', slug='demo') + o2.settings.customer_accounts = True + customer = env[0].customers.create(email='john@example.org', is_verified=True) + customer.set_password('foo') + customer.save() + + client.post('/bigevents/account/login', { + 'email': 'john@example.org', + 'password': 'foo', + }) + assert client.get('/bigevents/account/').status_code == 200 + assert client.get('/demo/account/').status_code == 302