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 %}
- {% 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 %}