mirror of
https://github.com/pretix/pretix.git
synced 2026-08-26 13:14:40 +00:00
Customer accounts & Memberships (#2024)
This commit is contained in:
@@ -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.
|
||||||
@@ -21,6 +21,9 @@ Resources and endpoints
|
|||||||
vouchers
|
vouchers
|
||||||
checkinlists
|
checkinlists
|
||||||
waitinglist
|
waitinglist
|
||||||
|
customers
|
||||||
|
membershiptypes
|
||||||
|
memberships
|
||||||
giftcards
|
giftcards
|
||||||
carts
|
carts
|
||||||
teams
|
teams
|
||||||
|
|||||||
@@ -69,6 +69,16 @@ require_approval boolean If ``true``, or
|
|||||||
approved by the event organizer before they can be
|
approved by the event organizer before they can be
|
||||||
paid.
|
paid.
|
||||||
require_bundling boolean If ``true``, this item is only available as part of bundles.
|
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
|
generate_tickets boolean If ``false``, tickets are never generated for this
|
||||||
product, regardless of other settings. If ``true``,
|
product, regardless of other settings. If ``true``,
|
||||||
tickets are generated even if this is a
|
tickets are generated even if this is a
|
||||||
@@ -198,6 +208,12 @@ Endpoints
|
|||||||
"show_quota_left": null,
|
"show_quota_left": null,
|
||||||
"require_approval": false,
|
"require_approval": false,
|
||||||
"require_bundling": 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": [
|
"variations": [
|
||||||
{
|
{
|
||||||
"value": {"en": "Student"},
|
"value": {"en": "Student"},
|
||||||
@@ -294,6 +310,12 @@ Endpoints
|
|||||||
"has_variations": false,
|
"has_variations": false,
|
||||||
"require_approval": false,
|
"require_approval": false,
|
||||||
"require_bundling": 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": [
|
"variations": [
|
||||||
{
|
{
|
||||||
"value": {"en": "Student"},
|
"value": {"en": "Student"},
|
||||||
@@ -370,6 +392,12 @@ Endpoints
|
|||||||
"checkin_attention": false,
|
"checkin_attention": false,
|
||||||
"require_approval": false,
|
"require_approval": false,
|
||||||
"require_bundling": 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": [
|
"variations": [
|
||||||
{
|
{
|
||||||
"value": {"en": "Student"},
|
"value": {"en": "Student"},
|
||||||
@@ -435,6 +463,12 @@ Endpoints
|
|||||||
"has_variations": true,
|
"has_variations": true,
|
||||||
"require_approval": false,
|
"require_approval": false,
|
||||||
"require_bundling": 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": [
|
"variations": [
|
||||||
{
|
{
|
||||||
"value": {"en": "Student"},
|
"value": {"en": "Student"},
|
||||||
@@ -531,6 +565,12 @@ Endpoints
|
|||||||
"has_variations": true,
|
"has_variations": true,
|
||||||
"require_approval": false,
|
"require_approval": false,
|
||||||
"require_bundling": 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": [
|
"variations": [
|
||||||
{
|
{
|
||||||
"value": {"en": "Student"},
|
"value": {"en": "Student"},
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
@@ -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.
|
||||||
@@ -31,6 +31,7 @@ testmode boolean If ``true``, th
|
|||||||
secret string The secret contained in the link sent to the customer
|
secret string The secret contained in the link sent to the customer
|
||||||
email string The customer email address
|
email string The customer email address
|
||||||
phone string The customer phone number
|
phone string The customer phone number
|
||||||
|
customer string The customer account identifier (or ``null``)
|
||||||
locale string The locale used for communication with this customer
|
locale string The locale used for communication with this customer
|
||||||
sales_channel string Channel this sale was created through, such as
|
sales_channel string Channel this sale was created through, such as
|
||||||
``"web"``.
|
``"web"``.
|
||||||
@@ -118,6 +119,10 @@ last_modified datetime Last modificati
|
|||||||
|
|
||||||
The ``phone`` attribute has been added.
|
The ``phone`` attribute has been added.
|
||||||
|
|
||||||
|
.. versionchanged:: 4.0
|
||||||
|
|
||||||
|
The ``customer`` attribute has been added.
|
||||||
|
|
||||||
|
|
||||||
.. _order-position-resource:
|
.. _order-position-resource:
|
||||||
|
|
||||||
@@ -289,6 +294,7 @@ List of all orders
|
|||||||
"url": "https://test.pretix.eu/dummy/dummy/order/ABC12/k24fiuwvu8kxz3y1/",
|
"url": "https://test.pretix.eu/dummy/dummy/order/ABC12/k24fiuwvu8kxz3y1/",
|
||||||
"email": "tester@example.org",
|
"email": "tester@example.org",
|
||||||
"phone": "+491234567",
|
"phone": "+491234567",
|
||||||
|
"customer": null,
|
||||||
"locale": "en",
|
"locale": "en",
|
||||||
"sales_channel": "web",
|
"sales_channel": "web",
|
||||||
"datetime": "2017-12-01T10:00:00Z",
|
"datetime": "2017-12-01T10:00:00Z",
|
||||||
@@ -457,6 +463,7 @@ Fetching individual orders
|
|||||||
"url": "https://test.pretix.eu/dummy/dummy/order/ABC12/k24fiuwvu8kxz3y1/",
|
"url": "https://test.pretix.eu/dummy/dummy/order/ABC12/k24fiuwvu8kxz3y1/",
|
||||||
"email": "tester@example.org",
|
"email": "tester@example.org",
|
||||||
"phone": "+491234567",
|
"phone": "+491234567",
|
||||||
|
"customer": null,
|
||||||
"locale": "en",
|
"locale": "en",
|
||||||
"sales_channel": "web",
|
"sales_channel": "web",
|
||||||
"datetime": "2017-12-01T10:00:00Z",
|
"datetime": "2017-12-01T10:00:00Z",
|
||||||
@@ -775,6 +782,8 @@ Creating orders
|
|||||||
|
|
||||||
* does not support redeeming gift cards
|
* does not support redeeming gift cards
|
||||||
|
|
||||||
|
* does not support or validate memberships
|
||||||
|
|
||||||
You can supply the following fields of the resource:
|
You can supply the following fields of the resource:
|
||||||
|
|
||||||
* ``code`` (optional)
|
* ``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
|
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
|
**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.
|
then call the ``mark_paid`` API method.
|
||||||
|
* ``customer`` (optional) – Customer identifier or ``null``
|
||||||
* ``testmode`` (optional) – Defaults to ``false``
|
* ``testmode`` (optional) – Defaults to ``false``
|
||||||
* ``consume_carts`` (optional) – A list of cart IDs. All cart positions with these IDs will be deleted if the
|
* ``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
|
order creation is successful. Any quotas or seats that become free by this operation will be credited to your order
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ limit_events list List of event s
|
|||||||
can_create_events boolean
|
can_create_events boolean
|
||||||
can_change_teams boolean
|
can_change_teams boolean
|
||||||
can_change_organizer_settings boolean
|
can_change_organizer_settings boolean
|
||||||
|
can_manage_customers boolean
|
||||||
can_manage_gift_cards boolean
|
can_manage_gift_cards boolean
|
||||||
can_change_event_settings boolean
|
can_change_event_settings boolean
|
||||||
can_change_items boolean
|
can_change_items boolean
|
||||||
|
|||||||
@@ -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 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.
|
* 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
|
Anyone with this permission can assign arbitrary other permissions to themselves, so this is the most powerful
|
||||||
permission there is to give.
|
permission there is to give.
|
||||||
|
|||||||
@@ -19,7 +19,8 @@
|
|||||||
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
|
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
|
||||||
# <https://www.gnu.org/licenses/>.
|
# <https://www.gnu.org/licenses/>.
|
||||||
#
|
#
|
||||||
from django.utils.translation import ugettext_lazy as _
|
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
|
|
||||||
class FullAccessSecurityProfile:
|
class FullAccessSecurityProfile:
|
||||||
|
|||||||
@@ -160,9 +160,17 @@ class ItemSerializer(I18nAwareModelSerializer):
|
|||||||
'require_voucher', 'hide_without_voucher', 'allow_cancel', 'require_bundling',
|
'require_voucher', 'hide_without_voucher', 'allow_cancel', 'require_bundling',
|
||||||
'min_per_order', 'max_per_order', 'checkin_attention', 'has_variations', 'variations',
|
'min_per_order', 'max_per_order', 'checkin_attention', 'has_variations', 'variations',
|
||||||
'addons', 'bundles', 'original_price', 'require_approval', 'generate_tickets',
|
'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',)
|
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):
|
def validate(self, data):
|
||||||
data = super().validate(data)
|
data = super().validate(data)
|
||||||
if self.instance and ('addons' in data or 'variations' in data or 'bundles' in data):
|
if self.instance and ('addons' in data or 'variations' in data or 'bundles' in data):
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ from pretix.base.channels import get_all_sales_channels
|
|||||||
from pretix.base.decimal import round_decimal
|
from pretix.base.decimal import round_decimal
|
||||||
from pretix.base.i18n import language
|
from pretix.base.i18n import language
|
||||||
from pretix.base.models import (
|
from pretix.base.models import (
|
||||||
CachedFile, Checkin, Invoice, InvoiceAddress, InvoiceLine, Item,
|
CachedFile, Checkin, Customer, Invoice, InvoiceAddress, InvoiceLine, Item,
|
||||||
ItemVariation, Order, OrderPosition, Question, QuestionAnswer, Seat,
|
ItemVariation, Order, OrderPosition, Question, QuestionAnswer, Seat,
|
||||||
SubEvent, TaxRule, Voucher,
|
SubEvent, TaxRule, Voucher,
|
||||||
)
|
)
|
||||||
@@ -537,7 +537,7 @@ class CheckinListOrderPositionSerializer(OrderPositionSerializer):
|
|||||||
self.fields['subevent'] = SubEventSerializer(read_only=True)
|
self.fields['subevent'] = SubEventSerializer(read_only=True)
|
||||||
|
|
||||||
if 'item' in self.context['request'].query_params.getlist('expand'):
|
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'):
|
if 'variation' in self.context['request'].query_params.getlist('expand'):
|
||||||
self.fields['variation'] = InlineItemVariationSerializer(read_only=True)
|
self.fields['variation'] = InlineItemVariationSerializer(read_only=True)
|
||||||
@@ -624,6 +624,7 @@ class OrderSerializer(I18nAwareModelSerializer):
|
|||||||
payment_date = OrderPaymentDateField(source='*', read_only=True)
|
payment_date = OrderPaymentDateField(source='*', read_only=True)
|
||||||
payment_provider = OrderPaymentTypeField(source='*', read_only=True)
|
payment_provider = OrderPaymentTypeField(source='*', read_only=True)
|
||||||
url = OrderURLField(source='*', read_only=True)
|
url = OrderURLField(source='*', read_only=True)
|
||||||
|
customer = serializers.SlugRelatedField(slug_field='identifier', read_only=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Order
|
model = Order
|
||||||
@@ -631,11 +632,11 @@ class OrderSerializer(I18nAwareModelSerializer):
|
|||||||
'code', 'status', 'testmode', 'secret', 'email', 'phone', 'locale', 'datetime', 'expires', 'payment_date',
|
'code', 'status', 'testmode', 'secret', 'email', 'phone', 'locale', 'datetime', 'expires', 'payment_date',
|
||||||
'payment_provider', 'fees', 'total', 'comment', 'invoice_address', 'positions', 'downloads',
|
'payment_provider', 'fees', 'total', 'comment', 'invoice_address', 'positions', 'downloads',
|
||||||
'checkin_attention', 'last_modified', 'payments', 'refunds', 'require_approval', 'sales_channel',
|
'checkin_attention', 'last_modified', 'payments', 'refunds', 'require_approval', 'sales_channel',
|
||||||
'url'
|
'url', 'customer'
|
||||||
)
|
)
|
||||||
read_only_fields = (
|
read_only_fields = (
|
||||||
'code', 'status', 'testmode', 'secret', 'datetime', 'expires', 'payment_date',
|
'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'
|
'last_modified', 'payments', 'refunds', 'require_approval', 'sales_channel'
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -907,16 +908,18 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
|
|||||||
payment_date = serializers.DateTimeField(required=False, allow_null=True)
|
payment_date = serializers.DateTimeField(required=False, allow_null=True)
|
||||||
send_email = serializers.BooleanField(default=False, required=False)
|
send_email = serializers.BooleanField(default=False, required=False)
|
||||||
simulate = 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):
|
def __init__(self, *args, **kwargs):
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
self.fields['positions'].child.fields['voucher'].queryset = self.context['event'].vouchers.all()
|
self.fields['positions'].child.fields['voucher'].queryset = self.context['event'].vouchers.all()
|
||||||
|
self.fields['customer'].queryset = self.context['event'].organizer.customers.all()
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Order
|
model = Order
|
||||||
fields = ('code', 'status', 'testmode', 'email', 'phone', 'locale', 'payment_provider', 'fees', 'comment', 'sales_channel',
|
fields = ('code', 'status', 'testmode', 'email', 'phone', 'locale', 'payment_provider', 'fees', 'comment', 'sales_channel',
|
||||||
'invoice_address', 'positions', 'checkin_attention', 'payment_info', 'payment_date', 'consume_carts',
|
'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):
|
def validate_payment_provider(self, pp):
|
||||||
if pp is None:
|
if pp is None:
|
||||||
|
|||||||
@@ -34,8 +34,9 @@ from pretix.api.serializers.settings import SettingsSerializer
|
|||||||
from pretix.base.auth import get_auth_backends
|
from pretix.base.auth import get_auth_backends
|
||||||
from pretix.base.i18n import get_language_without_region
|
from pretix.base.i18n import get_language_without_region
|
||||||
from pretix.base.models import (
|
from pretix.base.models import (
|
||||||
Device, GiftCard, GiftCardTransaction, Organizer, SeatingPlan, Team,
|
Customer, Device, GiftCard, GiftCardTransaction, Membership,
|
||||||
TeamAPIToken, TeamInvite, User,
|
MembershipType, Organizer, SeatingPlan, Team, TeamAPIToken, TeamInvite,
|
||||||
|
User,
|
||||||
)
|
)
|
||||||
from pretix.base.models.seating import SeatingPlanLayoutValidator
|
from pretix.base.models.seating import SeatingPlanLayoutValidator
|
||||||
from pretix.base.services.mail import SendMailException, mail
|
from pretix.base.services.mail import SendMailException, mail
|
||||||
@@ -61,6 +62,43 @@ class SeatingPlanSerializer(I18nAwareModelSerializer):
|
|||||||
fields = ('id', 'name', 'layout')
|
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):
|
class GiftCardSerializer(I18nAwareModelSerializer):
|
||||||
value = serializers.DecimalField(max_digits=10, decimal_places=2, min_value=Decimal('0.00'))
|
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',
|
'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_organizer_settings', 'can_manage_gift_cards', 'can_change_event_settings',
|
||||||
'can_change_items', 'can_view_orders', 'can_change_orders', 'can_view_vouchers',
|
'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):
|
def validate(self, data):
|
||||||
@@ -234,6 +272,7 @@ class TeamMemberSerializer(serializers.ModelSerializer):
|
|||||||
|
|
||||||
class OrganizerSettingsSerializer(SettingsSerializer):
|
class OrganizerSettingsSerializer(SettingsSerializer):
|
||||||
default_fields = [
|
default_fields = [
|
||||||
|
'customer_accounts',
|
||||||
'contact_mail',
|
'contact_mail',
|
||||||
'imprint_url',
|
'imprint_url',
|
||||||
'organizer_info_text',
|
'organizer_info_text',
|
||||||
|
|||||||
@@ -54,6 +54,9 @@ orga_router.register(r'subevents', event.SubEventViewSet)
|
|||||||
orga_router.register(r'webhooks', webhooks.WebHookViewSet)
|
orga_router.register(r'webhooks', webhooks.WebHookViewSet)
|
||||||
orga_router.register(r'seatingplans', organizer.SeatingPlanViewSet)
|
orga_router.register(r'seatingplans', organizer.SeatingPlanViewSet)
|
||||||
orga_router.register(r'giftcards', organizer.GiftCardViewSet)
|
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'teams', organizer.TeamViewSet)
|
||||||
orga_router.register(r'devices', organizer.DeviceViewSet)
|
orga_router.register(r'devices', organizer.DeviceViewSet)
|
||||||
orga_router.register(r'exporters', exporters.OrganizerExportersViewSet, basename='exporters')
|
orga_router.register(r'exporters', exporters.OrganizerExportersViewSet, basename='exporters')
|
||||||
|
|||||||
@@ -38,14 +38,16 @@ from rest_framework.viewsets import GenericViewSet
|
|||||||
|
|
||||||
from pretix.api.models import OAuthAccessToken
|
from pretix.api.models import OAuthAccessToken
|
||||||
from pretix.api.serializers.organizer import (
|
from pretix.api.serializers.organizer import (
|
||||||
DeviceSerializer, GiftCardSerializer, GiftCardTransactionSerializer,
|
CustomerSerializer, DeviceSerializer, GiftCardSerializer,
|
||||||
OrganizerSerializer, OrganizerSettingsSerializer, SeatingPlanSerializer,
|
GiftCardTransactionSerializer, MembershipSerializer,
|
||||||
TeamAPITokenSerializer, TeamInviteSerializer, TeamMemberSerializer,
|
MembershipTypeSerializer, OrganizerSerializer, OrganizerSettingsSerializer,
|
||||||
TeamSerializer,
|
SeatingPlanSerializer, TeamAPITokenSerializer, TeamInviteSerializer,
|
||||||
|
TeamMemberSerializer, TeamSerializer,
|
||||||
)
|
)
|
||||||
from pretix.base.models import (
|
from pretix.base.models import (
|
||||||
Device, GiftCard, GiftCardTransaction, Organizer, SeatingPlan, Team,
|
Customer, Device, GiftCard, GiftCardTransaction, Membership,
|
||||||
TeamAPIToken, TeamInvite, User,
|
MembershipType, Organizer, SeatingPlan, Team, TeamAPIToken, TeamInvite,
|
||||||
|
User,
|
||||||
)
|
)
|
||||||
from pretix.base.settings import SETTINGS_AFFECTING_CSS
|
from pretix.base.settings import SETTINGS_AFFECTING_CSS
|
||||||
from pretix.helpers.dicts import merge_dicts
|
from pretix.helpers.dicts import merge_dicts
|
||||||
@@ -480,3 +482,163 @@ class OrganizerSettingsView(views.APIView):
|
|||||||
'request': request
|
'request': request
|
||||||
})
|
})
|
||||||
return Response(s.data)
|
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
|
||||||
|
|||||||
@@ -71,8 +71,9 @@ class BaseHTMLMailRenderer:
|
|||||||
This is the base class for all HTML e-mail renderers.
|
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.event = event
|
||||||
|
self.organizer = organizer
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.identifier
|
return self.identifier
|
||||||
@@ -140,6 +141,9 @@ class TemplateBasedMailRenderer(BaseHTMLMailRenderer):
|
|||||||
'color': settings.PRETIX_PRIMARY_COLOR,
|
'color': settings.PRETIX_PRIMARY_COLOR,
|
||||||
'rtl': get_language() in settings.LANGUAGES_RTL or get_language().split('-')[0] in settings.LANGUAGES_RTL,
|
'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:
|
if self.event:
|
||||||
htmlctx['event'] = self.event
|
htmlctx['event'] = self.event
|
||||||
htmlctx['color'] = self.event.settings.primary_color
|
htmlctx['color'] = self.event.settings.primary_color
|
||||||
|
|||||||
@@ -234,8 +234,8 @@ class NamePartsFormField(forms.MultiValueField):
|
|||||||
if self.one_required and (not value or not any(v for v in value.values())):
|
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')
|
raise forms.ValidationError(self.error_messages['required'], code='required')
|
||||||
if self.one_required:
|
if self.one_required:
|
||||||
for k, v in value.items():
|
for k, label, size in self.scheme['fields']:
|
||||||
if k in REQUIRED_NAME_PARTS and not v:
|
if k in REQUIRED_NAME_PARTS and not value.get(k):
|
||||||
raise forms.ValidationError(self.error_messages['required'], code='required')
|
raise forms.ValidationError(self.error_messages['required'], code='required')
|
||||||
if self.require_all_fields and not all(v for v in value):
|
if self.require_all_fields and not all(v for v in value):
|
||||||
raise forms.ValidationError(self.error_messages['incomplete'], code='required')
|
raise forms.ValidationError(self.error_messages['incomplete'], code='required')
|
||||||
|
|||||||
@@ -85,6 +85,8 @@ class LocaleMiddleware(MiddlewareMixin):
|
|||||||
tzname = None
|
tzname = None
|
||||||
if hasattr(request, 'event'):
|
if hasattr(request, 'event'):
|
||||||
tzname = request.event.settings.timezone
|
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:
|
elif request.user.is_authenticated:
|
||||||
tzname = request.user.timezone
|
tzname = request.user.timezone
|
||||||
if tzname:
|
if tzname:
|
||||||
@@ -104,6 +106,13 @@ class LocaleMiddleware(MiddlewareMixin):
|
|||||||
return response
|
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:
|
def get_language_from_user_settings(request: HttpRequest) -> str:
|
||||||
if request.user.is_authenticated:
|
if request.user.is_authenticated:
|
||||||
lang_code = request.user.locale
|
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'):
|
if request.path.startswith(get_script_prefix() + 'control'):
|
||||||
return (
|
return (
|
||||||
get_language_from_user_settings(request)
|
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_session_or_cookie(request)
|
||||||
or get_language_from_browser(request)
|
or get_language_from_browser(request)
|
||||||
or get_language_from_event(request)
|
or get_language_from_event(request)
|
||||||
@@ -177,6 +187,7 @@ def get_language_from_request(request: HttpRequest) -> str:
|
|||||||
else:
|
else:
|
||||||
return (
|
return (
|
||||||
get_language_from_session_or_cookie(request)
|
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_user_settings(request)
|
||||||
or get_language_from_browser(request)
|
or get_language_from_browser(request)
|
||||||
or get_language_from_event(request)
|
or get_language_from_event(request)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -23,6 +23,7 @@ from ..settings import GlobalSettingsObject_SettingsStore
|
|||||||
from .auth import U2FDevice, User, WebAuthnDevice
|
from .auth import U2FDevice, User, WebAuthnDevice
|
||||||
from .base import CachedFile, LoggedModel, cachedfile_name
|
from .base import CachedFile, LoggedModel, cachedfile_name
|
||||||
from .checkin import Checkin, CheckinList
|
from .checkin import Checkin, CheckinList
|
||||||
|
from .customers import Customer
|
||||||
from .devices import Device, Gate
|
from .devices import Device, Gate
|
||||||
from .event import (
|
from .event import (
|
||||||
Event, Event_SettingsStore, EventLock, EventMetaProperty, EventMetaValue,
|
Event, Event_SettingsStore, EventLock, EventMetaProperty, EventMetaValue,
|
||||||
@@ -36,6 +37,7 @@ from .items import (
|
|||||||
SubEventItemVariation, itempicture_upload_to,
|
SubEventItemVariation, itempicture_upload_to,
|
||||||
)
|
)
|
||||||
from .log import LogEntry
|
from .log import LogEntry
|
||||||
|
from .memberships import Membership, MembershipType
|
||||||
from .notifications import NotificationSetting
|
from .notifications import NotificationSetting
|
||||||
from .orders import (
|
from .orders import (
|
||||||
AbstractPosition, CachedCombinedTicket, CachedTicket, CartPosition,
|
AbstractPosition, CachedCombinedTicket, CachedTicket, CartPosition,
|
||||||
|
|||||||
@@ -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 <https://pretix.eu/about/en/license>.
|
||||||
|
#
|
||||||
|
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
|
||||||
|
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||||||
|
# details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
|
||||||
|
# <https://www.gnu.org/licenses/>.
|
||||||
|
#
|
||||||
|
from django.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
|
||||||
@@ -670,6 +670,7 @@ class Event(EventMixin, LoggedModel):
|
|||||||
variation_map = {}
|
variation_map = {}
|
||||||
for i in Item.objects.filter(event=other).prefetch_related('variations'):
|
for i in Item.objects.filter(event=other).prefetch_related('variations'):
|
||||||
vars = list(i.variations.all())
|
vars = list(i.variations.all())
|
||||||
|
require_membership_types = list(i.require_membership_types.all())
|
||||||
item_map[i.pk] = i
|
item_map[i.pk] = i
|
||||||
i.pk = None
|
i.pk = None
|
||||||
i.event = self
|
i.event = self
|
||||||
@@ -679,8 +680,16 @@ class Event(EventMixin, LoggedModel):
|
|||||||
i.category = category_map[i.category_id]
|
i.category = category_map[i.category_id]
|
||||||
if i.tax_rule_id:
|
if i.tax_rule_id:
|
||||||
i.tax_rule = tax_map[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.save()
|
||||||
i.log_action('pretix.object.cloned')
|
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:
|
for v in vars:
|
||||||
variation_map[v.pk] = v
|
variation_map[v.pk] = v
|
||||||
v.pk = None
|
v.pk = None
|
||||||
|
|||||||
@@ -514,6 +514,34 @@ class Item(LoggedModel):
|
|||||||
'product price.'),
|
'product price.'),
|
||||||
default=False
|
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
|
# !!! Attention: If you add new fields here, also add them to the copying code in
|
||||||
# pretix/control/forms/item.py if applicable.
|
# 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 '
|
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.')
|
'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')
|
objects = ScopedManager(organizer='item__event__organizer')
|
||||||
|
|
||||||
|
|||||||
@@ -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 <https://pretix.eu/about/en/license>.
|
||||||
|
#
|
||||||
|
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
|
||||||
|
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||||||
|
# details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
|
||||||
|
# <https://www.gnu.org/licenses/>.
|
||||||
|
#
|
||||||
|
from django.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
|
||||||
@@ -47,6 +47,7 @@ import dateutil
|
|||||||
import pycountry
|
import pycountry
|
||||||
import pytz
|
import pytz
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
from django.core.exceptions import ValidationError
|
||||||
from django.db import models, transaction
|
from django.db import models, transaction
|
||||||
from django.db.models import (
|
from django.db.models import (
|
||||||
Case, Exists, F, Max, OuterRef, Q, Subquery, Sum, Value, When,
|
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.decimal import round_decimal
|
||||||
from pretix.base.email import get_email_context
|
from pretix.base.email import get_email_context
|
||||||
from pretix.base.i18n import language
|
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.reldate import RelativeDateWrapper
|
||||||
from pretix.base.services.locking import NoLockManager
|
from pretix.base.services.locking import NoLockManager
|
||||||
from pretix.base.settings import PERSON_NAME_SCHEMES
|
from pretix.base.settings import PERSON_NAME_SCHEMES
|
||||||
@@ -119,6 +120,8 @@ class Order(LockModel, LoggedModel):
|
|||||||
|
|
||||||
:param event: The event this order belongs to
|
:param event: The event this order belongs to
|
||||||
:type event: Event
|
:type event: Event
|
||||||
|
:param customer: The customer this order belongs to
|
||||||
|
:type customer: Customer
|
||||||
:param email: The email of the person who ordered this
|
:param email: The email of the person who ordered this
|
||||||
:type email: str
|
:type email: str
|
||||||
:param phone: The phone number of the person who ordered this
|
:param phone: The phone number of the person who ordered this
|
||||||
@@ -177,6 +180,13 @@ class Order(LockModel, LoggedModel):
|
|||||||
related_name="orders",
|
related_name="orders",
|
||||||
on_delete=models.CASCADE
|
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(
|
email = models.EmailField(
|
||||||
null=True, blank=True,
|
null=True, blank=True,
|
||||||
verbose_name=_('E-mail')
|
verbose_name=_('E-mail')
|
||||||
@@ -822,7 +832,11 @@ class Order(LockModel, LoggedModel):
|
|||||||
return self._is_still_available(count_waitinglist=count_waitinglist, force=force)
|
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,
|
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 = {
|
error_messages = {
|
||||||
'unavailable': _('The ordered product "{item}" is no longer available.'),
|
'unavailable': _('The ordered product "{item}" is no longer available.'),
|
||||||
'seat_unavailable': _('The seat "{seat}" 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.'),
|
'voucher_usages': _('The voucher "{voucher}" has been used in the meantime.'),
|
||||||
}
|
}
|
||||||
now_dt = now_dt or now()
|
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 = {}
|
quota_cache = {}
|
||||||
v_budget = {}
|
v_budget = {}
|
||||||
v_usage = Counter()
|
v_usage = Counter()
|
||||||
try:
|
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):
|
for i, op in enumerate(positions):
|
||||||
if op.seat:
|
if op.seat:
|
||||||
if not op.seat.is_available(ignore_orderpos=op):
|
if not op.seat.is_available(ignore_orderpos=op):
|
||||||
@@ -1181,6 +1201,9 @@ class AbstractPosition(models.Model):
|
|||||||
voucher = models.ForeignKey(
|
voucher = models.ForeignKey(
|
||||||
'Voucher', null=True, blank=True, on_delete=models.PROTECT
|
'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(
|
addon_to = models.ForeignKey(
|
||||||
'self', null=True, blank=True, on_delete=models.PROTECT, related_name='addons'
|
'self', null=True, blank=True, on_delete=models.PROTECT, related_name='addons'
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -35,6 +35,8 @@
|
|||||||
import string
|
import string
|
||||||
from datetime import date, datetime, time
|
from datetime import date, datetime, time
|
||||||
|
|
||||||
|
import pytz
|
||||||
|
from django.core.mail import get_connection
|
||||||
from django.core.validators import MinLengthValidator, RegexValidator
|
from django.core.validators import MinLengthValidator, RegexValidator
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.db.models import Exists, OuterRef, Q
|
from django.db.models import Exists, OuterRef, Q
|
||||||
@@ -123,6 +125,10 @@ class Organizer(LoggedModel):
|
|||||||
|
|
||||||
return ObjectRelatedCache(self)
|
return ObjectRelatedCache(self)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def timezone(self):
|
||||||
|
return pytz.timezone(self.settings.timezone)
|
||||||
|
|
||||||
@cached_property
|
@cached_property
|
||||||
def all_logentries_link(self):
|
def all_logentries_link(self):
|
||||||
return reverse(
|
return reverse(
|
||||||
@@ -173,6 +179,24 @@ class Organizer(LoggedModel):
|
|||||||
e.delete()
|
e.delete()
|
||||||
self.teams.all().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():
|
def generate_invite_token():
|
||||||
return get_random_string(length=32, allowed_chars=string.ascii_lowercase + string.digits)
|
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
|
:type can_create_events: bool
|
||||||
:param can_change_teams: If ``True``, the members can change the teams of this organizer account.
|
:param can_change_teams: If ``True``, the members can change the teams of this organizer account.
|
||||||
:type can_change_teams: bool
|
: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.
|
:param can_change_organizer_settings: If ``True``, the members can change the settings of this organizer account.
|
||||||
:type can_change_organizer_settings: bool
|
:type can_change_organizer_settings: bool
|
||||||
:param can_change_event_settings: If ``True``, the members can change the settings of the associated events.
|
: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 '
|
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!')
|
'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(
|
can_manage_gift_cards = models.BooleanField(
|
||||||
default=False,
|
default=False,
|
||||||
verbose_name=_("Can manage gift cards")
|
verbose_name=_("Can manage gift cards")
|
||||||
)
|
)
|
||||||
|
|
||||||
can_change_event_settings = models.BooleanField(
|
can_change_event_settings = models.BooleanField(
|
||||||
default=False,
|
default=False,
|
||||||
verbose_name=_("Can change event settings")
|
verbose_name=_("Can change event settings")
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ from cryptography.hazmat.primitives.serialization import (
|
|||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.dispatch import receiver
|
from django.dispatch import receiver
|
||||||
from django.utils.crypto import get_random_string
|
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.models import Item, ItemVariation, SubEvent
|
||||||
from pretix.base.secretgenerators import pretix_sig1_pb2
|
from pretix.base.secretgenerators import pretix_sig1_pb2
|
||||||
|
|||||||
@@ -66,7 +66,8 @@ from i18nfield.strings import LazyI18nString
|
|||||||
from pretix.base.email import ClassicMailRenderer
|
from pretix.base.email import ClassicMailRenderer
|
||||||
from pretix.base.i18n import language
|
from pretix.base.i18n import language
|
||||||
from pretix.base.models import (
|
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.invoices import invoice_pdf_task
|
||||||
from pretix.base.services.tasks import TransactionAwareTask
|
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],
|
def mail(email: Union[str, Sequence[str]], subject: str, template: Union[str, LazyI18nString],
|
||||||
context: Dict[str, Any] = None, event: Event = None, locale: str = None,
|
context: Dict[str, Any] = None, event: Event = None, locale: str = None, order: Order = None,
|
||||||
order: Order = None, position: OrderPosition = None, headers: dict = None, sender: str = None,
|
position: OrderPosition = None, *, headers: dict = None, sender: str = None, organizer: Organizer = None,
|
||||||
invoices: Sequence = None, attach_tickets=False, auto_email=True, user=None, attach_ical=False,
|
customer: Customer = None, invoices: Sequence = None, attach_tickets=False, auto_email=True, user=None,
|
||||||
attach_cached_files: Sequence = 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.
|
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,
|
: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.
|
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
|
: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.
|
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 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.
|
: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
|
: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_name': '',
|
||||||
'invoice_company': ''
|
'invoice_company': ''
|
||||||
})
|
})
|
||||||
renderer = ClassicMailRenderer(None)
|
renderer = ClassicMailRenderer(None, organizer)
|
||||||
content_plain = body_plain = render_mail(template, context)
|
content_plain = body_plain = render_mail(template, context)
|
||||||
subject = str(subject).format_map(TolerantDict(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:
|
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:
|
if len(sender_name) > 75:
|
||||||
sender_name = sender_name[:75] + "..."
|
sender_name = sender_name[:75] + "..."
|
||||||
sender_name = event.settings.mail_from_name or sender_name
|
|
||||||
sender = formataddr((sender_name, sender))
|
sender = formataddr((sender_name, sender))
|
||||||
else:
|
else:
|
||||||
sender = formataddr((settings.PRETIX_INSTANCE_NAME, sender))
|
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 = ""
|
signature = ""
|
||||||
|
|
||||||
bcc = []
|
bcc = []
|
||||||
|
|
||||||
|
settings_holder = event or organizer
|
||||||
|
|
||||||
if event:
|
if event:
|
||||||
timezone = event.timezone
|
timezone = event.timezone
|
||||||
renderer = event.get_html_mail_renderer()
|
elif user:
|
||||||
if event.settings.mail_bcc:
|
timezone = pytz.timezone(user.timezone)
|
||||||
for bcc_mail in event.settings.mail_bcc.split(','):
|
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())
|
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'):
|
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'] = event.settings.contact_mail
|
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(']'):
|
if prefix and prefix.startswith('[') and prefix.endswith(']'):
|
||||||
prefix = prefix[1:-1]
|
prefix = prefix[1:-1]
|
||||||
if prefix:
|
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"
|
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:
|
if signature:
|
||||||
signature = signature.format(event=event.name)
|
signature = signature.format(event=event.name if event else '')
|
||||||
body_plain += signature
|
body_plain += signature
|
||||||
body_plain += "\r\n\r\n-- \r\n"
|
body_plain += "\r\n\r\n-- \r\n"
|
||||||
|
|
||||||
|
if event:
|
||||||
|
renderer = event.get_html_mail_renderer()
|
||||||
|
|
||||||
if order and order.testmode:
|
if order and order.testmode:
|
||||||
subject = "[TESTMODE] " + subject
|
subject = "[TESTMODE] " + subject
|
||||||
|
|
||||||
@@ -242,10 +270,6 @@ def mail(email: Union[str, Sequence[str]], subject: str, template: Union[str, La
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
body_plain += "\r\n"
|
body_plain += "\r\n"
|
||||||
elif user:
|
|
||||||
timezone = pytz.timezone(user.timezone)
|
|
||||||
else:
|
|
||||||
timezone = pytz.timezone(settings.TIME_ZONE)
|
|
||||||
|
|
||||||
with override(timezone):
|
with override(timezone):
|
||||||
try:
|
try:
|
||||||
@@ -276,6 +300,8 @@ def mail(email: Union[str, Sequence[str]], subject: str, template: Union[str, La
|
|||||||
attach_tickets=attach_tickets,
|
attach_tickets=attach_tickets,
|
||||||
attach_ical=attach_ical,
|
attach_ical=attach_ical,
|
||||||
user=user.pk if user else None,
|
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 [],
|
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,
|
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,
|
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,
|
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)
|
email = CustomEmail(subject, body, sender, to=to, bcc=bcc, headers=headers)
|
||||||
if html is not None:
|
if html is not None:
|
||||||
html_message = SafeMIMEMultipart(_subtype='related', encoding=settings.DEFAULT_CHARSET)
|
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:
|
if user:
|
||||||
user = User.objects.get(pk=user)
|
user = User.objects.get(pk=user)
|
||||||
|
|
||||||
|
if customer:
|
||||||
|
customer = Customer.objects.get(pk=customer)
|
||||||
|
|
||||||
if event:
|
if event:
|
||||||
with scopes_disabled():
|
with scopes_disabled():
|
||||||
event = Event.objects.get(id=event)
|
event = Event.objects.get(id=event)
|
||||||
backend = event.get_mail_backend()
|
backend = event.get_mail_backend()
|
||||||
cm = lambda: scope(organizer=event.organizer) # noqa
|
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:
|
else:
|
||||||
backend = get_connection(fail_silently=False)
|
backend = get_connection(fail_silently=False)
|
||||||
cm = lambda: scopes_disabled() # noqa
|
cm = lambda: scopes_disabled() # noqa
|
||||||
|
|
||||||
|
log_target = order or user or customer
|
||||||
|
|
||||||
with cm():
|
with cm():
|
||||||
if event:
|
if event:
|
||||||
if order:
|
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')
|
logger.exception('Could not attach file to email')
|
||||||
pass
|
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:
|
try:
|
||||||
backend.send_messages([email])
|
backend.send_messages([email])
|
||||||
@@ -442,9 +479,9 @@ def mail_send_task(self, *args, to: List[str], subject: str, body: str, html: st
|
|||||||
try:
|
try:
|
||||||
self.retry(max_retries=5, countdown=2 ** (self.request.retries * 3)) # max is 2 ** (4*3) = 4096 seconds = 68 minutes
|
self.retry(max_retries=5, countdown=2 ** (self.request.retries * 3)) # max is 2 ** (4*3) = 4096 seconds = 68 minutes
|
||||||
except MaxRetriesExceededError:
|
except MaxRetriesExceededError:
|
||||||
if order:
|
if log_target:
|
||||||
order.log_action(
|
log_target.log_action(
|
||||||
'pretix.event.order.email.error',
|
'pretix.email.error',
|
||||||
data={
|
data={
|
||||||
'subject': 'SMTP code {}, max retries exceeded'.format(e.smtp_code),
|
'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),
|
'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
|
raise e
|
||||||
|
|
||||||
logger.exception('Error sending email')
|
logger.exception('Error sending email')
|
||||||
if order:
|
if log_target:
|
||||||
order.log_action(
|
log_target.log_action(
|
||||||
'pretix.event.order.email.error',
|
'pretix.email.error',
|
||||||
data={
|
data={
|
||||||
'subject': 'SMTP code {}'.format(e.smtp_code),
|
'subject': 'SMTP code {}'.format(e.smtp_code),
|
||||||
'message': e.smtp_error.decode() if isinstance(e.smtp_error, bytes) else str(e.smtp_error),
|
'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
|
pass
|
||||||
|
|
||||||
logger.exception('Error sending email')
|
logger.exception('Error sending email')
|
||||||
if order:
|
if log_target:
|
||||||
message = []
|
message = []
|
||||||
for e, val in e.recipients.items():
|
for e, val in e.recipients.items():
|
||||||
message.append(f'{e}: {val[0]} {val[1].decode()}')
|
message.append(f'{e}: {val[0]} {val[1].decode()}')
|
||||||
|
|
||||||
order.log_action(
|
logger.log_action(
|
||||||
'pretix.event.order.email.error',
|
'pretix.email.error',
|
||||||
data={
|
data={
|
||||||
'subject': 'SMTP error',
|
'subject': 'SMTP error',
|
||||||
'message': '\n'.join(message),
|
'message': '\n'.join(message),
|
||||||
@@ -500,9 +537,9 @@ def mail_send_task(self, *args, to: List[str], subject: str, body: str, html: st
|
|||||||
try:
|
try:
|
||||||
self.retry(max_retries=5, countdown=2 ** (self.request.retries * 3)) # max is 2 ** (4*3) = 4096 seconds = 68 minutes
|
self.retry(max_retries=5, countdown=2 ** (self.request.retries * 3)) # max is 2 ** (4*3) = 4096 seconds = 68 minutes
|
||||||
except MaxRetriesExceededError:
|
except MaxRetriesExceededError:
|
||||||
if order:
|
if log_target:
|
||||||
order.log_action(
|
log_target.log_action(
|
||||||
'pretix.event.order.email.error',
|
'pretix.email.error',
|
||||||
data={
|
data={
|
||||||
'subject': 'Internal error',
|
'subject': 'Internal error',
|
||||||
'message': 'Max retries exceeded',
|
'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
|
raise e
|
||||||
if order:
|
if logger:
|
||||||
order.log_action(
|
log_target.log_action(
|
||||||
'pretix.event.order.email.error',
|
'pretix.email.error',
|
||||||
data={
|
data={
|
||||||
'subject': 'Internal error',
|
'subject': 'Internal error',
|
||||||
'message': str(e),
|
'message': str(e),
|
||||||
|
|||||||
@@ -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 <https://pretix.eu/about/en/license>.
|
||||||
|
#
|
||||||
|
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
|
||||||
|
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||||||
|
# details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
|
||||||
|
# <https://www.gnu.org/licenses/>.
|
||||||
|
#
|
||||||
|
from 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)
|
||||||
@@ -43,6 +43,7 @@ from typing import List, Optional
|
|||||||
from celery.exceptions import MaxRetriesExceededError
|
from celery.exceptions import MaxRetriesExceededError
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core.cache import cache
|
from django.core.cache import cache
|
||||||
|
from django.core.exceptions import ValidationError
|
||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
from django.db.models import (
|
from django.db.models import (
|
||||||
Exists, F, IntegerField, Max, Min, OuterRef, Q, Sum, Value,
|
Exists, F, IntegerField, Max, Min, OuterRef, Q, Sum, Value,
|
||||||
@@ -62,8 +63,8 @@ from pretix.base.i18n import (
|
|||||||
LazyLocaleException, get_language_without_region, language,
|
LazyLocaleException, get_language_without_region, language,
|
||||||
)
|
)
|
||||||
from pretix.base.models import (
|
from pretix.base.models import (
|
||||||
CartPosition, Device, Event, GiftCard, Item, ItemVariation, Order,
|
CartPosition, Device, Event, GiftCard, Item, ItemVariation, Membership,
|
||||||
OrderPayment, OrderPosition, Quota, Seat, SeatCategoryMapping, User,
|
Order, OrderPayment, OrderPosition, Quota, Seat, SeatCategoryMapping, User,
|
||||||
Voucher,
|
Voucher,
|
||||||
)
|
)
|
||||||
from pretix.base.models.event import SubEvent
|
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.locking import LockTimeoutException, NoLockManager
|
||||||
from pretix.base.services.mail import SendMailException
|
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.pricing import get_price
|
||||||
from pretix.base.services.quotas import QuotaAvailability
|
from pretix.base.services.quotas import QuotaAvailability
|
||||||
from pretix.base.services.tasks import ProfiledEventTask, ProfiledTask
|
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.')
|
raise OrderError('The order was not canceled.')
|
||||||
|
|
||||||
with order.event.lock() as now_dt:
|
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 is_available is True:
|
||||||
if order.payment_refund_sum >= order.total:
|
if order.payment_refund_sum >= order.total:
|
||||||
order.status = Order.STATUS_PAID
|
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,
|
def _check_positions(event: Event, now_dt: datetime, positions: List[CartPosition], address: InvoiceAddress=None,
|
||||||
sales_channel='web'):
|
sales_channel='web', customer=None):
|
||||||
err = None
|
err = None
|
||||||
errargs = None
|
errargs = None
|
||||||
_check_date(event, now_dt)
|
_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)
|
deleted_positions.add(cp.pk)
|
||||||
cp.delete()
|
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:
|
if cp.pk in deleted_positions:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@@ -748,6 +754,13 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio
|
|||||||
else:
|
else:
|
||||||
# Sorry, can't let you keep that!
|
# Sorry, can't let you keep that!
|
||||||
delete(cp)
|
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:
|
if err:
|
||||||
raise OrderError(err, errargs)
|
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,
|
def _create_order(event: Event, email: str, positions: List[CartPosition], now_dt: datetime,
|
||||||
payment_provider: BasePaymentProvider, locale: str=None, address: InvoiceAddress=None,
|
payment_provider: BasePaymentProvider, locale: str=None, address: InvoiceAddress=None,
|
||||||
meta_info: dict=None, sales_channel: str='web', gift_cards: list=None,
|
meta_info: dict=None, sales_channel: str='web', gift_cards: list=None, shown_total=None,
|
||||||
shown_total=None):
|
customer=None):
|
||||||
p = None
|
p = None
|
||||||
sales_channel = get_all_sales_channels()[sales_channel]
|
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,
|
testmode=True if sales_channel.testmode_supported and event.testmode else False,
|
||||||
meta_info=json.dumps(meta_info or {}),
|
meta_info=json.dumps(meta_info or {}),
|
||||||
require_approval=any(p.item.require_approval for p in positions),
|
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.set_expires(now_dt, event.subevents.filter(id__in=[p.subevent_id for p in positions]))
|
||||||
order.save()
|
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],
|
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',
|
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:
|
if payment_provider:
|
||||||
pprov = event.get_payment_providers().get(payment_provider)
|
pprov = event.get_payment_providers().get(payment_provider)
|
||||||
if not pprov:
|
if not pprov:
|
||||||
@@ -935,6 +951,9 @@ def _perform_order(event: Event, payment_provider: str, position_ids: List[str],
|
|||||||
else:
|
else:
|
||||||
pprov = None
|
pprov = None
|
||||||
|
|
||||||
|
if customer:
|
||||||
|
customer = event.organizer.customers.get(pk=customer)
|
||||||
|
|
||||||
if email == settings.PRETIX_EMAIL_NONE_VALUE:
|
if email == settings.PRETIX_EMAIL_NONE_VALUE:
|
||||||
email = None
|
email = None
|
||||||
|
|
||||||
@@ -960,8 +979,8 @@ def _perform_order(event: Event, payment_provider: str, position_ids: List[str],
|
|||||||
id__in=position_ids, event=event
|
id__in=position_ids, event=event
|
||||||
)
|
)
|
||||||
|
|
||||||
validate_order.send(event, payment_provider=pprov, email=email, positions=positions,
|
validate_order.send(event, payment_provider=pprov, email=email, positions=positions, locale=locale,
|
||||||
locale=locale, invoice_address=addr, meta_info=meta_info)
|
invoice_address=addr, meta_info=meta_info, customer=customer)
|
||||||
|
|
||||||
lockfn = NoLockManager
|
lockfn = NoLockManager
|
||||||
locked = False
|
locked = False
|
||||||
@@ -980,10 +999,10 @@ def _perform_order(event: Event, payment_provider: str, position_ids: List[str],
|
|||||||
raise OrderError(error_messages['empty'])
|
raise OrderError(error_messages['empty'])
|
||||||
if len(position_ids) != len(positions):
|
if len(position_ids) != len(positions):
|
||||||
raise OrderError(error_messages['internal'])
|
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,
|
order, payment = _create_order(event, email, positions, now_dt, pprov,
|
||||||
locale=locale, address=addr, meta_info=meta_info, sales_channel=sales_channel,
|
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
|
free_order_flow = payment and payment_provider == 'free' and order.pending_sum == Decimal('0.00') and not order.require_approval
|
||||||
if free_order_flow:
|
if free_order_flow:
|
||||||
@@ -1228,8 +1247,9 @@ class OrderChangeManager:
|
|||||||
SeatOperation = namedtuple('SubeventOperation', ('position', 'seat'))
|
SeatOperation = namedtuple('SubeventOperation', ('position', 'seat'))
|
||||||
PriceOperation = namedtuple('PriceOperation', ('position', 'price'))
|
PriceOperation = namedtuple('PriceOperation', ('position', 'price'))
|
||||||
TaxRuleOperation = namedtuple('TaxRuleOperation', ('position', 'tax_rule'))
|
TaxRuleOperation = namedtuple('TaxRuleOperation', ('position', 'tax_rule'))
|
||||||
|
MembershipOperation = namedtuple('MembershipOperation', ('position', 'membership'))
|
||||||
CancelOperation = namedtuple('CancelOperation', ('position',))
|
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',))
|
SplitOperation = namedtuple('SplitOperation', ('position',))
|
||||||
FeeValueOperation = namedtuple('FeeValueOperation', ('fee', 'value'))
|
FeeValueOperation = namedtuple('FeeValueOperation', ('fee', 'value'))
|
||||||
AddFeeOperation = namedtuple('AddFeeOperation', ('fee',))
|
AddFeeOperation = namedtuple('AddFeeOperation', ('fee',))
|
||||||
@@ -1287,6 +1307,9 @@ class OrderChangeManager:
|
|||||||
self._seatdiff.update([seat])
|
self._seatdiff.update([seat])
|
||||||
self._operations.append(self.SeatOperation(position, 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):
|
def change_subevent(self, position: OrderPosition, subevent: SubEvent):
|
||||||
try:
|
try:
|
||||||
price = get_price(position.item, position.variation, voucher=position.voucher, subevent=subevent,
|
price = get_price(position.item, position.variation, voucher=position.voucher, subevent=subevent,
|
||||||
@@ -1416,7 +1439,7 @@ class OrderChangeManager:
|
|||||||
self._invoice_dirty = True
|
self._invoice_dirty = True
|
||||||
|
|
||||||
def add_position(self, item: Item, variation: ItemVariation, price: Decimal, addon_to: Order = None,
|
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 isinstance(seat, str):
|
||||||
if not seat:
|
if not seat:
|
||||||
seat = None
|
seat = None
|
||||||
@@ -1468,7 +1491,7 @@ class OrderChangeManager:
|
|||||||
self._quotadiff.update(new_quotas)
|
self._quotadiff.update(new_quotas)
|
||||||
if seat:
|
if seat:
|
||||||
self._seatdiff.update([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):
|
def split(self, position: OrderPosition):
|
||||||
if self.order.event.settings.invoice_include_free or position.price != Decimal('0.00'):
|
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
|
event=self.event, position=op.position, force_invalidate=False, save=False
|
||||||
)
|
)
|
||||||
op.position.save()
|
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):
|
elif isinstance(op, self.SeatOperation):
|
||||||
self.order.log_action('pretix.event.order.changed.seat', user=self.user, auth=self.auth, data={
|
self.order.log_action('pretix.event.order.changed.seat', user=self.user, auth=self.auth, data={
|
||||||
'position': op.position.pk,
|
'position': op.position.pk,
|
||||||
@@ -1773,7 +1805,8 @@ class OrderChangeManager:
|
|||||||
item=op.item, variation=op.variation, addon_to=op.addon_to,
|
item=op.item, variation=op.variation, addon_to=op.addon_to,
|
||||||
price=op.price.gross, order=self.order, tax_rate=op.price.rate,
|
price=op.price.gross, order=self.order, tax_rate=op.price.rate,
|
||||||
tax_value=op.price.tax, tax_rule=op.item.tax_rule,
|
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
|
nextposid += 1
|
||||||
self.order.log_action('pretix.event.order.changed.add', user=self.user, auth=self.auth, data={
|
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,
|
'addon_to': op.addon_to.pk if op.addon_to else None,
|
||||||
'price': op.price.gross,
|
'price': op.price.gross,
|
||||||
'positionid': pos.positionid,
|
'positionid': pos.positionid,
|
||||||
|
'membership': pos.used_membership_id,
|
||||||
'subevent': op.subevent.pk if op.subevent else None,
|
'subevent': op.subevent.pk if op.subevent else None,
|
||||||
'seat': op.seat.pk if op.seat else None,
|
'seat': op.seat.pk if op.seat else None,
|
||||||
})
|
})
|
||||||
@@ -1990,6 +2024,50 @@ class OrderChangeManager:
|
|||||||
except InvoiceAddress.DoesNotExist:
|
except InvoiceAddress.DoesNotExist:
|
||||||
return None
|
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):
|
def commit(self, check_quotas=True):
|
||||||
if self._committed:
|
if self._committed:
|
||||||
# an order change can only be committed once
|
# an order change can only be committed once
|
||||||
@@ -2011,6 +2089,7 @@ class OrderChangeManager:
|
|||||||
self._check_quotas()
|
self._check_quotas()
|
||||||
self._check_seats()
|
self._check_seats()
|
||||||
self._check_complete_cancel()
|
self._check_complete_cancel()
|
||||||
|
self._check_and_lock_memberships()
|
||||||
try:
|
try:
|
||||||
self._perform_operations()
|
self._perform_operations()
|
||||||
except TaxRule.SaleNotAllowed:
|
except TaxRule.SaleNotAllowed:
|
||||||
@@ -2055,12 +2134,12 @@ class OrderChangeManager:
|
|||||||
@app.task(base=ProfiledEventTask, bind=True, max_retries=5, default_retry_delay=1, throws=(OrderError,))
|
@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],
|
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,
|
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):
|
with language(locale):
|
||||||
try:
|
try:
|
||||||
try:
|
try:
|
||||||
return _perform_order(event, payment_provider, positions, email, locale, address, meta_info,
|
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:
|
except LockTimeoutException:
|
||||||
self.retry()
|
self.retry()
|
||||||
except (MaxRetriesExceededError, LockTimeoutException):
|
except (MaxRetriesExceededError, LockTimeoutException):
|
||||||
@@ -2321,3 +2400,14 @@ def signal_listener_issue_giftcards(sender: Event, order: Order, **kwargs):
|
|||||||
|
|
||||||
if any_giftcards:
|
if any_giftcards:
|
||||||
tickets.invalidate_cache.apply_async(kwargs={'event': sender.pk, 'order': order.pk})
|
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)
|
||||||
|
|||||||
@@ -107,6 +107,17 @@ class LazyI18nStringList(UserList):
|
|||||||
|
|
||||||
|
|
||||||
DEFAULTS = {
|
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': {
|
'max_items_per_order': {
|
||||||
'default': '10',
|
'default': '10',
|
||||||
'type': int,
|
'type': int,
|
||||||
@@ -1753,13 +1764,13 @@ Your {event} team"""))
|
|||||||
'type': LazyI18nString,
|
'type': LazyI18nString,
|
||||||
'default': LazyI18nString.from_gettext(gettext_noop("""Hello {attendee_name},
|
'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:
|
If you did not do so already, you can download your ticket here:
|
||||||
{url}
|
{url}
|
||||||
|
|
||||||
Best regards,
|
Best regards,
|
||||||
Your {event} team"""))
|
Your {event} team"""))
|
||||||
},
|
},
|
||||||
'mail_text_download_reminder': {
|
'mail_text_download_reminder': {
|
||||||
'type': LazyI18nString,
|
'type': LazyI18nString,
|
||||||
@@ -1772,6 +1783,60 @@ If you did not do so already, you can download your ticket here:
|
|||||||
|
|
||||||
Best regards,
|
Best regards,
|
||||||
Your {event} team"""))
|
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': {
|
'smtp_use_custom': {
|
||||||
'default': 'False',
|
'default': 'False',
|
||||||
|
|||||||
@@ -194,7 +194,7 @@ class EmailAddressShredder(BaseDataShredder):
|
|||||||
verbose_name = _('E-mails')
|
verbose_name = _('E-mails')
|
||||||
identifier = 'order_emails'
|
identifier = 'order_emails'
|
||||||
description = _('This will remove all e-mail addresses from orders and attendees, as well as logged email '
|
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]]:
|
def generate_files(self) -> List[Tuple[str, str, str]]:
|
||||||
yield 'emails-by-order.json', 'application/json', json.dumps({
|
yield 'emails-by-order.json', 'application/json', json.dumps({
|
||||||
@@ -211,12 +211,13 @@ class EmailAddressShredder(BaseDataShredder):
|
|||||||
|
|
||||||
for o in self.event.orders.all():
|
for o in self.event.orders.all():
|
||||||
o.email = None
|
o.email = None
|
||||||
|
o.customer = None
|
||||||
d = o.meta_info_data
|
d = o.meta_info_data
|
||||||
if d:
|
if d:
|
||||||
if 'contact_form_data' in d and 'email' in d['contact_form_data']:
|
if 'contact_form_data' in d and 'email' in d['contact_form_data']:
|
||||||
del d['contact_form_data']['email']
|
del d['contact_form_data']['email']
|
||||||
o.meta_info = json.dumps(d)
|
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"):
|
for le in self.event.logentry_set.filter(action_type__contains="order.email"):
|
||||||
shred_log_fields(le, banlist=['recipient', 'message', 'subject'])
|
shred_log_fields(le, banlist=['recipient', 'message', 'subject'])
|
||||||
|
|||||||
@@ -324,7 +324,7 @@ The ``sender`` keyword argument will contain an organizer.
|
|||||||
|
|
||||||
validate_order = EventPluginSignal(
|
validate_order = EventPluginSignal(
|
||||||
providing_args=["payment_provider", "positions", "email", "locale", "invoice_address",
|
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
|
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(
|
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
|
This signal allows you to implement a middleware-style filter on all outgoing emails. You are expected to
|
||||||
|
|||||||
@@ -202,6 +202,9 @@
|
|||||||
{% if event %}
|
{% if event %}
|
||||||
<h2><a href="{% abseventurl event "presale:event.index" %}" target="_blank">{{ event.name }}</a>
|
<h2><a href="{% abseventurl event "presale:event.index" %}" target="_blank">{{ event.name }}</a>
|
||||||
</h2>
|
</h2>
|
||||||
|
{% elif organizer %}
|
||||||
|
<h2><a href="{% abseventurl organizer "presale:organizer.index" %}" target="_blank">{{ organizer.name }}</a>
|
||||||
|
</h2>
|
||||||
{% else %}
|
{% else %}
|
||||||
<h2><a href="{{ site_url }}" target="_blank">{{ site }}</a></h2>
|
<h2><a href="{{ site_url }}" target="_blank">{{ site }}</a></h2>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -220,6 +220,9 @@
|
|||||||
{% if event %}
|
{% if event %}
|
||||||
<h2><a href="{% abseventurl event "presale:event.index" %}" target="_blank">{{ event.name }}</a>
|
<h2><a href="{% abseventurl event "presale:event.index" %}" target="_blank">{{ event.name }}</a>
|
||||||
</h2>
|
</h2>
|
||||||
|
{% elif organizer %}
|
||||||
|
<h2><a href="{% abseventurl organizer "presale:organizer.index" %}" target="_blank">{{ organizer.name }}</a>
|
||||||
|
</h2>
|
||||||
{% else %}
|
{% else %}
|
||||||
<h2><a href="{{ site_url }}" target="_blank">{{ site }}</a></h2>
|
<h2><a href="{{ site_url }}" target="_blank">{{ site }}</a></h2>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -69,6 +69,8 @@ class EventSlugBanlistValidator(BanlistValidator):
|
|||||||
'events',
|
'events',
|
||||||
'csp_report',
|
'csp_report',
|
||||||
'widget',
|
'widget',
|
||||||
|
'customer',
|
||||||
|
'account',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ from django.urls import reverse
|
|||||||
from django.utils.timezone import now
|
from django.utils.timezone import now
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
from ...base.forms import I18nModelForm
|
from ...base.forms import I18nModelForm, SecretKeySettingsField
|
||||||
|
|
||||||
# Import for backwards compatibility with okd import paths
|
# Import for backwards compatibility with okd import paths
|
||||||
from ...base.forms.widgets import ( # noqa
|
from ...base.forms.widgets import ( # noqa
|
||||||
@@ -368,3 +368,46 @@ class SplitDateTimeField(forms.SplitDateTimeField):
|
|||||||
|
|
||||||
class FontSelect(forms.RadioSelect):
|
class FontSelect(forms.RadioSelect):
|
||||||
option_template_name = 'pretixcontrol/font_option.html'
|
option_template_name = 'pretixcontrol/font_option.html'
|
||||||
|
|
||||||
|
|
||||||
|
class SMTPSettingsMixin(forms.Form):
|
||||||
|
smtp_use_custom = forms.BooleanField(
|
||||||
|
label=_("Use custom SMTP server"),
|
||||||
|
help_text=_("All mail related to your event will be sent over the smtp server specified by you."),
|
||||||
|
required=False
|
||||||
|
)
|
||||||
|
smtp_host = forms.CharField(
|
||||||
|
label=_("Hostname"),
|
||||||
|
required=False,
|
||||||
|
widget=forms.TextInput(attrs={'placeholder': 'mail.example.org'})
|
||||||
|
)
|
||||||
|
smtp_port = forms.IntegerField(
|
||||||
|
label=_("Port"),
|
||||||
|
required=False,
|
||||||
|
widget=forms.TextInput(attrs={'placeholder': 'e.g. 587, 465, 25, ...'})
|
||||||
|
)
|
||||||
|
smtp_username = forms.CharField(
|
||||||
|
label=_("Username"),
|
||||||
|
widget=forms.TextInput(attrs={'placeholder': 'myuser@example.org'}),
|
||||||
|
required=False
|
||||||
|
)
|
||||||
|
smtp_password = SecretKeySettingsField(
|
||||||
|
label=_("Password"),
|
||||||
|
required=False,
|
||||||
|
)
|
||||||
|
smtp_use_tls = forms.BooleanField(
|
||||||
|
label=_("Use STARTTLS"),
|
||||||
|
help_text=_("Commonly enabled on port 587."),
|
||||||
|
required=False
|
||||||
|
)
|
||||||
|
smtp_use_ssl = forms.BooleanField(
|
||||||
|
label=_("Use SSL"),
|
||||||
|
help_text=_("Commonly enabled on port 465."),
|
||||||
|
required=False
|
||||||
|
)
|
||||||
|
|
||||||
|
def clean(self):
|
||||||
|
data = super().clean()
|
||||||
|
if data.get('smtp_use_tls') and data.get('smtp_use_ssl'):
|
||||||
|
raise ValidationError(_('You can activate either SSL or STARTTLS security, but not both at the same time.'))
|
||||||
|
return data
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ from pretix.base.settings import (
|
|||||||
PERSON_NAME_SCHEMES, PERSON_NAME_TITLE_GROUPS, validate_event_settings,
|
PERSON_NAME_SCHEMES, PERSON_NAME_TITLE_GROUPS, validate_event_settings,
|
||||||
)
|
)
|
||||||
from pretix.control.forms import (
|
from pretix.control.forms import (
|
||||||
MultipleLanguagesWidget, SlugWidget, SplitDateTimeField,
|
MultipleLanguagesWidget, SlugWidget, SMTPSettingsMixin, SplitDateTimeField,
|
||||||
SplitDateTimePickerWidget,
|
SplitDateTimePickerWidget,
|
||||||
)
|
)
|
||||||
from pretix.control.forms.widgets import Select2
|
from pretix.control.forms.widgets import Select2
|
||||||
@@ -825,7 +825,7 @@ def contains_web_channel_validate(val):
|
|||||||
raise ValidationError(_("The online shop must be selected to receive these emails."))
|
raise ValidationError(_("The online shop must be selected to receive these emails."))
|
||||||
|
|
||||||
|
|
||||||
class MailSettingsForm(SettingsForm):
|
class MailSettingsForm(SMTPSettingsMixin, SettingsForm):
|
||||||
auto_fields = [
|
auto_fields = [
|
||||||
'mail_prefix',
|
'mail_prefix',
|
||||||
'mail_from',
|
'mail_from',
|
||||||
@@ -1020,43 +1020,6 @@ class MailSettingsForm(SettingsForm):
|
|||||||
required=False,
|
required=False,
|
||||||
widget=I18nTextarea,
|
widget=I18nTextarea,
|
||||||
)
|
)
|
||||||
smtp_use_custom = forms.BooleanField(
|
|
||||||
label=_("Use custom SMTP server"),
|
|
||||||
help_text=_("All mail related to your event will be sent over the smtp server specified by you."),
|
|
||||||
required=False
|
|
||||||
)
|
|
||||||
smtp_host = forms.CharField(
|
|
||||||
label=_("Hostname"),
|
|
||||||
required=False,
|
|
||||||
widget=forms.TextInput(attrs={'placeholder': 'mail.example.org'})
|
|
||||||
)
|
|
||||||
smtp_port = forms.IntegerField(
|
|
||||||
label=_("Port"),
|
|
||||||
required=False,
|
|
||||||
widget=forms.TextInput(attrs={'placeholder': 'e.g. 587, 465, 25, ...'})
|
|
||||||
)
|
|
||||||
smtp_username = forms.CharField(
|
|
||||||
label=_("Username"),
|
|
||||||
widget=forms.TextInput(attrs={'placeholder': 'myuser@example.org'}),
|
|
||||||
required=False
|
|
||||||
)
|
|
||||||
smtp_password = forms.CharField(
|
|
||||||
label=_("Password"),
|
|
||||||
required=False,
|
|
||||||
widget=forms.PasswordInput(attrs={
|
|
||||||
'autocomplete': 'new-password' # see https://bugs.chromium.org/p/chromium/issues/detail?id=370363#c7
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
smtp_use_tls = forms.BooleanField(
|
|
||||||
label=_("Use STARTTLS"),
|
|
||||||
help_text=_("Commonly enabled on port 587."),
|
|
||||||
required=False
|
|
||||||
)
|
|
||||||
smtp_use_ssl = forms.BooleanField(
|
|
||||||
label=_("Use SSL"),
|
|
||||||
help_text=_("Commonly enabled on port 465."),
|
|
||||||
required=False
|
|
||||||
)
|
|
||||||
base_context = {
|
base_context = {
|
||||||
'mail_text_order_placed': ['event', 'order', 'payment'],
|
'mail_text_order_placed': ['event', 'order', 'payment'],
|
||||||
'mail_text_order_placed_attendee': ['event', 'order', 'position'],
|
'mail_text_order_placed_attendee': ['event', 'order', 'position'],
|
||||||
@@ -1110,17 +1073,6 @@ class MailSettingsForm(SettingsForm):
|
|||||||
# the user interface with it
|
# the user interface with it
|
||||||
del self.fields[k]
|
del self.fields[k]
|
||||||
|
|
||||||
def clean(self):
|
|
||||||
data = self.cleaned_data
|
|
||||||
if not data.get('smtp_password') and data.get('smtp_username'):
|
|
||||||
# Leave password unchanged if the username is set and the password field is empty.
|
|
||||||
# This makes it impossible to set an empty password as long as a username is set, but
|
|
||||||
# Python's smtplib does not support password-less schemes anyway.
|
|
||||||
data['smtp_password'] = self.initial.get('smtp_password')
|
|
||||||
|
|
||||||
if data.get('smtp_use_tls') and data.get('smtp_use_ssl'):
|
|
||||||
raise ValidationError(_('You can activate either SSL or STARTTLS security, but not both at the same time.'))
|
|
||||||
|
|
||||||
|
|
||||||
class TicketSettingsForm(SettingsForm):
|
class TicketSettingsForm(SettingsForm):
|
||||||
auto_fields = [
|
auto_fields = [
|
||||||
|
|||||||
@@ -1022,6 +1022,44 @@ class GiftCardFilterForm(FilterForm):
|
|||||||
return qs.distinct()
|
return qs.distinct()
|
||||||
|
|
||||||
|
|
||||||
|
class CustomerFilterForm(FilterForm):
|
||||||
|
orders = {
|
||||||
|
'email': 'email',
|
||||||
|
'identifier': 'identifier',
|
||||||
|
'name_cached': 'name_cached',
|
||||||
|
}
|
||||||
|
query = forms.CharField(
|
||||||
|
label=_('Search query'),
|
||||||
|
widget=forms.TextInput(attrs={
|
||||||
|
'placeholder': _('Search query'),
|
||||||
|
'autofocus': 'autofocus'
|
||||||
|
}),
|
||||||
|
required=False
|
||||||
|
)
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
kwargs.pop('request')
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
def filter_qs(self, qs):
|
||||||
|
fdata = self.cleaned_data
|
||||||
|
|
||||||
|
if fdata.get('query'):
|
||||||
|
query = fdata.get('query')
|
||||||
|
qs = qs.filter(
|
||||||
|
Q(email__icontains=query)
|
||||||
|
| Q(name_cached__icontains=query)
|
||||||
|
| Q(identifier__istartswith=query)
|
||||||
|
)
|
||||||
|
|
||||||
|
if fdata.get('ordering'):
|
||||||
|
qs = qs.order_by(self.get_order_by())
|
||||||
|
else:
|
||||||
|
qs = qs.order_by('-email')
|
||||||
|
|
||||||
|
return qs
|
||||||
|
|
||||||
|
|
||||||
class TeamFilterForm(FilterForm):
|
class TeamFilterForm(FilterForm):
|
||||||
orders = {
|
orders = {
|
||||||
'name': 'name',
|
'name': 'name',
|
||||||
|
|||||||
@@ -368,6 +368,11 @@ class ItemCreateForm(I18nModelForm):
|
|||||||
'hidden_if_available',
|
'hidden_if_available',
|
||||||
'require_bundling',
|
'require_bundling',
|
||||||
'checkin_attention',
|
'checkin_attention',
|
||||||
|
'require_membership',
|
||||||
|
'grant_membership_type',
|
||||||
|
'grant_membership_duration_like_event',
|
||||||
|
'grant_membership_duration_days',
|
||||||
|
'grant_membership_duration_months',
|
||||||
)
|
)
|
||||||
for f in fields:
|
for f in fields:
|
||||||
setattr(self.instance, f, getattr(self.cleaned_data['copy_from'], f))
|
setattr(self.instance, f, getattr(self.cleaned_data['copy_from'], f))
|
||||||
@@ -399,6 +404,10 @@ class ItemCreateForm(I18nModelForm):
|
|||||||
'items': [self.instance.pk]
|
'items': [self.instance.pk]
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if self.cleaned_data.get('copy_from'):
|
||||||
|
self.instance.require_membership_types.set(
|
||||||
|
self.cleaned_data['copy_from'].require_membership_types.all()
|
||||||
|
)
|
||||||
if self.cleaned_data.get('has_variations'):
|
if self.cleaned_data.get('has_variations'):
|
||||||
if self.cleaned_data.get('copy_from') and self.cleaned_data.get('copy_from').has_variations:
|
if self.cleaned_data.get('copy_from') and self.cleaned_data.get('copy_from').has_variations:
|
||||||
for variation in self.cleaned_data['copy_from'].variations.all():
|
for variation in self.cleaned_data['copy_from'].variations.all():
|
||||||
@@ -523,6 +532,19 @@ class ItemUpdateForm(I18nModelForm):
|
|||||||
)
|
)
|
||||||
self.fields['category'].widget.choices = self.fields['category'].choices
|
self.fields['category'].widget.choices = self.fields['category'].choices
|
||||||
|
|
||||||
|
qs = self.event.organizer.membership_types.all()
|
||||||
|
if qs:
|
||||||
|
self.fields['require_membership_types'].queryset = qs
|
||||||
|
self.fields['grant_membership_type'].queryset = qs
|
||||||
|
self.fields['grant_membership_type'].empty_label = _('No membership granted')
|
||||||
|
else:
|
||||||
|
del self.fields['require_membership']
|
||||||
|
del self.fields['require_membership_types']
|
||||||
|
del self.fields['grant_membership_type']
|
||||||
|
del self.fields['grant_membership_duration_like_event']
|
||||||
|
del self.fields['grant_membership_duration_days']
|
||||||
|
del self.fields['grant_membership_duration_months']
|
||||||
|
|
||||||
def clean(self):
|
def clean(self):
|
||||||
d = super().clean()
|
d = super().clean()
|
||||||
if d['issue_giftcard']:
|
if d['issue_giftcard']:
|
||||||
@@ -571,15 +593,26 @@ class ItemUpdateForm(I18nModelForm):
|
|||||||
'show_quota_left',
|
'show_quota_left',
|
||||||
'hidden_if_available',
|
'hidden_if_available',
|
||||||
'issue_giftcard',
|
'issue_giftcard',
|
||||||
|
'require_membership',
|
||||||
|
'require_membership_types',
|
||||||
|
'grant_membership_type',
|
||||||
|
'grant_membership_duration_like_event',
|
||||||
|
'grant_membership_duration_days',
|
||||||
|
'grant_membership_duration_months',
|
||||||
]
|
]
|
||||||
field_classes = {
|
field_classes = {
|
||||||
'available_from': SplitDateTimeField,
|
'available_from': SplitDateTimeField,
|
||||||
'available_until': SplitDateTimeField,
|
'available_until': SplitDateTimeField,
|
||||||
'hidden_if_available': SafeModelChoiceField,
|
'hidden_if_available': SafeModelChoiceField,
|
||||||
|
'grant_membership_type': SafeModelChoiceField,
|
||||||
|
'require_membership_types': SafeModelMultipleChoiceField,
|
||||||
}
|
}
|
||||||
widgets = {
|
widgets = {
|
||||||
'available_from': SplitDateTimePickerWidget(),
|
'available_from': SplitDateTimePickerWidget(),
|
||||||
'available_until': SplitDateTimePickerWidget(attrs={'data-date-after': '#id_available_from_0'}),
|
'available_until': SplitDateTimePickerWidget(attrs={'data-date-after': '#id_available_from_0'}),
|
||||||
|
'require_membership_types': forms.CheckboxSelectMultiple(attrs={
|
||||||
|
'class': 'scrolling-multiple-choice'
|
||||||
|
}),
|
||||||
'generate_tickets': TicketNullBooleanSelect(),
|
'generate_tickets': TicketNullBooleanSelect(),
|
||||||
'show_quota_left': ShowQuotaNullBooleanSelect()
|
'show_quota_left': ShowQuotaNullBooleanSelect()
|
||||||
}
|
}
|
||||||
@@ -632,6 +665,13 @@ class ItemVariationForm(I18nModelForm):
|
|||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
change_decimal_field(self.fields['default_price'], self.event.currency)
|
change_decimal_field(self.fields['default_price'], self.event.currency)
|
||||||
|
|
||||||
|
qs = self.event.organizer.membership_types.all()
|
||||||
|
if qs:
|
||||||
|
self.fields['require_membership_types'].queryset = qs
|
||||||
|
else:
|
||||||
|
del self.fields['require_membership']
|
||||||
|
del self.fields['require_membership_types']
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = ItemVariation
|
model = ItemVariation
|
||||||
localized_fields = '__all__'
|
localized_fields = '__all__'
|
||||||
@@ -641,7 +681,14 @@ class ItemVariationForm(I18nModelForm):
|
|||||||
'default_price',
|
'default_price',
|
||||||
'original_price',
|
'original_price',
|
||||||
'description',
|
'description',
|
||||||
|
'require_membership',
|
||||||
|
'require_membership_types'
|
||||||
]
|
]
|
||||||
|
widgets = {
|
||||||
|
'require_membership_types': forms.CheckboxSelectMultiple(attrs={
|
||||||
|
'class': 'scrolling-multiple-choice'
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class ItemAddOnsFormSet(I18nFormSet):
|
class ItemAddOnsFormSet(I18nFormSet):
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ from django.utils.timezone import make_aware, now
|
|||||||
from django.utils.translation import (
|
from django.utils.translation import (
|
||||||
gettext_lazy as _, gettext_noop, pgettext_lazy,
|
gettext_lazy as _, gettext_noop, pgettext_lazy,
|
||||||
)
|
)
|
||||||
|
from django_scopes.forms import SafeModelChoiceField
|
||||||
from i18nfield.forms import I18nFormField, I18nTextarea, I18nTextInput
|
from i18nfield.forms import I18nFormField, I18nTextarea, I18nTextInput
|
||||||
from i18nfield.strings import LazyI18nString
|
from i18nfield.strings import LazyI18nString
|
||||||
|
|
||||||
@@ -290,6 +291,10 @@ class OrderPositionAddForm(forms.Form):
|
|||||||
widget=forms.TextInput(attrs={'placeholder': _('General admission'), 'data-seat-guid-field': 'true'}),
|
widget=forms.TextInput(attrs={'placeholder': _('General admission'), 'data-seat-guid-field': 'true'}),
|
||||||
label=_('Seat')
|
label=_('Seat')
|
||||||
)
|
)
|
||||||
|
used_membership = forms.ChoiceField(
|
||||||
|
label=_('Membership'),
|
||||||
|
required=False,
|
||||||
|
)
|
||||||
price = forms.DecimalField(
|
price = forms.DecimalField(
|
||||||
required=False,
|
required=False,
|
||||||
max_digits=10, decimal_places=2,
|
max_digits=10, decimal_places=2,
|
||||||
@@ -360,6 +365,23 @@ class OrderPositionAddForm(forms.Form):
|
|||||||
del self.fields['subevent']
|
del self.fields['subevent']
|
||||||
change_decimal_field(self.fields['price'], order.event.currency)
|
change_decimal_field(self.fields['price'], order.event.currency)
|
||||||
|
|
||||||
|
choices = [
|
||||||
|
('', ''),
|
||||||
|
]
|
||||||
|
if order.customer:
|
||||||
|
self.memberships = list(order.customer.memberships.all())
|
||||||
|
for m in self.memberships:
|
||||||
|
choices.append((str(m.pk), str(m)))
|
||||||
|
self.fields['used_membership'].choices = choices
|
||||||
|
|
||||||
|
def clean(self):
|
||||||
|
d = super().clean()
|
||||||
|
if d['used_membership']:
|
||||||
|
d['used_membership'] = [m for m in self.memberships if str(m.pk) == d['used_membership']][0]
|
||||||
|
else:
|
||||||
|
d['used_membership'] = None
|
||||||
|
return d
|
||||||
|
|
||||||
|
|
||||||
class OrderPositionAddFormset(forms.BaseFormSet):
|
class OrderPositionAddFormset(forms.BaseFormSet):
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
@@ -405,6 +427,9 @@ class OrderPositionChangeForm(forms.Form):
|
|||||||
localize=True,
|
localize=True,
|
||||||
label=_('New price (gross)')
|
label=_('New price (gross)')
|
||||||
)
|
)
|
||||||
|
used_membership = forms.ChoiceField(
|
||||||
|
required=False,
|
||||||
|
)
|
||||||
tax_rule = forms.ModelChoiceField(
|
tax_rule = forms.ModelChoiceField(
|
||||||
TaxRule.objects.none(),
|
TaxRule.objects.none(),
|
||||||
required=False,
|
required=False,
|
||||||
@@ -478,6 +503,24 @@ class OrderPositionChangeForm(forms.Form):
|
|||||||
self.fields['itemvar'].choices = choices
|
self.fields['itemvar'].choices = choices
|
||||||
change_decimal_field(self.fields['price'], instance.order.event.currency)
|
change_decimal_field(self.fields['price'], instance.order.event.currency)
|
||||||
|
|
||||||
|
choices = [
|
||||||
|
('', _('(Unchanged)')),
|
||||||
|
('CLEAR', _('(No membership)')),
|
||||||
|
]
|
||||||
|
if instance.order.customer:
|
||||||
|
self.memberships = list(instance.order.customer.memberships.all())
|
||||||
|
for m in self.memberships:
|
||||||
|
choices.append((str(m.pk), str(m)))
|
||||||
|
self.fields['used_membership'].choices = choices
|
||||||
|
|
||||||
|
def clean(self):
|
||||||
|
d = super().clean()
|
||||||
|
if d['used_membership'] and d['used_membership'] != 'CLEAR':
|
||||||
|
d['used_membership'] = [m for m in self.memberships if str(m.pk) == d['used_membership']][0]
|
||||||
|
elif not d['used_membership']:
|
||||||
|
d['used_membership'] = None
|
||||||
|
return d
|
||||||
|
|
||||||
|
|
||||||
class OrderFeeChangeForm(forms.Form):
|
class OrderFeeChangeForm(forms.Form):
|
||||||
value = forms.DecimalField(
|
value = forms.DecimalField(
|
||||||
@@ -516,16 +559,36 @@ class OrderContactForm(forms.ModelForm):
|
|||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Order
|
model = Order
|
||||||
fields = ['email', 'email_known_to_work', 'phone']
|
fields = ['customer', 'email', 'email_known_to_work', 'phone']
|
||||||
widgets = {
|
widgets = {
|
||||||
'phone': WrappedPhoneNumberPrefixWidget()
|
'phone': WrappedPhoneNumberPrefixWidget(),
|
||||||
|
}
|
||||||
|
field_classes = {
|
||||||
|
'customer': SafeModelChoiceField,
|
||||||
}
|
}
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
|
customers = kwargs.pop('customers')
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
if not self.instance.event.settings.order_phone_asked and not self.instance.phone:
|
if not self.instance.event.settings.order_phone_asked and not self.instance.phone:
|
||||||
del self.fields['phone']
|
del self.fields['phone']
|
||||||
|
|
||||||
|
if customers:
|
||||||
|
self.fields['customer'].queryset = self.instance.event.organizer.customers.all()
|
||||||
|
self.fields['customer'].widget = Select2(
|
||||||
|
attrs={
|
||||||
|
'data-model-select2': 'generic',
|
||||||
|
'data-select2-url': reverse('control:organizer.customers.select2', kwargs={
|
||||||
|
'organizer': self.instance.event.organizer.slug,
|
||||||
|
}),
|
||||||
|
'data-placeholder': _('Customer')
|
||||||
|
}
|
||||||
|
)
|
||||||
|
self.fields['customer'].widget.choices = self.fields['customer'].choices
|
||||||
|
self.fields['customer'].required = False
|
||||||
|
else:
|
||||||
|
del self.fields['customer']
|
||||||
|
|
||||||
|
|
||||||
class OrderLocaleForm(forms.ModelForm):
|
class OrderLocaleForm(forms.ModelForm):
|
||||||
locale = forms.ChoiceField()
|
locale = forms.ChoiceField()
|
||||||
|
|||||||
@@ -39,20 +39,31 @@ from django import forms
|
|||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
|
from django.utils.crypto import get_random_string
|
||||||
from django.utils.safestring import mark_safe
|
from django.utils.safestring import mark_safe
|
||||||
from django.utils.translation import gettext_lazy as _, pgettext_lazy
|
from django.utils.translation import gettext_lazy as _, pgettext_lazy
|
||||||
from django_scopes.forms import SafeModelMultipleChoiceField
|
from django_scopes.forms import SafeModelMultipleChoiceField
|
||||||
|
from i18nfield.forms import I18nFormField, I18nTextarea
|
||||||
|
from pytz import common_timezones
|
||||||
|
|
||||||
from pretix.api.models import WebHook
|
from pretix.api.models import WebHook
|
||||||
from pretix.api.webhooks import get_all_webhook_events
|
from pretix.api.webhooks import get_all_webhook_events
|
||||||
from pretix.base.forms import I18nModelForm, SettingsForm
|
from pretix.base.forms import I18nModelForm, PlaceholderValidator, SettingsForm
|
||||||
|
from pretix.base.forms.questions import NamePartsFormField
|
||||||
from pretix.base.forms.widgets import SplitDateTimePickerWidget
|
from pretix.base.forms.widgets import SplitDateTimePickerWidget
|
||||||
from pretix.base.models import (
|
from pretix.base.models import (
|
||||||
Device, EventMetaProperty, Gate, GiftCard, Organizer, Team,
|
Customer, Device, EventMetaProperty, Gate, GiftCard, Membership,
|
||||||
|
MembershipType, Organizer, Team,
|
||||||
|
)
|
||||||
|
from pretix.base.settings import PERSON_NAME_SCHEMES, PERSON_NAME_TITLE_GROUPS
|
||||||
|
from pretix.control.forms import (
|
||||||
|
ExtFileField, SMTPSettingsMixin, SplitDateTimeField,
|
||||||
|
)
|
||||||
|
from pretix.control.forms.event import (
|
||||||
|
SafeEventMultipleChoiceField, multimail_validate,
|
||||||
)
|
)
|
||||||
from pretix.control.forms import ExtFileField, SplitDateTimeField
|
|
||||||
from pretix.control.forms.event import SafeEventMultipleChoiceField
|
|
||||||
from pretix.multidomain.models import KnownDomain
|
from pretix.multidomain.models import KnownDomain
|
||||||
|
from pretix.multidomain.urlreverse import build_absolute_uri
|
||||||
|
|
||||||
|
|
||||||
class OrganizerForm(I18nModelForm):
|
class OrganizerForm(I18nModelForm):
|
||||||
@@ -168,6 +179,12 @@ class EventMetaPropertyForm(forms.ModelForm):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class MembershipTypeForm(I18nModelForm):
|
||||||
|
class Meta:
|
||||||
|
model = MembershipType
|
||||||
|
fields = ['name', 'transferable', 'allow_parallel_usage', 'max_usages']
|
||||||
|
|
||||||
|
|
||||||
class TeamForm(forms.ModelForm):
|
class TeamForm(forms.ModelForm):
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
@@ -181,7 +198,7 @@ class TeamForm(forms.ModelForm):
|
|||||||
model = Team
|
model = Team
|
||||||
fields = ['name', 'all_events', 'limit_events', 'can_create_events',
|
fields = ['name', 'all_events', 'limit_events', 'can_create_events',
|
||||||
'can_change_teams', 'can_change_organizer_settings',
|
'can_change_teams', 'can_change_organizer_settings',
|
||||||
'can_manage_gift_cards',
|
'can_manage_gift_cards', 'can_manage_customers',
|
||||||
'can_change_event_settings', 'can_change_items',
|
'can_change_event_settings', 'can_change_items',
|
||||||
'can_view_orders', 'can_change_orders', 'can_checkin_orders',
|
'can_view_orders', 'can_change_orders', 'can_checkin_orders',
|
||||||
'can_view_vouchers', 'can_change_vouchers']
|
'can_view_vouchers', 'can_change_vouchers']
|
||||||
@@ -250,7 +267,24 @@ class DeviceForm(forms.ModelForm):
|
|||||||
|
|
||||||
|
|
||||||
class OrganizerSettingsForm(SettingsForm):
|
class OrganizerSettingsForm(SettingsForm):
|
||||||
|
timezone = forms.ChoiceField(
|
||||||
|
choices=((a, a) for a in common_timezones),
|
||||||
|
label=_("Default timezone"),
|
||||||
|
)
|
||||||
|
name_scheme = forms.ChoiceField(
|
||||||
|
label=_("Name format"),
|
||||||
|
help_text=_("This defines how pretix will ask for human names. Changing this after you already received "
|
||||||
|
"orders might lead to unexpected behavior when sorting or changing names."),
|
||||||
|
required=True,
|
||||||
|
)
|
||||||
|
name_scheme_titles = forms.ChoiceField(
|
||||||
|
label=_("Allowed titles"),
|
||||||
|
help_text=_("If the naming scheme you defined above allows users to input a title, you can use this to "
|
||||||
|
"restrict the set of selectable titles."),
|
||||||
|
required=False,
|
||||||
|
)
|
||||||
auto_fields = [
|
auto_fields = [
|
||||||
|
'customer_accounts',
|
||||||
'contact_mail',
|
'contact_mail',
|
||||||
'imprint_url',
|
'imprint_url',
|
||||||
'organizer_info_text',
|
'organizer_info_text',
|
||||||
@@ -292,6 +326,115 @@ class OrganizerSettingsForm(SettingsForm):
|
|||||||
'We recommend a size of at least 200x200px to accommodate most devices.')
|
'We recommend a size of at least 200x200px to accommodate most devices.')
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
self.fields['name_scheme'].choices = (
|
||||||
|
(k, _('Ask for {fields}, display like {example}').format(
|
||||||
|
fields=' + '.join(str(vv[1]) for vv in v['fields']),
|
||||||
|
example=v['concatenation'](v['sample'])
|
||||||
|
))
|
||||||
|
for k, v in PERSON_NAME_SCHEMES.items()
|
||||||
|
)
|
||||||
|
self.fields['name_scheme_titles'].choices = [('', _('Free text input'))] + [
|
||||||
|
(k, '{scheme}: {samples}'.format(
|
||||||
|
scheme=v[0],
|
||||||
|
samples=', '.join(v[1])
|
||||||
|
))
|
||||||
|
for k, v in PERSON_NAME_TITLE_GROUPS.items()
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class MailSettingsForm(SMTPSettingsMixin, SettingsForm):
|
||||||
|
auto_fields = [
|
||||||
|
'mail_from',
|
||||||
|
'mail_from_name',
|
||||||
|
]
|
||||||
|
|
||||||
|
mail_bcc = forms.CharField(
|
||||||
|
label=_("Bcc address"),
|
||||||
|
help_text=_("All emails will be sent to this address as a Bcc copy"),
|
||||||
|
validators=[multimail_validate],
|
||||||
|
required=False,
|
||||||
|
max_length=255
|
||||||
|
)
|
||||||
|
mail_text_signature = I18nFormField(
|
||||||
|
label=_("Signature"),
|
||||||
|
required=False,
|
||||||
|
widget=I18nTextarea,
|
||||||
|
help_text=_("This will be attached to every email."),
|
||||||
|
validators=[PlaceholderValidator([])],
|
||||||
|
widget_kwargs={'attrs': {
|
||||||
|
'rows': '4',
|
||||||
|
'placeholder': _(
|
||||||
|
'e.g. your contact details'
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
)
|
||||||
|
|
||||||
|
mail_text_customer_registration = I18nFormField(
|
||||||
|
label=_("Text"),
|
||||||
|
required=False,
|
||||||
|
widget=I18nTextarea,
|
||||||
|
)
|
||||||
|
mail_text_customer_email_change = I18nFormField(
|
||||||
|
label=_("Text"),
|
||||||
|
required=False,
|
||||||
|
widget=I18nTextarea,
|
||||||
|
)
|
||||||
|
mail_text_customer_reset = I18nFormField(
|
||||||
|
label=_("Text"),
|
||||||
|
required=False,
|
||||||
|
widget=I18nTextarea,
|
||||||
|
)
|
||||||
|
|
||||||
|
base_context = {
|
||||||
|
'mail_text_customer_registration': ['customer', 'url'],
|
||||||
|
'mail_text_customer_email_change': ['customer', 'url'],
|
||||||
|
'mail_text_customer_reset': ['customer', 'url'],
|
||||||
|
}
|
||||||
|
|
||||||
|
def _get_sample_context(self, base_parameters):
|
||||||
|
placeholders = {
|
||||||
|
'organizer': self.organizer.name
|
||||||
|
}
|
||||||
|
|
||||||
|
if 'url' in base_parameters:
|
||||||
|
placeholders['url'] = build_absolute_uri(
|
||||||
|
self.organizer,
|
||||||
|
'presale:organizer.customer.activate'
|
||||||
|
) + '?token=' + get_random_string(30)
|
||||||
|
|
||||||
|
if 'customer' in base_parameters:
|
||||||
|
placeholders['name'] = pgettext_lazy('person_name_sample', 'John Doe')
|
||||||
|
name_scheme = PERSON_NAME_SCHEMES[self.organizer.settings.name_scheme]
|
||||||
|
for f, l, w in name_scheme['fields']:
|
||||||
|
if f == 'full_name':
|
||||||
|
continue
|
||||||
|
placeholders['name_%s' % f] = name_scheme['sample'][f]
|
||||||
|
return placeholders
|
||||||
|
|
||||||
|
def _set_field_placeholders(self, fn, base_parameters):
|
||||||
|
phs = [
|
||||||
|
'{%s}' % p
|
||||||
|
for p in sorted(self._get_sample_context(base_parameters).keys())
|
||||||
|
]
|
||||||
|
ht = _('Available placeholders: {list}').format(
|
||||||
|
list=', '.join(phs)
|
||||||
|
)
|
||||||
|
if self.fields[fn].help_text:
|
||||||
|
self.fields[fn].help_text += ' ' + str(ht)
|
||||||
|
else:
|
||||||
|
self.fields[fn].help_text = ht
|
||||||
|
self.fields[fn].validators.append(
|
||||||
|
PlaceholderValidator(phs)
|
||||||
|
)
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
self.organizer = kwargs.get('obj')
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
for k, v in self.base_context.items():
|
||||||
|
self._set_field_placeholders(k, v)
|
||||||
|
|
||||||
|
|
||||||
class WebHookForm(forms.ModelForm):
|
class WebHookForm(forms.ModelForm):
|
||||||
events = forms.MultipleChoiceField(
|
events = forms.MultipleChoiceField(
|
||||||
@@ -373,3 +516,67 @@ class GiftCardUpdateForm(forms.ModelForm):
|
|||||||
'expires': SplitDateTimePickerWidget,
|
'expires': SplitDateTimePickerWidget,
|
||||||
'conditions': forms.Textarea(attrs={"rows": 2})
|
'conditions': forms.Textarea(attrs={"rows": 2})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class CustomerUpdateForm(forms.ModelForm):
|
||||||
|
error_messages = {
|
||||||
|
'duplicate': _("An account with this email address is already registered."),
|
||||||
|
}
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Customer
|
||||||
|
fields = ['is_active', 'name_parts', 'email', 'is_verified', 'locale']
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
self.fields['name_parts'] = NamePartsFormField(
|
||||||
|
max_length=255,
|
||||||
|
required=False,
|
||||||
|
scheme=self.instance.organizer.settings.name_scheme,
|
||||||
|
titles=self.instance.organizer.settings.name_scheme_titles,
|
||||||
|
label=_('Name'),
|
||||||
|
)
|
||||||
|
|
||||||
|
def clean(self):
|
||||||
|
email = self.cleaned_data.get('email')
|
||||||
|
|
||||||
|
if email is not None:
|
||||||
|
try:
|
||||||
|
self.instance.organizer.customers.exclude(pk=self.instance.pk).get(email=email)
|
||||||
|
except Customer.DoesNotExist:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
raise forms.ValidationError(
|
||||||
|
self.error_messages['duplicate'],
|
||||||
|
code='duplicate',
|
||||||
|
)
|
||||||
|
|
||||||
|
return self.cleaned_data
|
||||||
|
|
||||||
|
|
||||||
|
class MembershipUpdateForm(forms.ModelForm):
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Membership
|
||||||
|
fields = ['membership_type', 'date_start', 'date_end', 'attendee_name_parts']
|
||||||
|
field_classes = {
|
||||||
|
'date_start': SplitDateTimeField,
|
||||||
|
'date_end': SplitDateTimeField,
|
||||||
|
}
|
||||||
|
widgets = {
|
||||||
|
'date_start': SplitDateTimePickerWidget(),
|
||||||
|
'date_end': SplitDateTimePickerWidget(attrs={'data-date-after': '#id_date_Start'}),
|
||||||
|
}
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
self.fields['membership_type'].queryset = self.instance.customer.organizer.membership_types.all()
|
||||||
|
self.fields['attendee_name_parts'] = NamePartsFormField(
|
||||||
|
max_length=255,
|
||||||
|
required=False,
|
||||||
|
scheme=self.instance.customer.organizer.settings.name_scheme,
|
||||||
|
titles=self.instance.customer.organizer.settings.name_scheme_titles,
|
||||||
|
label=_('Attendee name'),
|
||||||
|
)
|
||||||
|
|||||||
@@ -78,6 +78,10 @@ def _display_order_changed(event: Event, logentry: LogEntry):
|
|||||||
old_price=money_filter(Decimal(data['old_price']), event.currency),
|
old_price=money_filter(Decimal(data['old_price']), event.currency),
|
||||||
new_price=money_filter(Decimal(data['new_price']), event.currency),
|
new_price=money_filter(Decimal(data['new_price']), event.currency),
|
||||||
)
|
)
|
||||||
|
elif logentry.action_type == 'pretix.event.order.changed.membership':
|
||||||
|
return text + ' ' + _('Position #{posid}: Used membership changed.').format(
|
||||||
|
posid=data.get('positionid', '?'),
|
||||||
|
)
|
||||||
elif logentry.action_type == 'pretix.event.order.changed.seat':
|
elif logentry.action_type == 'pretix.event.order.changed.seat':
|
||||||
return text + ' ' + _('Position #{posid}: Seat "{old_seat}" changed '
|
return text + ' ' + _('Position #{posid}: Seat "{old_seat}" changed '
|
||||||
'to "{new_seat}".').format(
|
'to "{new_seat}".').format(
|
||||||
@@ -314,6 +318,17 @@ def pretixcontrol_logentry_display(sender: Event, logentry: LogEntry, **kwargs):
|
|||||||
'pretix.giftcards.acceptance.removed': _('Gift card acceptance for another organizer has been removed.'),
|
'pretix.giftcards.acceptance.removed': _('Gift card acceptance for another organizer has been removed.'),
|
||||||
'pretix.webhook.created': _('The webhook has been created.'),
|
'pretix.webhook.created': _('The webhook has been created.'),
|
||||||
'pretix.webhook.changed': _('The webhook has been changed.'),
|
'pretix.webhook.changed': _('The webhook has been changed.'),
|
||||||
|
'pretix.membershiptype.created': _('The membership type has been created.'),
|
||||||
|
'pretix.membershiptype.changed': _('The membership type has been changed.'),
|
||||||
|
'pretix.membershiptype.deleted': _('The membership type has been deleted.'),
|
||||||
|
'pretix.customer.created': _('The account has been created.'),
|
||||||
|
'pretix.customer.changed': _('The account has been changed.'),
|
||||||
|
'pretix.customer.membership.created': _('A membership for this account has been added.'),
|
||||||
|
'pretix.customer.membership.changed': _('A membership of this account has been changed.'),
|
||||||
|
'pretix.customer.anonymized': _('The account has been disabled and anonymized.'),
|
||||||
|
'pretix.customer.password.resetrequested': _('A new password has been requested.'),
|
||||||
|
'pretix.customer.password.set': _('A new password has been set.'),
|
||||||
|
'pretix.email.error': _('Sending of an email has failed.'),
|
||||||
'pretix.event.comment': _('The event\'s internal comment has been updated.'),
|
'pretix.event.comment': _('The event\'s internal comment has been updated.'),
|
||||||
'pretix.event.canceled': _('The event has been canceled.'),
|
'pretix.event.canceled': _('The event has been canceled.'),
|
||||||
'pretix.event.deleted': _('An event has been deleted.'),
|
'pretix.event.deleted': _('An event has been deleted.'),
|
||||||
@@ -338,6 +353,7 @@ def pretixcontrol_logentry_display(sender: Event, logentry: LogEntry, **kwargs):
|
|||||||
'in the email for the first time).'),
|
'in the email for the first time).'),
|
||||||
'pretix.event.order.phone.changed': _('The phone number has been changed from "{old_phone}" '
|
'pretix.event.order.phone.changed': _('The phone number has been changed from "{old_phone}" '
|
||||||
'to "{new_phone}".'),
|
'to "{new_phone}".'),
|
||||||
|
'pretix.event.order.customer.changed': _('The customer account has been changed.'),
|
||||||
'pretix.event.order.locale.changed': _('The order locale has been changed.'),
|
'pretix.event.order.locale.changed': _('The order locale has been changed.'),
|
||||||
'pretix.event.order.invoice.generated': _('The invoice has been generated.'),
|
'pretix.event.order.invoice.generated': _('The invoice has been generated.'),
|
||||||
'pretix.event.order.invoice.regenerated': _('The invoice has been regenerated.'),
|
'pretix.event.order.invoice.regenerated': _('The invoice has been regenerated.'),
|
||||||
|
|||||||
@@ -460,6 +460,13 @@ def get_organizer_navigation(request):
|
|||||||
}),
|
}),
|
||||||
'active': url.url_name.startswith('organizer.propert'),
|
'active': url.url_name.startswith('organizer.propert'),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
'label': _('E-mail'),
|
||||||
|
'url': reverse('control:organizer.settings.mail', kwargs={
|
||||||
|
'organizer': request.organizer.slug,
|
||||||
|
}),
|
||||||
|
'active': url.url_name == 'organizer.settings.mail',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
'label': _('Webhooks'),
|
'label': _('Webhooks'),
|
||||||
'url': reverse('control:organizer.webhooks', kwargs={
|
'url': reverse('control:organizer.webhooks', kwargs={
|
||||||
@@ -467,9 +474,10 @@ def get_organizer_navigation(request):
|
|||||||
}),
|
}),
|
||||||
'active': 'organizer.webhook' in url.url_name,
|
'active': 'organizer.webhook' in url.url_name,
|
||||||
'icon': 'bolt',
|
'icon': 'bolt',
|
||||||
}
|
},
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
|
|
||||||
if 'can_change_teams' in request.orgapermset:
|
if 'can_change_teams' in request.orgapermset:
|
||||||
nav.append({
|
nav.append({
|
||||||
'label': _('Teams'),
|
'label': _('Teams'),
|
||||||
@@ -490,6 +498,38 @@ def get_organizer_navigation(request):
|
|||||||
'icon': 'credit-card',
|
'icon': 'credit-card',
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if request.organizer.settings.customer_accounts:
|
||||||
|
children = []
|
||||||
|
if 'can_manage_customers' in request.orgapermset:
|
||||||
|
children.append(
|
||||||
|
{
|
||||||
|
'label': _('Customers'),
|
||||||
|
'url': reverse('control:organizer.customers', kwargs={
|
||||||
|
'organizer': request.organizer.slug
|
||||||
|
}),
|
||||||
|
'active': 'organizer.customer' in url.url_name,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if 'can_change_organizer_settings' in request.orgapermset:
|
||||||
|
children.append(
|
||||||
|
{
|
||||||
|
'label': _('Membership types'),
|
||||||
|
'url': reverse('control:organizer.membershiptypes', kwargs={
|
||||||
|
'organizer': request.organizer.slug
|
||||||
|
}),
|
||||||
|
'active': 'organizer.membershiptype' in url.url_name,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if children:
|
||||||
|
nav.append({
|
||||||
|
'label': _('Customer accounts'),
|
||||||
|
'url': reverse('control:organizer.customers', kwargs={
|
||||||
|
'organizer': request.organizer.slug
|
||||||
|
}),
|
||||||
|
'icon': 'user',
|
||||||
|
'children': children,
|
||||||
|
})
|
||||||
|
|
||||||
if 'can_change_organizer_settings' in request.orgapermset:
|
if 'can_change_organizer_settings' in request.orgapermset:
|
||||||
nav.append({
|
nav.append({
|
||||||
'label': _('Devices'),
|
'label': _('Devices'),
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
{% extends "pretixcontrol/event/settings_base.html" %}
|
{% extends "pretixcontrol/event/settings_base.html" %}
|
||||||
{% load i18n %}
|
{% load i18n %}
|
||||||
{% load bootstrap3 %}
|
{% load bootstrap3 %}
|
||||||
|
{% load hierarkey_form %}
|
||||||
{% load static %}
|
{% load static %}
|
||||||
{% block inside %}
|
{% block inside %}
|
||||||
<h1>{% trans "E-mail settings" %}</h1>
|
<h1>{% trans "E-mail settings" %}</h1>
|
||||||
@@ -11,11 +12,14 @@
|
|||||||
<div class="tabbed-form">
|
<div class="tabbed-form">
|
||||||
<fieldset>
|
<fieldset>
|
||||||
<legend>{% trans "General" %}</legend>
|
<legend>{% trans "General" %}</legend>
|
||||||
|
{% url "control:organizer.settings.mail" organizer=request.organizer.slug as org_url %}
|
||||||
|
{% propagated request.event org_url "mail_from" "mail_from_name" "mail_text_signature" "mail_bcc" %}
|
||||||
|
{% bootstrap_field form.mail_from layout="control" %}
|
||||||
|
{% bootstrap_field form.mail_from_name layout="control" %}
|
||||||
|
{% bootstrap_field form.mail_text_signature layout="control" %}
|
||||||
|
{% bootstrap_field form.mail_bcc layout="control" %}
|
||||||
|
{% endpropagated %}
|
||||||
{% bootstrap_field form.mail_prefix layout="control" %}
|
{% bootstrap_field form.mail_prefix layout="control" %}
|
||||||
{% bootstrap_field form.mail_from layout="control" %}
|
|
||||||
{% bootstrap_field form.mail_from_name layout="control" %}
|
|
||||||
{% bootstrap_field form.mail_text_signature layout="control" %}
|
|
||||||
{% bootstrap_field form.mail_bcc layout="control" %}
|
|
||||||
{% bootstrap_field form.mail_attach_tickets layout="control" %}
|
{% bootstrap_field form.mail_attach_tickets layout="control" %}
|
||||||
{% bootstrap_field form.mail_attach_ical layout="control" %}
|
{% bootstrap_field form.mail_attach_ical layout="control" %}
|
||||||
{% bootstrap_field form.mail_sales_channel_placed_paid layout="control" %}
|
{% bootstrap_field form.mail_sales_channel_placed_paid layout="control" %}
|
||||||
@@ -80,13 +84,15 @@
|
|||||||
</fieldset>
|
</fieldset>
|
||||||
<fieldset>
|
<fieldset>
|
||||||
<legend>{% trans "SMTP settings" %}</legend>
|
<legend>{% trans "SMTP settings" %}</legend>
|
||||||
{% bootstrap_field form.smtp_use_custom layout="control" %}
|
{% propagated request.event org_url "smtp_use_custom" "smtp_host" "smtp_port" "smtp_username" "smtp_password" "smtp_use_tls" "smtp_use_ssl" %}
|
||||||
{% bootstrap_field form.smtp_host layout="control" %}
|
{% bootstrap_field form.smtp_use_custom layout="control" %}
|
||||||
{% bootstrap_field form.smtp_port layout="control" %}
|
{% bootstrap_field form.smtp_host layout="control" %}
|
||||||
{% bootstrap_field form.smtp_username layout="control" %}
|
{% bootstrap_field form.smtp_port layout="control" %}
|
||||||
{% bootstrap_field form.smtp_password layout="control" %}
|
{% bootstrap_field form.smtp_username layout="control" %}
|
||||||
{% bootstrap_field form.smtp_use_tls layout="control" %}
|
{% bootstrap_field form.smtp_password layout="control" %}
|
||||||
{% bootstrap_field form.smtp_use_ssl layout="control" %}
|
{% bootstrap_field form.smtp_use_tls layout="control" %}
|
||||||
|
{% bootstrap_field form.smtp_use_ssl layout="control" %}
|
||||||
|
{% endpropagated %}
|
||||||
</fieldset>
|
</fieldset>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group submit-group">
|
<div class="form-group submit-group">
|
||||||
|
|||||||
@@ -35,9 +35,15 @@
|
|||||||
{% bootstrap_field field show_label=False form_group_class="" %}
|
{% bootstrap_field field show_label=False form_group_class="" %}
|
||||||
</div>
|
</div>
|
||||||
<div id="{{ item }}_preview" class="tab-pane mail-preview-group">
|
<div id="{{ item }}_preview" class="tab-pane mail-preview-group">
|
||||||
{% for l in request.event.settings.locales %}
|
{% if request.event %}
|
||||||
<div lang="{{ l }}" for="{{ item }}" class="mail-preview"></div>
|
{% for l in request.event.settings.locales %}
|
||||||
{% endfor %}
|
<div lang="{{ l }}" for="{{ item }}" class="mail-preview"></div>
|
||||||
|
{% endfor %}
|
||||||
|
{% else %}
|
||||||
|
{% for l in request.organizer.settings.locales %}
|
||||||
|
<div lang="{{ l }}" for="{{ item }}" class="mail-preview"></div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
{% load static %}
|
{% load static %}
|
||||||
{% load hierarkey_form %}
|
{% load hierarkey_form %}
|
||||||
{% load formset_tags %}
|
{% load formset_tags %}
|
||||||
|
{% block title %}{% trans "General settings" %}{% endblock %}
|
||||||
{% block custom_header %}
|
{% block custom_header %}
|
||||||
{{ block.super }}
|
{{ block.super }}
|
||||||
<link type="text/css" rel="stylesheet" href="{% url "control:pdf.css" %}">
|
<link type="text/css" rel="stylesheet" href="{% url "control:pdf.css" %}">
|
||||||
@@ -203,7 +204,7 @@
|
|||||||
{% bootstrap_field sform.logo_show_title layout="control" %}
|
{% bootstrap_field sform.logo_show_title layout="control" %}
|
||||||
{% bootstrap_field sform.og_image layout="control" %}
|
{% bootstrap_field sform.og_image layout="control" %}
|
||||||
{% url "control:organizer.edit" organizer=request.organizer.slug as org_url %}
|
{% url "control:organizer.edit" organizer=request.organizer.slug as org_url %}
|
||||||
{% propagated request.event org_url "primary_color" "primary_font" "theme_color_success" "theme_color_danger" %}
|
{% propagated request.event org_url "primary_color" "primary_font" "theme_color_success" "theme_color_danger" "theme_round_borders" %}
|
||||||
{% bootstrap_field sform.primary_color layout="control" %}
|
{% bootstrap_field sform.primary_color layout="control" %}
|
||||||
{% bootstrap_field sform.theme_color_success layout="control" %}
|
{% bootstrap_field sform.theme_color_success layout="control" %}
|
||||||
{% bootstrap_field sform.theme_color_danger layout="control" %}
|
{% bootstrap_field sform.theme_color_danger layout="control" %}
|
||||||
|
|||||||
@@ -46,6 +46,12 @@
|
|||||||
{% bootstrap_field form.default_price addon_after=request.event.currency layout="control" %}
|
{% bootstrap_field form.default_price addon_after=request.event.currency layout="control" %}
|
||||||
{% bootstrap_field form.original_price addon_after=request.event.currency layout="control" %}
|
{% bootstrap_field form.original_price addon_after=request.event.currency layout="control" %}
|
||||||
{% bootstrap_field form.description layout="control" %}
|
{% bootstrap_field form.description layout="control" %}
|
||||||
|
{% if form.require_membership %}
|
||||||
|
{% bootstrap_field form.require_membership layout="control" %}
|
||||||
|
<div data-display-dependency="#{{ form.require_membership.id_for_label }}">
|
||||||
|
{% bootstrap_field form.require_membership_types layout="control" %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
@@ -80,6 +86,12 @@
|
|||||||
{% bootstrap_field formset.empty_form.default_price addon_after=request.event.currency layout="control" %}
|
{% bootstrap_field formset.empty_form.default_price addon_after=request.event.currency layout="control" %}
|
||||||
{% bootstrap_field formset.empty_form.original_price addon_after=request.event.currency layout="control" %}
|
{% bootstrap_field formset.empty_form.original_price addon_after=request.event.currency layout="control" %}
|
||||||
{% bootstrap_field formset.empty_form.description layout="control" %}
|
{% bootstrap_field formset.empty_form.description layout="control" %}
|
||||||
|
{% if formset.empty_form.require_membership %}
|
||||||
|
{% bootstrap_field formset.empty_form.require_membership layout="control" %}
|
||||||
|
<div data-display-dependency="#{{ formset.empty_form.require_membership.id_for_label }}">
|
||||||
|
{% bootstrap_field formset.empty_form.require_membership_types layout="control" %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endescapescript %}
|
{% endescapescript %}
|
||||||
|
|||||||
@@ -101,6 +101,12 @@
|
|||||||
{% bootstrap_field form.require_voucher layout="control" %}
|
{% bootstrap_field form.require_voucher layout="control" %}
|
||||||
{% bootstrap_field form.hide_without_voucher layout="control" %}
|
{% bootstrap_field form.hide_without_voucher layout="control" %}
|
||||||
{% bootstrap_field form.require_bundling layout="control" %}
|
{% bootstrap_field form.require_bundling layout="control" %}
|
||||||
|
{% if form.require_membership %}
|
||||||
|
{% bootstrap_field form.require_membership layout="control" %}
|
||||||
|
<div data-display-dependency="#{{ form.require_membership.id_for_label }}">
|
||||||
|
{% bootstrap_field form.require_membership_types layout="control" %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
{% bootstrap_field form.allow_cancel layout="control" %}
|
{% bootstrap_field form.allow_cancel layout="control" %}
|
||||||
{% bootstrap_field form.allow_waitinglist layout="control" %}
|
{% bootstrap_field form.allow_waitinglist layout="control" %}
|
||||||
{% bootstrap_field form.hidden_if_available layout="control" %}
|
{% bootstrap_field form.hidden_if_available layout="control" %}
|
||||||
@@ -120,6 +126,28 @@
|
|||||||
<legend>{% trans "Additional settings" %}</legend>
|
<legend>{% trans "Additional settings" %}</legend>
|
||||||
{% bootstrap_field form.issue_giftcard layout="control" %}
|
{% bootstrap_field form.issue_giftcard layout="control" %}
|
||||||
{% bootstrap_field form.show_quota_left layout="control" %}
|
{% bootstrap_field form.show_quota_left layout="control" %}
|
||||||
|
{% if form.grant_membership_type %}
|
||||||
|
{% bootstrap_field form.grant_membership_type layout="control" %}
|
||||||
|
<div data-display-dependency="#id_grant_membership_type">
|
||||||
|
{% bootstrap_field form.grant_membership_duration_like_event layout="control" %}
|
||||||
|
<div data-display-dependency="#id_grant_membership_duration_like_event" data-inverse class="form-group">
|
||||||
|
{% blocktrans asvar days %}days{% endblocktrans %}
|
||||||
|
{% blocktrans asvar months %}months{% endblocktrans %}
|
||||||
|
<label class="col-md-3 col-xs-12 control-label">
|
||||||
|
{% trans "Membership duration after purchase" %}
|
||||||
|
</label>
|
||||||
|
<div class="col-md-4 col-xs-5">
|
||||||
|
{% bootstrap_field form.grant_membership_duration_months layout="" addon_after=months label_class="sr-only" form_group_class="" %}
|
||||||
|
</div>
|
||||||
|
<label class="col-md-1 col-xs-2 control-label text-center">
|
||||||
|
+
|
||||||
|
</label>
|
||||||
|
<div class="col-md-4 col-xs-5">
|
||||||
|
{% bootstrap_field form.grant_membership_duration_days layout="" addon_after=days label_class="sr-only" form_group_class="" %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
{% for f in plugin_forms %}
|
{% for f in plugin_forms %}
|
||||||
{% bootstrap_form f layout="control" %}
|
{% bootstrap_form f layout="control" %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|||||||
@@ -152,6 +152,18 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-sm-3">
|
||||||
|
<strong>{% trans "Membership" %}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="col-sm-5">
|
||||||
|
{{ position.used_membership|default:"–" }}
|
||||||
|
</div>
|
||||||
|
<div class="col-sm-4 field-container">
|
||||||
|
{% bootstrap_field position.form.used_membership layout='inline' %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-sm-3">
|
<div class="col-sm-3">
|
||||||
<strong>{% trans "Price" %}</strong>
|
<strong>{% trans "Price" %}</strong>
|
||||||
@@ -232,6 +244,9 @@
|
|||||||
{% if add_form.subevent %}
|
{% if add_form.subevent %}
|
||||||
{% bootstrap_field add_form.subevent layout="control" %}
|
{% bootstrap_field add_form.subevent layout="control" %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% if add_form.used_membership %}
|
||||||
|
{% bootstrap_field add_form.used_membership layout="control" %}
|
||||||
|
{% endif %}
|
||||||
{% bootstrap_field add_form.seat layout="control" %}
|
{% bootstrap_field add_form.seat layout="control" %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -264,6 +279,9 @@
|
|||||||
{% if add_formset.empty_form.subevent %}
|
{% if add_formset.empty_form.subevent %}
|
||||||
{% bootstrap_field add_formset.empty_form.subevent layout="control" %}
|
{% bootstrap_field add_formset.empty_form.subevent layout="control" %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% if add_formset.empty_form.used_membership %}
|
||||||
|
{% bootstrap_field add_formset.empty_form.used_membership layout="control" %}
|
||||||
|
{% endif %}
|
||||||
{% bootstrap_field add_formset.empty_form.seat layout="control" %}
|
{% bootstrap_field add_formset.empty_form.seat layout="control" %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -182,7 +182,20 @@
|
|||||||
<dt>{% trans "Expiry date" %}</dt>
|
<dt>{% trans "Expiry date" %}</dt>
|
||||||
<dd>{{ order.expires|date:"SHORT_DATETIME_FORMAT" }}</dd>
|
<dd>{{ order.expires|date:"SHORT_DATETIME_FORMAT" }}</dd>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<dt>{% trans "User" %}</dt>
|
{% if request.organizer.settings.customer_accounts %}
|
||||||
|
<dt>{% trans "Customer account" %}</dt>
|
||||||
|
<dd>
|
||||||
|
{% if order.customer %}
|
||||||
|
<a href="{% url "control:organizer.customer" organizer=request.organizer.slug customer=order.customer.identifier %}">
|
||||||
|
{{ order.customer.identifier }} – {{ order.customer.email }}
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
<a href="{% url "control:event.order.contact" event=request.event.slug organizer=request.event.organizer.slug code=order.code %}" class="btn btn-default btn-xs">
|
||||||
|
<span class="fa fa-edit"></span>
|
||||||
|
</a>
|
||||||
|
</dd>
|
||||||
|
{% endif %}
|
||||||
|
<dt>{% trans "Contact email" %}</dt>
|
||||||
<dd>
|
<dd>
|
||||||
{{ order.email|default_if_none:"" }}
|
{{ order.email|default_if_none:"" }}
|
||||||
{% if order.email and order.email_known_to_work %}
|
{% if order.email and order.email_known_to_work %}
|
||||||
@@ -197,7 +210,7 @@
|
|||||||
</a>
|
</a>
|
||||||
{% if order.status != "c" %}
|
{% if order.status != "c" %}
|
||||||
<form class="form-inline helper-display-inline" method="post"
|
<form class="form-inline helper-display-inline" method="post"
|
||||||
action="{% url "control:event.order.resendlink" event=request.event.slug organizer=request.event.organizer.slug code=order.code %}">
|
action="{% url "control:event.order.resendlink" event=request.event.slug organizer=request.event.organizer.slug code=order.code %}">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
<button class="btn btn-default btn-xs">
|
<button class="btn btn-default btn-xs">
|
||||||
{% trans "Resend link" %}
|
{% trans "Resend link" %}
|
||||||
@@ -336,7 +349,7 @@
|
|||||||
{{ line.seat }}
|
{{ line.seat }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if line.voucher %}
|
{% if line.voucher %}
|
||||||
<br/><span class="fa fa-tags"></span> {% trans "Voucher code used:" %}
|
<br/><span class="fa fa-tags fa-fw"></span> {% trans "Voucher code used:" %}
|
||||||
<a
|
<a
|
||||||
{% if line.price_before_voucher|default_if_none:"NONE" != "NONE" %}data-toggle="tooltip" title="{% blocktrans trimmed with price=line.price_before_voucher|money:request.event.currency %}Original price: {{ price }}{% endblocktrans %}"{% endif %}
|
{% if line.price_before_voucher|default_if_none:"NONE" != "NONE" %}data-toggle="tooltip" title="{% blocktrans trimmed with price=line.price_before_voucher|money:request.event.currency %}Original price: {{ price }}{% endblocktrans %}"{% endif %}
|
||||||
href="{% url "control:event.voucher" event=request.event.slug organizer=request.event.organizer.slug voucher=line.voucher.pk %}">
|
href="{% url "control:event.voucher" event=request.event.slug organizer=request.event.organizer.slug voucher=line.voucher.pk %}">
|
||||||
@@ -345,12 +358,18 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
{% if line.subevent %}
|
{% if line.subevent %}
|
||||||
<br/>
|
<br/>
|
||||||
<span class="fa fa-calendar"></span> {{ line.subevent.name }} · {{ line.subevent.get_date_range_display }}
|
<span class="fa fa-calendar fa-fw"></span> {{ line.subevent.name }} · {{ line.subevent.get_date_range_display }}
|
||||||
{% if event.settings.show_times %}
|
{% if event.settings.show_times %}
|
||||||
<span class="fa fa-clock-o"></span>
|
<span class="fa fa-clock-o"></span>
|
||||||
{{ line.subevent.date_from|date:"TIME_FORMAT" }}
|
{{ line.subevent.date_from|date:"TIME_FORMAT" }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% if line.used_membership %}
|
||||||
|
<br /><span class="fa fa-id-card fa-fw" aria-hidden="true"></span>
|
||||||
|
<a href="{% url "control:organizer.customer.membership.edit" organizer=request.organizer.slug customer=order.customer.identifier id=line.used_membership.pk %}">
|
||||||
|
{{ line.used_membership }}
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
{% if not line.canceled %}
|
{% if not line.canceled %}
|
||||||
<div class="position-buttons">
|
<div class="position-buttons">
|
||||||
{% if line.generate_ticket %}
|
{% if line.generate_ticket %}
|
||||||
|
|||||||
@@ -113,7 +113,7 @@
|
|||||||
<a href="?{% url_replace request 'ordering' '-datetime' %}"><i class="fa fa-caret-down"></i></a>
|
<a href="?{% url_replace request 'ordering' '-datetime' %}"><i class="fa fa-caret-down"></i></a>
|
||||||
<a href="?{% url_replace request 'ordering' 'datetime' %}"><i class="fa fa-caret-up"></i></a>
|
<a href="?{% url_replace request 'ordering' 'datetime' %}"><i class="fa fa-caret-up"></i></a>
|
||||||
</th>
|
</th>
|
||||||
<th class="text-right flip">{% trans "Order paid / total" %}
|
<th class="text-right flip">{% trans "Order paid / total" %}
|
||||||
<a href="?{% url_replace request 'ordering' '-total' %}"><i class="fa fa-caret-down"></i></a>
|
<a href="?{% url_replace request 'ordering' '-total' %}"><i class="fa fa-caret-down"></i></a>
|
||||||
<a href="?{% url_replace request 'ordering' 'total' %}"><i class="fa fa-caret-up"></i></a></th>
|
<a href="?{% url_replace request 'ordering' 'total' %}"><i class="fa fa-caret-up"></i></a></th>
|
||||||
<th class="text-right flip">{% trans "Positions" %}</th>
|
<th class="text-right flip">{% trans "Positions" %}</th>
|
||||||
|
|||||||
@@ -0,0 +1,231 @@
|
|||||||
|
{% extends "pretixcontrol/organizers/base.html" %}
|
||||||
|
{% load i18n %}
|
||||||
|
{% load bootstrap3 %}
|
||||||
|
{% load money %}
|
||||||
|
{% block title %}
|
||||||
|
{% blocktrans trimmed with id=customer.identifier %}
|
||||||
|
Customer #{{ id }}
|
||||||
|
{% endblocktrans %}
|
||||||
|
{% endblock %}
|
||||||
|
{% block inner %}
|
||||||
|
<h1>
|
||||||
|
{% blocktrans trimmed with id=customer.identifier %}
|
||||||
|
Customer #{{ id }}
|
||||||
|
{% endblocktrans %}
|
||||||
|
</h1>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-10 col-xs-12">
|
||||||
|
<div class="panel panel-primary items">
|
||||||
|
<div class="panel-heading">
|
||||||
|
<h3 class="panel-title">
|
||||||
|
{% trans "Details" %}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div class="panel-body">
|
||||||
|
<dl class="dl-horizontal">
|
||||||
|
<dt>{% trans "Customer ID" %}</dt>
|
||||||
|
<dd>#{{ customer.identifier }}</dd>
|
||||||
|
<dt>{% trans "Status" %}</dt>
|
||||||
|
<dd>
|
||||||
|
{% if not customer.is_active %}
|
||||||
|
{% trans "disabled" %}
|
||||||
|
{% elif not customer.is_verified %}
|
||||||
|
{% trans "not yet activated" %}
|
||||||
|
{% else %}
|
||||||
|
{% trans "active" %}
|
||||||
|
{% endif %}
|
||||||
|
</dd>
|
||||||
|
<dt>{% trans "E-mail" %}</dt>
|
||||||
|
<dd>{{ customer.email|default_if_none:"" }}</dd>
|
||||||
|
<dt>{% trans "Name" %}</dt>
|
||||||
|
<dd>{{ customer.name }}</dd>
|
||||||
|
<dt>{% trans "Locale" %}</dt>
|
||||||
|
<dd>{{ display_locale }}</dd>
|
||||||
|
<dt>{% trans "Registration date" %}</dt>
|
||||||
|
<dd>{{ customer.date_joined|date:"SHORT_DATETIME_FORMAT" }}</dd>
|
||||||
|
<dt>{% trans "Last login" %}</dt>
|
||||||
|
<dd>{% if customer.last_login %}{{ customer.last_login|date:"SHORT_DATETIME_FORMAT" }}{% else %}
|
||||||
|
–{% endif %}</dd>
|
||||||
|
</dl>
|
||||||
|
<div class="text-right">
|
||||||
|
<a href="{% url "control:organizer.customer.edit" organizer=request.organizer.slug customer=customer.identifier %}"
|
||||||
|
class="btn btn-default">
|
||||||
|
<i class="fa fa-edit"></i> {% trans "Edit" %}
|
||||||
|
</a>
|
||||||
|
<a href="{% url "control:organizer.customer.anonymize" organizer=request.organizer.slug customer=customer.identifier %}"
|
||||||
|
class="btn btn-danger">
|
||||||
|
<i class="fa fa-trash"></i> {% trans "Anonymize" %}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="panel panel-default items">
|
||||||
|
<div class="panel-heading">
|
||||||
|
<h3 class="panel-title">
|
||||||
|
{% trans "Memberships" %}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<table class="panel-body table table-hover">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{% trans "Membership type" %}</th>
|
||||||
|
<th>{% trans "Valid from" %}</th>
|
||||||
|
<th>{% trans "Valid until" %}</th>
|
||||||
|
<th>{% trans "Order" %}</th>
|
||||||
|
<th>{% trans "Attendee name" %}</th>
|
||||||
|
<th>{% trans "Usages" %}</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for m in memberships %}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
{{ m.membership_type.name }}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{{ m.date_start|date:"SHORT_DATETIME_FORMAT" }}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{{ m.date_end|date:"SHORT_DATETIME_FORMAT" }}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% if m.granted_in %}
|
||||||
|
<a href="{% url "control:event.order" event=m.granted_in.order.event.slug organizer=customer.organizer.slug code=m.granted_in.order.code %}">
|
||||||
|
{{ m.granted_in.order.code }}-{{ m.granted_in.positionid }}
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{{ m.attendee_name }}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="quotabox">
|
||||||
|
<div class="progress">
|
||||||
|
<div class="progress-bar progress-bar-success progress-bar-{{ m.percent }}">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="numbers">
|
||||||
|
{{ m.usages }} /
|
||||||
|
{{ m.membership_type.max_usages|default_if_none:"∞" }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="text-right flip">
|
||||||
|
<a href="{% url "control:organizer.customer.membership.edit" organizer=request.organizer.slug customer=customer.identifier id=m.pk %}"
|
||||||
|
data-toggle="tooltip"
|
||||||
|
title="{% trans "Edit" %}"
|
||||||
|
class="btn btn-default">
|
||||||
|
<i class="fa fa-edit"></i>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
<tfoot>
|
||||||
|
<tr>
|
||||||
|
<td colspan="7">
|
||||||
|
<a href="{% url "control:organizer.customer.membership.add" organizer=request.organizer.slug customer=customer.identifier %}"
|
||||||
|
class="btn btn-default">
|
||||||
|
<i class="fa fa-plus"></i>
|
||||||
|
{% trans "Add membership" %}
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="panel panel-default items">
|
||||||
|
<div class="panel-heading">
|
||||||
|
<h3 class="panel-title">
|
||||||
|
{% trans "Orders" %}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<table class="panel-body table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{% trans "Order code" %}</th>
|
||||||
|
<th>{% trans "Event" %}</th>
|
||||||
|
<th>{% trans "Order date" %}</th>
|
||||||
|
<th class="text-right">{% trans "Order paid / total" %}</th>
|
||||||
|
<th class="text-right">{% trans "Positions" %}</th>
|
||||||
|
<th class="text-right">{% trans "Status" %}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for o in orders %}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<strong>
|
||||||
|
<a href="{% url "control:event.order" event=o.event.slug organizer=customer.organizer.slug code=o.code %}">
|
||||||
|
{{ o.code }}
|
||||||
|
</a>
|
||||||
|
</strong>
|
||||||
|
{% if o.testmode %}
|
||||||
|
<span class="label label-warning">{% trans "TEST MODE" %}</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{{ o.event }}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="fa fa-{{ o.sales_channel_obj.icon }} text-muted"
|
||||||
|
data-toggle="tooltip" title="{% trans o.sales_channel_obj.verbose_name %}"></span>
|
||||||
|
{{ o.datetime|date:"SHORT_DATETIME_FORMAT" }}
|
||||||
|
{% if o.customer_id != customer.pk %}
|
||||||
|
<span class="fa fa-link text-muted"
|
||||||
|
data-toggle="tooltip"
|
||||||
|
title="{% trans "Matched to the account based on the email address." %}"
|
||||||
|
></span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="text-right flip">
|
||||||
|
{% if o.has_cancellation_request %}
|
||||||
|
<span class="label label-warning">{% trans "CANCELLATION REQUESTED" %}</span>
|
||||||
|
{% endif %}
|
||||||
|
{% if o.has_external_refund or o.has_pending_refund %}
|
||||||
|
<span class="label label-danger">{% trans "REFUND PENDING" %}</span>
|
||||||
|
{% elif o.has_pending_refund %}
|
||||||
|
<span class="label label-warning">{% trans "REFUND PENDING" %}</span>
|
||||||
|
{% endif %}
|
||||||
|
{% if o.is_overpaid %}
|
||||||
|
<span class="label label-warning">{% trans "OVERPAID" %}</span>
|
||||||
|
{% elif o.is_underpaid %}
|
||||||
|
<span class="label label-danger">{% trans "UNDERPAID" %}</span>
|
||||||
|
{% elif o.is_pending_with_full_payment %}
|
||||||
|
<span class="label label-danger">{% trans "FULLY PAID" %}</span>
|
||||||
|
{% endif %}
|
||||||
|
{% if o.computed_payment_refund_sum == o.total or o.computed_payment_refund_sum == 0 %}
|
||||||
|
<span class="text-muted">
|
||||||
|
{% endif %}
|
||||||
|
{{ o.computed_payment_refund_sum|money:o.event.currency }} /
|
||||||
|
{% if o.computed_payment_refund_sum == o.total or o.computed_payment_refund_sum == 0 %}
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
{{ o.total|money:o.event.currency }}
|
||||||
|
{% if o.status == "c" and o.icnt %}
|
||||||
|
<br>
|
||||||
|
<span class="label label-warning">{% trans "INVOICE NOT CANCELED" %}</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="text-right flip">{{ o.pcnt|default_if_none:"0" }}</td>
|
||||||
|
<td class="text-right flip">{% include "pretixcontrol/orders/fragment_order_status.html" with order=o %}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% include "pretixcontrol/pagination.html" %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2 col-xs-12">
|
||||||
|
<div class="panel panel-default">
|
||||||
|
<div class="panel-heading">
|
||||||
|
<h3 class="panel-title">
|
||||||
|
{% trans "Customer history" %}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
{% include "pretixcontrol/includes/logs.html" with obj=customer %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
{% extends "pretixcontrol/organizers/base.html" %}
|
||||||
|
{% load i18n %}
|
||||||
|
{% load bootstrap3 %}
|
||||||
|
{% block title %}
|
||||||
|
{% blocktrans trimmed with id=customer.identifier %}
|
||||||
|
Anonymize customer #{{ id }}
|
||||||
|
{% endblocktrans %}
|
||||||
|
{% endblock %}
|
||||||
|
{% block inner %}
|
||||||
|
<h1>
|
||||||
|
{% blocktrans trimmed with id=customer.identifier %}
|
||||||
|
Anonymize customer #{{ id }}
|
||||||
|
{% endblocktrans %}
|
||||||
|
</h1>
|
||||||
|
<p>
|
||||||
|
{% trans "Are you sure you want to anonymize this customer account?" %}
|
||||||
|
</p>
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
{% trans "All orders will be disconnected from this customer account." %}
|
||||||
|
<strong>
|
||||||
|
{% trans "The orders themselves will not be anonymized and can still contain personal information!" %}
|
||||||
|
</strong>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
{% trans "The customer will no longer be ble to log in and will lose access to any membership benefits." %}
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
{% trans "This action is irreversible." %}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<form action="" method="post">
|
||||||
|
{% csrf_token %}
|
||||||
|
<div class="form-group submit-group">
|
||||||
|
<a href="{% url "control:organizer.customer" organizer=request.organizer.slug customer=customer.identifier %}" class="btn btn-default btn-cancel">
|
||||||
|
{% trans "Cancel" %}
|
||||||
|
</a>
|
||||||
|
<button type="submit" class="btn btn-danger btn-save">
|
||||||
|
{% trans "Anonymize" %}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
{% extends "pretixcontrol/organizers/base.html" %}
|
||||||
|
{% load i18n %}
|
||||||
|
{% load bootstrap3 %}
|
||||||
|
{% block title %}
|
||||||
|
{% blocktrans trimmed with id=customer.identifier %}
|
||||||
|
Customer #{{ id }}
|
||||||
|
{% endblocktrans %}
|
||||||
|
{% endblock %}
|
||||||
|
{% block inner %}
|
||||||
|
<h1>
|
||||||
|
{% blocktrans trimmed with id=customer.identifier %}
|
||||||
|
Customer #{{ id }}
|
||||||
|
{% endblocktrans %}
|
||||||
|
</h1>
|
||||||
|
<form class="form-horizontal" action="" method="post">
|
||||||
|
{% csrf_token %}
|
||||||
|
{% bootstrap_form form layout="control" %}
|
||||||
|
<div class="form-group submit-group">
|
||||||
|
<button type="submit" class="btn btn-primary btn-save">
|
||||||
|
{% trans "Save" %}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
{% extends "pretixcontrol/organizers/base.html" %}
|
||||||
|
{% load i18n %}
|
||||||
|
{% load bootstrap3 %}
|
||||||
|
{% block title %}
|
||||||
|
{% trans "Membership" %}
|
||||||
|
{% endblock %}
|
||||||
|
{% block inner %}
|
||||||
|
<h1>
|
||||||
|
{% trans "Membership" %}
|
||||||
|
</h1>
|
||||||
|
<div class="panel panel-primary items">
|
||||||
|
<div class="panel-heading">
|
||||||
|
<h3 class="panel-title">
|
||||||
|
{% trans "Details" %}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div class="panel-body">
|
||||||
|
<form class="form-horizontal" action="" method="post">
|
||||||
|
{% csrf_token %}
|
||||||
|
{% bootstrap_form form layout="control" %}
|
||||||
|
<div class="form-group">
|
||||||
|
<div class="col-md-offset-3 col-md-9">
|
||||||
|
<button type="submit" class="btn btn-primary btn-lg">
|
||||||
|
{% trans "Save" %}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="panel panel-default items">
|
||||||
|
<div class="panel-heading">
|
||||||
|
<h3 class="panel-title">
|
||||||
|
{% trans "Usages" %}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<table class="panel-body table table-hover">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{% trans "Order code" %}</th>
|
||||||
|
<th>{% trans "Event" %}</th>
|
||||||
|
<th>{% trans "Date" context "subevent" %}</th>
|
||||||
|
<th>{% trans "Product" %}</th>
|
||||||
|
<th>{% trans "Order date" %}</th>
|
||||||
|
<th class="text-right">{% trans "Status" %}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for op in usages %}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<strong>
|
||||||
|
<a href="{% url "control:event.order" event=op.order.event.slug organizer=membership.customer.organizer.slug code=op.order.code %}">
|
||||||
|
{{ op.order.code }}</a>-{{ op.positionid }}
|
||||||
|
</strong>
|
||||||
|
{% if op.order.testmode %}
|
||||||
|
<span class="label label-warning">{% trans "TEST MODE" %}</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{{ op.order.event }}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{{ op.subevent|default:"" }}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{{ op.item }}
|
||||||
|
{% if op.variation %}– {{ op.variation }}{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{{ op.order.datetime|date:"SHORT_DATETIME_FORMAT" }}
|
||||||
|
</td>
|
||||||
|
<td class="text-right flip">
|
||||||
|
{% if op.canceled %}
|
||||||
|
<span class="label label-danger">
|
||||||
|
<span class="fa fa-times"></span>
|
||||||
|
{% trans "Canceled" %}
|
||||||
|
</span>
|
||||||
|
{% else %}
|
||||||
|
{% include "pretixcontrol/orders/fragment_order_status.html" with order=op.order %}
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
{% extends "pretixcontrol/organizers/base.html" %}
|
||||||
|
{% load i18n %}
|
||||||
|
{% load urlreplace %}
|
||||||
|
{% load bootstrap3 %}
|
||||||
|
{% load money %}
|
||||||
|
{% block title %}{% trans "Customers" %}{% endblock %}
|
||||||
|
{% block inner %}
|
||||||
|
<h1>
|
||||||
|
{% trans "Customers" %}
|
||||||
|
</h1>
|
||||||
|
{% if customers|length == 0 and not filter_form.filtered %}
|
||||||
|
<div class="empty-collection">
|
||||||
|
<p>
|
||||||
|
{% blocktrans trimmed %}
|
||||||
|
No customer accounts have been created yet.
|
||||||
|
{% endblocktrans %}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<form class="row filter-form" action="" method="get">
|
||||||
|
<div class="col-md-10 col-sm-6 col-xs-12">
|
||||||
|
{% bootstrap_field filter_form.query layout='inline' %}
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2 col-sm-6 col-xs-12">
|
||||||
|
<button class="btn btn-primary btn-block" type="submit">
|
||||||
|
<span class="fa fa-filter"></span>
|
||||||
|
<span class="hidden-md">
|
||||||
|
{% trans "Filter" %}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-condensed table-hover">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{% trans "Customer ID" %}
|
||||||
|
<a href="?{% url_replace request 'ordering' '-identifier' %}"><i
|
||||||
|
class="fa fa-caret-down"></i></a>
|
||||||
|
<a href="?{% url_replace request 'ordering' 'identifier' %}"><i class="fa fa-caret-up"></i></a>
|
||||||
|
</th>
|
||||||
|
<th>{% trans "Email" %}
|
||||||
|
<a href="?{% url_replace request 'ordering' '-email' %}"><i class="fa fa-caret-down"></i></a>
|
||||||
|
<a href="?{% url_replace request 'ordering' 'email' %}"><i class="fa fa-caret-up"></i></a></th>
|
||||||
|
<th>{% trans "Name" %}
|
||||||
|
<a href="?{% url_replace request 'ordering' '-name' %}"><i class="fa fa-caret-down"></i></a>
|
||||||
|
<a href="?{% url_replace request 'ordering' 'name' %}"><i class="fa fa-caret-up"></i></a></th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for c in customers %}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<a href="{% url "control:organizer.customer" organizer=request.organizer.slug customer=c.identifier %}">
|
||||||
|
{% if not c.is_active %}<strike>{% endif %}
|
||||||
|
<strong>#{{ c.identifier }}</strong>
|
||||||
|
{% if not c.is_active %}</strike>{% endif %}
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% if not c.is_verified %}<strike>{% endif %}
|
||||||
|
{{ c.email|default_if_none:"" }}
|
||||||
|
{% if not c.is_verified %}</strike>{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>{{ c.name }}</td>
|
||||||
|
<td class="text-right">
|
||||||
|
<a href="{% url "control:organizer.customer" organizer=request.organizer.slug customer=c.identifier %}"
|
||||||
|
class="btn btn-default btn-sm" data-toggle="tooltip" title="{% trans "Details" %}">
|
||||||
|
<i class="fa fa-eye"></i>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% include "pretixcontrol/pagination.html" %}
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
@@ -50,6 +50,13 @@
|
|||||||
<legend>{% trans "Localization" %}</legend>
|
<legend>{% trans "Localization" %}</legend>
|
||||||
{% bootstrap_field sform.locales layout="control" %}
|
{% bootstrap_field sform.locales layout="control" %}
|
||||||
{% bootstrap_field sform.region layout="control" %}
|
{% bootstrap_field sform.region layout="control" %}
|
||||||
|
{% bootstrap_field sform.timezone layout="control" %}
|
||||||
|
</fieldset>
|
||||||
|
<fieldset>
|
||||||
|
<legend>{% trans "Customer accounts" %}</legend>
|
||||||
|
{% bootstrap_field sform.customer_accounts layout="control" %}
|
||||||
|
{% bootstrap_field sform.name_scheme layout="control" %}
|
||||||
|
{% bootstrap_field sform.name_scheme_titles layout="control" %}
|
||||||
</fieldset>
|
</fieldset>
|
||||||
<fieldset>
|
<fieldset>
|
||||||
<legend>{% trans "Shop design" %}</legend>
|
<legend>{% trans "Shop design" %}</legend>
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
{% extends "pretixcontrol/base.html" %}
|
||||||
|
{% load i18n %}
|
||||||
|
{% load bootstrap3 %}
|
||||||
|
{% load formset_tags %}
|
||||||
|
{% block custom_header %}
|
||||||
|
{{ block.super }}
|
||||||
|
<link type="text/css" rel="stylesheet" href="{% url "control:pdf.css" %}">
|
||||||
|
{% endblock %}
|
||||||
|
{% block title %}{% trans "Organizer" %}{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<h1>{% trans "E-mail settings" %}</h1>
|
||||||
|
|
||||||
|
<form action="" method="post" class="form-horizontal" enctype="multipart/form-data"
|
||||||
|
mail-preview-url="{% url "control:organizer.settings.mail.preview" organizer=request.organizer.slug %}">
|
||||||
|
{% csrf_token %}
|
||||||
|
{% bootstrap_form_errors form %}
|
||||||
|
<div class="tabbed-form">
|
||||||
|
<fieldset>
|
||||||
|
<legend>{% trans "General" %}</legend>
|
||||||
|
{% bootstrap_field form.mail_from layout="control" %}
|
||||||
|
{% bootstrap_field form.mail_from_name layout="control" %}
|
||||||
|
{% bootstrap_field form.mail_text_signature layout="control" %}
|
||||||
|
{% bootstrap_field form.mail_bcc layout="control" %}
|
||||||
|
</fieldset>
|
||||||
|
<fieldset>
|
||||||
|
<legend>{% trans "E-mail content" %}</legend>
|
||||||
|
<div class="panel-group" id="questions_group">
|
||||||
|
{% blocktrans asvar title_customer_registration %}Customer account registration{% endblocktrans %}
|
||||||
|
{% include "pretixcontrol/event/mail_settings_fragment.html" with pid="customer_registration" title=title_customer_registration items="mail_text_customer_registration" %}
|
||||||
|
|
||||||
|
{% blocktrans asvar title_email_change %}Customer account email change{% endblocktrans %}
|
||||||
|
{% include "pretixcontrol/event/mail_settings_fragment.html" with pid="email_change" title=title_email_change items="mail_text_customer_email_change" %}
|
||||||
|
|
||||||
|
{% blocktrans asvar title_reset %}Customer account password reset{% endblocktrans %}
|
||||||
|
{% include "pretixcontrol/event/mail_settings_fragment.html" with pid="reset" title=title_reset items="mail_text_customer_reset" %}
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
<fieldset>
|
||||||
|
<legend>{% trans "SMTP settings" %}</legend>
|
||||||
|
{% bootstrap_field form.smtp_use_custom layout="control" %}
|
||||||
|
{% bootstrap_field form.smtp_host layout="control" %}
|
||||||
|
{% bootstrap_field form.smtp_port layout="control" %}
|
||||||
|
{% bootstrap_field form.smtp_username layout="control" %}
|
||||||
|
{% bootstrap_field form.smtp_password layout="control" %}
|
||||||
|
{% bootstrap_field form.smtp_use_tls layout="control" %}
|
||||||
|
{% bootstrap_field form.smtp_use_ssl layout="control" %}
|
||||||
|
</fieldset>
|
||||||
|
</div>
|
||||||
|
<div class="form-group submit-group">
|
||||||
|
<button type="submit" class="btn btn-primary btn-save">
|
||||||
|
{% trans "Save" %}
|
||||||
|
</button>
|
||||||
|
<button type="submit" class="btn btn-default btn-save pull-left" name="test" value="1">
|
||||||
|
{% trans "Save and test custom SMTP connection" %}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
{% extends "pretixcontrol/organizers/base.html" %}
|
||||||
|
{% load i18n %}
|
||||||
|
{% load bootstrap3 %}
|
||||||
|
{% block inner %}
|
||||||
|
<h1>{% trans "Delete membership type:" %} {{ type.name }}</h1>
|
||||||
|
<form action="" method="post" class="form-horizontal">
|
||||||
|
{% csrf_token %}
|
||||||
|
{% if is_allowed %}
|
||||||
|
<p>{% blocktrans %}Are you sure you want to delete this membership type?{% endblocktrans %}
|
||||||
|
{% else %}
|
||||||
|
<p>{% blocktrans %}This membership type cannot be deleted since it has already been used.{% endblocktrans %}
|
||||||
|
{% endif %}
|
||||||
|
</p>
|
||||||
|
<div class="form-group submit-group">
|
||||||
|
<a href="{% url "control:organizer.membershiptypes" organizer=request.organizer.slug %}" class="btn btn-default btn-cancel">
|
||||||
|
{% trans "Cancel" %}
|
||||||
|
</a>
|
||||||
|
{% if is_allowed %}
|
||||||
|
<button type="submit" class="btn btn-danger btn-save">
|
||||||
|
{% trans "Delete" %}
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
{% extends "pretixcontrol/organizers/base.html" %}
|
||||||
|
{% load i18n %}
|
||||||
|
{% load bootstrap3 %}
|
||||||
|
{% block inner %}
|
||||||
|
{% if type %}
|
||||||
|
<h1>{% trans "Membership type:" %} {{ type.name }}</h1>
|
||||||
|
{% else %}
|
||||||
|
<h1>{% trans "Create a new membership type" %}</h1>
|
||||||
|
{% endif %}
|
||||||
|
<form class="form-horizontal" action="" method="post">
|
||||||
|
{% csrf_token %}
|
||||||
|
{% bootstrap_form form layout="control" %}
|
||||||
|
<div class="form-group submit-group">
|
||||||
|
<button type="submit" class="btn btn-primary btn-save">
|
||||||
|
{% trans "Save" %}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</form>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
{% extends "pretixcontrol/organizers/base.html" %}
|
||||||
|
{% load i18n %}
|
||||||
|
{% load bootstrap3 %}
|
||||||
|
{% block title %}{% trans "Membership types" %}{% endblock %}
|
||||||
|
{% block inner %}
|
||||||
|
<h1>{% trans "Membership types" %}</h1>
|
||||||
|
<p>
|
||||||
|
{% blocktrans trimmed %}
|
||||||
|
You can define membership types. These allow you to link products from different events
|
||||||
|
together. You can sell a membership as part of a a product in one event, and require valid
|
||||||
|
memberships to allow purchases in another event.
|
||||||
|
{% endblocktrans %}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
{% blocktrans trimmed %}
|
||||||
|
This can be used to enable products like year passes, tickets of ten, etc.
|
||||||
|
{% endblocktrans %}
|
||||||
|
</p>
|
||||||
|
<a href="{% url "control:organizer.membershiptype.add" organizer=request.organizer.slug %}" class="btn btn-default">
|
||||||
|
<span class="fa fa-plus"></span>
|
||||||
|
{% trans "Create a new membership type" %}
|
||||||
|
</a>
|
||||||
|
<table class="table table-condensed table-hover">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{% trans "Name" %}</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for t in types %}
|
||||||
|
<tr>
|
||||||
|
<td><strong>
|
||||||
|
<a href="{% url "control:organizer.membershiptype.edit" organizer=request.organizer.slug type=t.id %}">
|
||||||
|
{{ t.name }}
|
||||||
|
</a>
|
||||||
|
</strong></td>
|
||||||
|
<td class="text-right flip">
|
||||||
|
<a href="{% url "control:organizer.membershiptype.edit" organizer=request.organizer.slug type=t.id %}"
|
||||||
|
class="btn btn-default btn-sm"><i class="fa fa-edit"></i></a>
|
||||||
|
<a href="{% url "control:organizer.membershiptype.delete" organizer=request.organizer.slug type=t.id %}"
|
||||||
|
class="btn btn-danger btn-sm"><i class="fa fa-trash"></i></a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% endblock %}
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
{% load i18n %}
|
{% load i18n %}
|
||||||
{% load bootstrap3 %}
|
{% load bootstrap3 %}
|
||||||
{% block inner %}
|
{% block inner %}
|
||||||
<h1>{% trans "Delete property:" %} {{ gate.name }}</h1>
|
<h1>{% trans "Delete property:" %} {{ type.name }}</h1>
|
||||||
<form action="" method="post" class="form-horizontal">
|
<form action="" method="post" class="form-horizontal">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
<p>{% blocktrans %}Are you sure you want to delete the property?{% endblocktrans %}
|
<p>{% blocktrans %}Are you sure you want to delete the property?{% endblocktrans %}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
{% load i18n %}
|
{% load i18n %}
|
||||||
{% load bootstrap3 %}
|
{% load bootstrap3 %}
|
||||||
{% block inner %}
|
{% block inner %}
|
||||||
{% if gate %}
|
{% if property %}
|
||||||
<h1>{% trans "Property:" %} {{ property.name }}</h1>
|
<h1>{% trans "Property:" %} {{ property.name }}</h1>
|
||||||
{% else %}
|
{% else %}
|
||||||
<h1>{% trans "Create a new property" %}</h1>
|
<h1>{% trans "Create a new property" %}</h1>
|
||||||
|
|||||||
@@ -23,6 +23,7 @@
|
|||||||
<legend>{% trans "Organizer permissions" %}</legend>
|
<legend>{% trans "Organizer permissions" %}</legend>
|
||||||
{% bootstrap_field form.can_create_events layout="control" %}
|
{% bootstrap_field form.can_create_events layout="control" %}
|
||||||
{% bootstrap_field form.can_manage_gift_cards layout="control" %}
|
{% bootstrap_field form.can_manage_gift_cards layout="control" %}
|
||||||
|
{% bootstrap_field form.can_manage_customers layout="control" %}
|
||||||
{% bootstrap_field form.can_change_teams layout="control" %}
|
{% bootstrap_field form.can_change_teams layout="control" %}
|
||||||
{% bootstrap_field form.can_change_organizer_settings layout="control" %}
|
{% bootstrap_field form.can_change_organizer_settings layout="control" %}
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|||||||
@@ -42,39 +42,41 @@ class PropagatedNode(Node):
|
|||||||
|
|
||||||
if all([fn not in event.settings._cache() for fn in self.field_names]):
|
if all([fn not in event.settings._cache() for fn in self.field_names]):
|
||||||
body = """
|
body = """
|
||||||
<div class="propagated-settings-box">
|
<div class="propagated-settings-box locked panel panel-default">
|
||||||
<input type="hidden" name="_settings_ignore" value="{fnames}">
|
<div class="panel-heading">
|
||||||
<div class="propagated-settings-form blurred">
|
<input type="hidden" name="_settings_ignore" value="{fnames}">
|
||||||
{body}
|
<button class="btn btn-default pull-right btn-xs" name="decouple" value="{fnames}" data-action="unlink">
|
||||||
</div>
|
<span class="fa fa-unlock"></span> {text_unlink}
|
||||||
<div class="propagated-settings-overlay">
|
|
||||||
<h4><span class="fa fa-link"></span> {text_inh}</h4>
|
|
||||||
<p>
|
|
||||||
{text_expl}
|
|
||||||
</p>
|
|
||||||
<button class="btn btn-default" name="decouple" value="{fnames}" data-action="unlink">
|
|
||||||
<span class="fa fa-unlink"></span> {text_unlink}
|
|
||||||
</button>
|
</button>
|
||||||
<a class="btn btn-default" href="{url}" target="_blank">
|
<h4 class="panel-title">
|
||||||
<span class="fa fa-group"></span> {text_orga}
|
<span class="fa fa-lock"></span> {text_inh}
|
||||||
|
</h4>
|
||||||
|
</div>
|
||||||
|
<div class="panel-body help-text">
|
||||||
|
{text_expl}<br>
|
||||||
|
<a href="{url}" target="_blank">
|
||||||
|
{text_orga}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="panel-body propagated-settings-form">
|
||||||
|
{body}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
""".format(
|
""".format(
|
||||||
body=body,
|
body=body,
|
||||||
text_inh=_("Organizer-level settings") if isinstance(event, Event) else _('Site-level settings'),
|
text_inh=_("Currently set on organizer level") if isinstance(event, Event) else _('Currently set on global level'),
|
||||||
fnames=','.join(self.field_names),
|
fnames=','.join(self.field_names),
|
||||||
text_expl=_(
|
text_expl=_(
|
||||||
'These settings are currently set on organizer level. This way, you can easily change them for '
|
'These settings are currently set on organizer level. This way, you can easily change them for '
|
||||||
'all of your events at the same time. You can either go to the organizer settings to change them '
|
'all of your events at the same time. You can either go to the organizer settings to change them for all your events '
|
||||||
'or decouple them from the organizer account to change them for this event individually.'
|
'or you can unlock them to change them for this event individually.'
|
||||||
) if isinstance(event, Event) else _(
|
) if isinstance(event, Event) else _(
|
||||||
'These settings are currently set on global level. This way, you can easily change them for '
|
'These settings are currently set on global level. This way, you can easily change them for '
|
||||||
'all organizers at the same time. You can either go to the global settings to change them '
|
'all organizers at the same time. You can either go to the global settings to change them for all your organizers '
|
||||||
'or decouple them from the global settings to change them for this event individually.'
|
'or you can unlock them to change them for this event individually.'
|
||||||
),
|
),
|
||||||
text_unlink=_('Change only for this event') if isinstance(event, Event) else _('Change only for this organizer'),
|
text_unlink=_('Unlock'),
|
||||||
text_orga=_('Change for all events') if isinstance(event, Event) else _('Change for all organizers'),
|
text_orga=_('Go to organizer settings') if isinstance(event, Event) else _('Go to global settings'),
|
||||||
url=url
|
url=url
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -110,6 +110,10 @@ urlpatterns = [
|
|||||||
url(r'^organizers/select2$', typeahead.organizer_select2, name='organizers.select2'),
|
url(r'^organizers/select2$', typeahead.organizer_select2, name='organizers.select2'),
|
||||||
url(r'^organizer/(?P<organizer>[^/]+)/$', organizer.OrganizerDetail.as_view(), name='organizer'),
|
url(r'^organizer/(?P<organizer>[^/]+)/$', organizer.OrganizerDetail.as_view(), name='organizer'),
|
||||||
url(r'^organizer/(?P<organizer>[^/]+)/edit$', organizer.OrganizerUpdate.as_view(), name='organizer.edit'),
|
url(r'^organizer/(?P<organizer>[^/]+)/edit$', organizer.OrganizerUpdate.as_view(), name='organizer.edit'),
|
||||||
|
url(r'^organizer/(?P<organizer>[^/]+)/settings/email$',
|
||||||
|
organizer.OrganizerMailSettings.as_view(), name='organizer.settings.mail'),
|
||||||
|
url(r'^organizer/(?P<organizer>[^/]+)/settings/email/preview$',
|
||||||
|
organizer.MailSettingsPreview.as_view(), name='organizer.settings.mail.preview'),
|
||||||
url(r'^organizer/(?P<organizer>[^/]+)/delete$', organizer.OrganizerDelete.as_view(), name='organizer.delete'),
|
url(r'^organizer/(?P<organizer>[^/]+)/delete$', organizer.OrganizerDelete.as_view(), name='organizer.delete'),
|
||||||
url(r'^organizer/(?P<organizer>[^/]+)/settings/display$', organizer.OrganizerDisplaySettings.as_view(),
|
url(r'^organizer/(?P<organizer>[^/]+)/settings/display$', organizer.OrganizerDisplaySettings.as_view(),
|
||||||
name='organizer.display'),
|
name='organizer.display'),
|
||||||
@@ -120,6 +124,25 @@ urlpatterns = [
|
|||||||
name='organizer.property.edit'),
|
name='organizer.property.edit'),
|
||||||
url(r'^organizer/(?P<organizer>[^/]+)/property/(?P<property>[^/]+)/delete$', organizer.EventMetaPropertyDeleteView.as_view(),
|
url(r'^organizer/(?P<organizer>[^/]+)/property/(?P<property>[^/]+)/delete$', organizer.EventMetaPropertyDeleteView.as_view(),
|
||||||
name='organizer.property.delete'),
|
name='organizer.property.delete'),
|
||||||
|
url(r'^organizer/(?P<organizer>[^/]+)/membershiptypes$', organizer.MembershipTypeListView.as_view(), name='organizer.membershiptypes'),
|
||||||
|
url(r'^organizer/(?P<organizer>[^/]+)/membershiptype/add$', organizer.MembershipTypeCreateView.as_view(),
|
||||||
|
name='organizer.membershiptype.add'),
|
||||||
|
url(r'^organizer/(?P<organizer>[^/]+)/membershiptype/(?P<type>[^/]+)/edit$', organizer.MembershipTypeUpdateView.as_view(),
|
||||||
|
name='organizer.membershiptype.edit'),
|
||||||
|
url(r'^organizer/(?P<organizer>[^/]+)/membershiptype/(?P<type>[^/]+)/delete$', organizer.MembershipTypeDeleteView.as_view(),
|
||||||
|
name='organizer.membershiptype.delete'),
|
||||||
|
url(r'^organizer/(?P<organizer>[^/]+)/customers$', organizer.CustomerListView.as_view(), name='organizer.customers'),
|
||||||
|
url(r'^organizer/(?P<organizer>[^/]+)/customers/select2$', typeahead.customer_select2, name='organizer.customers.select2'),
|
||||||
|
url(r'^organizer/(?P<organizer>[^/]+)/customer/(?P<customer>[^/]+)/$',
|
||||||
|
organizer.CustomerDetailView.as_view(), name='organizer.customer'),
|
||||||
|
url(r'^organizer/(?P<organizer>[^/]+)/customer/(?P<customer>[^/]+)/edit$',
|
||||||
|
organizer.CustomerUpdateView.as_view(), name='organizer.customer.edit'),
|
||||||
|
url(r'^organizer/(?P<organizer>[^/]+)/customer/(?P<customer>[^/]+)/membership/add$',
|
||||||
|
organizer.MembershipCreateView.as_view(), name='organizer.customer.membership.add'),
|
||||||
|
url(r'^organizer/(?P<organizer>[^/]+)/customer/(?P<customer>[^/]+)/membership/(?P<id>[^/]+)/edit$',
|
||||||
|
organizer.MembershipUpdateView.as_view(), name='organizer.customer.membership.edit'),
|
||||||
|
url(r'^organizer/(?P<organizer>[^/]+)/customer/(?P<customer>[^/]+)/anonymize$',
|
||||||
|
organizer.CustomerAnonymizeView.as_view(), name='organizer.customer.anonymize'),
|
||||||
url(r'^organizer/(?P<organizer>[^/]+)/giftcards$', organizer.GiftCardListView.as_view(), name='organizer.giftcards'),
|
url(r'^organizer/(?P<organizer>[^/]+)/giftcards$', organizer.GiftCardListView.as_view(), name='organizer.giftcards'),
|
||||||
url(r'^organizer/(?P<organizer>[^/]+)/giftcard/add$', organizer.GiftCardCreateView.as_view(), name='organizer.giftcard.add'),
|
url(r'^organizer/(?P<organizer>[^/]+)/giftcard/add$', organizer.GiftCardCreateView.as_view(), name='organizer.giftcard.add'),
|
||||||
url(r'^organizer/(?P<organizer>[^/]+)/giftcard/(?P<giftcard>[^/]+)/$', organizer.GiftCardDetailView.as_view(), name='organizer.giftcard'),
|
url(r'^organizer/(?P<organizer>[^/]+)/giftcard/(?P<giftcard>[^/]+)/$', organizer.GiftCardDetailView.as_view(), name='organizer.giftcard'),
|
||||||
|
|||||||
@@ -277,7 +277,7 @@ class Forgot(TemplateView):
|
|||||||
rc.setex('pretix_pwreset_%s' % (user.id), 3600 * 24, '1')
|
rc.setex('pretix_pwreset_%s' % (user.id), 3600 * 24, '1')
|
||||||
|
|
||||||
except User.DoesNotExist:
|
except User.DoesNotExist:
|
||||||
logger.warning('Password reset for unregistered e-mail \"' + email + '\"requested.')
|
logger.warning('Password reset for unregistered e-mail \"' + email + '\" requested.')
|
||||||
|
|
||||||
except SendMailException:
|
except SendMailException:
|
||||||
logger.exception('Sending password reset e-mail to \"' + email + '\" failed.')
|
logger.exception('Sending password reset e-mail to \"' + email + '\" failed.')
|
||||||
|
|||||||
@@ -336,7 +336,7 @@ class OrderDetail(OrderView):
|
|||||||
cartpos = queryset.order_by(
|
cartpos = queryset.order_by(
|
||||||
'item', 'variation'
|
'item', 'variation'
|
||||||
).select_related(
|
).select_related(
|
||||||
'item', 'variation', 'addon_to', 'tax_rule'
|
'item', 'variation', 'addon_to', 'tax_rule', 'used_membership', 'used_membership__membership_type'
|
||||||
).prefetch_related(
|
).prefetch_related(
|
||||||
'item__questions', 'issued_gift_cards',
|
'item__questions', 'issued_gift_cards',
|
||||||
Prefetch('answers', queryset=QuestionAnswer.objects.prefetch_related('options').select_related('question')),
|
Prefetch('answers', queryset=QuestionAnswer.objects.prefetch_related('options').select_related('question')),
|
||||||
@@ -1568,7 +1568,10 @@ class OrderChange(OrderView):
|
|||||||
|
|
||||||
@cached_property
|
@cached_property
|
||||||
def positions(self):
|
def positions(self):
|
||||||
positions = list(self.order.positions.select_related('item', 'item__tax_rule'))
|
positions = list(self.order.positions.select_related(
|
||||||
|
'item', 'item__tax_rule', 'used_membership', 'used_membership__membership_type', 'tax_rule',
|
||||||
|
'seat', 'subevent',
|
||||||
|
))
|
||||||
for p in positions:
|
for p in positions:
|
||||||
p.form = OrderPositionChangeForm(prefix='op-{}'.format(p.pk), instance=p, items=self.items,
|
p.form = OrderPositionChangeForm(prefix='op-{}'.format(p.pk), instance=p, items=self.items,
|
||||||
initial={'seat': p.seat.seat_guid if p.seat else None},
|
initial={'seat': p.seat.seat_guid if p.seat else None},
|
||||||
@@ -1616,7 +1619,8 @@ class OrderChange(OrderView):
|
|||||||
f.cleaned_data['price'],
|
f.cleaned_data['price'],
|
||||||
f.cleaned_data.get('addon_to'),
|
f.cleaned_data.get('addon_to'),
|
||||||
f.cleaned_data.get('subevent'),
|
f.cleaned_data.get('subevent'),
|
||||||
f.cleaned_data.get('seat'))
|
f.cleaned_data.get('seat'),
|
||||||
|
f.cleaned_data.get('used_membership'))
|
||||||
except OrderError as e:
|
except OrderError as e:
|
||||||
f.custom_error = str(e)
|
f.custom_error = str(e)
|
||||||
return False
|
return False
|
||||||
@@ -1685,6 +1689,12 @@ class OrderChange(OrderView):
|
|||||||
if p.form.cleaned_data['price'] is not None and p.form.cleaned_data['price'] != p.price:
|
if p.form.cleaned_data['price'] is not None and p.form.cleaned_data['price'] != p.price:
|
||||||
ocm.change_price(p, p.form.cleaned_data['price'])
|
ocm.change_price(p, p.form.cleaned_data['price'])
|
||||||
|
|
||||||
|
if p.form.cleaned_data['used_membership'] is not None and p.form.cleaned_data['used_membership'] != (p.used_membership or 'CLEAR'):
|
||||||
|
if p.form.cleaned_data['used_membership'] == 'CLEAR':
|
||||||
|
ocm.change_membership(p, None)
|
||||||
|
else:
|
||||||
|
ocm.change_membership(p, p.form.cleaned_data['used_membership'])
|
||||||
|
|
||||||
if p.form.cleaned_data['tax_rule'] and p.form.cleaned_data['tax_rule'] != p.tax_rule:
|
if p.form.cleaned_data['tax_rule'] and p.form.cleaned_data['tax_rule'] != p.tax_rule:
|
||||||
ocm.change_tax_rule(p, p.form.cleaned_data['tax_rule'])
|
ocm.change_tax_rule(p, p.form.cleaned_data['tax_rule'])
|
||||||
|
|
||||||
@@ -1792,12 +1802,18 @@ class OrderContactChange(OrderView):
|
|||||||
def form(self):
|
def form(self):
|
||||||
return OrderContactForm(
|
return OrderContactForm(
|
||||||
instance=self.order,
|
instance=self.order,
|
||||||
data=self.request.POST if self.request.method == "POST" else None
|
data=self.request.POST if self.request.method == "POST" else None,
|
||||||
|
customers=self.request.organizer.settings.customer_accounts and (
|
||||||
|
self.request.user.has_organizer_permission(
|
||||||
|
self.request.organizer, 'can_manage_customers', request=self.request
|
||||||
|
)
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
def post(self, *args, **kwargs):
|
def post(self, *args, **kwargs):
|
||||||
old_email = self.order.email
|
old_email = self.order.email
|
||||||
old_phone = self.order.phone
|
old_phone = self.order.phone
|
||||||
|
old_customer = self.order.customer
|
||||||
changed = False
|
changed = False
|
||||||
if self.form.is_valid():
|
if self.form.is_valid():
|
||||||
new_email = self.form.cleaned_data['email']
|
new_email = self.form.cleaned_data['email']
|
||||||
@@ -1824,6 +1840,18 @@ class OrderContactChange(OrderView):
|
|||||||
user=self.request.user,
|
user=self.request.user,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
new_customer = self.form.cleaned_data.get('customer')
|
||||||
|
if new_customer != old_customer:
|
||||||
|
changed = True
|
||||||
|
self.order.log_action(
|
||||||
|
'pretix.event.order.customer.changed',
|
||||||
|
data={
|
||||||
|
'old_customer': old_customer,
|
||||||
|
'new_customer': self.form.cleaned_data['customer'],
|
||||||
|
},
|
||||||
|
user=self.request.user,
|
||||||
|
)
|
||||||
|
|
||||||
if self.form.cleaned_data['regenerate_secrets']:
|
if self.form.cleaned_data['regenerate_secrets']:
|
||||||
changed = True
|
changed = True
|
||||||
self.order.secret = generate_secret()
|
self.order.secret = generate_secret()
|
||||||
|
|||||||
@@ -33,6 +33,7 @@
|
|||||||
# License for the specific language governing permissions and limitations under the License.
|
# License for the specific language governing permissions and limitations under the License.
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import re
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
|
||||||
@@ -43,11 +44,12 @@ from django.core.exceptions import PermissionDenied, ValidationError
|
|||||||
from django.core.files import File
|
from django.core.files import File
|
||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
from django.db.models import (
|
from django.db.models import (
|
||||||
Count, Max, Min, OuterRef, Prefetch, ProtectedError, Subquery, Sum,
|
Count, Exists, IntegerField, Max, Min, OuterRef, Prefetch, ProtectedError,
|
||||||
|
Q, Subquery, Sum,
|
||||||
)
|
)
|
||||||
from django.db.models.functions import Coalesce, Greatest
|
from django.db.models.functions import Coalesce, Greatest
|
||||||
from django.forms import DecimalField
|
from django.forms import DecimalField
|
||||||
from django.http import JsonResponse
|
from django.http import HttpResponseBadRequest, JsonResponse
|
||||||
from django.shortcuts import get_object_or_404, redirect
|
from django.shortcuts import get_object_or_404, redirect
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils.functional import cached_property
|
from django.utils.functional import cached_property
|
||||||
@@ -61,29 +63,37 @@ from django.views.generic import (
|
|||||||
|
|
||||||
from pretix.api.models import WebHook
|
from pretix.api.models import WebHook
|
||||||
from pretix.base.auth import get_auth_backends
|
from pretix.base.auth import get_auth_backends
|
||||||
|
from pretix.base.channels import get_all_sales_channels
|
||||||
|
from pretix.base.i18n import language
|
||||||
from pretix.base.models import (
|
from pretix.base.models import (
|
||||||
CachedFile, Device, Gate, GiftCard, LogEntry, OrderPayment, Organizer,
|
CachedFile, Customer, Device, Gate, GiftCard, Invoice, LogEntry,
|
||||||
|
Membership, MembershipType, Order, OrderPayment, OrderPosition, Organizer,
|
||||||
Team, TeamInvite, User,
|
Team, TeamInvite, User,
|
||||||
)
|
)
|
||||||
from pretix.base.models.event import Event, EventMetaProperty, EventMetaValue
|
from pretix.base.models.event import Event, EventMetaProperty, EventMetaValue
|
||||||
from pretix.base.models.giftcards import (
|
from pretix.base.models.giftcards import (
|
||||||
GiftCardTransaction, gen_giftcard_secret,
|
GiftCardTransaction, gen_giftcard_secret,
|
||||||
)
|
)
|
||||||
|
from pretix.base.models.orders import CancellationRequest
|
||||||
from pretix.base.models.organizer import TeamAPIToken
|
from pretix.base.models.organizer import TeamAPIToken
|
||||||
from pretix.base.payment import PaymentException
|
from pretix.base.payment import PaymentException
|
||||||
from pretix.base.services.export import multiexport
|
from pretix.base.services.export import multiexport
|
||||||
from pretix.base.services.mail import SendMailException, mail
|
from pretix.base.services.mail import SendMailException, mail
|
||||||
from pretix.base.settings import SETTINGS_AFFECTING_CSS
|
from pretix.base.settings import SETTINGS_AFFECTING_CSS
|
||||||
from pretix.base.signals import register_multievent_data_exporters
|
from pretix.base.signals import register_multievent_data_exporters
|
||||||
|
from pretix.base.templatetags.rich_text import markdown_compile_email
|
||||||
from pretix.base.views.tasks import AsyncAction
|
from pretix.base.views.tasks import AsyncAction
|
||||||
from pretix.control.forms.filter import (
|
from pretix.control.forms.filter import (
|
||||||
EventFilterForm, GiftCardFilterForm, OrganizerFilterForm, TeamFilterForm,
|
CustomerFilterForm, EventFilterForm, GiftCardFilterForm,
|
||||||
|
OrganizerFilterForm, TeamFilterForm,
|
||||||
)
|
)
|
||||||
from pretix.control.forms.orders import ExporterForm
|
from pretix.control.forms.orders import ExporterForm
|
||||||
from pretix.control.forms.organizer import (
|
from pretix.control.forms.organizer import (
|
||||||
DeviceForm, EventMetaPropertyForm, GateForm, GiftCardCreateForm,
|
CustomerUpdateForm, DeviceForm, EventMetaPropertyForm, GateForm,
|
||||||
GiftCardUpdateForm, OrganizerDeleteForm, OrganizerForm,
|
GiftCardCreateForm, GiftCardUpdateForm, MailSettingsForm,
|
||||||
OrganizerSettingsForm, OrganizerUpdateForm, TeamForm, WebHookForm,
|
MembershipTypeForm, MembershipUpdateForm, OrganizerDeleteForm,
|
||||||
|
OrganizerForm, OrganizerSettingsForm, OrganizerUpdateForm, TeamForm,
|
||||||
|
WebHookForm,
|
||||||
)
|
)
|
||||||
from pretix.control.logdisplay import OVERVIEW_BANLIST
|
from pretix.control.logdisplay import OVERVIEW_BANLIST
|
||||||
from pretix.control.permissions import (
|
from pretix.control.permissions import (
|
||||||
@@ -228,6 +238,104 @@ class OrganizerSettingsFormView(OrganizerDetailViewMixin, OrganizerPermissionReq
|
|||||||
return self.get(request)
|
return self.get(request)
|
||||||
|
|
||||||
|
|
||||||
|
class OrganizerMailSettings(OrganizerSettingsFormView):
|
||||||
|
form_class = MailSettingsForm
|
||||||
|
template_name = 'pretixcontrol/organizers/mail.html'
|
||||||
|
permission = 'can_change_organizer_settings'
|
||||||
|
|
||||||
|
def get_success_url(self):
|
||||||
|
return reverse('control:organizer.settings.mail', kwargs={
|
||||||
|
'organizer': self.request.organizer.slug,
|
||||||
|
})
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
|
def post(self, request, *args, **kwargs):
|
||||||
|
form = self.get_form()
|
||||||
|
if form.is_valid():
|
||||||
|
form.save()
|
||||||
|
if form.has_changed():
|
||||||
|
self.request.organizer.log_action(
|
||||||
|
'pretix.organizer.settings', user=self.request.user, data={
|
||||||
|
k: form.cleaned_data.get(k) for k in form.changed_data
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if request.POST.get('test', '0').strip() == '1':
|
||||||
|
backend = self.request.organizer.get_mail_backend(force_custom=True, timeout=10)
|
||||||
|
try:
|
||||||
|
backend.test(self.request.organizer.settings.mail_from)
|
||||||
|
except Exception as e:
|
||||||
|
messages.warning(self.request, _('An error occurred while contacting the SMTP server: %s') % str(e))
|
||||||
|
else:
|
||||||
|
if form.cleaned_data.get('smtp_use_custom'):
|
||||||
|
messages.success(self.request, _('Your changes have been saved and the connection attempt to '
|
||||||
|
'your SMTP server was successful.'))
|
||||||
|
else:
|
||||||
|
messages.success(self.request, _('We\'ve been able to contact the SMTP server you configured. '
|
||||||
|
'Remember to check the "use custom SMTP server" checkbox, '
|
||||||
|
'otherwise your SMTP server will not be used.'))
|
||||||
|
else:
|
||||||
|
messages.success(self.request, _('Your changes have been saved.'))
|
||||||
|
return redirect(self.get_success_url())
|
||||||
|
else:
|
||||||
|
messages.error(self.request, _('We could not save your changes. See below for details.'))
|
||||||
|
return self.get(request)
|
||||||
|
|
||||||
|
|
||||||
|
class MailSettingsPreview(OrganizerPermissionRequiredMixin, View):
|
||||||
|
permission = 'can_change_organizer_settings'
|
||||||
|
|
||||||
|
# return the origin text if key is missing in dict
|
||||||
|
class SafeDict(dict):
|
||||||
|
def __missing__(self, key):
|
||||||
|
return '{' + key + '}'
|
||||||
|
|
||||||
|
# create index-language mapping
|
||||||
|
@cached_property
|
||||||
|
def supported_locale(self):
|
||||||
|
locales = {}
|
||||||
|
for idx, val in enumerate(settings.LANGUAGES):
|
||||||
|
if val[0] in self.request.organizer.settings.locales:
|
||||||
|
locales[str(idx)] = val[0]
|
||||||
|
return locales
|
||||||
|
|
||||||
|
# get all supported placeholders with dummy values
|
||||||
|
def placeholders(self, item):
|
||||||
|
ctx = {}
|
||||||
|
for p, s in MailSettingsForm(obj=self.request.organizer)._get_sample_context(MailSettingsForm.base_context[item]).items():
|
||||||
|
if s.strip().startswith('*'):
|
||||||
|
ctx[p] = s
|
||||||
|
else:
|
||||||
|
ctx[p] = '<span class="placeholder" title="{}">{}</span>'.format(
|
||||||
|
_('This value will be replaced based on dynamic parameters.'),
|
||||||
|
s
|
||||||
|
)
|
||||||
|
return self.SafeDict(ctx)
|
||||||
|
|
||||||
|
def post(self, request, *args, **kwargs):
|
||||||
|
preview_item = request.POST.get('item', '')
|
||||||
|
if preview_item not in MailSettingsForm.base_context:
|
||||||
|
return HttpResponseBadRequest(_('invalid item'))
|
||||||
|
|
||||||
|
regex = r"^" + re.escape(preview_item) + r"_(?P<idx>[\d+])$"
|
||||||
|
msgs = {}
|
||||||
|
for k, v in request.POST.items():
|
||||||
|
# only accept allowed fields
|
||||||
|
matched = re.search(regex, k)
|
||||||
|
if matched is not None:
|
||||||
|
idx = matched.group('idx')
|
||||||
|
if idx in self.supported_locale:
|
||||||
|
with language(self.supported_locale[idx], self.request.organizer.settings.region):
|
||||||
|
msgs[self.supported_locale[idx]] = markdown_compile_email(
|
||||||
|
v.format_map(self.placeholders(preview_item))
|
||||||
|
)
|
||||||
|
|
||||||
|
return JsonResponse({
|
||||||
|
'item': preview_item,
|
||||||
|
'msgs': msgs
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
class OrganizerDisplaySettings(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin, View):
|
class OrganizerDisplaySettings(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin, View):
|
||||||
permission = None
|
permission = None
|
||||||
|
|
||||||
@@ -1502,3 +1610,339 @@ class LogView(OrganizerPermissionRequiredMixin, PaginationMixin, ListView):
|
|||||||
def get_context_data(self, **kwargs):
|
def get_context_data(self, **kwargs):
|
||||||
ctx = super().get_context_data()
|
ctx = super().get_context_data()
|
||||||
return ctx
|
return ctx
|
||||||
|
|
||||||
|
|
||||||
|
class MembershipTypeListView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin, ListView):
|
||||||
|
model = MembershipType
|
||||||
|
template_name = 'pretixcontrol/organizers/membershiptypes.html'
|
||||||
|
permission = 'can_change_organizer_settings'
|
||||||
|
context_object_name = 'types'
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
return self.request.organizer.membership_types.all()
|
||||||
|
|
||||||
|
|
||||||
|
class MembershipTypeCreateView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin, CreateView):
|
||||||
|
model = MembershipType
|
||||||
|
template_name = 'pretixcontrol/organizers/membershiptype_edit.html'
|
||||||
|
permission = 'can_change_organizer_settings'
|
||||||
|
form_class = MembershipTypeForm
|
||||||
|
|
||||||
|
def get_object(self, queryset=None):
|
||||||
|
return get_object_or_404(MembershipType, organizer=self.request.organizer, pk=self.kwargs.get('type'))
|
||||||
|
|
||||||
|
def get_success_url(self):
|
||||||
|
return reverse('control:organizer.membershiptypes', kwargs={
|
||||||
|
'organizer': self.request.organizer.slug,
|
||||||
|
})
|
||||||
|
|
||||||
|
def get_form_kwargs(self):
|
||||||
|
kwargs = super().get_form_kwargs()
|
||||||
|
kwargs['event'] = self.request.organizer
|
||||||
|
return kwargs
|
||||||
|
|
||||||
|
def form_valid(self, form):
|
||||||
|
messages.success(self.request, _('The membership type has been created.'))
|
||||||
|
form.instance.organizer = self.request.organizer
|
||||||
|
ret = super().form_valid(form)
|
||||||
|
form.instance.log_action('pretix.membershiptype.created', user=self.request.user, data={
|
||||||
|
k: getattr(self.object, k) for k in form.changed_data
|
||||||
|
})
|
||||||
|
return ret
|
||||||
|
|
||||||
|
def form_invalid(self, form):
|
||||||
|
messages.error(self.request, _('Your changes could not be saved.'))
|
||||||
|
return super().form_invalid(form)
|
||||||
|
|
||||||
|
|
||||||
|
class MembershipTypeUpdateView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin, UpdateView):
|
||||||
|
model = MembershipType
|
||||||
|
template_name = 'pretixcontrol/organizers/membershiptype_edit.html'
|
||||||
|
permission = 'can_change_organizer_settings'
|
||||||
|
context_object_name = 'type'
|
||||||
|
form_class = MembershipTypeForm
|
||||||
|
|
||||||
|
def get_object(self, queryset=None):
|
||||||
|
return get_object_or_404(MembershipType, organizer=self.request.organizer, pk=self.kwargs.get('type'))
|
||||||
|
|
||||||
|
def get_success_url(self):
|
||||||
|
return reverse('control:organizer.membershiptypes', kwargs={
|
||||||
|
'organizer': self.request.organizer.slug,
|
||||||
|
})
|
||||||
|
|
||||||
|
def get_form_kwargs(self):
|
||||||
|
kwargs = super().get_form_kwargs()
|
||||||
|
kwargs['event'] = self.request.organizer
|
||||||
|
return kwargs
|
||||||
|
|
||||||
|
def form_valid(self, form):
|
||||||
|
if form.has_changed():
|
||||||
|
self.object.log_action('pretix.membershiptype.changed', user=self.request.user, data={
|
||||||
|
k: getattr(self.object, k)
|
||||||
|
for k in form.changed_data
|
||||||
|
})
|
||||||
|
messages.success(self.request, _('Your changes have been saved.'))
|
||||||
|
return super().form_valid(form)
|
||||||
|
|
||||||
|
def form_invalid(self, form):
|
||||||
|
messages.error(self.request, _('Your changes could not be saved.'))
|
||||||
|
return super().form_invalid(form)
|
||||||
|
|
||||||
|
|
||||||
|
class MembershipTypeDeleteView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin, DeleteView):
|
||||||
|
model = MembershipType
|
||||||
|
template_name = 'pretixcontrol/organizers/membershiptype_delete.html'
|
||||||
|
permission = 'can_change_organizer_settings'
|
||||||
|
context_object_name = 'type'
|
||||||
|
|
||||||
|
def get_object(self, queryset=None):
|
||||||
|
return get_object_or_404(MembershipType, organizer=self.request.organizer, pk=self.kwargs.get('type'))
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
ctx = super().get_context_data(**kwargs)
|
||||||
|
ctx['is_allowed'] = self.object.allow_delete()
|
||||||
|
return ctx
|
||||||
|
|
||||||
|
def get_success_url(self):
|
||||||
|
return reverse('control:organizer.membershiptypes', kwargs={
|
||||||
|
'organizer': self.request.organizer.slug,
|
||||||
|
})
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
|
def delete(self, request, *args, **kwargs):
|
||||||
|
success_url = self.get_success_url()
|
||||||
|
self.object = self.get_object()
|
||||||
|
if self.object.allow_delete():
|
||||||
|
self.object.log_action('pretix.membershiptype.deleted', user=self.request.user)
|
||||||
|
self.object.delete()
|
||||||
|
messages.success(request, _('The selected object has been deleted.'))
|
||||||
|
return redirect(success_url)
|
||||||
|
|
||||||
|
|
||||||
|
class CustomerListView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin, PaginationMixin, ListView):
|
||||||
|
model = Customer
|
||||||
|
template_name = 'pretixcontrol/organizers/customers.html'
|
||||||
|
permission = 'can_manage_customers'
|
||||||
|
context_object_name = 'customers'
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
qs = self.request.organizer.customers.all()
|
||||||
|
if self.filter_form.is_valid():
|
||||||
|
qs = self.filter_form.filter_qs(qs)
|
||||||
|
return qs
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
ctx = super().get_context_data(**kwargs)
|
||||||
|
ctx['filter_form'] = self.filter_form
|
||||||
|
return ctx
|
||||||
|
|
||||||
|
@cached_property
|
||||||
|
def filter_form(self):
|
||||||
|
return CustomerFilterForm(data=self.request.GET, request=self.request)
|
||||||
|
|
||||||
|
|
||||||
|
class CustomerDetailView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin, PaginationMixin, ListView):
|
||||||
|
template_name = 'pretixcontrol/organizers/customer.html'
|
||||||
|
permission = 'can_manage_customers'
|
||||||
|
context_object_name = 'orders'
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
qs = Order.objects.filter(
|
||||||
|
Q(customer=self.customer)
|
||||||
|
| Q(email__iexact=self.customer.email)
|
||||||
|
).select_related('event').order_by('-datetime')
|
||||||
|
return qs
|
||||||
|
|
||||||
|
@cached_property
|
||||||
|
def customer(self):
|
||||||
|
return get_object_or_404(
|
||||||
|
self.request.organizer.customers,
|
||||||
|
identifier=self.kwargs.get('customer')
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
ctx = super().get_context_data(**kwargs)
|
||||||
|
ctx['customer'] = self.customer
|
||||||
|
ctx['display_locale'] = dict(settings.LANGUAGES)[self.customer.locale or self.request.organizer.settings.locale]
|
||||||
|
|
||||||
|
ctx['memberships'] = self.customer.memberships.with_usages().select_related(
|
||||||
|
'membership_type', 'granted_in', 'granted_in__order', 'granted_in__order__event'
|
||||||
|
)
|
||||||
|
|
||||||
|
for m in ctx['memberships']:
|
||||||
|
if m.membership_type.max_usages:
|
||||||
|
m.percent = int(m.usages / m.membership_type.max_usages * 100)
|
||||||
|
else:
|
||||||
|
m.percent = 0
|
||||||
|
|
||||||
|
# Only compute this annotations for this page (query optimization)
|
||||||
|
s = OrderPosition.objects.filter(
|
||||||
|
order=OuterRef('pk')
|
||||||
|
).order_by().values('order').annotate(k=Count('id')).values('k')
|
||||||
|
i = Invoice.objects.filter(
|
||||||
|
order=OuterRef('pk'),
|
||||||
|
is_cancellation=False,
|
||||||
|
refered__isnull=True,
|
||||||
|
).order_by().values('order').annotate(k=Count('id')).values('k')
|
||||||
|
annotated = {
|
||||||
|
o['pk']: o
|
||||||
|
for o in
|
||||||
|
Order.annotate_overpayments(Order.objects, sums=True).filter(
|
||||||
|
pk__in=[o.pk for o in ctx['orders']]
|
||||||
|
).annotate(
|
||||||
|
pcnt=Subquery(s, output_field=IntegerField()),
|
||||||
|
icnt=Subquery(i, output_field=IntegerField()),
|
||||||
|
has_cancellation_request=Exists(CancellationRequest.objects.filter(order=OuterRef('pk')))
|
||||||
|
).values(
|
||||||
|
'pk', 'pcnt', 'is_overpaid', 'is_underpaid', 'is_pending_with_full_payment', 'has_external_refund',
|
||||||
|
'has_pending_refund', 'has_cancellation_request', 'computed_payment_refund_sum', 'icnt'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
scs = get_all_sales_channels()
|
||||||
|
for o in ctx['orders']:
|
||||||
|
if o.pk not in annotated:
|
||||||
|
continue
|
||||||
|
o.pcnt = annotated.get(o.pk)['pcnt']
|
||||||
|
o.is_overpaid = annotated.get(o.pk)['is_overpaid']
|
||||||
|
o.is_underpaid = annotated.get(o.pk)['is_underpaid']
|
||||||
|
o.is_pending_with_full_payment = annotated.get(o.pk)['is_pending_with_full_payment']
|
||||||
|
o.has_external_refund = annotated.get(o.pk)['has_external_refund']
|
||||||
|
o.has_pending_refund = annotated.get(o.pk)['has_pending_refund']
|
||||||
|
o.has_cancellation_request = annotated.get(o.pk)['has_cancellation_request']
|
||||||
|
o.computed_payment_refund_sum = annotated.get(o.pk)['computed_payment_refund_sum']
|
||||||
|
o.icnt = annotated.get(o.pk)['icnt']
|
||||||
|
o.sales_channel_obj = scs[o.sales_channel]
|
||||||
|
|
||||||
|
return ctx
|
||||||
|
|
||||||
|
|
||||||
|
class CustomerUpdateView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin, UpdateView):
|
||||||
|
template_name = 'pretixcontrol/organizers/customer_edit.html'
|
||||||
|
permission = 'can_manage_customers'
|
||||||
|
context_object_name = 'customer'
|
||||||
|
form_class = CustomerUpdateForm
|
||||||
|
|
||||||
|
def get_object(self, queryset=None):
|
||||||
|
return get_object_or_404(
|
||||||
|
self.request.organizer.customers,
|
||||||
|
identifier=self.kwargs.get('customer')
|
||||||
|
)
|
||||||
|
|
||||||
|
def form_valid(self, form):
|
||||||
|
if form.has_changed():
|
||||||
|
self.object.log_action('pretix.customer.changed', user=self.request.user, data={
|
||||||
|
k: getattr(self.object, k)
|
||||||
|
for k in form.changed_data
|
||||||
|
})
|
||||||
|
messages.success(self.request, _('Your changes have been saved.'))
|
||||||
|
return super().form_valid(form)
|
||||||
|
|
||||||
|
def get_success_url(self):
|
||||||
|
return reverse('control:organizer.customer', kwargs={
|
||||||
|
'organizer': self.request.organizer.slug,
|
||||||
|
'customer': self.object.identifier,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
class MembershipUpdateView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin, UpdateView):
|
||||||
|
template_name = 'pretixcontrol/organizers/customer_membership.html'
|
||||||
|
permission = 'can_manage_customers'
|
||||||
|
context_object_name = 'membership'
|
||||||
|
form_class = MembershipUpdateForm
|
||||||
|
|
||||||
|
def get_object(self, queryset=None):
|
||||||
|
return get_object_or_404(
|
||||||
|
Membership,
|
||||||
|
customer__organizer=self.request.organizer,
|
||||||
|
customer__identifier=self.kwargs.get('customer'),
|
||||||
|
pk=self.kwargs.get('id')
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
ctx = super().get_context_data(**kwargs)
|
||||||
|
ctx['usages'] = self.object.orderposition_set.select_related(
|
||||||
|
'order', 'order__event', 'subevent', 'item', 'variation',
|
||||||
|
)
|
||||||
|
return ctx
|
||||||
|
|
||||||
|
def form_valid(self, form):
|
||||||
|
if form.has_changed():
|
||||||
|
d = {
|
||||||
|
k: getattr(self.object, k)
|
||||||
|
for k in form.changed_data
|
||||||
|
}
|
||||||
|
d['id'] = self.object.pk
|
||||||
|
self.object.customer.log_action('pretix.customer.membership.changed', user=self.request.user, data=d)
|
||||||
|
messages.success(self.request, _('Your changes have been saved.'))
|
||||||
|
return super().form_valid(form)
|
||||||
|
|
||||||
|
def get_success_url(self):
|
||||||
|
return reverse('control:organizer.customer', kwargs={
|
||||||
|
'organizer': self.request.organizer.slug,
|
||||||
|
'customer': self.object.customer.identifier,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
class MembershipCreateView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin, CreateView):
|
||||||
|
template_name = 'pretixcontrol/organizers/customer_membership.html'
|
||||||
|
permission = 'can_manage_customers'
|
||||||
|
context_object_name = 'membership'
|
||||||
|
form_class = MembershipUpdateForm
|
||||||
|
|
||||||
|
@cached_property
|
||||||
|
def customer(self):
|
||||||
|
return get_object_or_404(
|
||||||
|
self.request.organizer.customers,
|
||||||
|
identifier=self.kwargs.get('customer')
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_form_kwargs(self):
|
||||||
|
kwargs = super().get_form_kwargs()
|
||||||
|
kwargs['instance'] = Membership(
|
||||||
|
customer=self.customer,
|
||||||
|
)
|
||||||
|
return kwargs
|
||||||
|
|
||||||
|
def form_valid(self, form):
|
||||||
|
r = super().form_valid(form)
|
||||||
|
d = {
|
||||||
|
k: getattr(self.object, k)
|
||||||
|
for k in form.changed_data
|
||||||
|
}
|
||||||
|
d['id'] = self.object.pk
|
||||||
|
self.customer.log_action('pretix.customer.membership.created', user=self.request.user, data=d)
|
||||||
|
messages.success(self.request, _('Your changes have been saved.'))
|
||||||
|
return r
|
||||||
|
|
||||||
|
def get_success_url(self):
|
||||||
|
return reverse('control:organizer.customer', kwargs={
|
||||||
|
'organizer': self.request.organizer.slug,
|
||||||
|
'customer': self.object.customer.identifier,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
class CustomerAnonymizeView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin, DetailView):
|
||||||
|
template_name = 'pretixcontrol/organizers/customer_anonymize.html'
|
||||||
|
permission = 'can_manage_customers'
|
||||||
|
context_object_name = 'customer'
|
||||||
|
|
||||||
|
def get_object(self, queryset=None):
|
||||||
|
return get_object_or_404(
|
||||||
|
self.request.organizer.customers,
|
||||||
|
identifier=self.kwargs.get('customer')
|
||||||
|
)
|
||||||
|
|
||||||
|
def post(self, request, *args, **kwargs):
|
||||||
|
self.object = self.get_object()
|
||||||
|
with transaction.atomic():
|
||||||
|
self.object.anonymize()
|
||||||
|
self.object.log_action('pretix.customer.anonymized', user=self.request.user)
|
||||||
|
messages.success(self.request, _('The customer account has been anonymized.'))
|
||||||
|
return redirect(self.get_success_url())
|
||||||
|
|
||||||
|
def get_success_url(self):
|
||||||
|
return reverse('control:organizer.customer', kwargs={
|
||||||
|
'organizer': self.request.organizer.slug,
|
||||||
|
'customer': self.object.identifier,
|
||||||
|
})
|
||||||
|
|||||||
@@ -51,7 +51,9 @@ from pretix.base.models import (
|
|||||||
ItemVariation, Order, Organizer, User, Voucher,
|
ItemVariation, Order, Organizer, User, Voucher,
|
||||||
)
|
)
|
||||||
from pretix.control.forms.event import EventWizardCopyForm
|
from pretix.control.forms.event import EventWizardCopyForm
|
||||||
from pretix.control.permissions import event_permission_required
|
from pretix.control.permissions import (
|
||||||
|
event_permission_required, organizer_permission_required,
|
||||||
|
)
|
||||||
from pretix.helpers.daterange import daterange
|
from pretix.helpers.daterange import daterange
|
||||||
from pretix.helpers.i18n import i18ncomp
|
from pretix.helpers.i18n import i18ncomp
|
||||||
|
|
||||||
@@ -169,6 +171,36 @@ def event_list(request):
|
|||||||
return JsonResponse(doc)
|
return JsonResponse(doc)
|
||||||
|
|
||||||
|
|
||||||
|
@organizer_permission_required("can_manage_customers")
|
||||||
|
def customer_select2(request, **kwargs):
|
||||||
|
query = request.GET.get('query', '')
|
||||||
|
try:
|
||||||
|
page = int(request.GET.get('page', '1'))
|
||||||
|
except ValueError:
|
||||||
|
page = 1
|
||||||
|
|
||||||
|
qs = request.organizer.customers.filter(
|
||||||
|
Q(email__icontains=query) | Q(name_cached__icontains=query) | Q(identifier__istartswith=query)
|
||||||
|
).order_by('name_cached')
|
||||||
|
|
||||||
|
total = qs.count()
|
||||||
|
pagesize = 20
|
||||||
|
offset = (page - 1) * pagesize
|
||||||
|
doc = {
|
||||||
|
'results': [
|
||||||
|
{
|
||||||
|
'id': e.pk,
|
||||||
|
'text': str(e),
|
||||||
|
}
|
||||||
|
for e in qs[offset:offset + pagesize]
|
||||||
|
],
|
||||||
|
'pagination': {
|
||||||
|
"more": total >= (offset + pagesize)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return JsonResponse(doc)
|
||||||
|
|
||||||
|
|
||||||
def nav_context_list(request):
|
def nav_context_list(request):
|
||||||
query = request.GET.get('query', '').strip()
|
query = request.GET.get('query', '').strip()
|
||||||
organizer = request.GET.get('organizer', None)
|
organizer = request.GET.get('organizer', None)
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ from django.conf import settings
|
|||||||
from django.contrib import messages
|
from django.contrib import messages
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
from django.core.validators import EmailValidator
|
from django.core.validators import EmailValidator
|
||||||
|
from django.db.models import F, Q
|
||||||
from django.http import HttpResponseNotAllowed, JsonResponse
|
from django.http import HttpResponseNotAllowed, JsonResponse
|
||||||
from django.shortcuts import redirect
|
from django.shortcuts import redirect
|
||||||
from django.utils import translation
|
from django.utils import translation
|
||||||
@@ -50,26 +51,29 @@ from django.utils.translation import (
|
|||||||
from django.views.generic.base import TemplateResponseMixin
|
from django.views.generic.base import TemplateResponseMixin
|
||||||
from django_scopes import scopes_disabled
|
from django_scopes import scopes_disabled
|
||||||
|
|
||||||
from pretix.base.models import Order
|
from pretix.base.models import Customer, Order
|
||||||
from pretix.base.models.orders import InvoiceAddress, OrderPayment
|
from pretix.base.models.orders import InvoiceAddress, OrderPayment
|
||||||
from pretix.base.models.tax import TaxedPrice, TaxRule
|
from pretix.base.models.tax import TaxedPrice, TaxRule
|
||||||
from pretix.base.services.cart import (
|
from pretix.base.services.cart import (
|
||||||
CartError, error_messages, get_fees, set_cart_addons, update_tax_rates,
|
CartError, error_messages, get_fees, set_cart_addons, update_tax_rates,
|
||||||
)
|
)
|
||||||
|
from pretix.base.services.memberships import validate_memberships_in_order
|
||||||
from pretix.base.services.orders import perform_order
|
from pretix.base.services.orders import perform_order
|
||||||
from pretix.base.signals import validate_cart_addons
|
from pretix.base.signals import validate_cart_addons
|
||||||
from pretix.base.templatetags.rich_text import rich_text_snippet
|
from pretix.base.templatetags.rich_text import rich_text_snippet
|
||||||
from pretix.base.views.tasks import AsyncAction
|
from pretix.base.views.tasks import AsyncAction
|
||||||
from pretix.multidomain.urlreverse import eventreverse
|
from pretix.multidomain.urlreverse import eventreverse
|
||||||
from pretix.presale.forms.checkout import (
|
from pretix.presale.forms.checkout import (
|
||||||
ContactForm, InvoiceAddressForm, InvoiceNameForm,
|
ContactForm, InvoiceAddressForm, InvoiceNameForm, MembershipForm,
|
||||||
)
|
)
|
||||||
|
from pretix.presale.forms.customer import AuthenticationForm, RegistrationForm
|
||||||
from pretix.presale.signals import (
|
from pretix.presale.signals import (
|
||||||
checkout_all_optional, checkout_confirm_messages, checkout_flow_steps,
|
checkout_all_optional, checkout_confirm_messages, checkout_flow_steps,
|
||||||
contact_form_fields, contact_form_fields_overrides,
|
contact_form_fields, contact_form_fields_overrides,
|
||||||
order_meta_from_request, question_form_fields,
|
order_meta_from_request, question_form_fields,
|
||||||
question_form_fields_overrides,
|
question_form_fields_overrides,
|
||||||
)
|
)
|
||||||
|
from pretix.presale.utils import customer_login
|
||||||
from pretix.presale.views import (
|
from pretix.presale.views import (
|
||||||
CartMixin, get_cart, get_cart_is_free, get_cart_total,
|
CartMixin, get_cart, get_cart_is_free, get_cart_total,
|
||||||
)
|
)
|
||||||
@@ -222,6 +226,200 @@ class TemplateFlowStep(TemplateResponseMixin, BaseCheckoutFlowStep):
|
|||||||
raise NotImplementedError()
|
raise NotImplementedError()
|
||||||
|
|
||||||
|
|
||||||
|
class CustomerStep(QuestionsViewMixin, CartMixin, TemplateFlowStep):
|
||||||
|
priority = 45
|
||||||
|
identifier = "customer"
|
||||||
|
template_name = "pretixpresale/event/checkout_customer.html"
|
||||||
|
label = pgettext_lazy('checkoutflow', 'Customer account')
|
||||||
|
icon = 'user'
|
||||||
|
|
||||||
|
def is_applicable(self, request):
|
||||||
|
return request.organizer.settings.customer_accounts
|
||||||
|
|
||||||
|
@cached_property
|
||||||
|
def login_form(self):
|
||||||
|
f = AuthenticationForm(
|
||||||
|
data=(
|
||||||
|
self.request.POST
|
||||||
|
if self.request.method == "POST" and self.request.POST.get('customer_mode') == 'login'
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
prefix='login',
|
||||||
|
request=self.request.event,
|
||||||
|
)
|
||||||
|
for field in f.fields.values():
|
||||||
|
field._show_required = field.required
|
||||||
|
field.required = False
|
||||||
|
field.widget.is_required = False
|
||||||
|
return f
|
||||||
|
|
||||||
|
@cached_property
|
||||||
|
def guest_allowed(self):
|
||||||
|
return not any(
|
||||||
|
p.item.require_membership or
|
||||||
|
(p.variation and p.variation.require_membership) or
|
||||||
|
p.item.grant_membership_type_id
|
||||||
|
for p in self.positions
|
||||||
|
)
|
||||||
|
|
||||||
|
@cached_property
|
||||||
|
def register_form(self):
|
||||||
|
f = RegistrationForm(
|
||||||
|
data=(
|
||||||
|
self.request.POST
|
||||||
|
if self.request.method == "POST" and self.request.POST.get('customer_mode') == 'register'
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
prefix='register',
|
||||||
|
request=self.request,
|
||||||
|
)
|
||||||
|
for field in f.fields.values():
|
||||||
|
field._show_required = field.required
|
||||||
|
field.required = False
|
||||||
|
field.widget.is_required = False
|
||||||
|
return f
|
||||||
|
|
||||||
|
def post(self, request):
|
||||||
|
self.request = request
|
||||||
|
|
||||||
|
if request.POST.get("customer_mode") == 'login':
|
||||||
|
if 'customer' in self.cart_session:
|
||||||
|
return redirect(self.get_next_url(request))
|
||||||
|
elif request.customer:
|
||||||
|
self.cart_session['customer_mode'] = 'login'
|
||||||
|
self.cart_session['customer'] = request.customer.pk
|
||||||
|
return redirect(self.get_next_url(request))
|
||||||
|
elif self.login_form.is_valid():
|
||||||
|
customer_login(self.request, self.login_form.get_customer())
|
||||||
|
self.cart_session['customer_mode'] = 'login'
|
||||||
|
self.cart_session['customer'] = self.login_form.get_customer().pk
|
||||||
|
return redirect(self.get_next_url(request))
|
||||||
|
else:
|
||||||
|
return self.render()
|
||||||
|
elif request.POST.get("customer_mode") == 'register':
|
||||||
|
if self.register_form.is_valid():
|
||||||
|
customer = self.register_form.create()
|
||||||
|
self.cart_session['customer_mode'] = 'login'
|
||||||
|
self.cart_session['customer'] = customer.pk
|
||||||
|
return redirect(self.get_next_url(request))
|
||||||
|
else:
|
||||||
|
return self.render()
|
||||||
|
elif request.POST.get("customer_mode") == 'guest' and self.guest_allowed:
|
||||||
|
self.cart_session['customer'] = None
|
||||||
|
self.cart_session['customer_mode'] = 'guest'
|
||||||
|
return redirect(self.get_next_url(request))
|
||||||
|
else:
|
||||||
|
return self.render()
|
||||||
|
|
||||||
|
def is_completed(self, request, warn=False):
|
||||||
|
self.request = request
|
||||||
|
if self.guest_allowed:
|
||||||
|
return 'customer_mode' in self.cart_session
|
||||||
|
else:
|
||||||
|
return self.cart_session.get('customer_mode') == 'login'
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
ctx = super().get_context_data(**kwargs)
|
||||||
|
ctx['cart'] = self.get_cart()
|
||||||
|
ctx['cart_session'] = self.cart_session
|
||||||
|
ctx['login_form'] = self.login_form
|
||||||
|
ctx['register_form'] = self.register_form
|
||||||
|
ctx['selected'] = self.request.POST.get(
|
||||||
|
'customer_mode',
|
||||||
|
self.cart_session.get('customer_mode', 'login' if self.request.customer else '')
|
||||||
|
)
|
||||||
|
ctx['guest_allowed'] = self.guest_allowed
|
||||||
|
|
||||||
|
if 'customer' in self.cart_session:
|
||||||
|
try:
|
||||||
|
ctx['customer'] = self.request.organizer.customers.get(pk=self.cart_session.get('customer', -1))
|
||||||
|
except Customer.DoesNotExist:
|
||||||
|
self.cart_session['customer'] = None
|
||||||
|
self.cart_session['customer_mode'] = None
|
||||||
|
elif self.request.customer:
|
||||||
|
ctx['customer'] = self.request.customer
|
||||||
|
|
||||||
|
return ctx
|
||||||
|
|
||||||
|
|
||||||
|
class MembershipStep(QuestionsViewMixin, CartMixin, TemplateFlowStep):
|
||||||
|
priority = 47
|
||||||
|
identifier = "membership"
|
||||||
|
template_name = "pretixpresale/event/checkout_membership.html"
|
||||||
|
label = pgettext_lazy('checkoutflow', 'Membership')
|
||||||
|
icon = 'id-card'
|
||||||
|
|
||||||
|
def is_applicable(self, request):
|
||||||
|
self.request = request
|
||||||
|
return bool(self.applicable_positions)
|
||||||
|
|
||||||
|
@cached_property
|
||||||
|
def applicable_positions(self):
|
||||||
|
return [
|
||||||
|
p for p in self.positions
|
||||||
|
if p.item.require_membership or (p.variation and p.variation.require_membership)
|
||||||
|
]
|
||||||
|
|
||||||
|
@cached_property
|
||||||
|
def forms(self):
|
||||||
|
forms = []
|
||||||
|
|
||||||
|
memberships = list(self.cart_customer.memberships.with_usages().filter(
|
||||||
|
Q(Q(membership_type__max_usages__isnull=True) | Q(usages__lt=F('membership_type__max_usages'))),
|
||||||
|
).select_related('membership_type'))
|
||||||
|
|
||||||
|
for p in self.applicable_positions:
|
||||||
|
form = MembershipForm(
|
||||||
|
event=self.request.event,
|
||||||
|
memberships=memberships,
|
||||||
|
position=p,
|
||||||
|
prefix=f"membership-{p.id}",
|
||||||
|
initial={
|
||||||
|
'membership': str(p.used_membership_id)
|
||||||
|
},
|
||||||
|
data=self.request.POST if self.request.method == "POST" else None,
|
||||||
|
)
|
||||||
|
forms.append(form)
|
||||||
|
|
||||||
|
return forms
|
||||||
|
|
||||||
|
def post(self, request):
|
||||||
|
self.request = request
|
||||||
|
|
||||||
|
for f in self.forms:
|
||||||
|
if not f.is_valid():
|
||||||
|
messages.error(request, _('Your cart includes a product that requires an active membership to be selected.'))
|
||||||
|
return self.render()
|
||||||
|
|
||||||
|
f.position.used_membership = f.cleaned_data['membership']
|
||||||
|
|
||||||
|
try:
|
||||||
|
validate_memberships_in_order(self.cart_customer, self.positions, self.request.event, lock=False)
|
||||||
|
except ValidationError as e:
|
||||||
|
messages.error(self.request, e.message)
|
||||||
|
self.render()
|
||||||
|
else:
|
||||||
|
for f in self.forms:
|
||||||
|
f.position.save(update_fields=['used_membership'])
|
||||||
|
|
||||||
|
return redirect(self.get_next_url(request))
|
||||||
|
|
||||||
|
def is_completed(self, request, warn=False):
|
||||||
|
self.request = request
|
||||||
|
ok = all([p.used_membership_id for p in self.applicable_positions])
|
||||||
|
if not ok and warn:
|
||||||
|
messages.error(request, _('Your cart includes a product that requires an active membership to be selected.'))
|
||||||
|
return ok
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
ctx = super().get_context_data(**kwargs)
|
||||||
|
ctx['cart'] = self.get_cart()
|
||||||
|
ctx['cart_session'] = self.cart_session
|
||||||
|
ctx['forms'] = self.forms
|
||||||
|
|
||||||
|
return ctx
|
||||||
|
|
||||||
|
|
||||||
class AddOnsStep(CartMixin, AsyncAction, TemplateFlowStep):
|
class AddOnsStep(CartMixin, AsyncAction, TemplateFlowStep):
|
||||||
priority = 40
|
priority = 40
|
||||||
identifier = "addons"
|
identifier = "addons"
|
||||||
@@ -486,12 +684,14 @@ class QuestionsStep(QuestionsViewMixin, CartMixin, TemplateFlowStep):
|
|||||||
initial.update({
|
initial.update({
|
||||||
k: v['initial'] for k, v in overrides.items() if 'initial' in v
|
k: v['initial'] for k, v in overrides.items() if 'initial' in v
|
||||||
})
|
})
|
||||||
|
if self.cart_customer:
|
||||||
|
initial['email'] = self.cart_customer.email
|
||||||
|
|
||||||
f = ContactForm(data=self.request.POST if self.request.method == "POST" else None,
|
f = ContactForm(data=self.request.POST if self.request.method == "POST" else None,
|
||||||
event=self.request.event,
|
event=self.request.event,
|
||||||
request=self.request,
|
request=self.request,
|
||||||
initial=initial, all_optional=self.all_optional)
|
initial=initial, all_optional=self.all_optional)
|
||||||
if wd.get('email', '') and wd.get('fix', '') == "true":
|
if wd.get('email', '') and wd.get('fix', '') == "true" or self.cart_customer:
|
||||||
f.fields['email'].disabled = True
|
f.fields['email'].disabled = True
|
||||||
|
|
||||||
for overrides in override_sets:
|
for overrides in override_sets:
|
||||||
@@ -504,13 +704,31 @@ class QuestionsStep(QuestionsViewMixin, CartMixin, TemplateFlowStep):
|
|||||||
return f
|
return f
|
||||||
|
|
||||||
def get_question_override_sets(self, cart_position):
|
def get_question_override_sets(self, cart_position):
|
||||||
return [
|
o = []
|
||||||
|
if self.cart_customer:
|
||||||
|
o.append({
|
||||||
|
'attendee_name_parts': {
|
||||||
|
'initial': self.cart_customer.name_parts
|
||||||
|
}
|
||||||
|
})
|
||||||
|
o += [
|
||||||
resp for recv, resp in question_form_fields_overrides.send(
|
resp for recv, resp in question_form_fields_overrides.send(
|
||||||
self.request.event,
|
self.request.event,
|
||||||
position=cart_position,
|
position=cart_position,
|
||||||
request=self.request
|
request=self.request
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
|
if cart_position.used_membership:
|
||||||
|
d = {
|
||||||
|
'initial': cart_position.used_membership.attendee_name_parts
|
||||||
|
}
|
||||||
|
if not cart_position.used_membership.membership_type.transferable:
|
||||||
|
d['disabled'] = True
|
||||||
|
o.append({
|
||||||
|
'attendee_name_parts': d
|
||||||
|
})
|
||||||
|
|
||||||
|
return o
|
||||||
|
|
||||||
@cached_property
|
@cached_property
|
||||||
def eu_reverse_charge_relevant(self):
|
def eu_reverse_charge_relevant(self):
|
||||||
@@ -538,6 +756,11 @@ class QuestionsStep(QuestionsViewMixin, CartMixin, TemplateFlowStep):
|
|||||||
wd_initial = {}
|
wd_initial = {}
|
||||||
initial = dict(wd_initial)
|
initial = dict(wd_initial)
|
||||||
|
|
||||||
|
if self.cart_customer:
|
||||||
|
initial.update({
|
||||||
|
'name_parts': self.cart_customer.name_parts
|
||||||
|
})
|
||||||
|
|
||||||
override_sets = self._contact_override_sets
|
override_sets = self._contact_override_sets
|
||||||
for overrides in override_sets:
|
for overrides in override_sets:
|
||||||
initial.update({
|
initial.update({
|
||||||
@@ -852,6 +1075,7 @@ class ConfirmStep(CartMixin, AsyncAction, TemplateFlowStep):
|
|||||||
ctx['confirm_messages'] = self.confirm_messages
|
ctx['confirm_messages'] = self.confirm_messages
|
||||||
ctx['cart_session'] = self.cart_session
|
ctx['cart_session'] = self.cart_session
|
||||||
ctx['invoice_address_asked'] = self.address_asked
|
ctx['invoice_address_asked'] = self.address_asked
|
||||||
|
ctx['customer'] = self.cart_customer
|
||||||
|
|
||||||
self.cart_session['shown_total'] = str(ctx['cart']['total'])
|
self.cart_session['shown_total'] = str(ctx['cart']['total'])
|
||||||
|
|
||||||
@@ -928,11 +1152,19 @@ class ConfirmStep(CartMixin, AsyncAction, TemplateFlowStep):
|
|||||||
for receiver, response in order_meta_from_request.send(sender=request.event, request=request):
|
for receiver, response in order_meta_from_request.send(sender=request.event, request=request):
|
||||||
meta_info.update(response)
|
meta_info.update(response)
|
||||||
|
|
||||||
return self.do(self.request.event.id, self.payment_provider.identifier if self.payment_provider else None,
|
return self.do(
|
||||||
[p.id for p in self.positions], self.cart_session.get('email'),
|
self.request.event.id,
|
||||||
translation.get_language(), self.invoice_address.pk, meta_info,
|
payment_provider=self.payment_provider.identifier if self.payment_provider else None,
|
||||||
request.sales_channel.identifier, self.cart_session.get('gift_cards'),
|
positions=[p.id for p in self.positions],
|
||||||
self.cart_session.get('shown_total'))
|
email=self.cart_session.get('email'),
|
||||||
|
locale=translation.get_language(),
|
||||||
|
address=self.invoice_address.pk,
|
||||||
|
meta_info=meta_info,
|
||||||
|
sales_channel=request.sales_channel.identifier,
|
||||||
|
gift_cards=self.cart_session.get('gift_cards'),
|
||||||
|
shown_total=self.cart_session.get('shown_total'),
|
||||||
|
customer=self.cart_session.get('customer'),
|
||||||
|
)
|
||||||
|
|
||||||
def get_success_message(self, value):
|
def get_success_message(self, value):
|
||||||
create_empty_cart_id(self.request)
|
create_empty_cart_id(self.request)
|
||||||
@@ -966,6 +1198,8 @@ class ConfirmStep(CartMixin, AsyncAction, TemplateFlowStep):
|
|||||||
|
|
||||||
DEFAULT_FLOW = (
|
DEFAULT_FLOW = (
|
||||||
AddOnsStep,
|
AddOnsStep,
|
||||||
|
CustomerStep,
|
||||||
|
MembershipStep,
|
||||||
QuestionsStep,
|
QuestionsStep,
|
||||||
PaymentStep,
|
PaymentStep,
|
||||||
ConfirmStep
|
ConfirmStep
|
||||||
|
|||||||
@@ -37,6 +37,9 @@ from itertools import chain
|
|||||||
from django import forms
|
from django import forms
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
from django.utils.encoding import force_str
|
from django.utils.encoding import force_str
|
||||||
|
from django.utils.formats import date_format
|
||||||
|
from django.utils.html import escape
|
||||||
|
from django.utils.safestring import mark_safe
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from phonenumber_field.formfields import PhoneNumberField
|
from phonenumber_field.formfields import PhoneNumberField
|
||||||
from phonenumber_field.phonenumber import PhoneNumber
|
from phonenumber_field.phonenumber import PhoneNumber
|
||||||
@@ -178,3 +181,60 @@ class AddOnVariationField(forms.ChoiceField):
|
|||||||
if value == k or text_value == force_str(k):
|
if value == k or text_value == force_str(k):
|
||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
class MembershipForm(forms.Form):
|
||||||
|
required_css_class = 'required'
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
self.memberships = kwargs.pop('memberships')
|
||||||
|
event = kwargs.pop('event')
|
||||||
|
self.position = kwargs.pop('position')
|
||||||
|
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
ev = self.position.subevent or event
|
||||||
|
if self.position.variation and self.position.variation.require_membership:
|
||||||
|
types = self.position.variation.require_membership_types.all()
|
||||||
|
else:
|
||||||
|
types = self.position.item.require_membership_types.all()
|
||||||
|
|
||||||
|
initial = None
|
||||||
|
|
||||||
|
memberships = [
|
||||||
|
m for m in self.memberships
|
||||||
|
if m.is_valid(ev) and m.membership_type in types
|
||||||
|
]
|
||||||
|
|
||||||
|
if len(memberships) == 1:
|
||||||
|
initial = str(memberships[0].pk)
|
||||||
|
|
||||||
|
self.fields['membership'] = forms.ChoiceField(
|
||||||
|
label=_('Membership'),
|
||||||
|
choices=[
|
||||||
|
(str(m.pk), self._label_from_instance(m))
|
||||||
|
for m in memberships
|
||||||
|
],
|
||||||
|
initial=initial,
|
||||||
|
widget=forms.RadioSelect,
|
||||||
|
)
|
||||||
|
self.is_empty = not memberships
|
||||||
|
|
||||||
|
def _label_from_instance(self, obj):
|
||||||
|
ds = date_format(obj.date_start, 'SHORT_DATE_FORMAT')
|
||||||
|
de = date_format(obj.date_end, 'SHORT_DATE_FORMAT')
|
||||||
|
if obj.membership_type.max_usages is not None:
|
||||||
|
usages = f'({obj.usages} / {obj.membership_type.max_usages})'
|
||||||
|
else:
|
||||||
|
usages = ''
|
||||||
|
return mark_safe(
|
||||||
|
f'<strong>{escape(obj.membership_type)}</strong> {usages}<br>'
|
||||||
|
f'{escape(obj.attendee_name)}<br>'
|
||||||
|
f'<span class="text-muted">{ds} – {de}</span>'
|
||||||
|
)
|
||||||
|
|
||||||
|
def clean(self):
|
||||||
|
d = super().clean()
|
||||||
|
if d.get('membership'):
|
||||||
|
d['membership'] = [m for m in self.memberships if str(m.pk) == d['membership']][0]
|
||||||
|
return d
|
||||||
|
|||||||
@@ -0,0 +1,456 @@
|
|||||||
|
#
|
||||||
|
# This file is part of pretix (Community Edition).
|
||||||
|
#
|
||||||
|
# Copyright (C) 2014-2020 Raphael Michel and contributors
|
||||||
|
# Copyright (C) 2020-2021 rami.io GmbH and contributors
|
||||||
|
#
|
||||||
|
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
|
||||||
|
# Public License as published by the Free Software Foundation in version 3 of the License.
|
||||||
|
#
|
||||||
|
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
|
||||||
|
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
|
||||||
|
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
|
||||||
|
# this file, see <https://pretix.eu/about/en/license>.
|
||||||
|
#
|
||||||
|
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
|
||||||
|
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||||||
|
# details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
|
||||||
|
# <https://www.gnu.org/licenses/>.
|
||||||
|
#
|
||||||
|
import hashlib
|
||||||
|
import ipaddress
|
||||||
|
|
||||||
|
from django import forms
|
||||||
|
from django.conf import settings
|
||||||
|
from django.contrib.auth.hashers import check_password
|
||||||
|
from django.contrib.auth.password_validation import (
|
||||||
|
password_validators_help_texts, validate_password,
|
||||||
|
)
|
||||||
|
from django.contrib.auth.tokens import PasswordResetTokenGenerator
|
||||||
|
from django.utils.functional import cached_property
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
|
from pretix.base.forms.questions import NamePartsFormField
|
||||||
|
from pretix.base.i18n import get_language_without_region
|
||||||
|
from pretix.base.models import Customer
|
||||||
|
from pretix.base.services.mail import mail
|
||||||
|
from pretix.helpers.http import get_client_ip
|
||||||
|
from pretix.multidomain.urlreverse import build_absolute_uri
|
||||||
|
|
||||||
|
|
||||||
|
class TokenGenerator(PasswordResetTokenGenerator):
|
||||||
|
key_salt = "pretix.presale.forms.customer.TokenGenerator"
|
||||||
|
|
||||||
|
|
||||||
|
class AuthenticationForm(forms.Form):
|
||||||
|
required_css_class = 'required'
|
||||||
|
email = forms.EmailField(
|
||||||
|
label=_("E-mail"),
|
||||||
|
widget=forms.EmailInput(attrs={'autofocus': True})
|
||||||
|
)
|
||||||
|
password = forms.CharField(
|
||||||
|
label=_("Password"),
|
||||||
|
strip=False,
|
||||||
|
widget=forms.PasswordInput(attrs={'autocomplete': 'current-password'}),
|
||||||
|
)
|
||||||
|
|
||||||
|
error_messages = {
|
||||||
|
'incomplete': _('You need to fill out all fields.'),
|
||||||
|
'invalid_login': _(
|
||||||
|
"We have not found an account with this email address and password."
|
||||||
|
),
|
||||||
|
'inactive': _("This account is disabled."),
|
||||||
|
'unverified': _("You have not yet activated your account and set a password. Please click the link in the "
|
||||||
|
"email we sent you. Click \"Reset password\" to receive a new email in case you cannot find "
|
||||||
|
"it again."),
|
||||||
|
}
|
||||||
|
|
||||||
|
def __init__(self, request=None, *args, **kwargs):
|
||||||
|
self.request = request
|
||||||
|
self.customer_cache = None
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
def clean(self):
|
||||||
|
email = self.cleaned_data.get('email')
|
||||||
|
password = self.cleaned_data.get('password')
|
||||||
|
|
||||||
|
if email is not None and password:
|
||||||
|
try:
|
||||||
|
u = self.request.organizer.customers.get(email=email)
|
||||||
|
except Customer.DoesNotExist:
|
||||||
|
# Run the default password hasher once to reduce the timing
|
||||||
|
# difference between an existing and a nonexistent user (django #20760).
|
||||||
|
Customer().set_password(password)
|
||||||
|
else:
|
||||||
|
if u.check_password(password):
|
||||||
|
self.customer_cache = u
|
||||||
|
if self.customer_cache is None:
|
||||||
|
raise forms.ValidationError(
|
||||||
|
self.error_messages['invalid_login'],
|
||||||
|
code='invalid_login',
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self.confirm_login_allowed(self.customer_cache)
|
||||||
|
else:
|
||||||
|
raise forms.ValidationError(
|
||||||
|
self.error_messages['incomplete'],
|
||||||
|
code='incomplete'
|
||||||
|
)
|
||||||
|
|
||||||
|
return self.cleaned_data
|
||||||
|
|
||||||
|
def confirm_login_allowed(self, user):
|
||||||
|
if not user.is_active:
|
||||||
|
raise forms.ValidationError(
|
||||||
|
self.error_messages['inactive'],
|
||||||
|
code='inactive',
|
||||||
|
)
|
||||||
|
if not user.is_verified:
|
||||||
|
raise forms.ValidationError(
|
||||||
|
self.error_messages['unverified'],
|
||||||
|
code='unverified',
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_customer(self):
|
||||||
|
return self.customer_cache
|
||||||
|
|
||||||
|
|
||||||
|
class RegistrationForm(forms.Form):
|
||||||
|
required_css_class = 'required'
|
||||||
|
name_parts = forms.CharField()
|
||||||
|
email = forms.EmailField(
|
||||||
|
label=_("E-mail"),
|
||||||
|
)
|
||||||
|
|
||||||
|
error_messages = {
|
||||||
|
'rate_limit': _("We've received a lot of registration requests from you, please wait 10 minutes before you try again."),
|
||||||
|
'duplicate': _(
|
||||||
|
"An account with this email address is already registered. Please try to log in or reset your password "
|
||||||
|
"instead."
|
||||||
|
),
|
||||||
|
'required': _('This field is required.'),
|
||||||
|
}
|
||||||
|
|
||||||
|
def __init__(self, request=None, *args, **kwargs):
|
||||||
|
self.request = request
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
self.fields['name_parts'] = NamePartsFormField(
|
||||||
|
max_length=255,
|
||||||
|
required=True,
|
||||||
|
scheme=request.organizer.settings.name_scheme,
|
||||||
|
titles=request.organizer.settings.name_scheme_titles,
|
||||||
|
label=_('Name'),
|
||||||
|
)
|
||||||
|
|
||||||
|
@cached_property
|
||||||
|
def ratelimit_key(self):
|
||||||
|
if not settings.HAS_REDIS:
|
||||||
|
return None
|
||||||
|
client_ip = get_client_ip(self.request)
|
||||||
|
if not client_ip:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
client_ip = ipaddress.ip_address(client_ip)
|
||||||
|
except ValueError:
|
||||||
|
# Web server not set up correctly
|
||||||
|
return None
|
||||||
|
if client_ip.is_private:
|
||||||
|
# This is the private IP of the server, web server not set up correctly
|
||||||
|
return None
|
||||||
|
return 'pretix_customer_registration_{}'.format(hashlib.sha1(str(client_ip).encode()).hexdigest())
|
||||||
|
|
||||||
|
def clean(self):
|
||||||
|
email = self.cleaned_data.get('email')
|
||||||
|
|
||||||
|
if email is not None:
|
||||||
|
try:
|
||||||
|
self.request.organizer.customers.get(email=email)
|
||||||
|
except Customer.DoesNotExist:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
raise forms.ValidationError(
|
||||||
|
{'email': self.error_messages['duplicate']},
|
||||||
|
code='duplicate',
|
||||||
|
)
|
||||||
|
|
||||||
|
if not self.cleaned_data.get('email'):
|
||||||
|
raise forms.ValidationError(
|
||||||
|
{'email': self.error_messages['required']},
|
||||||
|
code='incomplete'
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
if self.ratelimit_key:
|
||||||
|
from django_redis import get_redis_connection
|
||||||
|
|
||||||
|
rc = get_redis_connection("redis")
|
||||||
|
cnt = rc.incr(self.ratelimit_key)
|
||||||
|
rc.expire(self.ratelimit_key, 600)
|
||||||
|
if cnt > 10:
|
||||||
|
raise forms.ValidationError(
|
||||||
|
self.error_messages['rate_limit'],
|
||||||
|
code='rate_limit',
|
||||||
|
)
|
||||||
|
return self.cleaned_data
|
||||||
|
|
||||||
|
def create(self):
|
||||||
|
customer = self.request.organizer.customers.create(
|
||||||
|
email=self.cleaned_data['email'],
|
||||||
|
name_parts=self.cleaned_data['name_parts'],
|
||||||
|
is_active=True,
|
||||||
|
is_verified=False,
|
||||||
|
locale=get_language_without_region(),
|
||||||
|
)
|
||||||
|
customer.set_unusable_password()
|
||||||
|
customer.save()
|
||||||
|
customer.log_action('pretix.customer.created', {})
|
||||||
|
ctx = customer.get_email_context()
|
||||||
|
token = TokenGenerator().make_token(customer)
|
||||||
|
ctx['url'] = build_absolute_uri(self.request.organizer,
|
||||||
|
'presale:organizer.customer.activate') + '?id=' + customer.identifier + '&token=' + token
|
||||||
|
mail(
|
||||||
|
customer.email,
|
||||||
|
_('Activate your account at {organizer}').format(organizer=self.request.organizer.name),
|
||||||
|
self.request.organizer.settings.mail_text_customer_registration,
|
||||||
|
ctx,
|
||||||
|
locale=customer.locale,
|
||||||
|
customer=customer,
|
||||||
|
organizer=self.request.organizer,
|
||||||
|
)
|
||||||
|
return customer
|
||||||
|
|
||||||
|
|
||||||
|
class SetPasswordForm(forms.Form):
|
||||||
|
required_css_class = 'required'
|
||||||
|
error_messages = {
|
||||||
|
'pw_mismatch': _("Please enter the same password twice"),
|
||||||
|
}
|
||||||
|
email = forms.EmailField(
|
||||||
|
label=_('E-mail'),
|
||||||
|
disabled=True
|
||||||
|
)
|
||||||
|
password = forms.CharField(
|
||||||
|
label=_('Password'),
|
||||||
|
widget=forms.PasswordInput(attrs={'minlength': '8', 'autocomplete': 'new-password'}),
|
||||||
|
required=True
|
||||||
|
)
|
||||||
|
password_repeat = forms.CharField(
|
||||||
|
label=_('Repeat password'),
|
||||||
|
widget=forms.PasswordInput(attrs={'minlength': '8', 'autocomplete': 'new-password'}),
|
||||||
|
)
|
||||||
|
|
||||||
|
def __init__(self, customer=None, *args, **kwargs):
|
||||||
|
self.customer = customer
|
||||||
|
kwargs.setdefault('initial', {})
|
||||||
|
kwargs['initial']['email'] = self.customer.email
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
def clean(self):
|
||||||
|
password1 = self.cleaned_data.get('password', '')
|
||||||
|
password2 = self.cleaned_data.get('password_repeat')
|
||||||
|
|
||||||
|
if password1 and password1 != password2:
|
||||||
|
raise forms.ValidationError({
|
||||||
|
'password_repeat': self.error_messages['pw_mismatch'],
|
||||||
|
}, code='pw_mismatch')
|
||||||
|
|
||||||
|
return self.cleaned_data
|
||||||
|
|
||||||
|
def clean_password(self):
|
||||||
|
password1 = self.cleaned_data.get('password', '')
|
||||||
|
if validate_password(password1, user=self.customer) is not None:
|
||||||
|
raise forms.ValidationError(_(password_validators_help_texts()), code='pw_invalid')
|
||||||
|
return password1
|
||||||
|
|
||||||
|
|
||||||
|
class ResetPasswordForm(forms.Form):
|
||||||
|
required_css_class = 'required'
|
||||||
|
error_messages = {
|
||||||
|
'rate_limit': _("For security reasons, please wait 10 minutes before you try again."),
|
||||||
|
'unknown': _("A user with this email address is not known in our system."),
|
||||||
|
}
|
||||||
|
email = forms.EmailField(
|
||||||
|
label=_('E-mail'),
|
||||||
|
)
|
||||||
|
|
||||||
|
def __init__(self, request=None, *args, **kwargs):
|
||||||
|
self.request = request
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
def clean_email(self):
|
||||||
|
if 'email' not in self.cleaned_data:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
self.customer = self.request.organizer.customers.get(email=self.cleaned_data['email'])
|
||||||
|
return self.customer.email
|
||||||
|
except Customer.DoesNotExist:
|
||||||
|
# Yup, this is an information leak. But it prevents dozens of support requests – and even if we didn't
|
||||||
|
# have it, there'd be an info leak in the registration flow (trying to sign up for an account, which fails
|
||||||
|
# if the email address already exists).
|
||||||
|
raise forms.ValidationError(self.error_messages['unknown'], code='unknown')
|
||||||
|
|
||||||
|
def clean(self):
|
||||||
|
d = super().clean()
|
||||||
|
if d.get('email') and settings.HAS_REDIS:
|
||||||
|
from django_redis import get_redis_connection
|
||||||
|
|
||||||
|
rc = get_redis_connection("redis")
|
||||||
|
cnt = rc.incr('pretix_pwreset_customer_%s' % self.customer.pk)
|
||||||
|
rc.expire('pretix_pwreset_customer_%s' % self.customer.pk, 600)
|
||||||
|
if cnt > 2:
|
||||||
|
raise forms.ValidationError(
|
||||||
|
self.error_messages['rate_limit'],
|
||||||
|
code='rate_limit',
|
||||||
|
)
|
||||||
|
return d
|
||||||
|
|
||||||
|
|
||||||
|
class ChangePasswordForm(forms.Form):
|
||||||
|
required_css_class = 'required'
|
||||||
|
error_messages = {
|
||||||
|
'pw_current_wrong': _("The current password you entered was not correct."),
|
||||||
|
'pw_mismatch': _("Please enter the same password twice"),
|
||||||
|
'rate_limit': _("For security reasons, please wait 5 minutes before you try again."),
|
||||||
|
}
|
||||||
|
email = forms.EmailField(
|
||||||
|
label=_('E-mail'),
|
||||||
|
disabled=True
|
||||||
|
)
|
||||||
|
password_current = forms.CharField(
|
||||||
|
label=_('Your current password'),
|
||||||
|
widget=forms.PasswordInput,
|
||||||
|
required=True
|
||||||
|
)
|
||||||
|
password = forms.CharField(
|
||||||
|
label=_('New password'),
|
||||||
|
widget=forms.PasswordInput,
|
||||||
|
required=True
|
||||||
|
)
|
||||||
|
password_repeat = forms.CharField(
|
||||||
|
label=_('Repeat password'),
|
||||||
|
widget=forms.PasswordInput(attrs={'minlength': '8', 'autocomplete': 'new-password'}),
|
||||||
|
)
|
||||||
|
|
||||||
|
def __init__(self, customer, *args, **kwargs):
|
||||||
|
self.customer = customer
|
||||||
|
kwargs.setdefault('initial', {})
|
||||||
|
kwargs['initial']['email'] = self.customer.email
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
def clean(self):
|
||||||
|
password1 = self.cleaned_data.get('password', '')
|
||||||
|
password2 = self.cleaned_data.get('password_repeat')
|
||||||
|
|
||||||
|
if password1 and password1 != password2:
|
||||||
|
raise forms.ValidationError({
|
||||||
|
'password_repeat': self.error_messages['pw_mismatch'],
|
||||||
|
}, code='pw_mismatch')
|
||||||
|
|
||||||
|
return self.cleaned_data
|
||||||
|
|
||||||
|
def clean_password(self):
|
||||||
|
password1 = self.cleaned_data.get('password', '')
|
||||||
|
if validate_password(password1, user=self.customer) is not None:
|
||||||
|
raise forms.ValidationError(_(password_validators_help_texts()), code='pw_invalid')
|
||||||
|
return password1
|
||||||
|
|
||||||
|
def clean_password_current(self):
|
||||||
|
old_pw = self.cleaned_data.get('password_current')
|
||||||
|
|
||||||
|
if old_pw and settings.HAS_REDIS:
|
||||||
|
from django_redis import get_redis_connection
|
||||||
|
|
||||||
|
rc = get_redis_connection("redis")
|
||||||
|
cnt = rc.incr('pretix_pwchange_customer_%s' % self.customer.pk)
|
||||||
|
rc.expire('pretix_pwchange_customer_%s' % self.customer.pk, 300)
|
||||||
|
if cnt > 10:
|
||||||
|
raise forms.ValidationError(
|
||||||
|
self.error_messages['rate_limit'],
|
||||||
|
code='rate_limit',
|
||||||
|
)
|
||||||
|
|
||||||
|
if old_pw and not check_password(old_pw, self.customer.password):
|
||||||
|
raise forms.ValidationError(
|
||||||
|
self.error_messages['pw_current_wrong'],
|
||||||
|
code='pw_current_wrong',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ChangeInfoForm(forms.ModelForm):
|
||||||
|
required_css_class = 'required'
|
||||||
|
error_messages = {
|
||||||
|
'pw_current_wrong': _("The current password you entered was not correct."),
|
||||||
|
'rate_limit': _("For security reasons, please wait 5 minutes before you try again."),
|
||||||
|
'duplicate': _("An account with this email address is already registered."),
|
||||||
|
}
|
||||||
|
password_current = forms.CharField(
|
||||||
|
label=_('Your current password'),
|
||||||
|
widget=forms.PasswordInput,
|
||||||
|
help_text=_('Only required if you change your email address'),
|
||||||
|
required=False
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Customer
|
||||||
|
fields = ('name_parts', 'email')
|
||||||
|
|
||||||
|
def __init__(self, request=None, *args, **kwargs):
|
||||||
|
self.request = request
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
self.fields['name_parts'] = NamePartsFormField(
|
||||||
|
max_length=255,
|
||||||
|
required=True,
|
||||||
|
scheme=request.organizer.settings.name_scheme,
|
||||||
|
titles=request.organizer.settings.name_scheme_titles,
|
||||||
|
label=_('Name'),
|
||||||
|
)
|
||||||
|
|
||||||
|
def clean_password_current(self):
|
||||||
|
old_pw = self.cleaned_data.get('password_current')
|
||||||
|
|
||||||
|
if old_pw:
|
||||||
|
if settings.HAS_REDIS:
|
||||||
|
from django_redis import get_redis_connection
|
||||||
|
|
||||||
|
rc = get_redis_connection("redis")
|
||||||
|
cnt = rc.incr('pretix_pwchange_customer_%s' % self.instance.pk)
|
||||||
|
rc.expire('pretix_pwchange_customer_%s' % self.instance.pk, 300)
|
||||||
|
if cnt > 10:
|
||||||
|
raise forms.ValidationError(
|
||||||
|
self.error_messages['rate_limit'],
|
||||||
|
code='rate_limit',
|
||||||
|
)
|
||||||
|
|
||||||
|
if not check_password(old_pw, self.instance.password):
|
||||||
|
raise forms.ValidationError(
|
||||||
|
self.error_messages['pw_current_wrong'],
|
||||||
|
code='pw_current_wrong',
|
||||||
|
)
|
||||||
|
|
||||||
|
return "***valid***"
|
||||||
|
|
||||||
|
def clean(self):
|
||||||
|
email = self.cleaned_data.get('email')
|
||||||
|
password_current = self.cleaned_data.get('password_current')
|
||||||
|
|
||||||
|
if email != self.instance.email and not password_current:
|
||||||
|
raise forms.ValidationError(
|
||||||
|
self.error_messages['pw_current_wrong'],
|
||||||
|
code='pw_current_wrong',
|
||||||
|
)
|
||||||
|
|
||||||
|
if email is not None:
|
||||||
|
try:
|
||||||
|
self.request.organizer.customers.exclude(pk=self.instance.pk).get(email=email)
|
||||||
|
except Customer.DoesNotExist:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
raise forms.ValidationError(
|
||||||
|
self.error_messages['duplicate'],
|
||||||
|
code='duplicate',
|
||||||
|
)
|
||||||
|
|
||||||
|
return self.cleaned_data
|
||||||
@@ -120,7 +120,10 @@ class CheckoutFieldRenderer(FieldRenderer):
|
|||||||
def add_label(self, html):
|
def add_label(self, html):
|
||||||
label = self.get_label()
|
label = self.get_label()
|
||||||
|
|
||||||
if hasattr(self.field.field, '_required'):
|
if hasattr(self.field.field, '_show_required'):
|
||||||
|
# e.g. payment settings forms where a field is only required if the payment provider is active
|
||||||
|
required = self.field.field._show_required
|
||||||
|
elif hasattr(self.field.field, '_required'):
|
||||||
# e.g. payment settings forms where a field is only required if the payment provider is active
|
# e.g. payment settings forms where a field is only required if the payment provider is active
|
||||||
required = self.field.field._required
|
required = self.field.field._required
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -34,21 +34,24 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<div class="container page-header-links {% if event.settings.theme_color_background|upper != "#FFFFFF" or event.settings.logo_image_large %}page-header-links-outside{% endif %}">
|
<div class="container page-header-links {% if event.settings.theme_color_background|upper != "#FFFFFF" or event.settings.logo_image_large %}page-header-links-outside{% endif %}">
|
||||||
{% if event.settings.locales|length > 1 %}
|
{% if event.settings.locales|length > 1 or request.organizer.settings.customer_accounts %}
|
||||||
{% if event.settings.theme_color_background|upper != "#FFFFFF" or event.settings.logo_image_large %}
|
{% if event.settings.theme_color_background|upper != "#FFFFFF" or event.settings.logo_image_large %}
|
||||||
<div class="pull-right header-part flip hidden-print">
|
<div class="pull-right header-part flip hidden-print">
|
||||||
<nav class="locales" aria-label="{% trans "select language" %}">
|
{% if event.settings.locales|length > 1 %}
|
||||||
{% for l in languages %}
|
<nav class="locales" aria-label="{% trans "select language" %}">
|
||||||
<a href="{% url "presale:locale.set" %}?locale={{ l.code }}&next={{ request.path }}{% if request.META.QUERY_STRING %}%3F{{ request.META.QUERY_STRING|urlencode }}{% endif %}" class="{% if l.code == request.LANGUAGE_CODE %}active{% endif %}" rel="nofollow" lang="{{ l.code }}" hreflang="{{ l.code }}">
|
{% for l in languages %}
|
||||||
{{ l.name_local }}</a>
|
<a href="{% url "presale:locale.set" %}?locale={{ l.code }}&next={{ request.path }}{% if request.META.QUERY_STRING %}%3F{{ request.META.QUERY_STRING|urlencode }}{% endif %}" class="{% if l.code == request.LANGUAGE_CODE %}active{% endif %}" rel="nofollow" lang="{{ l.code }}" hreflang="{{ l.code }}">
|
||||||
{% endfor %}
|
{{ l.name_local }}</a>
|
||||||
</nav>
|
{% endfor %}
|
||||||
|
</nav>
|
||||||
|
{% endif %}
|
||||||
|
{% include "pretixpresale/fragment_login_status.html" %}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if request.event.settings.organizer_link_back %}
|
{% if request.event.settings.organizer_link_back %}
|
||||||
<div class="pull-left header-part flip hidden-print">
|
<div class="pull-left header-part flip hidden-print">
|
||||||
<a href="{% eventurl request.organizer "presale:organizer.index" %}">
|
<a href="{% abseventurl request.organizer "presale:organizer.index" %}">
|
||||||
« {% blocktrans trimmed with name=request.organizer.name %}
|
« {% blocktrans trimmed with name=request.organizer.name %}
|
||||||
Show all events of {{ name }}
|
Show all events of {{ name }}
|
||||||
{% endblocktrans %}
|
{% endblocktrans %}
|
||||||
@@ -80,15 +83,18 @@
|
|||||||
</h1>
|
</h1>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% if event.settings.locales|length > 1 %}
|
{% if event.settings.locales|length > 1 or request.organizer.settings.customer_accounts %}
|
||||||
{% if event.settings.theme_color_background|upper == "#FFFFFF" and not event.settings.logo_image_large %}
|
{% if event.settings.theme_color_background|upper == "#FFFFFF" and not event.settings.logo_image_large %}
|
||||||
<div class="{% if not event_logo or not event.settings.logo_image_large %}pull-right flip{% endif %} loginbox hidden-print">
|
<div class="{% if not event_logo or not event.settings.logo_image_large %}pull-right flip{% endif %} loginbox hidden-print">
|
||||||
<nav class="locales" aria-label="{% trans "select language" %}">
|
{% if event.settings.locales|length > 1 %}
|
||||||
{% for l in languages %}
|
<nav class="locales" aria-label="{% trans "select language" %}">
|
||||||
<a href="{% url "presale:locale.set" %}?locale={{ l.code }}&next={{ request.path }}{% if request.META.QUERY_STRING %}%3F{{ request.META.QUERY_STRING|urlencode }}{% endif %}" class="{% if l.code == request.LANGUAGE_CODE %}active{% endif %}" rel="nofollow">
|
{% for l in languages %}
|
||||||
{{ l.name_local }}</a>
|
<a href="{% url "presale:locale.set" %}?locale={{ l.code }}&next={{ request.path }}{% if request.META.QUERY_STRING %}%3F{{ request.META.QUERY_STRING|urlencode }}{% endif %}" class="{% if l.code == request.LANGUAGE_CODE %}active{% endif %}" rel="nofollow">
|
||||||
{% endfor %}
|
{{ l.name_local }}</a>
|
||||||
</nav>
|
{% endfor %}
|
||||||
|
</nav>
|
||||||
|
{% endif %}
|
||||||
|
{% include "pretixpresale/fragment_login_status.html" %}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -139,6 +139,12 @@
|
|||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
<div class="panel-body">
|
<div class="panel-body">
|
||||||
|
{% if customer %}
|
||||||
|
<dl class="dl-horizontal">
|
||||||
|
<dt>{% trans "Customer account" %}</dt>
|
||||||
|
<dd>{{ customer.email }}<br>{{ customer.name }}<br>#{{ customer.identifier }}</dd>
|
||||||
|
</dl>
|
||||||
|
{% endif %}
|
||||||
{% if not asked and event.settings.invoice_name_required %}
|
{% if not asked and event.settings.invoice_name_required %}
|
||||||
<dl class="dl-horizontal">
|
<dl class="dl-horizontal">
|
||||||
<dt>{% trans "Name" %}</dt>
|
<dt>{% trans "Name" %}</dt>
|
||||||
|
|||||||
@@ -0,0 +1,141 @@
|
|||||||
|
{% extends "pretixpresale/event/checkout_base.html" %}
|
||||||
|
{% load i18n %}
|
||||||
|
{% load money %}
|
||||||
|
{% load bootstrap3 %}
|
||||||
|
{% load eventurl %}
|
||||||
|
{% load rich_text %}
|
||||||
|
{% block inner %}
|
||||||
|
<form method="post">
|
||||||
|
{% csrf_token %}
|
||||||
|
<div class="panel-group" id="customer">
|
||||||
|
<div class="panel panel-default">
|
||||||
|
<label class="accordion-radio">
|
||||||
|
<div class="panel-heading">
|
||||||
|
<h4 class="panel-title">
|
||||||
|
<input type="radio" name="customer_mode" value="login"
|
||||||
|
data-parent="#customer"
|
||||||
|
{% if selected == "login" %}checked="checked"{% endif %}
|
||||||
|
data-toggle="radiocollapse" data-target="#customer_login"/>
|
||||||
|
<strong>
|
||||||
|
{% trans "Log in with a customer account" %}
|
||||||
|
</strong>
|
||||||
|
</h4>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
<div id="customer_login"
|
||||||
|
class="panel-collapse collapsed {% if selected == "login" %}in{% endif %}">
|
||||||
|
<div class="panel-body form-horizontal">
|
||||||
|
{% if customer %}
|
||||||
|
<p>
|
||||||
|
{% blocktrans trimmed with org=request.organizer.name %}
|
||||||
|
You are currently logged in with the following credentials.
|
||||||
|
{% endblocktrans %}
|
||||||
|
</p>
|
||||||
|
<dl class="dl-horizontal">
|
||||||
|
<dt>{% trans "Email" %}</dt>
|
||||||
|
<dd>
|
||||||
|
{{ customer.email }}
|
||||||
|
</dd>
|
||||||
|
<dt>{% trans "Name" %}</dt>
|
||||||
|
<dd>{{ customer.name }}</dd>
|
||||||
|
<dt>{% trans "Customer ID" %}</dt>
|
||||||
|
<dd>
|
||||||
|
#{{ customer.identifier }}
|
||||||
|
</dd>
|
||||||
|
</dl>
|
||||||
|
{% else %}
|
||||||
|
<p>
|
||||||
|
{% blocktrans trimmed with org=request.organizer.name %}
|
||||||
|
If you created a customer account at {{ org }} before, you can log in now and connect
|
||||||
|
your order to your account. This will allow you to see all your orders in one place
|
||||||
|
and access them at any time.
|
||||||
|
{% endblocktrans %}
|
||||||
|
</p>
|
||||||
|
{% bootstrap_form login_form layout="checkout" %}
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-offset-3 col-md-9">
|
||||||
|
<a
|
||||||
|
href="{% abseventurl request.organizer "presale:organizer.customer.resetpw" %}"
|
||||||
|
target="_blank">
|
||||||
|
{% trans "Reset password" %}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="panel panel-default">
|
||||||
|
<label class="accordion-radio">
|
||||||
|
<div class="panel-heading">
|
||||||
|
<h4 class="panel-title">
|
||||||
|
<input type="radio" name="customer_mode" value="register"
|
||||||
|
data-parent="#customer"
|
||||||
|
{% if selected == "register" %}checked="checked"{% endif %}
|
||||||
|
data-toggle="radiocollapse" data-target="#customer_register"/>
|
||||||
|
<strong>
|
||||||
|
{% trans "Create a new customer account" %}
|
||||||
|
</strong>
|
||||||
|
</h4>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
<div id="customer_register"
|
||||||
|
class="panel-collapse collapsed {% if selected == "register" %}in{% endif %}">
|
||||||
|
<div class="panel-body form-horizontal">
|
||||||
|
{% bootstrap_form register_form layout="checkout" %}
|
||||||
|
<p>
|
||||||
|
{% blocktrans trimmed with org=request.organizer.name %}
|
||||||
|
We will send you an email with a link to activate your account and set a password, so
|
||||||
|
you can use the account for future orders at {{ org }}. You can still go ahead with this
|
||||||
|
purchase before you received the email.
|
||||||
|
{% endblocktrans %}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% if guest_allowed %}
|
||||||
|
<div class="panel panel-default">
|
||||||
|
<label class="accordion-radio">
|
||||||
|
<div class="panel-heading">
|
||||||
|
<h4 class="panel-title">
|
||||||
|
<input type="radio" name="customer_mode" value="guest"
|
||||||
|
data-parent="#customer"
|
||||||
|
{% if selected == "guest" %}checked="checked"{% endif %}
|
||||||
|
data-toggle="radiocollapse" data-target="#customer_guest"/>
|
||||||
|
<strong>
|
||||||
|
{% trans "Continue as a guest" %}
|
||||||
|
</strong>
|
||||||
|
</h4>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
<div id="customer_guest"
|
||||||
|
class="panel-collapse collapsed {% if selected == "guest" %}in{% endif %}">
|
||||||
|
<div class="panel-body form-horizontal">
|
||||||
|
<p>
|
||||||
|
{% blocktrans trimmed %}
|
||||||
|
You are not required to create an account. If you proceed as a guest, you will be able
|
||||||
|
to access the details and status of your order any time through the secret link we will
|
||||||
|
send you via email once the order is complete.
|
||||||
|
{% endblocktrans %}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="row checkout-button-row">
|
||||||
|
<div class="col-md-4 col-sm-6">
|
||||||
|
<a class="btn btn-block btn-default btn-lg"
|
||||||
|
href="{{ prev_url }}">
|
||||||
|
{% trans "Go back" %}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4 col-md-offset-4 col-sm-6">
|
||||||
|
<button class="btn btn-block btn-primary btn-lg" type="submit">
|
||||||
|
{% trans "Continue" %}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="clearfix"></div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
{% extends "pretixpresale/event/checkout_base.html" %}
|
||||||
|
{% load i18n %}
|
||||||
|
{% load bootstrap3 %}
|
||||||
|
{% load rich_text %}
|
||||||
|
{% block inner %}
|
||||||
|
<p>{% trans "Some of the products in your cart can only be purchased if there is an active membership on your account." %}</p>
|
||||||
|
<form class="form-horizontal" method="post">
|
||||||
|
{% csrf_token %}
|
||||||
|
{% for form in forms %}
|
||||||
|
<details class="panel panel-default" open>
|
||||||
|
<summary class="panel-heading">
|
||||||
|
<h4 class="panel-title">
|
||||||
|
<strong>{{ form.position.item.name }}{% if form.position.variation %}
|
||||||
|
– {{ form.position.variation }}
|
||||||
|
{% endif %}</strong>
|
||||||
|
<i class="fa fa-angle-down collapse-indicator" aria-hidden="true"></i>
|
||||||
|
</h4>
|
||||||
|
</summary>
|
||||||
|
<div>
|
||||||
|
<div class="panel-body questions-form">
|
||||||
|
{% if form.position.seat %}
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="col-md-3 control-label">
|
||||||
|
{% trans "Seat" %}
|
||||||
|
</label>
|
||||||
|
<div class="col-md-9 form-control-text">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="14" viewBox="0 0 4.7624999 3.7041668" class="svg-icon">
|
||||||
|
<path
|
||||||
|
d="m 1.9592032,1.8522629e-4 c -0.21468,0 -0.38861,0.17394000371 -0.38861,0.38861000371 0,0.21466 0.17393,0.38861 0.38861,0.38861 0.21468,0 0.3886001,-0.17395 0.3886001,-0.38861 0,-0.21467 -0.1739201,-0.38861000371 -0.3886001,-0.38861000371 z m 0.1049,0.84543000371 c -0.20823,-0.0326 -0.44367,0.12499 -0.39998,0.40462997 l 0.20361,1.01854 c 0.0306,0.15316 0.15301,0.28732 0.3483,0.28732 h 0.8376701 v 0.92708 c 0,0.29313 0.41187,0.29447 0.41187,0.005 v -1.19115 c 0,-0.14168 -0.0995,-0.29507 -0.29094,-0.29507 l -0.65578,-10e-4 -0.1757,-0.87644 C 2.3042533,0.95300523 2.1890432,0.86500523 2.0641032,0.84547523 Z m -0.58549,0.44906997 c -0.0946,-0.0134 -0.20202,0.0625 -0.17829,0.19172 l 0.18759,0.91054 c 0.0763,0.33956 0.36802,0.55914 0.66042,0.55914 h 0.6015201 c 0.21356,0 0.21448,-0.32143 -0.003,-0.32143 H 2.1954632 c -0.19911,0 -0.36364,-0.11898 -0.41341,-0.34107 l -0.17777,-0.87126 c -0.0165,-0.0794 -0.0688,-0.11963 -0.12557,-0.12764 z"/>
|
||||||
|
</svg>
|
||||||
|
{{ form.position.seat }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if form.position.addons.all %}
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="col-md-3 control-label">
|
||||||
|
{% trans "Selected add-ons" %}
|
||||||
|
</label>
|
||||||
|
<div class="col-md-9 form-control-text">
|
||||||
|
<ul class="addon-list">
|
||||||
|
{% for a in form.position.addons.all %}
|
||||||
|
<li>{{ a.item.name }}{% if a.variation %} – {{ a.variation.value }}{% endif %}</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if form.position.subevent %}
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="col-md-3 control-label">
|
||||||
|
{% trans "Date" context "subevent" %}
|
||||||
|
</label>
|
||||||
|
<div class="col-md-9 form-control-text">
|
||||||
|
<ul class="addon-list">
|
||||||
|
{{ form.position.subevent.name }} · {{ form.position.subevent.get_date_range_display }}
|
||||||
|
{% if form.position.event.settings.show_times %}
|
||||||
|
<span data-time="{{ form.position.subevent.date_from.isoformat }}" data-timezone="{{ request.event.timezone }}">
|
||||||
|
<span class="fa fa-clock-o" aria-hidden="true"></span>
|
||||||
|
{{ form.position.subevent.date_from|date:"TIME_FORMAT" }}
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if form.is_empty %}
|
||||||
|
<div class="alert alert-danger">
|
||||||
|
{% trans "Your account does not include an active membership that allows you to buy this product." %}
|
||||||
|
{% trans "You will not be able to continue." %}
|
||||||
|
</div>
|
||||||
|
<div class="sr-only">
|
||||||
|
{% bootstrap_form form layout="checkout" %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
{% bootstrap_form form layout="checkout" %}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
{% endfor %}
|
||||||
|
<div class="row checkout-button-row">
|
||||||
|
<div class="col-md-4 col-sm-6">
|
||||||
|
<a class="btn btn-block btn-default btn-lg"
|
||||||
|
href="{{ prev_url }}">
|
||||||
|
{% trans "Go back" %}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4 col-md-offset-4 col-sm-6">
|
||||||
|
<button class="btn btn-block btn-primary btn-lg" type="submit">
|
||||||
|
{% trans "Continue" %}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="clearfix"></div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{% endblock %}
|
||||||
@@ -34,6 +34,9 @@
|
|||||||
</span>
|
</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% if line.used_membership %}
|
||||||
|
<br /><span class="fa fa-id-card fa-fw" aria-hidden="true"></span> {{ line.used_membership }}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{% if line.issued_gift_cards %}
|
{% if line.issued_gift_cards %}
|
||||||
<dl>
|
<dl>
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
{% load i18n %}
|
||||||
|
{% load eventurl %}
|
||||||
|
|
||||||
|
{% if request.organizer.settings.customer_accounts %}
|
||||||
|
<nav class="loginstatus" aria-label="{% trans "customer account" %}">
|
||||||
|
{% if request.customer %}
|
||||||
|
<a href="{% abseventurl request.organizer "presale:organizer.customer.profile" %}"
|
||||||
|
aria-label="{% trans "View customer account" %}" data-placement="bottom"
|
||||||
|
title="{% trans "View user profile" %}" data-toggle="tooltip">
|
||||||
|
<span class="fa fa-user" aria-hidden="true"></span>
|
||||||
|
{{ request.customer.name|default:request.customer.email }}</a>
|
||||||
|
<a href="{% abseventurl request.organizer "presale:organizer.customer.logout" %}?next={{ request.path|urlencode }}%3F{{ request.META.QUERY_STRING|urlencode }}"
|
||||||
|
aria-label="{% trans "Log out" %}" data-toggle="tooltip" data-placement="left"
|
||||||
|
title="{% trans "Log out" %}">
|
||||||
|
<span class="fa fa-sign-out" aria-hidden="true"></span>
|
||||||
|
</a>
|
||||||
|
{% else %}
|
||||||
|
<a href="{% abseventurl request.organizer "presale:organizer.customer.login" %}?next={{ request.path|urlencode }}%3F{{ request.META.QUERY_STRING|urlencode }}">
|
||||||
|
{% trans "Log in" %}</a>
|
||||||
|
|
||||||
|
{% endif %}
|
||||||
|
</nav>
|
||||||
|
{% endif %}
|
||||||
@@ -8,16 +8,19 @@
|
|||||||
{% block title %}{% endblock %}{% if url_name != "organizer.index" %} :: {% endif %}{{ organizer.name }}
|
{% block title %}{% endblock %}{% if url_name != "organizer.index" %} :: {% endif %}{{ organizer.name }}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
{% block above %}
|
{% block above %}
|
||||||
{% if organizer.settings.locales|length > 1 %}
|
{% if organizer.settings.locales|length > 1 or request.organizer.settings.customer_accounts %}
|
||||||
{% if organizer.settings.theme_color_background|upper != "#FFFFFF" or organizer.settings.organizer_logo_image_large %}
|
{% if organizer.settings.theme_color_background|upper != "#FFFFFF" or organizer.settings.organizer_logo_image_large %}
|
||||||
<div class="container page-header-links">
|
<div class="container page-header-links">
|
||||||
<div class="pull-right header-part flip">
|
<div class="pull-right header-part flip">
|
||||||
<div class="locales">
|
{% if organizer.settings.locales|length > 1 %}
|
||||||
{% for l in languages %}
|
<nav class="locales" aria-label="{% trans "select language" %}">
|
||||||
<a href="{% url "presale:locale.set" %}?locale={{ l.code }}&next={{ request.path }}%3F{{ request.META.QUERY_STRING|urlencode }}" class="{% if l.code == request.LANGUAGE_CODE %}active{% endif %}" rel="nofollow">
|
{% for l in languages %}
|
||||||
{{ l.name_local }}</a>
|
<a href="{% url "presale:locale.set" %}?locale={{ l.code }}&next={{ request.path }}%3F{{ request.META.QUERY_STRING|urlencode }}" class="{% if l.code == request.LANGUAGE_CODE %}active{% endif %}" rel="nofollow">
|
||||||
{% endfor %}
|
{{ l.name_local }}</a>
|
||||||
</div>
|
{% endfor %}
|
||||||
|
</nav>
|
||||||
|
{% endif %}
|
||||||
|
{% include "pretixpresale/fragment_login_status.html" %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -40,15 +43,18 @@
|
|||||||
<h1><a href="{% eventurl organizer "presale:organizer.index" %}">{{ organizer.name }}</a></h1>
|
<h1><a href="{% eventurl organizer "presale:organizer.index" %}">{{ organizer.name }}</a></h1>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% if organizer.settings.locales|length > 1 %}
|
{% if organizer.settings.locales|length > 1 or request.organizer.settings.customer_accounts %}
|
||||||
{% if organizer.settings.theme_color_background|upper == "#FFFFFF" and not organizer.settings.organizer_logo_image_large %}
|
{% if organizer.settings.theme_color_background|upper == "#FFFFFF" and not organizer.settings.organizer_logo_image_large %}
|
||||||
<div class="{% if not organizer_logo or not organizer.settings.organizer_logo_image_large %}pull-right flip{% endif %} loginbox">
|
<div class="{% if not organizer_logo or not organizer.settings.organizer_logo_image_large %}pull-right flip{% endif %} loginbox">
|
||||||
<div class="locales">
|
{% if organizer.settings.locales|length > 1 %}
|
||||||
{% for l in languages %}
|
<nav class="locales" aria-label="{% trans "select language" %}">
|
||||||
<a href="{% url "presale:locale.set" %}?locale={{ l.code }}&next={{ request.path }}%3F{{ request.META.QUERY_STRING|urlencode }}" class="{% if l.code == request.LANGUAGE_CODE %}active{% endif %}" rel="nofollow">
|
{% for l in languages %}
|
||||||
{{ l.name_local }}</a>
|
<a href="{% url "presale:locale.set" %}?locale={{ l.code }}&next={{ request.path }}%3F{{ request.META.QUERY_STRING|urlencode }}" class="{% if l.code == request.LANGUAGE_CODE %}active{% endif %}" rel="nofollow">
|
||||||
{% endfor %}
|
{{ l.name_local }}</a>
|
||||||
</div>
|
{% endfor %}
|
||||||
|
</nav>
|
||||||
|
{% endif %}
|
||||||
|
{% include "pretixpresale/fragment_login_status.html" %}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
{% extends "pretixpresale/organizers/base.html" %}
|
||||||
|
{% load i18n %}
|
||||||
|
{% load eventurl %}
|
||||||
|
{% load urlreplace %}
|
||||||
|
{% load bootstrap3 %}
|
||||||
|
{% block title %}{% trans "Account information" %}{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6 col-md-offset-3">
|
||||||
|
<h2>
|
||||||
|
{% blocktrans trimmed %}
|
||||||
|
Update your account information
|
||||||
|
{% endblocktrans %}
|
||||||
|
</h2>
|
||||||
|
<form action="" method="post">
|
||||||
|
{% csrf_token %}
|
||||||
|
{% bootstrap_form form %}
|
||||||
|
<div class="form-group buttons">
|
||||||
|
<button type="submit" class="btn btn-primary btn-lg btn-block">
|
||||||
|
{% trans "Save" %}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p> </p>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
{% extends "pretixpresale/organizers/base.html" %}
|
||||||
|
{% load i18n %}
|
||||||
|
{% load eventurl %}
|
||||||
|
{% load urlreplace %}
|
||||||
|
{% load bootstrap3 %}
|
||||||
|
{% block title %}{% trans "Log in" %}{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6 col-md-offset-3">
|
||||||
|
<h2>
|
||||||
|
{% blocktrans trimmed with org=request.organizer.name %}
|
||||||
|
Sign in to your account at {{ org }}
|
||||||
|
{% endblocktrans %}
|
||||||
|
</h2>
|
||||||
|
<form action="" method="post">
|
||||||
|
{% csrf_token %}
|
||||||
|
{% bootstrap_form form %}
|
||||||
|
<div class="form-group buttons">
|
||||||
|
<button type="submit" class="btn btn-primary btn-lg btn-block">
|
||||||
|
{% trans "Log in" %}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<a class="btn btn-link btn-block" href="{% eventurl request.organizer "presale:organizer.customer.register" %}">
|
||||||
|
{% trans "Create account" %}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<a class="btn btn-link btn-block" href="{% eventurl request.organizer "presale:organizer.customer.resetpw" %}">
|
||||||
|
{% trans "Reset password" %}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p> </p>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
{% extends "pretixpresale/organizers/base.html" %}
|
||||||
|
{% load i18n %}
|
||||||
|
{% load eventurl %}
|
||||||
|
{% load urlreplace %}
|
||||||
|
{% load money %}
|
||||||
|
{% load bootstrap3 %}
|
||||||
|
{% block title %}{% trans "Your membership" %}{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<h2>
|
||||||
|
{% trans "Your membership" %}
|
||||||
|
</h2>
|
||||||
|
<div class="panel panel-primary items">
|
||||||
|
<div class="panel-heading">
|
||||||
|
<h3 class="panel-title">
|
||||||
|
{% trans "Details" %}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div class="panel-body">
|
||||||
|
<dl class="dl-horizontal">
|
||||||
|
<dt>{% trans "Membership type" %}</dt>
|
||||||
|
<dd>{{ membership.membership_type.name }}</dd>
|
||||||
|
<dt>{% trans "Valid from" %}</dt>
|
||||||
|
<dd>{{ membership.date_start|date:"SHORT_DATETIME_FORMAT" }}
|
||||||
|
<dt>{% trans "Valid until" %}</dt>
|
||||||
|
<dd>{{ membership.date_end|date:"SHORT_DATETIME_FORMAT" }}
|
||||||
|
<dt>{% trans "Attendee name" %}</dt>
|
||||||
|
<dd>{{ membership.attendee_name }}
|
||||||
|
<dt>{% trans "Maximum usages" %}</dt>
|
||||||
|
<dd>{{ membership.membership_type.max_usages|default_if_none:"–" }}</dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="panel panel-default items">
|
||||||
|
<div class="panel-heading">
|
||||||
|
<h3 class="panel-title">
|
||||||
|
{% trans "Usages" %}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<table class="panel-body table table-hover">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{% trans "Order code" %}</th>
|
||||||
|
<th>{% trans "Event" %}</th>
|
||||||
|
<th>{% trans "Product" %}</th>
|
||||||
|
<th>{% trans "Order date" %}</th>
|
||||||
|
<th class="text-right">{% trans "Status" %}</th>
|
||||||
|
<th class="text-right"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for op in usages %}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<strong>
|
||||||
|
{{ op.order.code }}-{{ op.positionid }}
|
||||||
|
</strong>
|
||||||
|
{% if op.order.testmode %}
|
||||||
|
<span class="label label-warning">{% trans "TEST MODE" %}</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{{ op.order.event }}
|
||||||
|
{% if op.subevent %}
|
||||||
|
<br>
|
||||||
|
{{ op.subevent|default:"" }}
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{{ op.item.name }}
|
||||||
|
{% if op.variation %}– {{ op.variation }}{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{{ op.order.datetime|date:"SHORT_DATETIME_FORMAT" }}
|
||||||
|
</td>
|
||||||
|
<td class="text-right flip">
|
||||||
|
{% if op.canceled %}
|
||||||
|
<span class="label label-danger">
|
||||||
|
<span class="fa fa-times"></span>
|
||||||
|
{% trans "Canceled" %}
|
||||||
|
</span>
|
||||||
|
{% else %}
|
||||||
|
{% include "pretixcontrol/orders/fragment_order_status.html" with order=op.order %}
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="text-right flip">
|
||||||
|
<a href="{% abseventurl op.order.event "presale:event.order" order=op.order.code secret=op.order.secret %}"
|
||||||
|
target="_blank"
|
||||||
|
class="btn btn-default">
|
||||||
|
{% trans "Details" %}
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% include "pretixcontrol/pagination.html" %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
{% extends "pretixpresale/organizers/base.html" %}
|
||||||
|
{% load i18n %}
|
||||||
|
{% load eventurl %}
|
||||||
|
{% load urlreplace %}
|
||||||
|
{% load bootstrap3 %}
|
||||||
|
{% block title %}{% trans "Password reset" %}{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6 col-md-offset-3">
|
||||||
|
<h2>
|
||||||
|
{% blocktrans trimmed %}
|
||||||
|
Set a new password for your account
|
||||||
|
{% endblocktrans %}
|
||||||
|
</h2>
|
||||||
|
<form action="" method="post">
|
||||||
|
{% csrf_token %}
|
||||||
|
{% bootstrap_form form %}
|
||||||
|
<div class="form-group buttons">
|
||||||
|
<button type="submit" class="btn btn-primary btn-lg btn-block">
|
||||||
|
{% trans "Save" %}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p> </p>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
{% extends "pretixpresale/organizers/base.html" %}
|
||||||
|
{% load i18n %}
|
||||||
|
{% load eventurl %}
|
||||||
|
{% load urlreplace %}
|
||||||
|
{% load money %}
|
||||||
|
{% load bootstrap3 %}
|
||||||
|
{% block title %}{% trans "Your account" %}{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<h2>
|
||||||
|
{% trans "Your account" %}
|
||||||
|
</h2>
|
||||||
|
<div class="panel panel-primary items">
|
||||||
|
<div class="panel-heading">
|
||||||
|
<h3 class="panel-title">
|
||||||
|
{% trans "Account information" %}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div class="panel-body">
|
||||||
|
<dl class="dl-horizontal">
|
||||||
|
<dt>{% trans "Customer ID" %}</dt>
|
||||||
|
<dd>#{{ customer.identifier }}</dd>
|
||||||
|
<dt>{% trans "E-mail" %}</dt>
|
||||||
|
<dd>{{ customer.email }}
|
||||||
|
</dd>
|
||||||
|
<dt>{% trans "Name" %}</dt>
|
||||||
|
<dd>{{ customer.name }}</dd>
|
||||||
|
</dl>
|
||||||
|
<div class="text-right">
|
||||||
|
<a href="{% eventurl request.organizer "presale:organizer.customer.change" %}"
|
||||||
|
class="btn btn-default">
|
||||||
|
{% trans "Change account information" %}
|
||||||
|
</a>
|
||||||
|
<a href="{% eventurl request.organizer "presale:organizer.customer.password" %}"
|
||||||
|
class="btn btn-default">
|
||||||
|
{% trans "Change password" %}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="panel panel-default items">
|
||||||
|
<div class="panel-heading">
|
||||||
|
<h3 class="panel-title">
|
||||||
|
{% trans "Memberships" %}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<table class="panel-body table table-hover">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{% trans "Membership type" %}</th>
|
||||||
|
<th>{% trans "Valid from" %}</th>
|
||||||
|
<th>{% trans "Valid until" %}</th>
|
||||||
|
<th>{% trans "Attendee name" %}</th>
|
||||||
|
<th>{% trans "Usages" %}</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for m in memberships %}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
{{ m.membership_type.name }}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{{ m.date_start|date:"SHORT_DATETIME_FORMAT" }}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{{ m.date_end|date:"SHORT_DATETIME_FORMAT" }}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{{ m.attendee_name }}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="quotabox">
|
||||||
|
<div class="progress">
|
||||||
|
<div class="progress-bar progress-bar-success progress-bar-{{ m.percent }}">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="numbers">
|
||||||
|
{{ m.usages }} /
|
||||||
|
{{ m.membership_type.max_usages|default_if_none:"∞" }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="text-right flip">
|
||||||
|
<a href="{% abseventurl request.organizer "presale:organizer.customer.membership" id=m.id %}"
|
||||||
|
data-toggle="tooltip"
|
||||||
|
title="{% trans "Details" %}"
|
||||||
|
class="btn btn-default">
|
||||||
|
<i class="fa fa-list"></i>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="panel panel-default items">
|
||||||
|
<div class="panel-heading">
|
||||||
|
<h3 class="panel-title">
|
||||||
|
{% trans "Orders" %}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<table class="panel-body table table-hover">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{% trans "Order code" %}</th>
|
||||||
|
<th>{% trans "Event" %}</th>
|
||||||
|
<th>{% trans "Order date" %}</th>
|
||||||
|
<th class="text-right">{% trans "Order total" %}</th>
|
||||||
|
<th class="text-right">{% trans "Positions" %}</th>
|
||||||
|
<th class="text-right">{% trans "Status" %}</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for o in orders %}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<strong>
|
||||||
|
<a href="{% abseventurl o.event "presale:event.order" order=o.code secret=o.secret %}" target="_blank">
|
||||||
|
{{ o.code }}
|
||||||
|
</a>
|
||||||
|
</strong>
|
||||||
|
{% if o.testmode %}
|
||||||
|
<span class="label label-warning">{% trans "TEST MODE" %}</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{{ o.event }}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{{ o.datetime|date:"SHORT_DATETIME_FORMAT" }}
|
||||||
|
{% if o.customer_id != customer.pk %}
|
||||||
|
<span class="fa fa-link text-muted"
|
||||||
|
data-toggle="tooltip"
|
||||||
|
title="{% trans "Matched to the account based on the email address." %}"
|
||||||
|
></span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="text-right flip">
|
||||||
|
{{ o.total|money:o.event.currency }}
|
||||||
|
</td>
|
||||||
|
<td class="text-right flip">{{ o.count_positions|default_if_none:"0" }}</td>
|
||||||
|
<td class="text-right flip">{% include "pretixpresale/event/fragment_order_status.html" with order=o event=o.event %}</td>
|
||||||
|
<td class="text-right flip">
|
||||||
|
<a href="{% abseventurl o.event "presale:event.order" order=o.code secret=o.secret %}"
|
||||||
|
target="_blank"
|
||||||
|
class="btn btn-default">
|
||||||
|
{% trans "Details" %}
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% include "pretixcontrol/pagination.html" %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
{% extends "pretixpresale/organizers/base.html" %}
|
||||||
|
{% load i18n %}
|
||||||
|
{% load eventurl %}
|
||||||
|
{% load urlreplace %}
|
||||||
|
{% load bootstrap3 %}
|
||||||
|
{% block title %}{% trans "Registration" %}{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6 col-md-offset-3">
|
||||||
|
<h2>
|
||||||
|
{% blocktrans trimmed with org=request.organizer.name %}
|
||||||
|
Create a new account at {{ org }}
|
||||||
|
{% endblocktrans %}
|
||||||
|
</h2>
|
||||||
|
<form action="" method="post">
|
||||||
|
{% csrf_token %}
|
||||||
|
{% bootstrap_form form %}
|
||||||
|
<div class="form-group buttons">
|
||||||
|
<button type="submit" class="btn btn-primary btn-lg btn-block">
|
||||||
|
{% trans "Create account" %}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<a class="btn btn-link btn-block" href="{% eventurl request.organizer "presale:organizer.customer.login" %}">
|
||||||
|
{% trans "Log in to an existing account" %}
|
||||||
|
</a>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p> </p>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
{% extends "pretixpresale/organizers/base.html" %}
|
||||||
|
{% load i18n %}
|
||||||
|
{% load eventurl %}
|
||||||
|
{% load urlreplace %}
|
||||||
|
{% load bootstrap3 %}
|
||||||
|
{% block title %}{% trans "Password reset" %}{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6 col-md-offset-3">
|
||||||
|
<h2>
|
||||||
|
{% blocktrans trimmed %}
|
||||||
|
Password reset
|
||||||
|
{% endblocktrans %}
|
||||||
|
</h2>
|
||||||
|
<form action="" method="post">
|
||||||
|
{% csrf_token %}
|
||||||
|
{% bootstrap_form form %}
|
||||||
|
<div class="form-group buttons">
|
||||||
|
<button type="submit" class="btn btn-primary btn-lg btn-block">
|
||||||
|
{% trans "Request a new password" %}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p> </p>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
{% extends "pretixpresale/organizers/base.html" %}
|
||||||
|
{% load i18n %}
|
||||||
|
{% load eventurl %}
|
||||||
|
{% load urlreplace %}
|
||||||
|
{% load bootstrap3 %}
|
||||||
|
{% block title %}{% trans "Password reset" %}{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6 col-md-offset-3">
|
||||||
|
<h2>
|
||||||
|
{% blocktrans trimmed %}
|
||||||
|
Set a new password for your account
|
||||||
|
{% endblocktrans %}
|
||||||
|
</h2>
|
||||||
|
<form action="" method="post">
|
||||||
|
{% csrf_token %}
|
||||||
|
{% bootstrap_form form %}
|
||||||
|
<div class="form-group buttons">
|
||||||
|
<button type="submit" class="btn btn-primary btn-lg btn-block">
|
||||||
|
{% trans "Save" %}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p> </p>
|
||||||
|
{% endblock %}
|
||||||
@@ -37,6 +37,7 @@ from django.views.decorators.csrf import csrf_exempt
|
|||||||
|
|
||||||
import pretix.presale.views.cart
|
import pretix.presale.views.cart
|
||||||
import pretix.presale.views.checkout
|
import pretix.presale.views.checkout
|
||||||
|
import pretix.presale.views.customer
|
||||||
import pretix.presale.views.event
|
import pretix.presale.views.event
|
||||||
import pretix.presale.views.locale
|
import pretix.presale.views.locale
|
||||||
import pretix.presale.views.order
|
import pretix.presale.views.order
|
||||||
@@ -165,6 +166,17 @@ organizer_patterns = [
|
|||||||
url(r'^widget/product_list$', pretix.presale.views.widget.WidgetAPIProductList.as_view(),
|
url(r'^widget/product_list$', pretix.presale.views.widget.WidgetAPIProductList.as_view(),
|
||||||
name='organizer.widget.productlist'),
|
name='organizer.widget.productlist'),
|
||||||
url(r'^widget/v1.css$', pretix.presale.views.widget.widget_css, name='organizer.widget.css'),
|
url(r'^widget/v1.css$', pretix.presale.views.widget.widget_css, name='organizer.widget.css'),
|
||||||
|
url(r'^account/login$', pretix.presale.views.customer.LoginView.as_view(), name='organizer.customer.login'),
|
||||||
|
url(r'^account/logout$', pretix.presale.views.customer.LogoutView.as_view(), name='organizer.customer.logout'),
|
||||||
|
url(r'^account/register$', pretix.presale.views.customer.RegistrationView.as_view(), name='organizer.customer.register'),
|
||||||
|
url(r'^account/pwreset$', pretix.presale.views.customer.ResetPasswordView.as_view(), name='organizer.customer.resetpw'),
|
||||||
|
url(r'^account/pwrecover$', pretix.presale.views.customer.SetPasswordView.as_view(), name='organizer.customer.recoverpw'),
|
||||||
|
url(r'^account/activate$', pretix.presale.views.customer.SetPasswordView.as_view(), name='organizer.customer.activate'),
|
||||||
|
url(r'^account/password$', pretix.presale.views.customer.ChangePasswordView.as_view(), name='organizer.customer.password'),
|
||||||
|
url(r'^account/change$', pretix.presale.views.customer.ChangeInformationView.as_view(), name='organizer.customer.change'),
|
||||||
|
url(r'^account/confirmchange$', pretix.presale.views.customer.ConfirmChangeView.as_view(), name='organizer.customer.change.confirm'),
|
||||||
|
url(r'^account/membership/(?P<id>\d+)/$', pretix.presale.views.customer.MembershipUsageView.as_view(), name='organizer.customer.membership'),
|
||||||
|
url(r'^account/$', pretix.presale.views.customer.ProfileView.as_view(), name='organizer.customer.profile'),
|
||||||
]
|
]
|
||||||
|
|
||||||
locale_patterns = [
|
locale_patterns = [
|
||||||
|
|||||||
@@ -39,15 +39,19 @@ from urllib.parse import urljoin
|
|||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core.exceptions import PermissionDenied
|
from django.core.exceptions import PermissionDenied
|
||||||
from django.http import Http404
|
from django.http import Http404
|
||||||
|
from django.middleware.csrf import rotate_token
|
||||||
from django.shortcuts import redirect
|
from django.shortcuts import redirect
|
||||||
from django.template.response import TemplateResponse
|
from django.template.response import TemplateResponse
|
||||||
from django.urls import resolve
|
from django.urls import resolve
|
||||||
|
from django.utils.crypto import constant_time_compare
|
||||||
|
from django.utils.functional import SimpleLazyObject
|
||||||
|
from django.utils.timezone import now
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from django.views.defaults import permission_denied
|
from django.views.defaults import permission_denied
|
||||||
from django_scopes import scope
|
from django_scopes import scope
|
||||||
|
|
||||||
from pretix.base.middleware import LocaleMiddleware
|
from pretix.base.middleware import LocaleMiddleware
|
||||||
from pretix.base.models import Event, Organizer
|
from pretix.base.models import Customer, Event, Organizer
|
||||||
from pretix.multidomain.urlreverse import (
|
from pretix.multidomain.urlreverse import (
|
||||||
get_event_domain, get_organizer_domain,
|
get_event_domain, get_organizer_domain,
|
||||||
)
|
)
|
||||||
@@ -56,8 +60,96 @@ from pretix.presale.signals import process_request, process_response
|
|||||||
SessionStore = import_module(settings.SESSION_ENGINE).SessionStore
|
SessionStore = import_module(settings.SESSION_ENGINE).SessionStore
|
||||||
|
|
||||||
|
|
||||||
|
def get_customer(request):
|
||||||
|
if not hasattr(request, '_cached_customer'):
|
||||||
|
session_key = f'customer_auth_id:{request.organizer.pk}'
|
||||||
|
hash_session_key = f'customer_auth_hash:{request.organizer.pk}'
|
||||||
|
|
||||||
|
with scope(organizer=request.organizer):
|
||||||
|
try:
|
||||||
|
customer = request.organizer.customers.get(
|
||||||
|
is_active=True, is_verified=True,
|
||||||
|
pk=request.session[session_key]
|
||||||
|
)
|
||||||
|
except (Customer.DoesNotExist, KeyError):
|
||||||
|
request._cached_customer = None
|
||||||
|
else:
|
||||||
|
session_hash = request.session.get(hash_session_key)
|
||||||
|
session_hash_verified = session_hash and constant_time_compare(
|
||||||
|
session_hash,
|
||||||
|
customer.get_session_auth_hash()
|
||||||
|
)
|
||||||
|
if session_hash_verified:
|
||||||
|
request._cached_customer = customer
|
||||||
|
else:
|
||||||
|
request.session.flush()
|
||||||
|
request._cached_customer = None
|
||||||
|
|
||||||
|
return request._cached_customer
|
||||||
|
|
||||||
|
|
||||||
|
def update_customer_session_auth_hash(request, customer):
|
||||||
|
hash_session_key = f'customer_auth_hash:{request.organizer.pk}'
|
||||||
|
session_auth_hash = customer.get_session_auth_hash()
|
||||||
|
request.session.cycle_key()
|
||||||
|
request.session[hash_session_key] = session_auth_hash
|
||||||
|
|
||||||
|
|
||||||
|
def add_customer_to_request(request):
|
||||||
|
request.customer = SimpleLazyObject(lambda: get_customer(request))
|
||||||
|
|
||||||
|
|
||||||
|
def customer_login(request, customer):
|
||||||
|
session_key = f'customer_auth_id:{request.organizer.pk}'
|
||||||
|
hash_session_key = f'customer_auth_hash:{request.organizer.pk}'
|
||||||
|
session_auth_hash = customer.get_session_auth_hash()
|
||||||
|
|
||||||
|
if session_key in request.session:
|
||||||
|
if request.session[session_key] != customer.pk or (
|
||||||
|
not constant_time_compare(request.session.get(hash_session_key, ''), session_auth_hash)):
|
||||||
|
# To avoid reusing another user's session, create a new, empty
|
||||||
|
# session if the existing session corresponds to a different
|
||||||
|
# authenticated user.
|
||||||
|
request.session.flush()
|
||||||
|
else:
|
||||||
|
request.session.cycle_key()
|
||||||
|
|
||||||
|
request.session[session_key] = customer.pk
|
||||||
|
request.session[hash_session_key] = session_auth_hash
|
||||||
|
request.customer = customer
|
||||||
|
|
||||||
|
customer.last_login = now()
|
||||||
|
customer.save(update_fields=['last_login'])
|
||||||
|
|
||||||
|
rotate_token(request)
|
||||||
|
|
||||||
|
|
||||||
|
def customer_logout(request):
|
||||||
|
session_key = f'customer_auth_id:{request.organizer.pk}'
|
||||||
|
hash_session_key = f'customer_auth_hash:{request.organizer.pk}'
|
||||||
|
|
||||||
|
# Remove user session
|
||||||
|
customer_id = request.session.pop(session_key, None)
|
||||||
|
request.session.pop(hash_session_key, None)
|
||||||
|
|
||||||
|
# Remove carts tied to this user
|
||||||
|
carts = request.session.get('carts', {})
|
||||||
|
for k, v in list(carts.items()):
|
||||||
|
if v.get('customer') == customer_id:
|
||||||
|
carts.pop(k)
|
||||||
|
request.session['carts'] = carts
|
||||||
|
|
||||||
|
# Cycle session key and CSRF token
|
||||||
|
request.session.cycle_key()
|
||||||
|
rotate_token(request)
|
||||||
|
|
||||||
|
request.customer = None
|
||||||
|
request._cached_customer = None
|
||||||
|
|
||||||
|
|
||||||
@scope(organizer=None)
|
@scope(organizer=None)
|
||||||
def _detect_event(request, require_live=True, require_plugin=None):
|
def _detect_event(request, require_live=True, require_plugin=None):
|
||||||
|
|
||||||
if hasattr(request, '_event_detected'):
|
if hasattr(request, '_event_detected'):
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -132,6 +224,9 @@ def _detect_event(request, require_live=True, require_plugin=None):
|
|||||||
r['Access-Control-Allow-Origin'] = '*'
|
r['Access-Control-Allow-Origin'] = '*'
|
||||||
return r
|
return r
|
||||||
|
|
||||||
|
if not hasattr(request, 'customer'):
|
||||||
|
add_customer_to_request(request)
|
||||||
|
|
||||||
if hasattr(request, 'event'):
|
if hasattr(request, 'event'):
|
||||||
# Restrict locales to the ones available for this event
|
# Restrict locales to the ones available for this event
|
||||||
LocaleMiddleware().process_request(request)
|
LocaleMiddleware().process_request(request)
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ from django_scopes import scopes_disabled
|
|||||||
|
|
||||||
from pretix.base.i18n import language
|
from pretix.base.i18n import language
|
||||||
from pretix.base.models import (
|
from pretix.base.models import (
|
||||||
CartPosition, InvoiceAddress, ItemAddOn, OrderPosition, Question,
|
CartPosition, Customer, InvoiceAddress, ItemAddOn, OrderPosition, Question,
|
||||||
QuestionAnswer, QuestionOption,
|
QuestionAnswer, QuestionOption,
|
||||||
)
|
)
|
||||||
from pretix.base.services.cart import get_fees
|
from pretix.base.services.cart import get_fees
|
||||||
@@ -91,6 +91,14 @@ class CartMixin:
|
|||||||
from pretix.presale.views.cart import cart_session
|
from pretix.presale.views.cart import cart_session
|
||||||
return cart_session(self.request)
|
return cart_session(self.request)
|
||||||
|
|
||||||
|
@cached_property
|
||||||
|
def cart_customer(self):
|
||||||
|
if self.cart_session.get('customer_mode', 'guest') == 'login':
|
||||||
|
try:
|
||||||
|
return self.request.organizer.customers.get(pk=self.cart_session.get('customer', -1))
|
||||||
|
except Customer.DoesNotExist:
|
||||||
|
return
|
||||||
|
|
||||||
@cached_property
|
@cached_property
|
||||||
def invoice_address(self):
|
def invoice_address(self):
|
||||||
return cached_invoice_address(self.request)
|
return cached_invoice_address(self.request)
|
||||||
@@ -273,7 +281,7 @@ def get_cart(request):
|
|||||||
'item__category__position', 'item__category_id', 'item__position', 'item__name', 'variation__value'
|
'item__category__position', 'item__category_id', 'item__position', 'item__name', 'variation__value'
|
||||||
).select_related(
|
).select_related(
|
||||||
'item', 'variation', 'subevent', 'subevent__event', 'subevent__event__organizer',
|
'item', 'variation', 'subevent', 'subevent__event', 'subevent__event__organizer',
|
||||||
'item__tax_rule', 'addon_to'
|
'item__tax_rule', 'addon_to', 'used_membership', 'used_membership__membership_type'
|
||||||
).select_related(
|
).select_related(
|
||||||
'addon_to'
|
'addon_to'
|
||||||
).prefetch_related(
|
).prefetch_related(
|
||||||
|
|||||||
@@ -0,0 +1,472 @@
|
|||||||
|
#
|
||||||
|
# This file is part of pretix (Community Edition).
|
||||||
|
#
|
||||||
|
# Copyright (C) 2014-2020 Raphael Michel and contributors
|
||||||
|
# Copyright (C) 2020-2021 rami.io GmbH and contributors
|
||||||
|
#
|
||||||
|
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
|
||||||
|
# Public License as published by the Free Software Foundation in version 3 of the License.
|
||||||
|
#
|
||||||
|
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
|
||||||
|
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
|
||||||
|
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
|
||||||
|
# this file, see <https://pretix.eu/about/en/license>.
|
||||||
|
#
|
||||||
|
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
|
||||||
|
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||||||
|
# details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
|
||||||
|
# <https://www.gnu.org/licenses/>.
|
||||||
|
#
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
|
from django.contrib import messages
|
||||||
|
from django.core.signing import BadSignature, dumps, loads
|
||||||
|
from django.db import transaction
|
||||||
|
from django.db.models import Count, IntegerField, OuterRef, Q, Subquery
|
||||||
|
from django.http import Http404, HttpResponseRedirect
|
||||||
|
from django.shortcuts import get_object_or_404, redirect
|
||||||
|
from django.utils.decorators import method_decorator
|
||||||
|
from django.utils.functional import cached_property
|
||||||
|
from django.utils.http import url_has_allowed_host_and_scheme
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
from django.views.decorators.cache import never_cache
|
||||||
|
from django.views.decorators.csrf import csrf_protect
|
||||||
|
from django.views.decorators.debug import sensitive_post_parameters
|
||||||
|
from django.views.generic import FormView, ListView, View
|
||||||
|
|
||||||
|
from pretix.base.models import Customer, Order, OrderPosition
|
||||||
|
from pretix.base.services.mail import mail
|
||||||
|
from pretix.multidomain.urlreverse import build_absolute_uri, eventreverse
|
||||||
|
from pretix.presale.forms.customer import (
|
||||||
|
AuthenticationForm, ChangeInfoForm, ChangePasswordForm, RegistrationForm,
|
||||||
|
ResetPasswordForm, SetPasswordForm, TokenGenerator,
|
||||||
|
)
|
||||||
|
from pretix.presale.utils import (
|
||||||
|
customer_login, customer_logout, update_customer_session_auth_hash,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class RedirectBackMixin:
|
||||||
|
redirect_field_name = 'next'
|
||||||
|
|
||||||
|
def get_redirect_url(self):
|
||||||
|
"""Return the user-originating redirect URL if it's safe."""
|
||||||
|
redirect_to = self.request.POST.get(
|
||||||
|
self.redirect_field_name,
|
||||||
|
self.request.GET.get(self.redirect_field_name, '')
|
||||||
|
)
|
||||||
|
url_is_safe = url_has_allowed_host_and_scheme(
|
||||||
|
url=redirect_to,
|
||||||
|
allowed_hosts=None,
|
||||||
|
require_https=self.request.is_secure(),
|
||||||
|
)
|
||||||
|
return redirect_to if url_is_safe else ''
|
||||||
|
|
||||||
|
|
||||||
|
class LoginView(RedirectBackMixin, FormView):
|
||||||
|
"""
|
||||||
|
Display the login form and handle the login action.
|
||||||
|
"""
|
||||||
|
form_class = AuthenticationForm
|
||||||
|
template_name = 'pretixpresale/organizers/customer_login.html'
|
||||||
|
redirect_authenticated_user = True
|
||||||
|
|
||||||
|
@method_decorator(sensitive_post_parameters())
|
||||||
|
@method_decorator(csrf_protect)
|
||||||
|
@method_decorator(never_cache)
|
||||||
|
def dispatch(self, request, *args, **kwargs):
|
||||||
|
if not request.organizer.settings.customer_accounts:
|
||||||
|
raise Http404('Feature not enabled')
|
||||||
|
if self.redirect_authenticated_user and self.request.customer:
|
||||||
|
redirect_to = self.get_success_url()
|
||||||
|
if redirect_to == self.request.path:
|
||||||
|
raise ValueError(
|
||||||
|
"Redirection loop for authenticated user detected. Check that "
|
||||||
|
"your LOGIN_REDIRECT_URL doesn't point to a login page."
|
||||||
|
)
|
||||||
|
return HttpResponseRedirect(redirect_to)
|
||||||
|
return super().dispatch(request, *args, **kwargs)
|
||||||
|
|
||||||
|
def get_form_kwargs(self):
|
||||||
|
kwargs = super().get_form_kwargs()
|
||||||
|
kwargs['request'] = self.request
|
||||||
|
return kwargs
|
||||||
|
|
||||||
|
def get_success_url(self):
|
||||||
|
url = self.get_redirect_url()
|
||||||
|
return url or eventreverse(self.request.organizer, 'presale:organizer.customer.profile', kwargs={})
|
||||||
|
|
||||||
|
def form_valid(self, form):
|
||||||
|
"""Security check complete. Log the user in."""
|
||||||
|
customer_login(self.request, form.get_customer())
|
||||||
|
return HttpResponseRedirect(self.get_success_url())
|
||||||
|
|
||||||
|
|
||||||
|
class LogoutView(View):
|
||||||
|
redirect_field_name = 'next'
|
||||||
|
|
||||||
|
@method_decorator(never_cache)
|
||||||
|
def dispatch(self, request, *args, **kwargs):
|
||||||
|
customer_logout(request)
|
||||||
|
next_page = self.get_next_page()
|
||||||
|
return HttpResponseRedirect(next_page)
|
||||||
|
|
||||||
|
def get_next_page(self):
|
||||||
|
next_page = eventreverse(self.request.organizer, 'presale:organizer.index', kwargs={})
|
||||||
|
|
||||||
|
if (self.redirect_field_name in self.request.POST or
|
||||||
|
self.redirect_field_name in self.request.GET):
|
||||||
|
next_page = self.request.POST.get(
|
||||||
|
self.redirect_field_name,
|
||||||
|
self.request.GET.get(self.redirect_field_name)
|
||||||
|
)
|
||||||
|
url_is_safe = url_has_allowed_host_and_scheme(
|
||||||
|
url=next_page,
|
||||||
|
allowed_hosts=None,
|
||||||
|
require_https=self.request.is_secure(),
|
||||||
|
)
|
||||||
|
# Security check -- Ensure the user-originating redirection URL is
|
||||||
|
# safe.
|
||||||
|
if not url_is_safe:
|
||||||
|
next_page = self.request.path
|
||||||
|
return next_page
|
||||||
|
|
||||||
|
|
||||||
|
class RegistrationView(RedirectBackMixin, FormView):
|
||||||
|
form_class = RegistrationForm
|
||||||
|
template_name = 'pretixpresale/organizers/customer_registration.html'
|
||||||
|
redirect_authenticated_user = True
|
||||||
|
|
||||||
|
@method_decorator(sensitive_post_parameters())
|
||||||
|
@method_decorator(csrf_protect)
|
||||||
|
@method_decorator(never_cache)
|
||||||
|
def dispatch(self, request, *args, **kwargs):
|
||||||
|
if not request.organizer.settings.customer_accounts:
|
||||||
|
raise Http404('Feature not enabled')
|
||||||
|
if self.redirect_authenticated_user and self.request.customer:
|
||||||
|
redirect_to = self.get_success_url()
|
||||||
|
if redirect_to == self.request.path:
|
||||||
|
raise ValueError(
|
||||||
|
"Redirection loop for authenticated user detected. Check that "
|
||||||
|
"your LOGIN_REDIRECT_URL doesn't point to a login page."
|
||||||
|
)
|
||||||
|
return HttpResponseRedirect(redirect_to)
|
||||||
|
return super().dispatch(request, *args, **kwargs)
|
||||||
|
|
||||||
|
def get_form_kwargs(self):
|
||||||
|
kwargs = super().get_form_kwargs()
|
||||||
|
kwargs['request'] = self.request
|
||||||
|
return kwargs
|
||||||
|
|
||||||
|
def get_success_url(self):
|
||||||
|
url = self.get_redirect_url()
|
||||||
|
return url or eventreverse(self.request.organizer, 'presale:organizer.customer.login', kwargs={})
|
||||||
|
|
||||||
|
def form_valid(self, form):
|
||||||
|
with transaction.atomic():
|
||||||
|
form.create()
|
||||||
|
messages.success(
|
||||||
|
self.request,
|
||||||
|
_('Your account has been created. Please follow the link in the email we sent you to activate your '
|
||||||
|
'account and choose a password.')
|
||||||
|
)
|
||||||
|
return HttpResponseRedirect(self.get_success_url())
|
||||||
|
|
||||||
|
|
||||||
|
class SetPasswordView(FormView):
|
||||||
|
form_class = SetPasswordForm
|
||||||
|
template_name = 'pretixpresale/organizers/customer_setpassword.html'
|
||||||
|
|
||||||
|
@method_decorator(sensitive_post_parameters())
|
||||||
|
@method_decorator(csrf_protect)
|
||||||
|
@method_decorator(never_cache)
|
||||||
|
def dispatch(self, request, *args, **kwargs):
|
||||||
|
if not request.organizer.settings.customer_accounts:
|
||||||
|
raise Http404('Feature not enabled')
|
||||||
|
try:
|
||||||
|
self.customer = request.organizer.customers.get(identifier=self.request.GET.get('id'))
|
||||||
|
except Customer.DoesNotExist:
|
||||||
|
messages.error(request, _('You clicked an invalid link.'))
|
||||||
|
return HttpResponseRedirect(self.get_success_url())
|
||||||
|
if not TokenGenerator().check_token(self.customer, self.request.GET.get('token', '')):
|
||||||
|
messages.error(request, _('You clicked an invalid link.'))
|
||||||
|
return HttpResponseRedirect(self.get_success_url())
|
||||||
|
return super().dispatch(request, *args, **kwargs)
|
||||||
|
|
||||||
|
def get_form_kwargs(self):
|
||||||
|
kwargs = super().get_form_kwargs()
|
||||||
|
kwargs['customer'] = self.customer
|
||||||
|
return kwargs
|
||||||
|
|
||||||
|
def get_success_url(self):
|
||||||
|
return eventreverse(self.request.organizer, 'presale:organizer.customer.login', kwargs={})
|
||||||
|
|
||||||
|
def form_valid(self, form):
|
||||||
|
with transaction.atomic():
|
||||||
|
self.customer.set_password(form.cleaned_data['password'])
|
||||||
|
self.customer.is_verified = True
|
||||||
|
self.customer.save()
|
||||||
|
self.customer.log_action('pretix.customer.password.set', {})
|
||||||
|
messages.success(
|
||||||
|
self.request,
|
||||||
|
_('Your new password has been set! You can now use it to log in.'),
|
||||||
|
)
|
||||||
|
return HttpResponseRedirect(self.get_success_url())
|
||||||
|
|
||||||
|
|
||||||
|
class ResetPasswordView(FormView):
|
||||||
|
form_class = ResetPasswordForm
|
||||||
|
template_name = 'pretixpresale/organizers/customer_resetpw.html'
|
||||||
|
|
||||||
|
@method_decorator(sensitive_post_parameters())
|
||||||
|
@method_decorator(csrf_protect)
|
||||||
|
@method_decorator(never_cache)
|
||||||
|
def dispatch(self, request, *args, **kwargs):
|
||||||
|
if not request.organizer.settings.customer_accounts:
|
||||||
|
raise Http404('Feature not enabled')
|
||||||
|
return super().dispatch(request, *args, **kwargs)
|
||||||
|
|
||||||
|
def get_success_url(self):
|
||||||
|
return eventreverse(self.request.organizer, 'presale:organizer.customer.login', kwargs={})
|
||||||
|
|
||||||
|
def form_valid(self, form):
|
||||||
|
customer = form.customer
|
||||||
|
customer.log_action('pretix.customer.password.resetrequested', {})
|
||||||
|
ctx = customer.get_email_context()
|
||||||
|
token = TokenGenerator().make_token(customer)
|
||||||
|
ctx['url'] = build_absolute_uri(self.request.organizer,
|
||||||
|
'presale:organizer.customer.recoverpw') + '?id=' + customer.identifier + '&token=' + token
|
||||||
|
mail(
|
||||||
|
customer.email,
|
||||||
|
_('Set a new password for your account at {organizer}').format(organizer=self.request.organizer.name),
|
||||||
|
self.request.organizer.settings.mail_text_customer_reset,
|
||||||
|
ctx,
|
||||||
|
locale=customer.locale,
|
||||||
|
customer=customer,
|
||||||
|
organizer=self.request.organizer,
|
||||||
|
)
|
||||||
|
messages.success(
|
||||||
|
self.request,
|
||||||
|
_('We\'ve sent you an email with further instructions on resetting your password.')
|
||||||
|
)
|
||||||
|
return HttpResponseRedirect(self.get_success_url())
|
||||||
|
|
||||||
|
def get_form_kwargs(self):
|
||||||
|
kwargs = super().get_form_kwargs()
|
||||||
|
kwargs['request'] = self.request
|
||||||
|
return kwargs
|
||||||
|
|
||||||
|
|
||||||
|
class CustomerRequiredMixin:
|
||||||
|
def dispatch(self, request, *args, **kwargs):
|
||||||
|
if not request.organizer.settings.customer_accounts:
|
||||||
|
raise Http404('Feature not enabled')
|
||||||
|
if not getattr(request, 'customer', None):
|
||||||
|
return redirect(
|
||||||
|
eventreverse(self.request.organizer, 'presale:organizer.customer.login', kwargs={}) +
|
||||||
|
'?next=' + quote(self.request.path_info + '?' + self.request.GET.urlencode())
|
||||||
|
)
|
||||||
|
return super().dispatch(request, *args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
class ProfileView(CustomerRequiredMixin, ListView):
|
||||||
|
template_name = 'pretixpresale/organizers/customer_profile.html'
|
||||||
|
context_object_name = 'orders'
|
||||||
|
paginate_by = 20
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
qs = Order.objects.filter(
|
||||||
|
Q(customer=self.request.customer)
|
||||||
|
| Q(email__iexact=self.request.customer.email)
|
||||||
|
# This is safe because we only let customers with verified emails log in
|
||||||
|
).select_related('event').order_by('-datetime')
|
||||||
|
return qs
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
ctx = super().get_context_data(**kwargs)
|
||||||
|
ctx['customer'] = self.request.customer
|
||||||
|
ctx['memberships'] = self.request.customer.memberships.with_usages().select_related(
|
||||||
|
'membership_type', 'granted_in', 'granted_in__order', 'granted_in__order__event'
|
||||||
|
)
|
||||||
|
ctx['is_paginated'] = True
|
||||||
|
|
||||||
|
for m in ctx['memberships']:
|
||||||
|
if m.membership_type.max_usages:
|
||||||
|
m.percent = int(m.usages / m.membership_type.max_usages * 100)
|
||||||
|
else:
|
||||||
|
m.percent = 0
|
||||||
|
|
||||||
|
s = OrderPosition.objects.filter(
|
||||||
|
order=OuterRef('pk')
|
||||||
|
).order_by().values('order').annotate(k=Count('id')).values('k')
|
||||||
|
annotated = {
|
||||||
|
o['pk']: o
|
||||||
|
for o in
|
||||||
|
Order.annotate_overpayments(Order.objects, sums=True).filter(
|
||||||
|
pk__in=[o.pk for o in ctx['orders']]
|
||||||
|
).annotate(
|
||||||
|
pcnt=Subquery(s, output_field=IntegerField()),
|
||||||
|
).values(
|
||||||
|
'pk', 'pcnt',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
for o in ctx['orders']:
|
||||||
|
if o.pk not in annotated:
|
||||||
|
continue
|
||||||
|
o.count_positions = annotated.get(o.pk)['pcnt']
|
||||||
|
return ctx
|
||||||
|
|
||||||
|
|
||||||
|
class MembershipUsageView(CustomerRequiredMixin, ListView):
|
||||||
|
template_name = 'pretixpresale/organizers/customer_membership.html'
|
||||||
|
context_object_name = 'usages'
|
||||||
|
paginate_by = 20
|
||||||
|
|
||||||
|
@cached_property
|
||||||
|
def membership(self):
|
||||||
|
return get_object_or_404(
|
||||||
|
self.request.customer.memberships,
|
||||||
|
pk=self.kwargs.get('id')
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
return self.membership.orderposition_set.select_related(
|
||||||
|
'order', 'order__event', 'subevent', 'item', 'variation',
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
ctx = super().get_context_data(**kwargs)
|
||||||
|
ctx['membership'] = self.membership
|
||||||
|
ctx['is_paginated'] = True
|
||||||
|
return ctx
|
||||||
|
|
||||||
|
|
||||||
|
class ChangePasswordView(CustomerRequiredMixin, FormView):
|
||||||
|
template_name = 'pretixpresale/organizers/customer_password.html'
|
||||||
|
form_class = ChangePasswordForm
|
||||||
|
|
||||||
|
@method_decorator(sensitive_post_parameters())
|
||||||
|
@method_decorator(csrf_protect)
|
||||||
|
@method_decorator(never_cache)
|
||||||
|
def dispatch(self, request, *args, **kwargs):
|
||||||
|
if not request.organizer.settings.customer_accounts:
|
||||||
|
raise Http404('Feature not enabled')
|
||||||
|
return super().dispatch(request, *args, **kwargs)
|
||||||
|
|
||||||
|
def get_success_url(self):
|
||||||
|
return eventreverse(self.request.organizer, 'presale:organizer.customer.profile', kwargs={})
|
||||||
|
|
||||||
|
@transaction.atomic()
|
||||||
|
def form_valid(self, form):
|
||||||
|
customer = form.customer
|
||||||
|
customer.log_action('pretix.customer.password.set', {})
|
||||||
|
customer.set_password(form.cleaned_data['password'])
|
||||||
|
customer.save()
|
||||||
|
messages.success(self.request, _('Your changes have been saved.'))
|
||||||
|
update_customer_session_auth_hash(self.request, customer)
|
||||||
|
return HttpResponseRedirect(self.get_success_url())
|
||||||
|
|
||||||
|
def get_form_kwargs(self):
|
||||||
|
kwargs = super().get_form_kwargs()
|
||||||
|
kwargs['customer'] = self.request.customer
|
||||||
|
return kwargs
|
||||||
|
|
||||||
|
|
||||||
|
class ChangeInformationView(CustomerRequiredMixin, FormView):
|
||||||
|
template_name = 'pretixpresale/organizers/customer_info.html'
|
||||||
|
form_class = ChangeInfoForm
|
||||||
|
|
||||||
|
@method_decorator(sensitive_post_parameters())
|
||||||
|
@method_decorator(csrf_protect)
|
||||||
|
@method_decorator(never_cache)
|
||||||
|
def dispatch(self, request, *args, **kwargs):
|
||||||
|
if not request.organizer.settings.customer_accounts:
|
||||||
|
raise Http404('Feature not enabled')
|
||||||
|
if self.request.customer:
|
||||||
|
self.initial_email = self.request.customer.email
|
||||||
|
return super().dispatch(request, *args, **kwargs)
|
||||||
|
|
||||||
|
def get_success_url(self):
|
||||||
|
return eventreverse(self.request.organizer, 'presale:organizer.customer.profile', kwargs={})
|
||||||
|
|
||||||
|
def form_valid(self, form):
|
||||||
|
if form.cleaned_data['email'] != self.initial_email:
|
||||||
|
new_email = form.cleaned_data['email']
|
||||||
|
form.cleaned_data['email'] = form.instance.email = self.initial_email
|
||||||
|
ctx = form.instance.get_email_context()
|
||||||
|
ctx['url'] = build_absolute_uri(
|
||||||
|
self.request.organizer,
|
||||||
|
'presale:organizer.customer.change.confirm'
|
||||||
|
) + '?token=' + dumps({
|
||||||
|
'customer': form.instance.pk,
|
||||||
|
'email': new_email
|
||||||
|
}, salt='pretix.presale.views.customer.ChangeInformationView')
|
||||||
|
mail(
|
||||||
|
new_email,
|
||||||
|
_('Confirm email address for your account at {organizer}').format(organizer=self.request.organizer.name),
|
||||||
|
self.request.organizer.settings.mail_text_customer_email_change,
|
||||||
|
ctx,
|
||||||
|
locale=form.instance.locale,
|
||||||
|
customer=form.instance,
|
||||||
|
organizer=self.request.organizer,
|
||||||
|
)
|
||||||
|
messages.success(self.request, _('Your changes have been saved. We\'ve sent you an email with a link to update your '
|
||||||
|
'email address. The email address of your account will be changed as soon as you '
|
||||||
|
'click that link.'))
|
||||||
|
else:
|
||||||
|
messages.success(self.request, _('Your changes have been saved.'))
|
||||||
|
|
||||||
|
with transaction.atomic():
|
||||||
|
form.save()
|
||||||
|
d = dict(form.cleaned_data)
|
||||||
|
del d['email']
|
||||||
|
self.request.customer.log_action('pretix.customer.changed', d)
|
||||||
|
|
||||||
|
update_customer_session_auth_hash(self.request, form.instance)
|
||||||
|
return HttpResponseRedirect(self.get_success_url())
|
||||||
|
|
||||||
|
def get_form_kwargs(self):
|
||||||
|
kwargs = super().get_form_kwargs()
|
||||||
|
kwargs['request'] = self.request
|
||||||
|
kwargs['instance'] = self.request.customer
|
||||||
|
return kwargs
|
||||||
|
|
||||||
|
|
||||||
|
class ConfirmChangeView(View):
|
||||||
|
template_name = 'pretixpresale/organizers/customer_info.html'
|
||||||
|
|
||||||
|
def get(self, request, *args, **kwargs):
|
||||||
|
if not request.organizer.settings.customer_accounts:
|
||||||
|
raise Http404('Feature not enabled')
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = loads(request.GET.get('token', ''), salt='pretix.presale.views.customer.ChangeInformationView', max_age=3600 * 24)
|
||||||
|
except BadSignature:
|
||||||
|
messages.error(request, _('You clicked an invalid link.'))
|
||||||
|
return HttpResponseRedirect(self.get_success_url())
|
||||||
|
|
||||||
|
try:
|
||||||
|
customer = request.organizer.customers.get(pk=data.get('customer'))
|
||||||
|
except Customer.DoesNotExist:
|
||||||
|
messages.error(request, _('You clicked an invalid link.'))
|
||||||
|
return HttpResponseRedirect(self.get_success_url())
|
||||||
|
|
||||||
|
with transaction.atomic():
|
||||||
|
customer.email = data['email']
|
||||||
|
customer.save()
|
||||||
|
customer.log_action('pretix.customer.changed', {
|
||||||
|
'email': data['email']
|
||||||
|
})
|
||||||
|
|
||||||
|
messages.success(request, _('Your email address has been updated.'))
|
||||||
|
|
||||||
|
if customer == request.customer:
|
||||||
|
update_customer_session_auth_hash(self.request, customer)
|
||||||
|
|
||||||
|
return HttpResponseRedirect(self.get_success_url())
|
||||||
|
|
||||||
|
def get_success_url(self):
|
||||||
|
return eventreverse(self.request.organizer, 'presale:organizer.customer.profile', kwargs={})
|
||||||
@@ -731,6 +731,14 @@ class OrderModify(EventViewMixin, OrderDetailMixin, OrderQuestionsViewMixin, Tem
|
|||||||
for k in override:
|
for k in override:
|
||||||
# We don't want initial values to be modified, they should come from the order directly
|
# We don't want initial values to be modified, they should come from the order directly
|
||||||
override[k].pop('initial', None)
|
override[k].pop('initial', None)
|
||||||
|
|
||||||
|
if order_position.used_membership and not order_position.used_membership.membership_type.transferable:
|
||||||
|
override_sets.append({
|
||||||
|
'attendee_name_parts': {
|
||||||
|
'disabled': True
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
return override_sets
|
return override_sets
|
||||||
|
|
||||||
def post(self, request, *args, **kwargs):
|
def post(self, request, *args, **kwargs):
|
||||||
|
|||||||
@@ -287,6 +287,7 @@ CACHE_TICKETS_HOURS = config.getint('cache', 'tickets', fallback=24 * 3)
|
|||||||
|
|
||||||
ENTROPY = {
|
ENTROPY = {
|
||||||
'order_code': config.getint('entropy', 'order_code', fallback=5),
|
'order_code': config.getint('entropy', 'order_code', fallback=5),
|
||||||
|
'customer_identifier': config.getint('entropy', 'customer_identifier', fallback=7),
|
||||||
'ticket_secret': config.getint('entropy', 'ticket_secret', fallback=32),
|
'ticket_secret': config.getint('entropy', 'ticket_secret', fallback=32),
|
||||||
'voucher_code': config.getint('entropy', 'voucher_code', fallback=16),
|
'voucher_code': config.getint('entropy', 'voucher_code', fallback=16),
|
||||||
'giftcard_secret': config.getint('entropy', 'giftcard_secret', fallback=12),
|
'giftcard_secret': config.getint('entropy', 'giftcard_secret', fallback=12),
|
||||||
|
|||||||
@@ -289,6 +289,9 @@ var form_handlers = function (el) {
|
|||||||
dependency = $($(this).attr("data-display-dependency")),
|
dependency = $($(this).attr("data-display-dependency")),
|
||||||
update = function (ev) {
|
update = function (ev) {
|
||||||
var enabled = (dependency.attr("type") === 'checkbox' || dependency.attr("type") === 'radio') ? dependency.prop('checked') : !!dependency.val();
|
var enabled = (dependency.attr("type") === 'checkbox' || dependency.attr("type") === 'radio') ? dependency.prop('checked') : !!dependency.val();
|
||||||
|
if (dependent.is("[data-inverse]")) {
|
||||||
|
enabled = !enabled;
|
||||||
|
}
|
||||||
var $toggling = dependent;
|
var $toggling = dependent;
|
||||||
if (dependent.get(0).tagName.toLowerCase() !== "div") {
|
if (dependent.get(0).tagName.toLowerCase() !== "div") {
|
||||||
$toggling = dependent.closest('.form-group');
|
$toggling = dependent.closest('.form-group');
|
||||||
@@ -304,8 +307,8 @@ var form_handlers = function (el) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
update();
|
update();
|
||||||
dependency.closest('.form-group').find('input[name=' + dependency.attr("name") + ']').on("change", update);
|
dependency.closest('.form-group').find('[name=' + dependency.attr("name") + ']').on("change", update);
|
||||||
dependency.closest('.form-group').find('input[name=' + dependency.attr("name") + ']').on("dp.change", update);
|
dependency.closest('.form-group').find('[name=' + dependency.attr("name") + ']').on("dp.change", update);
|
||||||
});
|
});
|
||||||
|
|
||||||
el.find("input[data-required-if], select[data-required-if], textarea[data-required-if]").each(function () {
|
el.find("input[data-required-if], select[data-required-if], textarea[data-required-if]").each(function () {
|
||||||
@@ -702,11 +705,13 @@ $(function () {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
$(".propagated-settings-box").find("input, textarea, select").not("[disabled]")
|
||||||
|
.attr("data-propagated-locked", "true").prop("disabled", true);
|
||||||
|
|
||||||
$(".propagated-settings-box button[data-action=unlink]").click(function (ev) {
|
$(".propagated-settings-box button[data-action=unlink]").click(function (ev) {
|
||||||
var $box = $(this).closest(".propagated-settings-box");
|
var $box = $(this).closest(".propagated-settings-box");
|
||||||
$box.find(".propagated-settings-overlay").fadeOut();
|
$box.find("[data-propagated-locked]").prop("disabled", false);
|
||||||
$box.find("input[name=_settings_ignore]").attr("name", "decouple");
|
$box.removeClass("locked").addClass("unlocked");
|
||||||
$box.find(".propagated-settings-form").removeClass("blurred");
|
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,10 +1,16 @@
|
|||||||
input[lang] {
|
input[lang] {
|
||||||
background: no-repeat 10px center;
|
background: no-repeat 10px center;
|
||||||
padding-left: 34px;
|
padding-left: 34px;
|
||||||
|
&[disabled] {
|
||||||
|
background-color: $input-bg-disabled;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
textarea[lang] {
|
textarea[lang] {
|
||||||
background: no-repeat 10px 10px;
|
background: no-repeat 10px 10px;
|
||||||
padding-left: 34px;
|
padding-left: 34px;
|
||||||
|
&[disabled] {
|
||||||
|
background-color: $input-bg-disabled;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
pre[lang] {
|
pre[lang] {
|
||||||
background: no-repeat 10px 10px;
|
background: no-repeat 10px 10px;
|
||||||
@@ -23,6 +29,10 @@ div[lang] {
|
|||||||
input[lang], textarea[lang], div[lang], pre[lang] {
|
input[lang], textarea[lang], div[lang], pre[lang] {
|
||||||
background: none;
|
background: none;
|
||||||
padding-left: 12px;
|
padding-left: 12px;
|
||||||
|
|
||||||
|
&[disabled] {
|
||||||
|
background-color: $input-bg-disabled;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -333,25 +333,27 @@ input[type=number].short {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.propagated-settings-box {
|
.propagated-settings-box.locked {
|
||||||
position: relative;
|
.propagated-settings-form {
|
||||||
|
opacity: 0.7;
|
||||||
.propagated-settings-overlay {
|
|
||||||
background: rgba(255, 255, 255, 0.7);
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
text-align: center;
|
|
||||||
}
|
}
|
||||||
|
.panel-body.help-text {
|
||||||
.propagated-settings-form.blurred {
|
border-bottom: 1px solid $panel-default-heading-bg;
|
||||||
-webkit-filter: blur(2px);
|
}
|
||||||
-moz-filter: blur(2px);
|
}
|
||||||
-ms-filter: blur(2px);
|
.propagated-settings-box.unlocked {
|
||||||
-o-filter: blur(2px);
|
border: 0;
|
||||||
filter: blur(2px);
|
transition: border-width 0.5s linear;
|
||||||
|
.panel-heading, .panel-body.help-text {
|
||||||
|
height: 0;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: height 0.5s linear, padding 0.5s linear, border-width 0.5s linear;
|
||||||
|
}
|
||||||
|
.panel-body {
|
||||||
|
padding: 0;
|
||||||
|
transition: padding 0.5s linear;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@media (max-width: $screen-sm-max) {
|
@media (max-width: $screen-sm-max) {
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ footer nav {
|
|||||||
.js-only {
|
.js-only {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
.locales {
|
.locales, .loginstatus {
|
||||||
display: inline;
|
display: inline;
|
||||||
a {
|
a {
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
@@ -45,6 +45,9 @@ footer nav {
|
|||||||
vertical-align: baseline;
|
vertical-align: baseline;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.loginstatus a {
|
||||||
|
margin-left: 10px;
|
||||||
|
}
|
||||||
.huge {
|
.huge {
|
||||||
font-size: 40px;
|
font-size: 40px;
|
||||||
}
|
}
|
||||||
@@ -300,6 +303,29 @@ h2 .label {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.quotabox {
|
||||||
|
display: inline-block;
|
||||||
|
vertical-align: top;
|
||||||
|
width: 50px;
|
||||||
|
.progress {
|
||||||
|
height: 7px;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
.numbers {
|
||||||
|
font-size: 10px;
|
||||||
|
color: $text-muted;
|
||||||
|
display: block;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
&.availability .progress-bar-success {
|
||||||
|
background: lighten($brand-success, 20%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@for $i from 0 through 100 {
|
||||||
|
.progress-bar-#{$i} { width: 1% * $i; }
|
||||||
|
}
|
||||||
|
|
||||||
@import "_iframe.scss";
|
@import "_iframe.scss";
|
||||||
@import "_a11y.scss";
|
@import "_a11y.scss";
|
||||||
@import "_print.scss";
|
@import "_print.scss";
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user