diff --git a/doc/api/guides/index.rst b/doc/api/guides/index.rst index ee2859070..b148c4df6 100644 --- a/doc/api/guides/index.rst +++ b/doc/api/guides/index.rst @@ -8,4 +8,5 @@ This part of the documentation contains how-to guides on some special use cases .. toctree:: :maxdepth: 2 + order_lifecycle custom_checkout diff --git a/doc/api/guides/order_lifecycle.rst b/doc/api/guides/order_lifecycle.rst new file mode 100644 index 000000000..6e48582d6 --- /dev/null +++ b/doc/api/guides/order_lifecycle.rst @@ -0,0 +1,56 @@ +Understanding the life cycle of orders +====================================== + +When integrating pretix with other systems, it is important that you understand how orders and related objects +such as order positions, fees, payments, refunds, and invoices work together, in order to react to their changes +properly and map them to processes in your system. + +Order states +------------ + +Generally, an order can be in six states. For compatibility reasons, the ``status`` field only allows four values +and the two remaining states are modeled through the ``require_approval`` field and the number of positions within +an order. The states and their allowed changes are shown in the following graph: + +.. image:: /images/order_states.png + + +Object types +------------ + +Order + One order represents one purchase. It's the main object you interact with and bundles all the other objects + together. Orders can change in many ways during their lifetime, but will never be deleted (unless ``testmode`` + is set to ``true``). + +Order position + An order position represents one product contained in the order. Orders can usually have multiple positions. + There might be a parent-child relation between order positions if one position is an add-on to another position. + Order positions can change in many ways during their lifetime, and can also be removed or added to an order. + +Order fees + A fee represents a charge that is not related to a product. Examples include shipping fees, service fees, and + cancellation fees. + Order fees can change in many ways during their lifetime, and can also be removed or added to an order. + +Order payment + An order payment represents one payment attempt with a specific payment method and amount. An order can have + multiple payments attached. + Order payments have their own state diagram. Apart from their state and their meta information (e.g. used + credit card, …) they usually don't change. They may be added at any time, but will never be deleted. + +Order refund + An order payment represents one refund attempt with a specific payment method and amount. An order can have + multiple refunds attached. + Order refunds have their own state diagram. Apart from their state and their meta information (e.g. used + credit card, …) they usually don't change. They may be added at any time, but will never be deleted. + +Invoice + An invoice represents a legal document stating the contents of an order. While the backend technically allows + to update an invoice in some situations, invoices are generally considered immutable. Once they are issued, + they no longer change. If the order changes substantially (e.g. prices change), an invoice is canceled through + creation of a new invoice with the opposite amount, plus the issuance of a new invoice. + +Here's an example of how they all play together: + +.. image:: /images/order_objects.png diff --git a/doc/api/resources/carts.rst b/doc/api/resources/carts.rst index 3076257a6..a717880dd 100644 --- a/doc/api/resources/carts.rst +++ b/doc/api/resources/carts.rst @@ -42,10 +42,6 @@ seat objects The assigned se └ seat_guid string Identifier of the seat within the seating plan ===================================== ========================== ======================================================= -.. versionchanged:: 1.17 - - This resource has been added. - .. versionchanged:: 3.0 This ``seat`` attribute has been added. diff --git a/doc/api/resources/categories.rst b/doc/api/resources/categories.rst index 1fad90fbb..ec8613d87 100644 --- a/doc/api/resources/categories.rst +++ b/doc/api/resources/categories.rst @@ -25,14 +25,6 @@ is_addon boolean If ``true``, it defining add-ons for other products. ===================================== ========================== ======================================================= -.. versionchanged:: 1.14 - - The operations POST, PATCH, PUT and DELETE have been added. - -.. versionchanged:: 1.16 - - The field ``internal_name`` has been added. - Endpoints --------- diff --git a/doc/api/resources/checkinlists.rst b/doc/api/resources/checkinlists.rst index d1a2edad5..edb564d98 100644 --- a/doc/api/resources/checkinlists.rst +++ b/doc/api/resources/checkinlists.rst @@ -36,22 +36,6 @@ rules object Custom check-in exit_all_at datetime Automatically check out (i.e. perform an exit scan) at this point in time. After this happened, this property will automatically be set exactly one day into the future. Note that this field is considered "internal configuration" and if you pull the list with ``If-Modified-Since``, the daily change in this field will not trigger a response. ===================================== ========================== ======================================================= -.. versionchanged:: 1.10 - - This resource has been added. - -.. versionchanged:: 1.11 - - The ``positions`` endpoints have been added. - -.. versionchanged:: 1.13 - - The ``include_pending`` field has been added. - -.. versionchanged:: 3.2 - - The ``auto_checkin_sales_channels`` field has been added. - .. versionchanged:: 3.9 The ``subevent`` attribute may now be ``null`` inside event series. The ``allow_multiple_entries``, @@ -68,10 +52,6 @@ exit_all_at datetime Automatically c Endpoints --------- -.. versionchanged:: 1.15 - - The ``../status/`` detail endpoint has been added. - .. http:get:: /api/v1/organizers/(organizer)/events/(event)/checkinlists/ Returns a list of all check-in lists within a given event. @@ -380,29 +360,6 @@ Endpoints Order position endpoints ------------------------ -.. versionchanged:: 1.15 - - The order positions endpoint has been extended by the filter queries ``item__in``, ``variation__in``, - ``order__status__in``, ``subevent__in``, ``addon_to__in``, and ``search``. The search for attendee names and order - codes is now case-insensitive. - - The ``.../redeem/`` endpoint has been added. - -.. versionchanged:: 2.0 - - The order positions endpoint has been extended by the filter queries ``voucher`` and ``voucher__code``. - -.. versionchanged:: 2.7 - - The resource now contains the new attributes ``require_attention`` and ``order__status`` and accepts the new - ``ignore_status`` filter. The ``attendee_name`` field is now "smart" (see below) and the redemption endpoint - returns ``400`` instead of ``404`` on tickets which are known but not paid. - -.. versionchanged:: 3.2 - - The ``checkins`` dict now also contains a ``auto_checked_in`` value to indicate if the check-in has been performed - automatically by the system. - .. http:get:: /api/v1/organizers/(organizer)/events/(event)/checkinlists/(list)/positions/ Returns a list of all order positions within a given event. The result is the same as diff --git a/doc/api/resources/events.rst b/doc/api/resources/events.rst index 207cdf8f7..25b641f6b 100644 --- a/doc/api/resources/events.rst +++ b/doc/api/resources/events.rst @@ -52,31 +52,6 @@ sales_channels list A list of sales ===================================== ========================== ======================================================= -.. versionchanged:: 1.7 - - The ``meta_data`` field has been added. - -.. versionchanged:: 1.15 - - The ``plugins`` field has been added. - The operations POST, PATCH, PUT and DELETE have been added. - -.. versionchanged:: 2.1 - - Filters have been added to the list of events. - -.. versionchanged:: 2.5 - - The ``testmode`` attribute has been added. - -.. versionchanged:: 2.8 - - When cloning events, the ``testmode`` attribute will now be cloned, too. - -.. versionchanged:: 3.0 - - The attributes ``seating_plan`` and ``seat_category_mapping`` have been added. - .. versionchanged:: 3.3 The attributes ``geo_lat`` and ``geo_lon`` have been added. diff --git a/doc/api/resources/invoices.rst b/doc/api/resources/invoices.rst index 086e8b206..b54c0ecf6 100644 --- a/doc/api/resources/invoices.rst +++ b/doc/api/resources/invoices.rst @@ -46,24 +46,6 @@ internal_reference string Customer's refe ===================================== ========================== ======================================================= -.. versionchanged:: 1.6 - - The attribute ``invoice_no`` has been dropped in favor of ``number`` which includes the number including the prefix, - since the prefix can now vary. Also, invoices now need to be identified by their ``number`` instead of the raw - number. - - -.. versionchanged:: 1.7 - - The attributes ``lines.tax_name``, ``foreign_currency_display``, ``foreign_currency_rate``, and - ``foreign_currency_rate_date`` have been added. - - -.. versionchanged:: 1.9 - - The attribute ``internal_reference`` has been added. - - .. versionchanged:: 3.4 The attribute ``lines.number`` has been added. diff --git a/doc/api/resources/item_add-ons.rst b/doc/api/resources/item_add-ons.rst index 2e3078eb8..b68bfcf26 100644 --- a/doc/api/resources/item_add-ons.rst +++ b/doc/api/resources/item_add-ons.rst @@ -28,10 +28,6 @@ multi_allowed boolean Adding the same price_included boolean Adding this add-on to the item is free ===================================== ========================== ======================================================= -.. versionchanged:: 1.12 - - This resource has been added. - Endpoints --------- diff --git a/doc/api/resources/item_bundles.rst b/doc/api/resources/item_bundles.rst index 1babc4bed..c66a959ef 100644 --- a/doc/api/resources/item_bundles.rst +++ b/doc/api/resources/item_bundles.rst @@ -30,10 +30,6 @@ designated_price money (string) Designated pric taxation. This is not added to the price. ===================================== ========================== ======================================================= -.. versionchanged:: 2.6 - - This resource has been added. - Endpoints --------- diff --git a/doc/api/resources/item_variations.rst b/doc/api/resources/item_variations.rst index 6f0367dff..e92ad33c7 100644 --- a/doc/api/resources/item_variations.rst +++ b/doc/api/resources/item_variations.rst @@ -26,14 +26,6 @@ description multi-lingual string A public descri position integer An integer, used for sorting ===================================== ========================== ======================================================= -.. versionchanged:: 2.7 - - The attribute ``original_price`` has been added. - -.. versionchanged:: 1.12 - - This resource has been added. - Endpoints --------- diff --git a/doc/api/resources/items.rst b/doc/api/resources/items.rst index d5a478250..c9d513d80 100644 --- a/doc/api/resources/items.rst +++ b/doc/api/resources/items.rst @@ -118,44 +118,6 @@ bundles list of objects Definition of b meta_data object Values set for event-specific meta data parameters. ===================================== ========================== ======================================================= -.. versionchanged:: 2.7 - - The attribute ``original_price`` has been added for ``variations``. - -.. versionchanged:: 1.7 - - The attribute ``tax_rule`` has been added. ``tax_rate`` is kept for compatibility. The attribute - ``checkin_attention`` has been added. - -.. versionchanged:: 1.12 - - The write operations ``POST``, ``PATCH``, ``PUT``, and ``DELETE`` have been added. - The attribute ``price_included`` has been added to ``addons``. - -.. versionchanged:: 1.16 - - The ``internal_name`` and ``original_price`` fields have been added. - -.. versionchanged:: 2.0 - - The field ``require_approval`` has been added. - -.. versionchanged:: 2.3 - - The ``sales_channels`` attribute has been added. - -.. versionchanged:: 2.4 - - The ``generate_tickets`` attribute has been added. - -.. versionchanged:: 2.6 - - The ``bundles`` and ``require_bundling`` attributes have been added. - -.. versionchanged:: 3.0 - - The ``show_quota_left``, ``allow_waitinglist``, and ``hidden_if_available`` attributes have been added. - .. versionchanged:: 3.7 The attribute ``meta_data`` has been added. diff --git a/doc/api/resources/orders.rst b/doc/api/resources/orders.rst index 133aab76a..443df60b3 100644 --- a/doc/api/resources/orders.rst +++ b/doc/api/resources/orders.rst @@ -94,60 +94,6 @@ last_modified datetime Last modificati ===================================== ========================== ======================================================= -.. versionchanged:: 1.6 - - The ``invoice_address.country`` attribute contains a two-letter country code for all new orders. For old orders, - a custom text might still be returned. - -.. versionchanged:: 1.7 - - The attributes ``invoice_address.vat_id_validated`` and ``invoice_address.is_business`` have been added. - The attributes ``order.payment_fee``, ``order.payment_fee_tax_rate`` and ``order.payment_fee_tax_value`` have been - deprecated in favor of the new ``fees`` attribute but will still be served and removed in 1.9. - -.. versionchanged:: 1.9 - - First write operations (``…/mark_paid/``, ``…/mark_pending/``, ``…/mark_canceled/``, ``…/mark_expired/``) have been added. - The attribute ``invoice_address.internal_reference`` has been added. - -.. versionchanged:: 1.13 - - The field ``checkin_attention`` has been added. - -.. versionchanged:: 1.15 - - The attributes ``order.payment_fee``, ``order.payment_fee_tax_rate``, ``order.payment_fee_tax_value`` and - ``order.payment_fee_tax_rule`` have finally been removed. - -.. versionchanged:: 1.16 - - The attributes ``order.last_modified`` as well as the corresponding filters to the resource have been added. - An endpoint for order creation as well as ``…/mark_refunded/`` has been added. - -.. versionchanged:: 2.0 - - The ``order.payment_date`` and ``order.payment_provider`` attributes have been deprecated in favor of the new - nested ``payments`` and ``refunds`` resources, but will still be served and removed in 2.2. The ``require_approval`` - attribute has been added, as have been the ``…/approve/`` and ``…/deny/`` endpoints. - -.. versionchanged:: 2.3 - - The ``sales_channel`` attribute has been added. - -.. versionchanged:: 2.4 - - ``order.status`` can no longer be ``r``, ``…/mark_canceled/`` now accepts a ``cancellation_fee`` parameter and - ``…/mark_refunded/`` has been deprecated. - -.. versionchanged:: 2.5 - - The ``testmode`` attribute has been added and ``DELETE`` has been implemented for orders. - -.. versionchanged:: 3.1 - - The ``invoice_address.state`` and ``url`` attributes have been added. When creating orders through the API, - vouchers are now supported and many fields are now optional. - .. versionchanged:: 3.5 The ``order.fees.canceled`` attribute has been added. @@ -233,30 +179,6 @@ pdf_data object Data object req ``pdf_data=true`` query parameter to your request. ===================================== ========================== ======================================================= -.. versionchanged:: 1.7 - - The attribute ``tax_rule`` has been added. - -.. versionchanged:: 1.11 - - The attribute ``checkins.list`` has been added. - -.. versionchanged:: 1.14 - - The attributes ``answers.question_identifier`` and ``answers.option_identifiers`` have been added. - -.. versionchanged:: 1.16 - - The attributes ``pseudonymization_id`` and ``pdf_data`` have been added. - -.. versionchanged:: 3.0 - - The attribute ``seat`` has been added. - -.. versionchanged:: 3.2 - - The value ``auto_checked_in`` has been added to the ``checkins``-attribute. - .. versionchanged:: 3.3 The ``url`` of a ticket ``download`` can now also return a ``text/uri-list`` instead of a file. See @@ -306,14 +228,6 @@ details object Payment-specifi the object is empty. ===================================== ========================== ======================================================= -.. versionchanged:: 2.0 - - This resource has been added. - -.. versionchanged:: 3.1 - - The attributes ``payment_url`` and ``details`` have been added. - .. _order-refund-resource: Order refund resource @@ -334,17 +248,9 @@ execution_date datetime Date and time o provider string Identification string of the payment provider ===================================== ========================== ======================================================= -.. versionchanged:: 2.0 - - This resource has been added. - List of all orders ------------------ -.. versionchanged:: 1.15 - - Filtering for emails or order codes is now case-insensitive. - .. versionchanged:: 3.5 The ``include_canceled_positions`` and ``include_canceled_fees`` query parameters have been added. @@ -1450,21 +1356,6 @@ Sending e-mails List of all order positions --------------------------- -.. versionchanged:: 1.15 - - The order positions endpoint has been extended by the filter queries ``item__in``, ``variation__in``, - ``order__status__in``, ``subevent__in``, ``addon_to__in`` and ``search``. The search for attendee names and order - codes is now case-insensitive. - -.. versionchanged:: 2.0 - - The order positions endpoint has been extended by the filter queries ``voucher``, ``voucher__code`` and - ``pseudonymization_id``. - -.. versionchanged:: 3.2 - - The value ``auto_checked_in`` has been added to the ``checkins``-attribute. - .. versionchanged:: 3.5 The ``include_canceled_positions`` and ``include_canceled_fees`` query parameters have been added. @@ -1804,10 +1695,6 @@ Manipulating individual positions Order payment endpoints ----------------------- -.. versionchanged:: 2.0 - - These endpoints have been added. - .. versionchanged:: 3.6 Payments can now be created through the API. @@ -2087,10 +1974,6 @@ Order payment endpoints Order refund endpoints ---------------------- -.. versionchanged:: 2.0 - - These endpoints have been added. - .. http:get:: /api/v1/organizers/(organizer)/events/(event)/orders/(code)/refunds/ Returns a list of all refunds for an order. diff --git a/doc/api/resources/question_options.rst b/doc/api/resources/question_options.rst index 28398244a..19def62c6 100644 --- a/doc/api/resources/question_options.rst +++ b/doc/api/resources/question_options.rst @@ -19,10 +19,6 @@ identifier string An arbitrary st answer multi-lingual string The displayed value of this option ===================================== ========================== ======================================================= -.. versionchanged:: 1.12 - - This resource has been added. - Endpoints --------- diff --git a/doc/api/resources/questions.rst b/doc/api/resources/questions.rst index f790dbce4..1ba8ee9c9 100644 --- a/doc/api/resources/questions.rst +++ b/doc/api/resources/questions.rst @@ -75,28 +75,6 @@ dependency_value string An old version for one value. **Deprecated.** ===================================== ========================== ======================================================= -.. versionchanged:: 1.12 - - The values ``D``, ``H``, and ``W`` for the field ``type`` are now allowed and the ``ask_during_checkin`` field has - been added. - -.. versionchanged:: 1.14 - - Write methods have been added. The attribute ``identifier`` has been added to both the resource itself and the - options resource. The ``position`` attribute has been added to the options resource. - -.. versionchanged:: 2.7 - - The attribute ``hidden`` and the question type ``CC`` have been added. - -.. versionchanged:: 3.0 - - The attribute ``dependency_values`` has been added. - -.. versionchanged:: 3.1 - - The attribute ``print_on_invoice`` has been added. - .. versionchanged:: 3.5 The attribute ``help_text`` has been added. diff --git a/doc/api/resources/quotas.rst b/doc/api/resources/quotas.rst index 2d7f416fc..b7939c358 100644 --- a/doc/api/resources/quotas.rst +++ b/doc/api/resources/quotas.rst @@ -30,14 +30,6 @@ release_after_exit boolean Whether the quo have been scanned at an exit. ===================================== ========================== ======================================================= -.. versionchanged:: 1.10 - - The write operations ``POST``, ``PATCH``, ``PUT``, and ``DELETE`` have been added. - -.. versionchanged:: 3.0 - - The attributes ``close_when_sold_out`` and ``closed`` have been added. - .. versionchanged:: 3.10 The attribute ``release_after_exit`` has been added. diff --git a/doc/api/resources/seatingplans.rst b/doc/api/resources/seatingplans.rst index 4086f1ce1..2a0fdd005 100644 --- a/doc/api/resources/seatingplans.rst +++ b/doc/api/resources/seatingplans.rst @@ -20,10 +20,6 @@ layout object JSON representa still evolves. The version in use can be found `here`_. ===================================== ========================== ======================================================= -.. versionchanged:: 3.0 - - This endpoint has been added. - Endpoints --------- diff --git a/doc/api/resources/subevents.rst b/doc/api/resources/subevents.rst index e308e267c..65d559e27 100644 --- a/doc/api/resources/subevents.rst +++ b/doc/api/resources/subevents.rst @@ -33,6 +33,7 @@ date_to datetime The sub-event's date_admission datetime The sub-event's admission date (or ``null``) presale_start datetime The sub-date at which the ticket shop opens (or ``null``) presale_end datetime The sub-date at which the ticket shop closes (or ``null``) +frontpage_text multi-lingual string The description of the event (or ``null``) location multi-lingual string The sub-event location (or ``null``) geo_lat float Latitude of the location (or ``null``) geo_lon float Longitude of the location (or ``null``) @@ -54,25 +55,6 @@ seat_category_mapping object An object mappi last_modified datetime Last modification of this object ===================================== ========================== ======================================================= -.. versionchanged:: 1.7 - - The ``meta_data`` field has been added. - -.. versionchanged:: 2.1 - - The ``event`` field has been added, together with filters on the list of dates and an organizer-level list. - -.. versionchanged:: 2.6 - The write operations ``POST``, ``PATCH``, ``PUT``, and ``DELETE`` have been added. - -.. versionchanged:: 2.7 - - The attribute ``is_public`` has been added. - -.. versionchanged:: 3.0 - - The attributes ``seating_plan`` and ``seat_category_mapping`` have been added. - .. versionchanged:: 3.3 The attributes ``geo_lat`` and ``geo_lon`` have been added. diff --git a/doc/api/resources/taxrules.rst b/doc/api/resources/taxrules.rst index c50ddcc96..37d84942e 100644 --- a/doc/api/resources/taxrules.rst +++ b/doc/api/resources/taxrules.rst @@ -24,14 +24,6 @@ home_country string Merchant countr ``null`` or empty string ===================================== ========================== ======================================================= -.. versionchanged:: 1.7 - - This resource has been added. - -.. versionchanged:: 1.9 - - The write operations ``POST``, ``PATCH``, ``PUT``, and ``DELETE`` have been added. - Endpoints --------- diff --git a/doc/api/resources/vouchers.rst b/doc/api/resources/vouchers.rst index f3d6de70b..8cdc30838 100644 --- a/doc/api/resources/vouchers.rst +++ b/doc/api/resources/vouchers.rst @@ -46,14 +46,6 @@ show_hidden_items boolean Only if set to ===================================== ========================== ======================================================= -.. versionchanged:: 1.9 - - The write operations ``POST``, ``PATCH``, ``PUT``, and ``DELETE`` have been added. - -.. versionchanged:: 3.0 - - The attribute ``show_hidden_items`` has been added. - .. versionchanged:: 3.4 The attribute ``seat`` has been added. diff --git a/doc/development/concepts.rst b/doc/development/concepts.rst index 3018addb4..063274376 100644 --- a/doc/development/concepts.rst +++ b/doc/development/concepts.rst @@ -82,11 +82,15 @@ Orders ^^^^^^ If a customer completes the checkout process, an **Order** will be created containing all the entered information. -An order can be in one of currently four states that are listed in the diagram below: +An order can be in one of currently six states that are listed in the diagram below: .. image:: /images/order_states.png -There are additional "fake" states that are displayed like states but not represented as states in the system: +The dotted lines represent status changes that usually do not happen as part of the regular process, but can be +performed manually in the admin backend. + +For historical reasons, there are only four valid values of the ``status`` field, and the two additional states are +represented differently: * An order is considered **canceled (with paid fee)** if it is in **paid** status but does not include any non-cancelled positions. diff --git a/doc/images/order_objects.png b/doc/images/order_objects.png new file mode 100644 index 000000000..880d0bc77 Binary files /dev/null and b/doc/images/order_objects.png differ diff --git a/doc/images/order_objects.puml b/doc/images/order_objects.puml new file mode 100644 index 000000000..1e1641a51 --- /dev/null +++ b/doc/images/order_objects.puml @@ -0,0 +1,34 @@ +@startuml + +participant User +collections "OrderPayment\nOrderRefund" as P +collections "Order\nOrderPosition" as O +collections "Invoice\nInvoiceLine" as I + +User -> O: Order placed (€100) +rnote over O #6DD96D: Order A1B2C\nstatus = **n**\ntotal = €100 +O -> P: Payment created +O -> I: Invoice created\n(can also happen later) +rnote over I #6DD96D: Invoice 00001\n€100 +rnote over P #6DD96D: OrderPayment A1B2C-P-1\nstate = **created** +P -> User: Payment details (web, email) +User -> P: Payment performed +rnote over P #EFF46B: OrderPayment A1B2C-P-1\nstate = **confirmed** +P -> O: Order marked as paid +rnote over O #EFF46B: Order A1B2C\nstatus = **p**\ntotal = €100 +User -> O: Data change (e.g. invoice address) +O -> I: Invoice reissued +rnote over I #6DD96D: Invoice 00002\n€-100 +rnote over I #6DD96D: Invoice 00003\n€100 +rnote over O #EFF46B: Order A1B2C\nstatus = **p**\ntotal = €100 +User -> O: Order canceled +rnote over O #EFF46B: Order A1B2C\nstatus = **c** +O -> I: Invoice canceled +rnote over I #6DD96D: Invoice 00004\n€-100 +O -> P: Refund started +rnote over P #6DD96D: OrderRefund\nA1B2C-R-1\nstate = **created** +P -> User: Money sent +rnote over P #EFF46B: OrderRefund\nA1B2C-R-1\nstate = **done** + +@enduml + diff --git a/doc/images/order_states.png b/doc/images/order_states.png index 84e9b0eb1..4c5cc4a3c 100644 Binary files a/doc/images/order_states.png and b/doc/images/order_states.png differ diff --git a/doc/images/order_states.puml b/doc/images/order_states.puml index 5465725af..a1ef55fac 100644 --- a/doc/images/order_states.puml +++ b/doc/images/order_states.puml @@ -1,19 +1,39 @@ @startuml -Pending: Order is expecting payment\nOrder reduces quotas -Expired: Payment period is over\nOrder does not affect quotas -Paid: Order was successful\nOrder reduces quotas -Canceled: Order has been canceled\nOrder does not affect quotas +state "Approval Pending" as AP +state "Canceled (with paid fee)" as CP +AP: status = "n" +AP: require_approval = true +Pending: status = "n" +Pending: require_approval = false +Pending: Tickets reserved: yes +Expired: status = "e" +Expired: Tickets reserved: no +Paid: status = "p" +Paid: count(positions | !canceled) > 0 +Paid: Tickets reserved: yes +CP: status = "p" +CP: count(positions | !canceled) = 0 +Canceled: status = "c" +Canceled: Tickets reserved: no -[*] --> Pending: customer\nplaces order -Pending --> Paid: successful payment -Pending --> Expired: automatically\nor manually\non admin action -Expired --> Paid: if payment is received\nonly if quota left -Expired --> Canceled -Expired --> Pending: manually\non admin action -Paid --> Canceled: manually on\nadmin action\nor if an external\npayment provider\nnotifies about a\npayment refund -Pending --> Canceled: on admin or\ncustomer action -Paid -> Pending: manually on admin action -[*] --> Paid: customer\nplaces free order + +[*] -> Pending: order placed\ntotal > 0 +[*] -> Paid: order placed\ntotal = 0 +[*] -> AP: order placed\napproval required +Pending --> Paid: order paid +Pending --> Expired: after payment\ndeadline +Expired --> Paid: order paid\n(only if quota left) +Expired -[dashed]-> Canceled +Expired -[dashed]-> Pending: order extended +Paid --> Canceled: order canceled +Pending --> Canceled: order canceled +Paid -[dashed]-> Pending: refund +AP --> Pending: order approved +AP --> Canceled: order denied +Paid --> CP: order canceled\n(with cancellation fee) +Canceled -[dashed]-> Pending: order reactivated +Canceled -[dashed]-> Paid: order reactivated +CP -[dashed]-> Canceled: fee canceled @enduml diff --git a/doc/plugins/badges.rst b/doc/plugins/badges.rst index 51be4ba3b..e46ab7e91 100644 --- a/doc/plugins/badges.rst +++ b/doc/plugins/badges.rst @@ -22,10 +22,6 @@ item_assignments list of objects Products this l └ item integer Item ID ===================================== ========================== ======================================================= -.. versionchanged:: 1.16 - - This resource has been added. - Endpoints --------- diff --git a/doc/plugins/ticketoutputpdf.rst b/doc/plugins/ticketoutputpdf.rst index ccb12327e..ab5e0cce0 100644 --- a/doc/plugins/ticketoutputpdf.rst +++ b/doc/plugins/ticketoutputpdf.rst @@ -24,14 +24,6 @@ item_assignments list of objects Products this l └ item integer Item ID ===================================== ========================== ======================================================= -.. versionchanged:: 1.16 - - This resource has been added. - -.. versionchanged:: 2.3 - - The ``item_assignments.sales_channel`` field has been added. - Endpoints --------- diff --git a/doc/user/events/widget.rst b/doc/user/events/widget.rst index ad5db038e..6e5e55053 100644 --- a/doc/user/events/widget.rst +++ b/doc/user/events/widget.rst @@ -437,11 +437,6 @@ Hosted or pretix Enterprise are active, you can pass the following fields: -.. versionchanged:: 2.3 - - Data passing options have been added in pretix 2.3. If you use a self-hosted version of pretix, they only work - fully if you configured a redis server. - .. versionchanged:: 3.6 Dynamically opening the widget has been added in pretix 3.6. diff --git a/src/pretix/api/serializers/event.py b/src/pretix/api/serializers/event.py index 036fb76dc..b3575d584 100644 --- a/src/pretix/api/serializers/event.py +++ b/src/pretix/api/serializers/event.py @@ -409,8 +409,8 @@ class SubEventSerializer(I18nAwareModelSerializer): model = SubEvent fields = ('id', 'name', 'date_from', 'date_to', 'active', 'date_admission', 'presale_start', 'presale_end', 'location', 'geo_lat', 'geo_lon', 'event', 'is_public', - 'seating_plan', 'item_price_overrides', 'variation_price_overrides', 'meta_data', - 'seat_category_mapping', 'last_modified') + 'frontpage_text', 'seating_plan', 'item_price_overrides', 'variation_price_overrides', + 'meta_data', 'seat_category_mapping', 'last_modified') def validate(self, data): data = super().validate(data) diff --git a/src/pretix/base/email.py b/src/pretix/base/email.py index 2fae8111a..5b252dc9d 100644 --- a/src/pretix/base/email.py +++ b/src/pretix/base/email.py @@ -468,7 +468,8 @@ def base_placeholders(sender, **kwargs): '68CYU2H6ZTP3WLK5' ), SimpleFunctionalMailTextPlaceholder( - 'voucher_list', ['voucher_list'], lambda voucher_list: '\n'.join(voucher_list), + # join vouchers with two spaces at end of line so markdown-parser inserts a
+ 'voucher_list', ['voucher_list'], lambda voucher_list: ' \n'.join(voucher_list), ' 68CYU2H6ZTP3WLK5\n 7MB94KKPVEPSMVF2' ), SimpleFunctionalMailTextPlaceholder( diff --git a/src/pretix/base/exporter.py b/src/pretix/base/exporter.py index 91da572d9..4338e7936 100644 --- a/src/pretix/base/exporter.py +++ b/src/pretix/base/exporter.py @@ -1,4 +1,5 @@ import io +import re import tempfile from collections import OrderedDict, namedtuple from decimal import Decimal @@ -10,11 +11,21 @@ from django.db.models import QuerySet from django.utils.formats import localize from django.utils.translation import gettext, gettext_lazy as _ from openpyxl import Workbook -from openpyxl.cell.cell import KNOWN_TYPES +from openpyxl.cell.cell import ILLEGAL_CHARACTERS_RE, KNOWN_TYPES from pretix.base.models import Event +def excel_safe(val): + if not isinstance(val, KNOWN_TYPES): + val = str(val) + + if isinstance(val, str): + val = re.sub(ILLEGAL_CHARACTERS_RE, '', val) + + return val + + class BaseExporter: """ This is the base class for all data exporters @@ -181,7 +192,7 @@ class ListExporter(BaseExporter): total = line.total continue ws.append([ - str(val) if not isinstance(val, KNOWN_TYPES) else val + excel_safe(val) if not isinstance(val, KNOWN_TYPES) else val for val in line ]) if total: @@ -301,7 +312,7 @@ class MultiSheetListExporter(ListExporter): total = line.total continue ws.append([ - str(val) if not isinstance(val, KNOWN_TYPES) else val + excel_safe(val) for val in line ]) if total: diff --git a/src/pretix/base/models/organizer.py b/src/pretix/base/models/organizer.py index c77988187..eb0b09d88 100644 --- a/src/pretix/base/models/organizer.py +++ b/src/pretix/base/models/organizer.py @@ -4,6 +4,7 @@ from datetime import date, datetime, time from django.core.validators import MinLengthValidator, RegexValidator from django.db import models from django.db.models import Exists, OuterRef, Q +from django.urls import reverse from django.utils.crypto import get_random_string from django.utils.functional import cached_property from django.utils.timezone import get_current_timezone, make_aware, now @@ -88,6 +89,15 @@ class Organizer(LoggedModel): return ObjectRelatedCache(self) + @cached_property + def all_logentries_link(self): + return reverse( + 'control:organizer.log', + kwargs={ + 'organizer': self.slug, + } + ) + @property def has_gift_cards(self): return self.cache.get_or_set( diff --git a/src/pretix/control/forms/filter.py b/src/pretix/control/forms/filter.py index fbca154f3..19db3f309 100644 --- a/src/pretix/control/forms/filter.py +++ b/src/pretix/control/forms/filter.py @@ -1,4 +1,4 @@ -from datetime import datetime, time +from datetime import datetime, time, timedelta from decimal import Decimal from urllib.parse import urlencode @@ -766,10 +766,15 @@ class SubEventFilterForm(FilterForm): ), required=False ) - date = forms.DateField( - label=_('Date'), + date_from = forms.DateField( + label=_('Date from'), required=False, - widget=DatePickerWidget + widget=DatePickerWidget, + ) + date_until = forms.DateField( + label=_('Date until'), + required=False, + widget=DatePickerWidget, ) weekday = forms.ChoiceField( label=_('Weekday'), @@ -796,7 +801,8 @@ class SubEventFilterForm(FilterForm): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self.fields['date'].widget = DatePickerWidget() + self.fields['date_from'].widget = DatePickerWidget() + self.fields['date_until'].widget = DatePickerWidget() def filter_qs(self, qs): fdata = self.cleaned_data @@ -838,19 +844,21 @@ class SubEventFilterForm(FilterForm): Q(name__icontains=i18ncomp(query)) | Q(location__icontains=query) ) - if fdata.get('date'): - date_start = make_aware(datetime.combine( - fdata.get('date'), + if fdata.get('date_until'): + date_end = make_aware(datetime.combine( + fdata.get('date_until') + timedelta(days=1), time(hour=0, minute=0, second=0, microsecond=0) ), get_current_timezone()) - date_end = make_aware(datetime.combine( - fdata.get('date'), - time(hour=23, minute=59, second=59, microsecond=999999) - ), get_current_timezone()) qs = qs.filter( - Q(date_to__isnull=True, date_from__gte=date_start, date_from__lte=date_end) | - Q(date_to__isnull=False, date_from__lte=date_end, date_to__gte=date_start) + Q(date_to__isnull=True, date_from__lt=date_end) | + Q(date_to__isnull=False, date_to__lt=date_end) ) + if fdata.get('date_from'): + date_start = make_aware(datetime.combine( + fdata.get('date_from'), + time(hour=0, minute=0, second=0, microsecond=0) + ), get_current_timezone()) + qs = qs.filter(date_from__gte=date_start) if fdata.get('ordering'): qs = qs.order_by(self.get_order_by()) diff --git a/src/pretix/control/forms/orders.py b/src/pretix/control/forms/orders.py index 9f6d3d968..e79931245 100644 --- a/src/pretix/control/forms/orders.py +++ b/src/pretix/control/forms/orders.py @@ -104,7 +104,7 @@ class ConfirmPaymentForm(forms.Form): class CancelForm(ConfirmPaymentForm): send_email = forms.BooleanField( required=False, - label=_('Notify user by e-mail'), + label=_('Notify customer by email'), initial=True ) cancellation_fee = forms.DecimalField( @@ -139,6 +139,11 @@ class CancelForm(ConfirmPaymentForm): class MarkPaidForm(ConfirmPaymentForm): + send_email = forms.BooleanField( + required=False, + label=_('Notify customer by email'), + initial=True + ) amount = forms.DecimalField( required=True, max_digits=10, decimal_places=2, diff --git a/src/pretix/control/forms/renderers.py b/src/pretix/control/forms/renderers.py index 7b7edc227..41a55bdbe 100644 --- a/src/pretix/control/forms/renderers.py +++ b/src/pretix/control/forms/renderers.py @@ -1,4 +1,4 @@ -from bootstrap3.renderers import FieldRenderer +from bootstrap3.renderers import FieldRenderer, InlineFieldRenderer from bootstrap3.text import text_value from django.forms import CheckboxInput from django.forms.utils import flatatt @@ -58,3 +58,40 @@ class ControlFieldRenderer(FieldRenderer): optional=not required and not isinstance(self.widget, CheckboxInput) ) + html return html + + +class BulkEditMixin: + + def __init__(self, *args, **kwargs): + kwargs['layout'] = self.layout + super().__init__(*args, **kwargs) + + def wrap_field(self, html): + field_class = self.get_field_class() + name = '{}{}'.format(self.field.form.prefix, self.field.name) + checked = self.field.form.data and name in self.field.form.data.getlist('_bulk') + html = ( + '
' + '' + '
' + '{html}' + '
' + '
' + ).format( + klass=field_class or '', + name=name, + label=pgettext('form_bulk', 'change'), + checked='checked' if checked else '', + html=html + ) + return html + + +class BulkEditFieldRenderer(BulkEditMixin, FieldRenderer): + layout = 'horizontal' + + +class InlineBulkEditFieldRenderer(BulkEditMixin, InlineFieldRenderer): + layout = 'inline' diff --git a/src/pretix/control/forms/subevents.py b/src/pretix/control/forms/subevents.py index 1bbcb7ac4..d06439202 100644 --- a/src/pretix/control/forms/subevents.py +++ b/src/pretix/control/forms/subevents.py @@ -1,8 +1,9 @@ -from datetime import timedelta +from datetime import datetime, timedelta from urllib.parse import urlencode from django import forms from django.forms import formset_factory +from django.forms.utils import ErrorDict from django.urls import reverse from django.utils.dates import MONTHS, WEEKDAYS from django.utils.functional import cached_property @@ -11,6 +12,7 @@ from django.utils.translation import gettext_lazy as _, pgettext_lazy from i18nfield.forms import I18nInlineFormSet from pretix.base.forms import I18nModelForm +from pretix.base.forms.widgets import DatePickerWidget, TimePickerWidget from pretix.base.models.event import SubEvent, SubEventMetaValue from pretix.base.models.items import SubEventItem from pretix.base.reldate import RelativeDateTimeField @@ -88,6 +90,142 @@ class SubEventBulkForm(SubEventForm): del self.fields['date_admission'] +class NullBooleanSelect(forms.NullBooleanSelect): + def __init__(self, attrs=None): + choices = ( + ('unknown', _('Keep the current values')), + ('true', _('Yes')), + ('false', _('No')), + ) + super(forms.NullBooleanSelect, self).__init__(attrs, choices) + + +class SubEventBulkEditForm(I18nModelForm): + def __init__(self, *args, **kwargs): + self.mixed_values = kwargs.pop('mixed_values') + self.queryset = kwargs.pop('queryset') + super().__init__(*args, **kwargs) + self.fields['location'].widget.attrs['rows'] = '3' + + for k in ('name', 'location', 'frontpage_text'): + # i18n fields + if k in self.mixed_values: + self.fields[k].widget.attrs['placeholder'] = '[{}]'.format(_('Selection contains various values')) + else: + self.fields[k].widget.attrs['placeholder'] = '' + self.fields[k].one_required = False + + for k in ('geo_lat', 'geo_lon'): + # scalar fields + if k in self.mixed_values: + self.fields[k].widget.attrs['placeholder'] = '[{}]'.format(_('Selection contains various values')) + else: + self.fields[k].widget.attrs['placeholder'] = '' + self.fields[k].widget.is_required = False + self.fields[k].required = False + + for k in ('date_from', 'date_to', 'date_admission', 'presale_start', 'presale_end'): + self.fields[k + '_day'] = forms.DateField( + label=self._meta.model._meta.get_field(k).verbose_name, + help_text=self._meta.model._meta.get_field(k).help_text, + widget=DatePickerWidget(), + required=False, + ) + self.fields[k + '_time'] = forms.TimeField( + label=self._meta.model._meta.get_field(k).verbose_name, + help_text=self._meta.model._meta.get_field(k).help_text, + widget=TimePickerWidget(), + required=False, + ) + + class Meta: + model = SubEvent + localized_fields = '__all__' + fields = [ + 'name', + 'location', + 'frontpage_text', + 'geo_lat', + 'geo_lon', + 'is_public', + 'active', + ] + field_classes = { + } + widgets = { + } + + def save(self, commit=True): + objs = list(self.queryset) + fields = set() + + check_map = { + 'geo_lat': '__geo', + 'geo_lon': '__geo', + } + for k in self.fields: + cb_val = self.prefix + check_map.get(k, k) + if cb_val not in self.data.getlist('_bulk'): + continue + + if k.endswith('_day'): + for obj in objs: + oldval = getattr(obj, k.replace('_day', '')) + cval = self.cleaned_data[k] + if cval is None: + newval = None + if not self._meta.model._meta.get_field(k.replace('_day', '')).null: + continue + elif oldval: + oldval = oldval.astimezone(self.event.timezone) + newval = oldval.replace( + year=cval.year, + month=cval.month, + day=cval.day, + ) + else: + # If there is no previous date/time set, we'll just set to midnight + # If the user also selected a time, this will be overridden anyways + newval = datetime( + year=cval.year, + month=cval.month, + day=cval.day, + tzinfo=self.event.timezone + ) + setattr(obj, k.replace('_day', ''), newval) + fields.add(k.replace('_day', '')) + elif k.endswith('_time'): + for obj in objs: + # If there is no previous date/time set and only a time is changed not the + # date, we instead use the date of the event + oldval = getattr(obj, k.replace('_time', '')) or obj.date_from + cval = self.cleaned_data[k] + if cval is None: + continue + oldval = oldval.astimezone(self.event.timezone) + newval = oldval.replace( + hour=cval.hour, + minute=cval.minute, + second=cval.second, + ) + setattr(obj, k.replace('_time', ''), newval) + fields.add(k.replace('_time', '')) + else: + fields.add(k) + for obj in objs: + setattr(obj, k, self.cleaned_data[k]) + + if fields: + SubEvent.objects.bulk_update(objs, fields, 200) + + def full_clean(self): + if len(self.data) == 0: + # form wasn't submitted + self._errors = ErrorDict() + return + super().full_clean() + + class SubEventItemOrVariationFormMixin: def __init__(self, *args, **kwargs): self.item = kwargs.pop('item') @@ -162,7 +300,7 @@ class SubEventMetaValueForm(forms.ModelForm): def __init__(self, *args, **kwargs): self.property = kwargs.pop('property') self.default = kwargs.pop('default', None) - self.disabled = kwargs.pop('disabled') + self.disabled = kwargs.pop('disabled', False) super().__init__(*args, **kwargs) if self.property.allowed_values: self.fields['value'] = forms.ChoiceField( diff --git a/src/pretix/control/logdisplay.py b/src/pretix/control/logdisplay.py index 3609a7b86..c7f42486d 100644 --- a/src/pretix/control/logdisplay.py +++ b/src/pretix/control/logdisplay.py @@ -273,8 +273,15 @@ def _display_checkin(event, logentry): def pretixcontrol_logentry_display(sender: Event, logentry: LogEntry, **kwargs): plains = { 'pretix.object.cloned': _('This object has been created by cloning.'), + 'pretix.organizer.changed': _('The organizer has been changed.'), + 'pretix.organizer.settings': _('The organizer settings have been changed.'), + 'pretix.giftcards.acceptance.added': _('Gift card acceptance for another organizer has been added.'), + 'pretix.giftcards.acceptance.removed': _('Gift card acceptance for another organizer has been removed.'), + 'pretix.webhook.created': _('The webhook has been created.'), + 'pretix.webhook.changed': _('The webhook has been changed.'), 'pretix.event.comment': _('The event\'s internal comment has been updated.'), 'pretix.event.canceled': _('The event has been canceled.'), + 'pretix.event.deleted': _('An event has been deleted.'), 'pretix.event.order.modified': _('The order details have been changed.'), 'pretix.event.order.unpaid': _('The order has been marked as unpaid.'), 'pretix.event.order.secret.changed': _('The order\'s secret has been changed.'), diff --git a/src/pretix/control/templates/pretixcontrol/order/pay.html b/src/pretix/control/templates/pretixcontrol/order/pay.html index cc359d46d..78403cfbe 100644 --- a/src/pretix/control/templates/pretixcontrol/order/pay.html +++ b/src/pretix/control/templates/pretixcontrol/order/pay.html @@ -24,6 +24,7 @@ {% bootstrap_form_errors form %} {% bootstrap_field form.amount layout='horizontal' %} {% bootstrap_field form.payment_date layout='horizontal' %} + {% bootstrap_field form.send_email layout='horizontal' %} {% if form.force %} {% bootstrap_field form.force layout='horizontal' horizontal_label_class='sr-only' horizontal_field_class='col-md-12' %} {% endif %} diff --git a/src/pretix/control/templates/pretixcontrol/order/refund_choose.html b/src/pretix/control/templates/pretixcontrol/order/refund_choose.html index 8ae010e9e..1d384b740 100644 --- a/src/pretix/control/templates/pretixcontrol/order/refund_choose.html +++ b/src/pretix/control/templates/pretixcontrol/order/refund_choose.html @@ -15,7 +15,8 @@ {% endblocktrans %} -
+ {% csrf_token %}
{% trans "How should the refund be sent?" %} diff --git a/src/pretix/control/templates/pretixcontrol/orders/export.html b/src/pretix/control/templates/pretixcontrol/orders/export.html index ca7ff51b8..75eb961aa 100644 --- a/src/pretix/control/templates/pretixcontrol/orders/export.html +++ b/src/pretix/control/templates/pretixcontrol/orders/export.html @@ -11,7 +11,7 @@ {% endif %} {% for e in exporters %} -
+

{{ e.verbose_name }} diff --git a/src/pretix/control/templates/pretixcontrol/organizers/edit.html b/src/pretix/control/templates/pretixcontrol/organizers/edit.html index 363e86c5d..1d33e9dc9 100644 --- a/src/pretix/control/templates/pretixcontrol/organizers/edit.html +++ b/src/pretix/control/templates/pretixcontrol/organizers/edit.html @@ -22,54 +22,68 @@ {% csrf_token %} {% bootstrap_form_errors sform %} {% bootstrap_form_errors form %} -
-
- {% trans "General" %} - {% bootstrap_field form.name layout="control" %} - {% bootstrap_field form.slug layout="control" %} - {% if form.domain %} - {% bootstrap_field form.domain layout="control" %} - {% endif %} - {% bootstrap_field sform.imprint_url layout="control" %} - {% bootstrap_field sform.contact_mail layout="control" %} - {% bootstrap_field sform.organizer_info_text layout="control" %} - {% bootstrap_field sform.event_team_provisioning layout="control" %} -
-
- {% trans "Organizer page" %} - {% bootstrap_field sform.organizer_logo_image layout="control" %} - {% bootstrap_field sform.organizer_logo_image_large layout="control" %} - {% bootstrap_field sform.organizer_homepage_text layout="control" %} - {% bootstrap_field sform.event_list_type layout="control" %} - {% bootstrap_field sform.event_list_availability layout="control" %} - {% bootstrap_field sform.organizer_link_back layout="control" %} -
-
- {% trans "Localization" %} - {% bootstrap_field sform.locales layout="control" %} - {% bootstrap_field sform.region layout="control" %} -
-
- {% trans "Shop design" %} -

- {% blocktrans trimmed %} - These settings will be used for the organizer page as well as for the default settings - for all events in this account that do not have their own design settings. - {% endblocktrans %} -

- {% bootstrap_field sform.primary_color layout="control" %} - {% bootstrap_field sform.theme_color_success layout="control" %} - {% bootstrap_field sform.theme_color_danger layout="control" %} - {% bootstrap_field sform.theme_color_background layout="control" %} - {% bootstrap_field sform.theme_round_borders layout="control" %} - {% bootstrap_field sform.primary_font layout="control" %} - {% bootstrap_field sform.favicon layout="control" %} -
-
- {% trans "Gift cards" %} - {% bootstrap_field sform.giftcard_expiry_years layout="control" %} - {% bootstrap_field sform.giftcard_length layout="control" %} -
+
+
+
+
+ {% trans "General" %} + {% bootstrap_field form.name layout="control" %} + {% bootstrap_field form.slug layout="control" %} + {% if form.domain %} + {% bootstrap_field form.domain layout="control" %} + {% endif %} + {% bootstrap_field sform.imprint_url layout="control" %} + {% bootstrap_field sform.contact_mail layout="control" %} + {% bootstrap_field sform.organizer_info_text layout="control" %} + {% bootstrap_field sform.event_team_provisioning layout="control" %} +
+
+ {% trans "Organizer page" %} + {% bootstrap_field sform.organizer_logo_image layout="control" %} + {% bootstrap_field sform.organizer_logo_image_large layout="control" %} + {% bootstrap_field sform.organizer_homepage_text layout="control" %} + {% bootstrap_field sform.event_list_type layout="control" %} + {% bootstrap_field sform.event_list_availability layout="control" %} + {% bootstrap_field sform.organizer_link_back layout="control" %} +
+
+ {% trans "Localization" %} + {% bootstrap_field sform.locales layout="control" %} + {% bootstrap_field sform.region layout="control" %} +
+
+ {% trans "Shop design" %} +

+ {% blocktrans trimmed %} + These settings will be used for the organizer page as well as for the default settings + for all events in this account that do not have their own design settings. + {% endblocktrans %} +

+ {% bootstrap_field sform.primary_color layout="control" %} + {% bootstrap_field sform.theme_color_success layout="control" %} + {% bootstrap_field sform.theme_color_danger layout="control" %} + {% bootstrap_field sform.theme_color_background layout="control" %} + {% bootstrap_field sform.theme_round_borders layout="control" %} + {% bootstrap_field sform.primary_font layout="control" %} + {% bootstrap_field sform.favicon layout="control" %} +
+
+ {% trans "Gift cards" %} + {% bootstrap_field sform.giftcard_expiry_years layout="control" %} + {% bootstrap_field sform.giftcard_length layout="control" %} +
+
+
+
+
+
+

+ {% trans "Change history" %} +

+
+ {% include "pretixcontrol/includes/logs.html" with obj=organizer %} +
+

{% for e in exporters %} -
+

{{ e.verbose_name }} diff --git a/src/pretix/control/templates/pretixcontrol/organizers/logs.html b/src/pretix/control/templates/pretixcontrol/organizers/logs.html new file mode 100644 index 000000000..d849570e6 --- /dev/null +++ b/src/pretix/control/templates/pretixcontrol/organizers/logs.html @@ -0,0 +1,85 @@ +{% extends "pretixcontrol/items/base.html" %} +{% load i18n %} +{% load static %} +{% block title %}{% trans "Organizer logs" %}{% endblock %} +{% block inside %} +

{% trans "Organizer logs" %}

+ + + +

+ + +

+ +
    + {% for log in logs %} +
  • +
    +
    + + {{ log.datetime|date:"SHORT_DATETIME_FORMAT" }} + {% if log.shredded %} + + + {% endif %} +
    +
    + {% if log.user %} + {% if log.user.is_staff %} + + + {% else %} + + {% endif %} + {{ log.user.get_full_name }} + {% if log.oauth_application %} +
    + {{ log.oauth_application.name }} + {% endif %} + {% elif log.device %} + + {{ log.device.name }} + {% elif log.api_token %} + + {{ log.api_token.name }} + {% endif %} +
    +
    + {% if log.display_object %} + {{ log.display_object|safe }} + {% endif %} +
    +
    + {{ log.display }} + {% if staff_session %} + + + {% trans "Inspect" %} + + {% endif %} +
    +
    +
  • + {% empty %} +
    + {% trans "No results" %} +
    + {% endfor %} +
+ {% include "pretixcontrol/pagination.html" %} +{% endblock %} diff --git a/src/pretix/control/templates/pretixcontrol/subevents/bulk_edit.html b/src/pretix/control/templates/pretixcontrol/subevents/bulk_edit.html new file mode 100644 index 000000000..292a4ba91 --- /dev/null +++ b/src/pretix/control/templates/pretixcontrol/subevents/bulk_edit.html @@ -0,0 +1,351 @@ +{% extends "pretixcontrol/event/base.html" %} +{% load i18n %} +{% load bootstrap3 %} +{% load formset_tags %} +{% load captureas %} +{% load static %} +{% load eventsignal %} +{% block title %}{% trans "Change multiple dates" context "subevent" %}{% endblock %} +{% block content %} +

+ {% trans "Change multiple dates" context "subevent" %} + + {% blocktrans trimmed with number=subevents.count %} + {{ number }} selected + {% endblocktrans %} + +

+
+ {% csrf_token %} + {% bootstrap_form_errors form %} + {% for f in itemvar_forms %} + {% bootstrap_form_errors f %} + {% endfor %} + +
+ {% trans "General information" %} + {% bootstrap_field form.name layout="bulkedit" %} + {% bootstrap_field form.active layout="bulkedit" %} +
+ {% bootstrap_field form.location layout="bulkedit" %} +
+ +
+
+ +
+
+
+ {% bootstrap_field form.geo_lat layout="inline" %} + {% if global_settings.opencagedata_apikey %} +

+ + {% trans "Geocoding data © OpenStreetMap" %} + +

+ {% endif %} +
+
+ {% bootstrap_field form.geo_lon layout="inline" %} +
+
+
+
+
+
+
+ {% bootstrap_field form.frontpage_text layout="bulkedit" %} + {% bootstrap_field form.is_public layout="bulkedit" %} + {% if meta_forms %} + + {% endif %} +
+
+ {% trans "Timeline" %} +
+ +
+ {% bootstrap_field form.date_from_day layout="bulkedit_inline" form_group_class="" %} +
+
+ {% bootstrap_field form.date_from_time layout="bulkedit_inline" form_group_class="" %} +
+
+
+ +
+ {% bootstrap_field form.date_to_day layout="bulkedit_inline" form_group_class="" %} +
+
+ {% bootstrap_field form.date_to_time layout="bulkedit_inline" form_group_class="" %} +
+
+
+ +
+ {% bootstrap_field form.date_admission_day layout="bulkedit_inline" form_group_class="" %} +
+
+ {% bootstrap_field form.date_admission_time layout="bulkedit_inline" form_group_class="" %} +
+
+
+ +
+ {% bootstrap_field form.presale_start_day layout="bulkedit_inline" form_group_class="" %} +
+
+ {% bootstrap_field form.presale_start_time layout="bulkedit_inline" form_group_class="" %} +
+
+
+ +
+ {% bootstrap_field form.presale_end_day layout="bulkedit_inline" form_group_class="" %} +
+
+ {% bootstrap_field form.presale_end_time layout="bulkedit_inline" form_group_class="" %} +
+
+
+
+ {% trans "Item prices" %} + {% for f in itemvar_forms %} +
+ +
+ {% bootstrap_field f.price addon_after=request.event.currency form_group_class="" layout="bulkedit_inline" %} +
+
+ {% bootstrap_field f.disabled layout="bulkedit_inline" form_group_class="" %} +
+
+ {% endfor %} +
+
+ {% trans "Quotas" %} + {% if sampled_quotas|default_if_none:"NONE" == "NONE" %} +
+ {% blocktrans trimmed %} + You selected a set of dates that currently have different quota setups. You can therefore + not change their quotas in bulk. If you want, you can set up a new set of quotas to + replace the quota setup of all selected dates. + {% endblocktrans %} +
+ {% endif %} +
+ +
+
+ {{ formset.management_form }} + {% bootstrap_formset_errors formset %} +
+ {% for form in formset %} +
+
+ {{ form.id }} + {% bootstrap_field form.DELETE form_group_class="" layout="inline" %} +
+
+

+
+
+ {% bootstrap_field form.name layout='inline' form_group_class="" %} +
+
+ +
+
+

+
+
+ {% bootstrap_form_errors form %} + {% bootstrap_field form.size layout="control" %} + {% bootstrap_field form.itemvars layout="control" %} + {% bootstrap_field form.release_after_exit layout="control" %} +
+
+ {% endfor %} +
+ +

+ +

+
+
+
+
+

 

+
+ {% trans "Check-in lists" %} + {% if sampled_lists|default_if_none:"NONE" == "NONE" %} +
+ {% blocktrans trimmed %} + You selected a set of dates that currently have different check-in list setups. You can + therefore not change their check-in lists in bulk. + {% endblocktrans %} +
+ {% else %} +
+ +
+
+ {{ cl_formset.management_form }} + {% bootstrap_formset_errors cl_formset %} +
+ {% for form in cl_formset %} +
+
+ {{ form.id }} + {% bootstrap_field form.DELETE form_group_class="" layout="inline" %} +
+
+

+
+
+ {% bootstrap_field form.name layout='inline' form_group_class="" %} +
+
+ +
+
+

+
+
+ {% bootstrap_form_errors form %} + {% bootstrap_field form.include_pending layout="control" %} + {% bootstrap_field form.all_products layout="control" %} + {% bootstrap_field form.limit_products layout="control" %} + {% bootstrap_field form.allow_entry_after_exit layout="control" %} + {% if form.gates %} + {% bootstrap_field form.gates layout="control" %} + {% endif %} +
+
+ {% endfor %} +
+ +

+ +

+
+
+ {% endif %} +
+
+ +
+
+{% endblock %} diff --git a/src/pretix/control/templates/pretixcontrol/subevents/index.html b/src/pretix/control/templates/pretixcontrol/subevents/index.html index 1a343bf2e..89ed7f4be 100644 --- a/src/pretix/control/templates/pretixcontrol/subevents/index.html +++ b/src/pretix/control/templates/pretixcontrol/subevents/index.html @@ -22,14 +22,17 @@ {% else %}
-
+
{% bootstrap_field filter_form.query layout='inline' %}
-
+
{% bootstrap_field filter_form.status layout='inline' %}
- {% bootstrap_field filter_form.date layout='inline' %} + {% bootstrap_field filter_form.date_from layout='inline' %} +
+
+ {% bootstrap_field filter_form.date_until layout='inline' %}
{% bootstrap_field filter_form.weekday layout='inline' %} @@ -43,23 +46,28 @@
-

- - {% trans "Create a new date" context "subevent" %} - - {% trans "Create many new dates" context "subevent" %} -

+ {% if "can_change_event_settings" in request.eventpermset %} +

+ + {% trans "Create a new date" context "subevent" %} + + {% trans "Create many new dates" context "subevent" %} +

+ {% endif %}
{% csrf_token %} +
+ {% if "can_change_event_settings" in request.eventpermset %} + + + + + {% endif %} {% for s in subevents %}
{% if "can_change_event_settings" in request.eventpermset %} - + {% endif %} @@ -67,28 +75,40 @@ {% trans "Begin" %} - - + + {% trans "Paid tickets per quota" %} - - + + {% trans "Status" %} - - + +
{% if "can_change_event_settings" in request.eventpermset %} - + {% endif %} @@ -150,6 +170,10 @@ + diff --git a/src/pretix/control/urls.py b/src/pretix/control/urls.py index cae129e67..54202fad5 100644 --- a/src/pretix/control/urls.py +++ b/src/pretix/control/urls.py @@ -122,6 +122,7 @@ urlpatterns = [ url(r'^organizer/(?P[^/]+)/team/(?P[^/]+)/delete$', organizer.TeamDeleteView.as_view(), name='organizer.team.delete'), url(r'^organizer/(?P[^/]+)/slugrng', main.SlugRNG.as_view(), name='events.add.slugrng'), + url(r'^organizer/(?P[^/]+)/logs', organizer.LogView.as_view(), name='organizer.log'), url(r'^organizer/(?P[^/]+)/export/$', organizer.ExportView.as_view(), name='organizer.export'), url(r'^organizer/(?P[^/]+)/export/do$', organizer.ExportDoView.as_view(), name='organizer.export.do'), url(r'^nav/typeahead/$', typeahead.nav_context_list, name='nav.typeahead'), @@ -173,6 +174,7 @@ urlpatterns = [ url(r'^subevents/add$', subevents.SubEventCreate.as_view(), name='event.subevents.add'), url(r'^subevents/bulk_add$', subevents.SubEventBulkCreate.as_view(), name='event.subevents.bulk'), url(r'^subevents/bulk_action$', subevents.SubEventBulkAction.as_view(), name='event.subevents.bulkaction'), + url(r'^subevents/bulk_edit$', subevents.SubEventBulkEdit.as_view(), name='event.subevents.bulkedit'), url(r'^items/$', item.ItemList.as_view(), name='event.items'), url(r'^items/add$', item.ItemCreate.as_view(), name='event.items.add'), url(r'^items/(?P\d+)/$', item.ItemUpdateGeneral.as_view(), name='event.item'), diff --git a/src/pretix/control/views/orders.py b/src/pretix/control/views/orders.py index 659e2327f..e10b2e3e4 100644 --- a/src/pretix/control/views/orders.py +++ b/src/pretix/control/views/orders.py @@ -1105,6 +1105,7 @@ class OrderTransition(OrderView): try: p.confirm(user=self.request.user, count_waitinglist=False, payment_date=payment_date, + send_mail=self.mark_paid_form.cleaned_data['send_email'], force=self.mark_paid_form.cleaned_data.get('force', False)) except Quota.QuotaExceededException as e: p.state = OrderPayment.PAYMENT_STATE_FAILED @@ -2079,11 +2080,17 @@ class ExportMixin: exporters.append(ex) return exporters + def get_context_data(self, **kwargs): + ctx = super().get_context_data(**kwargs) + ctx['exporters'] = self.exporters + return ctx -class ExportDoView(EventPermissionRequiredMixin, ExportMixin, AsyncAction, View): + +class ExportDoView(EventPermissionRequiredMixin, ExportMixin, AsyncAction, TemplateView): permission = 'can_view_orders' known_errortypes = ['ExportError'] task = export + template_name = 'pretixcontrol/orders/export.html' def get_success_message(self, value): return None @@ -2103,6 +2110,11 @@ class ExportDoView(EventPermissionRequiredMixin, ExportMixin, AsyncAction, View) if ex.identifier == self.request.POST.get("exporter"): return ex + def get(self, request, *args, **kwargs): + if 'async_id' in request.GET and settings.HAS_CELERY: + return self.get_result(request) + return TemplateView.get(self, request, *args, **kwargs) + def post(self, request, *args, **kwargs): if not self.exporter: messages.error(self.request, _('The selected exporter was not found.')) @@ -2112,16 +2124,8 @@ class ExportDoView(EventPermissionRequiredMixin, ExportMixin, AsyncAction, View) })) if not self.exporter.form.is_valid(): - messages.error( - self.request, - str(_('There was a problem processing your input:')) + ' ' + ', '.join( - ', '.join(line) for line in self.exporter.form.errors.values() - ) - ) - return redirect(reverse('control:event.orders.export', kwargs={ - 'event': self.request.event.slug, - 'organizer': self.request.event.organizer.slug - }) + '?identifier=' + self.exporter.identifier) + messages.error(self.request, _('There was a problem processing your input. See below for error details.')) + return self.get(request, *args, **kwargs) cf = CachedFile(web_download=True, session_key=request.session.session_key) cf.date = now() @@ -2134,11 +2138,6 @@ class ExportView(EventPermissionRequiredMixin, ExportMixin, TemplateView): permission = 'can_view_orders' template_name = 'pretixcontrol/orders/export.html' - def get_context_data(self, **kwargs): - ctx = super().get_context_data(**kwargs) - ctx['exporters'] = self.exporters - return ctx - class RefundList(EventPermissionRequiredMixin, PaginationMixin, ListView): model = OrderRefund diff --git a/src/pretix/control/views/organizer.py b/src/pretix/control/views/organizer.py index b40344e97..b572502a4 100644 --- a/src/pretix/control/views/organizer.py +++ b/src/pretix/control/views/organizer.py @@ -51,6 +51,7 @@ from pretix.control.forms.organizer import ( GiftCardUpdateForm, OrganizerDeleteForm, OrganizerForm, OrganizerSettingsForm, OrganizerUpdateForm, TeamForm, WebHookForm, ) +from pretix.control.logdisplay import OVERVIEW_BANLIST from pretix.control.permissions import ( AdministratorPermissionRequiredMixin, OrganizerPermissionRequiredMixin, ) @@ -1147,8 +1148,9 @@ class ExportMixin: organizer=self.request.organizer ) responses = register_multievent_data_exporters.send(self.request.organizer) + id = self.request.GET.get("identifier") or self.request.POST.get("exporter") for ex in sorted([response(events) for r, response in responses if response], key=lambda ex: str(ex.verbose_name)): - if self.request.GET.get("identifier") and ex.identifier != self.request.GET.get("identifier"): + if id and ex.identifier != id: continue # Use form parse cycle to generate useful defaults @@ -1180,10 +1182,16 @@ class ExportMixin: exporters.append(ex) return exporters + def get_context_data(self, **kwargs): + ctx = super().get_context_data(**kwargs) + ctx['exporters'] = self.exporters + return ctx -class ExportDoView(OrganizerPermissionRequiredMixin, ExportMixin, AsyncAction, View): + +class ExportDoView(OrganizerPermissionRequiredMixin, ExportMixin, AsyncAction, TemplateView): known_errortypes = ['ExportError'] task = multiexport + template_name = 'pretixcontrol/organizers/export.html' def get_success_message(self, value): return None @@ -1202,6 +1210,11 @@ class ExportDoView(OrganizerPermissionRequiredMixin, ExportMixin, AsyncAction, V if ex.identifier == self.request.POST.get("exporter"): return ex + def get(self, request, *args, **kwargs): + if 'async_id' in request.GET and settings.HAS_CELERY: + return self.get_result(request) + return TemplateView.get(self, request, *args, **kwargs) + def post(self, request, *args, **kwargs): if not self.exporter: messages.error(self.request, _('The selected exporter was not found.')) @@ -1231,11 +1244,6 @@ class ExportDoView(OrganizerPermissionRequiredMixin, ExportMixin, AsyncAction, V class ExportView(OrganizerPermissionRequiredMixin, ExportMixin, TemplateView): template_name = 'pretixcontrol/organizers/export.html' - def get_context_data(self, **kwargs): - ctx = super().get_context_data(**kwargs) - ctx['exporters'] = self.exporters - return ctx - class GateListView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin, ListView): model = Gate @@ -1427,3 +1435,24 @@ class EventMetaPropertyDeleteView(OrganizerDetailViewMixin, OrganizerPermissionR self.object.delete() messages.success(request, _('The selected property has been deleted.')) return redirect(success_url) + + +class LogView(OrganizerPermissionRequiredMixin, ListView): + template_name = 'pretixcontrol/organizers/logs.html' + permission = 'can_change_organizer_settings' + model = LogEntry + context_object_name = 'logs' + paginate_by = 20 + + def get_queryset(self): + qs = self.request.organizer.all_logentries().select_related( + 'user', 'content_type', 'api_token', 'oauth_application', 'device' + ).order_by('-datetime') + qs = qs.exclude(action_type__in=OVERVIEW_BANLIST) + if self.request.GET.get('user'): + qs = qs.filter(user_id=self.request.GET.get('user')) + return qs + + def get_context_data(self, **kwargs): + ctx = super().get_context_data() + return ctx diff --git a/src/pretix/control/views/subevents.py b/src/pretix/control/views/subevents.py index 4d1fb4051..74137f3b3 100644 --- a/src/pretix/control/views/subevents.py +++ b/src/pretix/control/views/subevents.py @@ -1,14 +1,17 @@ import copy +from collections import defaultdict from datetime import datetime, time, timedelta from dateutil.rrule import DAILY, MONTHLY, WEEKLY, YEARLY, rrule, rruleset from django.contrib import messages from django.core.files import File from django.db import connections, transaction -from django.db.models import F, IntegerField, OuterRef, Prefetch, Subquery, Sum -from django.db.models.functions import Coalesce +from django.db.models import ( + Count, F, IntegerField, OuterRef, Prefetch, Subquery, Sum, +) +from django.db.models.functions import Coalesce, TruncDate, TruncTime from django.forms import inlineformset_factory -from django.http import Http404, HttpResponseRedirect +from django.http import Http404, HttpResponse, HttpResponseRedirect from django.shortcuts import redirect, render from django.urls import reverse from django.utils.formats import get_format @@ -16,7 +19,9 @@ from django.utils.functional import cached_property from django.utils.timezone import make_aware from django.utils.translation import gettext_lazy as _, pgettext_lazy from django.views import View -from django.views.generic import CreateView, DeleteView, ListView, UpdateView +from django.views.generic import ( + CreateView, DeleteView, FormView, ListView, UpdateView, +) from pretix.base.models import CartPosition, LogEntry from pretix.base.models.checkin import CheckinList @@ -31,24 +36,27 @@ from pretix.control.forms.checkin import SimpleCheckinListForm from pretix.control.forms.filter import SubEventFilterForm from pretix.control.forms.item import QuotaForm from pretix.control.forms.subevents import ( - CheckinListFormSet, QuotaFormSet, RRuleFormSet, SubEventBulkForm, - SubEventForm, SubEventItemForm, SubEventItemVariationForm, - SubEventMetaValueForm, TimeFormSet, + CheckinListFormSet, QuotaFormSet, RRuleFormSet, SubEventBulkEditForm, + SubEventBulkForm, SubEventForm, SubEventItemForm, + SubEventItemVariationForm, SubEventMetaValueForm, TimeFormSet, ) from pretix.control.permissions import EventPermissionRequiredMixin from pretix.control.signals import subevent_forms from pretix.control.views import PaginationMixin from pretix.control.views.event import MetaDataEditorMixin +from pretix.helpers import GroupConcat from pretix.helpers.models import modelcopy -class SubEventList(EventPermissionRequiredMixin, PaginationMixin, ListView): - model = SubEvent - context_object_name = 'subevents' - template_name = 'pretixcontrol/subevents/index.html' - permission = 'can_change_settings' +class SubEventQueryMixin: - def get_queryset(self): + @cached_property + def request_data(self): + if self.request.method == "POST": + return self.request.POST + return self.request.GET + + def get_queryset(self, list=False): sum_tickets_paid = Quota.objects.filter( subevent=OuterRef('pk') ).order_by().values('subevent').annotate( @@ -56,18 +64,39 @@ class SubEventList(EventPermissionRequiredMixin, PaginationMixin, ListView): ).values( 's' ) - - qs = self.request.event.subevents.annotate( - sum_tickets_paid=Subquery(sum_tickets_paid, output_field=IntegerField()) - ).prefetch_related( - Prefetch('quotas', - queryset=Quota.objects.annotate(s=Coalesce(F('size'), 0)).order_by('-s'), - to_attr='first_quotas') - ) + qs = self.request.event.subevents + if list: + qs = qs.annotate( + sum_tickets_paid=Subquery(sum_tickets_paid, output_field=IntegerField()) + ).prefetch_related( + Prefetch('quotas', + queryset=Quota.objects.annotate(s=Coalesce(F('size'), 0)).order_by('-s'), + to_attr='first_quotas') + ) if self.filter_form.is_valid(): qs = self.filter_form.filter_qs(qs) + + if 'subevent' in self.request_data and '__ALL' not in self.request_data: + qs = qs.filter( + id__in=self.request_data.getlist('subevent') + ) + return qs + @cached_property + def filter_form(self): + return SubEventFilterForm(data=self.request_data, prefix='filter') + + +class SubEventList(EventPermissionRequiredMixin, PaginationMixin, SubEventQueryMixin, ListView): + model = SubEvent + context_object_name = 'subevents' + template_name = 'pretixcontrol/subevents/index.html' + permission = 'can_change_settings' + + def get_queryset(self): + return super().get_queryset(True) + def get_context_data(self, **kwargs): ctx = super().get_context_data(**kwargs) ctx['filter_form'] = self.filter_form @@ -95,10 +124,6 @@ class SubEventList(EventPermissionRequiredMixin, PaginationMixin, ListView): ) return ctx - @cached_property - def filter_form(self): - return SubEventFilterForm(data=self.request.GET) - class SubEventDelete(EventPermissionRequiredMixin, DeleteView): model = SubEvent @@ -535,19 +560,13 @@ class SubEventCreate(SubEventEditorMixin, EventPermissionRequiredMixin, CreateVi return formlist -class SubEventBulkAction(EventPermissionRequiredMixin, View): +class SubEventBulkAction(SubEventQueryMixin, EventPermissionRequiredMixin, View): permission = 'can_change_settings' - @cached_property - def objects(self): - return self.request.event.subevents.filter( - id__in=self.request.POST.getlist('subevent') - ) - @transaction.atomic def post(self, request, *args, **kwargs): if request.POST.get('action') == 'disable': - for obj in self.objects: + for obj in self.get_queryset(): obj.log_action( 'pretix.subevent.changed', user=self.request.user, data={ 'active': False @@ -557,7 +576,7 @@ class SubEventBulkAction(EventPermissionRequiredMixin, View): obj.save(update_fields=['active']) messages.success(request, pgettext_lazy('subevent', 'The selected dates have been disabled.')) elif request.POST.get('action') == 'enable': - for obj in self.objects: + for obj in self.get_queryset(): obj.log_action( 'pretix.subevent.changed', user=self.request.user, data={ 'active': True @@ -568,11 +587,11 @@ class SubEventBulkAction(EventPermissionRequiredMixin, View): messages.success(request, pgettext_lazy('subevent', 'The selected dates have been enabled.')) elif request.POST.get('action') == 'delete': return render(request, 'pretixcontrol/subevents/delete_bulk.html', { - 'allowed': self.objects.filter(orderposition__isnull=True), - 'forbidden': self.objects.filter(orderposition__isnull=False), + 'allowed': self.get_queryset().filter(orderposition__isnull=True), + 'forbidden': self.get_queryset().filter(orderposition__isnull=False).distinct(), }) elif request.POST.get('action') == 'delete_confirm': - for obj in self.objects: + for obj in self.get_queryset(): if obj.allow_delete(): CartPosition.objects.filter(addon_to__subevent=obj).delete() obj.cartposition_set.all().delete() @@ -899,3 +918,537 @@ class SubEventBulkCreate(SubEventEditorMixin, EventPermissionRequiredMixin, Crea messages.error(self.request, _('We could not save your changes. See below for details.')) return self.form_invalid(form) + + +class SubEventBulkEdit(SubEventQueryMixin, EventPermissionRequiredMixin, FormView): + permission = 'can_change_settings' + form_class = SubEventBulkEditForm + template_name = 'pretixcontrol/subevents/bulk_edit.html' + context_object_name = 'subevent' + + def get_queryset(self): + return super().get_queryset().prefetch_related(None).order_by() + + def get_success_url(self) -> str: + return reverse('control:event.subevents', kwargs={ + 'organizer': self.request.event.organizer.slug, + 'event': self.request.event.slug, + }) + + def get(self, request, *args, **kwargs): + return HttpResponse(status=405) + + @cached_property + def cached_num(self): + return self.get_queryset().count() + + @cached_property + def itemvar_forms(self): + matches = defaultdict(list) + for sei in SubEventItem.objects.filter( + subevent__in=self.get_queryset() + ).order_by().values('item', 'price', 'disabled').annotate(c=Count('*')): + matches['item', sei['item']].append(sei) + for sei in SubEventItemVariation.objects.filter( + subevent__in=self.get_queryset() + ).order_by().values('variation', 'price', 'disabled').annotate(c=Count('*')): + matches['variation', sei['variation']].append(sei) + total = self.cached_num + + formlist = [] + for i in self.request.event.items.filter(active=True).prefetch_related('variations'): + if i.has_variations: + for v in i.variations.all(): + m = matches['variation', v.pk] + if m and len(m) == 1 and m[0]['c'] == total: + inst = SubEventItemVariation(variation=v, disabled=m[0]['disabled'], price=m[0]['price']) + else: + inst = SubEventItemVariation(variation=v) + formlist.append(SubEventItemVariationForm( + prefix='itemvar-{}'.format(v.pk), + item=i, variation=v, + instance=inst, + data=(self.request.POST if self.is_submitted else None) + )) + else: + m = matches['item', i.pk] + if m and len(m) == 1 and m[0]['c'] == total: + inst = SubEventItem(item=i, disabled=m[0]['disabled'], price=m[0]['price']) + else: + inst = SubEventItem(item=i) + formlist.append(SubEventItemForm( + prefix='item-{}'.format(i.pk), + item=i, + instance=inst, + data=(self.request.POST if self.is_submitted else None) + )) + return formlist + + @cached_property + def meta_forms(self): + matches = defaultdict(list) + for smv in SubEventMetaValue.objects.filter( + subevent__in=self.get_queryset() + ).order_by().values('property', 'value').annotate(c=Count('*')): + matches[smv['property']].append(smv) + total = self.cached_num + + formlist = [] + + if not hasattr(self, '_default_meta'): + self._default_meta = self.request.event.meta_data + + for p in self.request.organizer.meta_properties.all(): + inst = SubEventMetaValue(property=p) + if len(matches[p.id]) == 1 and matches[p.id][0]['c'] == total: + inst.value = matches[p.id][0]['value'] + formlist.append(SubEventMetaValueForm( + prefix='prop-{}'.format(p.pk), + property=p, + default=self._default_meta.get(p.name, ''), + instance=inst, + data=(self.request.POST if self.is_submitted else None) + )) + return formlist + + @cached_property + def quota_formset(self): + extra = 0 + kwargs = {} + + if self.sampled_quotas is not None: + kwargs['instance'] = self.get_queryset()[0] + + formsetclass = inlineformset_factory( + SubEvent, Quota, + form=QuotaForm, formset=QuotaFormSet, min_num=0, validate_min=False, + can_order=False, can_delete=True, extra=extra, + ) + return formsetclass( + self.request.POST if self.is_submitted else None, + event=self.request.event, **kwargs + ) + + @cached_property + def list_formset(self): + extra = 0 + kwargs = {} + + if self.sampled_lists is not None: + kwargs['instance'] = self.get_queryset()[0] + else: + return None + + formsetclass = inlineformset_factory( + SubEvent, CheckinList, + form=SimpleCheckinListForm, formset=CheckinListFormSet, min_num=0, validate_min=False, + can_order=False, can_delete=True, extra=extra, + ) + return formsetclass( + self.request.POST if self.is_submitted else None, + event=self.request.event, **kwargs + ) + + def save_list_formset(self, log_entries): + if not self.list_formset.has_changed() or self.sampled_lists is None: + return + qidx = 0 + subevents = list(self.get_queryset().prefetch_related('checkinlist_set')) + to_save_products = [] + to_save_gates = [] + + for f in self.list_formset.forms: + if self.list_formset._should_delete_form(f) and f in self.list_formset.extra_forms: + continue + + if self.list_formset._should_delete_form(f): + for se in subevents: + q = list(se.checkinlist_set.all())[qidx] + log_entries += [ + q.log_action(action='pretix.event.checkinlist.deleted', user=self.request.user, save=False), + ] + q.delete() + elif f in self.list_formset.extra_forms: + change_data = {k: f.cleaned_data.get(k) for k in f.changed_data} + for se in subevents: + q = copy.copy(f.instance) + q.pk = None + q.subevent = se + q.event = self.request.event + q.save() + for _i in f.cleaned_data.get('limit_products', []): + to_save_products.append(CheckinList.limit_products.through(checkinlist_id=q.pk, item_id=_i.pk)) + for _i in f.cleaned_data.get('gates', []): + to_save_gates.append(CheckinList.gates.through(checkinlist_id=q.pk, gate_id=_i.pk)) + change_data['id'] = q.pk + log_entries.append( + q.log_action(action='pretix.event.checkinlist.added', user=self.request.user, + data=change_data, save=False) + ) + else: + if f.changed_data: + change_data = {k: f.cleaned_data.get(k) for k in f.changed_data} + for se in subevents: + q = list(se.checkinlist_set.all())[qidx] + for fname in ('name', 'all_products', 'include_pending', 'allow_entry_after_exit'): + setattr(q, fname, f.cleaned_data.get(fname)) + q.save() + if 'limit_products' in f.changed_data: + q.limit_products.set(f.cleaned_data.get('limit_products', [])) + if 'gates' in f.changed_data: + q.gates.set(f.cleaned_data.get('limit_products', [])) + log_entries.append( + q.log_action(action='pretix.event.checkinlist.changed', user=self.request.user, + data=change_data, save=False) + ) + qidx += 1 + if to_save_products: + CheckinList.limit_products.through.objects.bulk_create(to_save_products) + if to_save_gates: + CheckinList.gates.through.objects.bulk_create(to_save_gates) + + def save_quota_formset(self, log_entries): + if not self.quota_formset.has_changed(): + return + qidx = 0 + subevents = list(self.get_queryset().prefetch_related('quotas')) + to_save_items = [] + to_save_variations = [] + to_delete_quota_ids = [] + + if self.sampled_quotas is None: + if len(self.quota_formset.forms) == 0: + return + else: + for se in subevents: + for q in se.quotas.all(): + to_delete_quota_ids.append(q.pk) + log_entries += [ + q.log_action(action='pretix.event.quota.deleted', user=self.request.user, save=False), + se.log_action('pretix.subevent.quota.deleted', user=self.request.user, data={ + 'id': q.pk + }, save=False) + ] + + if to_delete_quota_ids: + Quota.objects.filter(id__in=to_delete_quota_ids).delete() + + for f in self.quota_formset.forms: + if self.quota_formset._should_delete_form(f) and f in self.quota_formset.extra_forms: + continue + + selected_items = set(list(self.request.event.items.filter(id__in=[ + i.split('-')[0] for i in f.cleaned_data.get('itemvars', []) + ]))) + selected_variations = list(ItemVariation.objects.filter(item__event=self.request.event, id__in=[ + i.split('-')[1] for i in f.cleaned_data.get('itemvars', []) if '-' in i + ])) + + if self.quota_formset._should_delete_form(f): + for se in subevents: + q = list(se.quotas.all())[qidx] + log_entries += [ + q.log_action(action='pretix.event.quota.deleted', user=self.request.user, save=False), + se.log_action('pretix.subevent.quota.deleted', user=self.request.user, data={ + 'id': q.pk + }, save=False) + ] + q.delete() + elif f in self.quota_formset.extra_forms: + change_data = {k: f.cleaned_data.get(k) for k in f.changed_data} + for se in subevents: + q = copy.copy(f.instance) + q.pk = None + q.subevent = se + q.event = self.request.event + q.save(clear_cache=False) + for _i in selected_items: + to_save_items.append(Quota.items.through(quota_id=q.pk, item_id=_i.pk)) + for _i in selected_variations: + to_save_variations.append(Quota.variations.through(quota_id=q.pk, itemvariation_id=_i.pk)) + + change_data['id'] = q.pk + log_entries.append( + q.log_action(action='pretix.event.quota.added', user=self.request.user, + data=change_data, save=False) + ) + log_entries.append( + se.log_action('pretix.subevent.quota.added', user=self.request.user, data=change_data, + save=False) + ) + else: + if f.changed_data: + change_data = {k: f.cleaned_data.get(k) for k in f.changed_data} + for se in subevents: + q = list(se.quotas.all())[qidx] + for fname in ('size', 'name', 'release_after_exit'): + setattr(q, fname, f.cleaned_data.get(fname)) + q.save(clear_cache=False) + if 'itemvar' in f.changed_data: + q.items.set(selected_items) + q.variations.set(selected_variations) + log_entries.append( + q.log_action(action='pretix.event.quota.added', user=self.request.user, + data=change_data, save=False) + ) + qidx += 1 + if to_save_items: + Quota.items.through.objects.bulk_create(to_save_items) + if to_save_variations: + Quota.variations.through.objects.bulk_create(to_save_variations) + + def get_context_data(self, **kwargs): + ctx = super().get_context_data(**kwargs) + ctx['subevents'] = self.get_queryset() + ctx['filter_form'] = self.filter_form + ctx['sampled_quotas'] = self.sampled_quotas + ctx['sampled_lists'] = self.sampled_lists + ctx['formset'] = self.quota_formset + ctx['cl_formset'] = self.list_formset + ctx['itemvar_forms'] = self.itemvar_forms + ctx['bulk_selected'] = self.request.POST.getlist("_bulk") + ctx['meta_forms'] = self.meta_forms + return ctx + + @cached_property + def sampled_quotas(self): + all_quotas = Quota.objects.filter( + subevent__in=self.get_queryset() + ).annotate( + item_list=GroupConcat('items__id'), + var_list=GroupConcat('variations__id'), + ).values( + 'item_list', 'var_list', + *(f.name for f in Quota._meta.fields if f.name not in ( + 'id', 'event', 'items', 'variations', 'cached_availability_state', 'cached_availability_number', + 'cached_availability_paid_orders', 'cached_availability_time', 'closed', + )) + ).order_by('subevent_id') + + if not all_quotas: + return Quota.objects.none() + + quotas_by_subevent = defaultdict(list) + for q in all_quotas: + quotas_by_subevent[q.pop('subevent')].append(q) + + prev = None + for se in self.get_queryset(): + if se.pk not in quotas_by_subevent: + return None + + if prev is None: + prev = quotas_by_subevent[se.pk] + + if quotas_by_subevent[se.pk] != prev: + return None + return se.quotas.all() + + @cached_property + def sampled_lists(self): + all_lists = CheckinList.objects.filter( + subevent__in=self.get_queryset() + ).annotate( + item_list=GroupConcat('limit_products__id'), + gates_list=GroupConcat('gates__id'), + ).values( + 'item_list', 'gates_list', + *(f.name for f in CheckinList._meta.fields if f.name not in ( + 'id', 'event', 'limit_products', 'gates', + )) + ).order_by('subevent_id') + + if not all_lists: + return SubEvent.objects.none() + + lists_by_subevent = defaultdict(list) + for cl in all_lists: + lists_by_subevent[cl.pop('subevent')].append(cl) + + prev = None + for se in self.get_queryset(): + if se.pk not in lists_by_subevent: + return None + + if prev is None: + prev = lists_by_subevent[se.pk] + + if lists_by_subevent[se.pk] != prev: + return None + return se.checkinlist_set.all() + + @cached_property + def is_submitted(self): + # Usually, django considers a form "bound" / "submitted" on every POST request. However, this view is always + # called with POST method, even if just to pass the selection of objects to work on, so we want to modify + # that behaviour + return '_bulk' in self.request.POST + + def get_form_kwargs(self): + initial = {} + mixed_values = set() + qs = self.get_queryset() + + qs = qs.annotate( + **{ + # TODO: Once we're on Django 3.2, pass a tzinfo parameter + # Before Django 3.2, it uses the current timezone, which is hopefully fine + # as well in all cases we are concerned about + # See also: https://code.djangoproject.com/ticket/31948 + k + '_day': TruncDate(k) + for k in ('date_from', 'date_to', 'date_admission', 'presale_start', 'presale_end') + }, + **{ + k + '_time': TruncTime(k) + for k in ('date_from', 'date_to', 'date_admission', 'presale_start', 'presale_end') + }, + ) + + fields = { + 'name', + 'location', + 'frontpage_text', + 'geo_lat', + 'geo_lon', + 'is_public', + 'active', + 'date_from_day', + 'date_from_time', + 'date_to_day', + 'date_to_time', + 'date_admission_day', + 'date_admission_time', + 'presale_start_day', + 'presale_start_time', + 'presale_end_day', + 'presale_end_time', + } + for k in fields: + existing_values = list(qs.order_by(k).values(k).annotate(c=Count('*'))) + if len(existing_values) == 1: + initial[k] = existing_values[0][k] + elif len(existing_values) > 1: + mixed_values.add(k) + initial[k] = None + + kwargs = super().get_form_kwargs() + kwargs['event'] = self.request.event + kwargs['prefix'] = 'bulkedit' + kwargs['initial'] = initial + kwargs['queryset'] = self.get_queryset() + kwargs['mixed_values'] = mixed_values + if not self.is_submitted: + kwargs['data'] = None + kwargs['files'] = None + return kwargs + + def post(self, request, *args, **kwargs): + form = self.get_form() + is_valid = ( + self.is_submitted and + form.is_valid() and + self.quota_formset.is_valid() and + (not self.list_formset or self.list_formset.is_valid()) and + all(f.is_valid() for f in self.itemvar_forms)and + all(f.is_valid() for f in self.meta_forms) + ) + if is_valid: + return self.form_valid(form) + else: + if self.is_submitted: + messages.error(self.request, _('We could not save your changes. See below for details.')) + return self.form_invalid(form) + + def save_meta(self): + for f in self.meta_forms: + if f.prefix + 'value' not in self.request.POST.getlist('_bulk'): + continue + + if f.cleaned_data.get('value'): + for obj in self.get_queryset(): + SubEventMetaValue.objects.update_or_create( + property=f.instance.property, + subevent=obj, + defaults={ + 'value': f.cleaned_data['value'] + } + ) + else: + SubEventMetaValue.objects.filter( + property=f.instance.property, + subevent__in=self.get_queryset() + ).delete() + + def save_itemvars(self): + for f in self.itemvar_forms: + u = {} + if f.prefix + 'price' in self.request.POST.getlist('_bulk'): + u['price'] = f.cleaned_data.get('price') + if f.prefix + 'disabled' in self.request.POST.getlist('_bulk'): + u['disabled'] = f.cleaned_data.get('disabled') + + if not u: + continue + + if isinstance(f, SubEventItemForm): + if u.get('price') is None and not u.get('disabled'): + SubEventItem.objects.filter( + subevent__in=self.get_queryset(), + item=f.instance.item, + ).delete() + else: + for obj in self.get_queryset(): + SubEventItem.objects.update_or_create( + subevent=obj, + item=f.instance.item, + defaults=u + ) + elif isinstance(f, SubEventItemVariationForm): + if u.get('price') is None and not u.get('disabled'): + SubEventItemVariation.objects.filter( + subevent__in=self.get_queryset(), + variation=f.instance.variation, + ).delete() + else: + for obj in self.get_queryset(): + SubEventItemVariation.objects.update_or_create( + subevent=obj, + variation=f.instance.variation, + defaults=u + ) + + @transaction.atomic() + def form_valid(self, form): + log_entries = [] + + # Main form + form.save() + data = { + k: v for k, v in form.cleaned_data.items() if k in form.changed_data + } + data['_raw_bulk_data'] = self.request.POST.dict() + for obj in self.get_queryset(): + log_entries.append( + obj.log_action('pretix.subevent.changed', data=data, user=self.request.user, save=False) + ) + + # Formsets + if '__quotas' in self.request.POST.getlist('_bulk'): + self.save_quota_formset(log_entries) + if '__checkinlists' in self.request.POST.getlist('_bulk'): + self.save_list_formset(log_entries) + + self.save_itemvars() + self.save_meta() + + if connections['default'].features.can_return_rows_from_bulk_insert: + LogEntry.objects.bulk_create(log_entries, batch_size=200) + LogEntry.bulk_postprocess(log_entries) + else: + for le in log_entries: + le.save() + LogEntry.bulk_postprocess(log_entries) + + self.request.event.cache.clear() + messages.success(self.request, _('Your changes have been saved.')) + return super().form_valid(form) diff --git a/src/pretix/control/views/vouchers.py b/src/pretix/control/views/vouchers.py index d98b13713..06e1f5c8a 100644 --- a/src/pretix/control/views/vouchers.py +++ b/src/pretix/control/views/vouchers.py @@ -67,7 +67,8 @@ class VoucherList(PaginationMixin, EventPermissionRequiredMixin, ListView): headers = [ _('Voucher code'), _('Valid until'), _('Product'), _('Reserve quota'), _('Bypass quota'), - _('Price effect'), _('Value'), _('Tag'), _('Redeemed'), _('Maximum usages'), _('Seat') + _('Price effect'), _('Value'), _('Tag'), _('Redeemed'), _('Maximum usages'), _('Seat'), + _('Comment') ] writer.writerow(headers) @@ -92,7 +93,8 @@ class VoucherList(PaginationMixin, EventPermissionRequiredMixin, ListView): v.tag, str(v.redeemed), str(v.max_usages), - str(v.seat) if v.seat else "" + str(v.seat) if v.seat else "", + str(v.comment) if v.comment else "" ] writer.writerow(row) diff --git a/src/pretix/locale/cs/LC_MESSAGES/django.po b/src/pretix/locale/cs/LC_MESSAGES/django.po index 22bde9010..b56eaf570 100644 --- a/src/pretix/locale/cs/LC_MESSAGES/django.po +++ b/src/pretix/locale/cs/LC_MESSAGES/django.po @@ -8,16 +8,16 @@ msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2021-01-27 17:45+0000\n" -"PO-Revision-Date: 2020-12-14 10:00+0000\n" +"PO-Revision-Date: 2021-02-16 06:00+0000\n" "Last-Translator: Ondřej Sokol \n" -"Language-Team: Czech \n" +"Language-Team: Czech " +"\n" "Language: cs\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=3; plural=(n==1) ? 0 : (n>=2 && n<=4) ? 1 : 2;\n" -"X-Generator: Weblate 3.10.3\n" +"X-Generator: Weblate 4.4.2\n" #: htmlcov/pretix_control_views_dashboards_py.html:898 #: pretix/control/templates/pretixcontrol/events/index.html:144 @@ -218,17 +218,17 @@ msgstr "" #: pretix/api/serializers/organizer.py:142 #: pretix/control/views/organizer.py:539 msgid "pretix account invitation" -msgstr "" +msgstr "pozvánka k pretix účtu" #: pretix/api/serializers/organizer.py:164 #: pretix/control/views/organizer.py:638 msgid "This user already has been invited for this team." -msgstr "Tento uživatel byl již pozván do této skupiny." +msgstr "Tento uživatel byl již pozván do tohoto týmu." #: pretix/api/serializers/organizer.py:180 #: pretix/control/views/organizer.py:655 msgid "This user already has permissions for this team." -msgstr "" +msgstr "Tento uživatel již má nastavena práva pro tento tým." #: pretix/api/views/oauth.py:85 pretix/control/logdisplay.py:356 #, python-brace-format @@ -240,7 +240,7 @@ msgstr "" #: pretix/api/views/order.py:460 pretix/control/views/orders.py:1186 #: pretix/presale/views/order.py:663 pretix/presale/views/order.py:728 msgid "You cannot generate an invoice for this order." -msgstr "" +msgstr "Nemůžete vygenerovat fakturu pro tuto objednávku." #: pretix/api/views/order.py:465 pretix/control/views/orders.py:1188 #: pretix/presale/views/order.py:665 pretix/presale/views/order.py:730 @@ -1868,7 +1868,7 @@ msgstr "Časové pásmo" #: pretix/base/models/auth.py:108 msgid "Two-factor authentication is required to log in" -msgstr "" +msgstr "Pro přihlášení je vyžadována dvoufaktorová autentizace" #: pretix/base/models/auth.py:112 msgid "Receive notifications according to my settings below" @@ -1991,7 +1991,7 @@ msgstr "" #: pretix/base/models/devices.py:91 #: pretix/control/templates/pretixcontrol/organizers/gates.html:16 msgid "Gate" -msgstr "" +msgstr "Brána" #: pretix/base/models/devices.py:109 #: pretix/control/templates/pretixcontrol/organizers/devices.html:38 @@ -2008,7 +2008,7 @@ msgstr "" #: pretix/base/models/event.py:46 msgid "The end of the event has to be later than its start." -msgstr "" +msgstr "Konec události musí být pozdější než začátek." #: pretix/base/models/event.py:353 msgid "" @@ -2030,7 +2030,7 @@ msgstr "" #: pretix/base/models/event.py:369 msgid "Shop is live" -msgstr "" +msgstr "Obchod je spuštěný" #: pretix/base/models/event.py:371 msgid "Event currency" @@ -2057,7 +2057,7 @@ msgstr "" #: pretix/base/models/event.py:380 pretix/base/models/event.py:1106 msgid "Show in lists" -msgstr "" +msgstr "Zobrazit v seznamu" #: pretix/base/models/event.py:381 msgid "" @@ -2068,7 +2068,7 @@ msgstr "" #: pretix/base/models/event.py:384 pretix/base/models/event.py:1120 #: pretix/control/forms/subevents.py:75 msgid "End of presale" -msgstr "" +msgstr "Konec předprodeje" #: pretix/base/models/event.py:385 pretix/base/models/event.py:1121 #: pretix/control/forms/subevents.py:76 @@ -2080,7 +2080,7 @@ msgstr "" #: pretix/base/models/event.py:390 pretix/base/models/event.py:1126 #: pretix/control/forms/subevents.py:69 msgid "Start of presale" -msgstr "" +msgstr "Začátek předprodeje" #: pretix/base/models/event.py:391 pretix/base/models/event.py:1127 #: pretix/control/forms/subevents.py:70 @@ -2101,13 +2101,13 @@ msgstr "" #: pretix/base/models/event.py:416 pretix/control/navigation.py:44 msgid "Plugins" -msgstr "" +msgstr "Zásuvné moduly" #: pretix/base/models/event.py:419 #: pretix/control/templates/pretixcontrol/event/index.html:143 #: pretix/control/templates/pretixcontrol/order/index.html:865 msgid "Internal comment" -msgstr "" +msgstr "Interní poznámka" #: pretix/base/models/event.py:423 pretix/control/forms/event.py:211 #: pretix/control/forms/filter.py:988 @@ -2133,7 +2133,7 @@ msgstr "" #: pretix/control/templates/pretixcontrol/search/orders.html:44 #: pretix/presale/templates/pretixpresale/event/waitinglist.html:18 msgid "Event" -msgstr "" +msgstr "Událost" #: pretix/base/models/event.py:437 pretix/control/navigation.py:305 #: pretix/control/navigation.py:407 @@ -2144,7 +2144,7 @@ msgstr "" #: pretix/control/templates/pretixcontrol/organizers/webhooks.html:37 #: pretix/control/views/organizer.py:1205 msgid "Events" -msgstr "" +msgstr "Události" #: pretix/base/models/event.py:951 msgid "" @@ -2182,7 +2182,7 @@ msgstr "" #: pretix/control/forms/filter.py:1250 #: pretix/control/templates/pretixcontrol/users/index.html:46 msgid "Active" -msgstr "" +msgstr "Aktivní" #: pretix/base/models/event.py:1103 msgid "" @@ -6399,7 +6399,7 @@ msgstr "" #: pretix/base/settings.py:1694 msgid "Primary color" -msgstr "" +msgstr "Hlavní barva" #: pretix/base/settings.py:1714 msgid "Accent color for success" @@ -9737,7 +9737,7 @@ msgstr "" #: pretix/control/templates/pretixcontrol/dashboard.html:3 #: pretix/control/templates/pretixcontrol/dashboard.html:5 msgid "Dashboard" -msgstr "" +msgstr "Nástěnka" #: pretix/control/navigation.py:28 pretix/control/navigation.py:329 #: pretix/control/navigation.py:424 @@ -10328,7 +10328,7 @@ msgstr "" #: pretix/control/templates/pretixcontrol/waitinglist/index.html:123 #: pretix/plugins/banktransfer/templates/pretixplugins/banktransfer/import_form.html:80 msgid "Filter" -msgstr "" +msgstr "Filtrovat" #: pretix/control/templates/pretixcontrol/checkin/index.html:52 msgid "No attendee record was found." @@ -10558,11 +10558,11 @@ msgstr "" #: pretix/control/templates/pretixcontrol/dashboard.html:9 msgid "Go to event" -msgstr "" +msgstr "Jít na událost" #: pretix/control/templates/pretixcontrol/dashboard.html:15 msgid "Your upcoming events" -msgstr "" +msgstr "Vaše nadcházející události" #: pretix/control/templates/pretixcontrol/dashboard.html:20 #: pretix/control/templates/pretixcontrol/events/create_base.html:4 @@ -10571,11 +10571,11 @@ msgstr "" #: pretix/control/templates/pretixcontrol/events/index.html:57 #: pretix/control/templates/pretixcontrol/organizers/detail.html:12 msgid "Create a new event" -msgstr "" +msgstr "Vytvořit novou událost" #: pretix/control/templates/pretixcontrol/dashboard.html:39 msgid "View all upcoming events" -msgstr "" +msgstr "Zobrazit všechny nadcházející události" #: pretix/control/templates/pretixcontrol/dashboard.html:44 msgid "Your most recent events" @@ -13546,7 +13546,7 @@ msgstr "" #: pretix/control/templates/pretixcontrol/orders/index.html:98 msgid "Remove filter" -msgstr "" +msgstr "Odstranit filtr" #: pretix/control/templates/pretixcontrol/orders/index.html:116 msgid "Order paid / total" @@ -13618,7 +13618,7 @@ msgstr "" #: pretix/control/templates/pretixcontrol/orders/overview.html:69 #: pretix/plugins/reports/exporters.py:259 msgid "Purchased" -msgstr "" +msgstr "Zakoupeno" #: pretix/control/templates/pretixcontrol/orders/overview.html:178 msgid "" @@ -13679,7 +13679,7 @@ msgstr "" #: pretix/control/templates/pretixcontrol/organizers/device_connect.html:14 msgid "Download pretixSCAN" -msgstr "" +msgstr "Stáhnout pretixSCAN" #: pretix/control/templates/pretixcontrol/organizers/device_connect.html:18 msgid "" @@ -17735,12 +17735,12 @@ msgstr "" #: pretix/plugins/reports/exporters.py:120 #, python-format msgid "Page %d" -msgstr "" +msgstr "Strana %d" #: pretix/plugins/reports/exporters.py:122 #, python-format msgid "Created: %s" -msgstr "" +msgstr "Vytvořeno: %s" #: pretix/plugins/reports/exporters.py:162 msgid "Order overview (PDF)" @@ -17810,10 +17810,8 @@ msgstr "" #: pretix/plugins/reports/exporters.py:668 #: pretix/plugins/reports/exporters.py:713 -#, fuzzy -#| msgid "Country" msgid "Country code" -msgstr "Stát" +msgstr "Kód země" #: pretix/plugins/returnurl/__init__.py:9 #: pretix/plugins/returnurl/__init__.py:12 diff --git a/src/pretix/locale/fi/LC_MESSAGES/django.po b/src/pretix/locale/fi/LC_MESSAGES/django.po index 031610a7a..e7baf133a 100644 --- a/src/pretix/locale/fi/LC_MESSAGES/django.po +++ b/src/pretix/locale/fi/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2020-12-22 11:05+0000\n" -"PO-Revision-Date: 2021-01-26 03:00+0000\n" +"PO-Revision-Date: 2021-02-15 05:00+0000\n" "Last-Translator: Jaakko Rinta-Filppula \n" "Language-Team: Finnish \n" @@ -17,7 +17,7 @@ msgstr "" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=n != 1;\n" -"X-Generator: Weblate 3.10.3\n" +"X-Generator: Weblate 4.4.2\n" #: htmlcov/pretix_control_views_dashboards_py.html:898 #: pretix/control/templates/pretixcontrol/events/index.html:144 @@ -3136,10 +3136,8 @@ msgstr "Ulkoinen" #: pretix/base/models/orders.py:1720 #: pretix/control/templates/pretixcontrol/order/refund_choose.html:191 -#, fuzzy -#| msgid "Refund order" msgid "Refund reason" -msgstr "Hyvitä tilaus" +msgstr "Hyvityksen syy" #: pretix/base/models/orders.py:1721 #: pretix/control/templates/pretixcontrol/order/refund_choose.html:192 @@ -4537,10 +4535,8 @@ msgstr "" #: pretix/base/services/cancelevent.py:200 #: pretix/base/services/cancelevent.py:258 -#, fuzzy -#| msgid "Event created" msgid "Event canceled" -msgstr "Tapahtuma luotu" +msgstr "Tapahtuma peruttu" #: pretix/base/services/cart.py:52 pretix/base/services/orders.py:73 msgid "" @@ -7817,16 +7813,12 @@ msgid "Order placed before" msgstr "Tilattu jälkeen" #: pretix/control/forms/filter.py:468 -#, fuzzy -#| msgid "Order payments and refunds" msgid "Minimal sum of payments and refunds" -msgstr "Tilauksen maksut ja palautukset" +msgstr "Maksujen ja palautusten vähimmäissumma" #: pretix/control/forms/filter.py:473 -#, fuzzy -#| msgid "Order payments and refunds" msgid "Maximal sum of payments and refunds" -msgstr "Tilauksen maksut ja palautukset" +msgstr "Tilausten ja maksujen enimmäissumma" #: pretix/control/forms/filter.py:515 pretix/control/forms/filter.py:520 #: pretix/control/forms/filter.py:546 pretix/control/forms/filter.py:551 @@ -8487,11 +8479,9 @@ msgid "Automatically refund money if possible" msgstr "" #: pretix/control/forms/orders.py:619 -#, fuzzy -#| msgid "This payment method does not support automatic refunds." msgid "" "Create manual refund if the payment method does not support automatic refunds" -msgstr "Tätä maksutapa ei tue automaattisia hyvityksiä." +msgstr "Luo manuaalinen hyvitys, jos maksutapa ei tue automaattista hyvitystä" #: pretix/control/forms/orders.py:623 msgid "" @@ -12876,10 +12866,8 @@ msgid "" msgstr "" #: pretix/control/templates/pretixcontrol/order/index.html:104 -#, fuzzy -#| msgid "Refund order" msgid "Refund for overpayment" -msgstr "Hyvitä tilaus" +msgstr "" #: pretix/control/templates/pretixcontrol/order/index.html:106 #, python-format @@ -13262,16 +13250,12 @@ msgid "" msgstr "" #: pretix/control/templates/pretixcontrol/order/refund_choose.html:30 -#, fuzzy -#| msgid "Refund order" msgid "Refund to original payment method" -msgstr "Hyvitä tilaus" +msgstr "Hyvitä alkuperäiseen maksutapaan" #: pretix/control/templates/pretixcontrol/order/refund_choose.html:36 -#, fuzzy -#| msgid "Payment method" msgid "Payment details" -msgstr "Maksutapa" +msgstr "Maksun tiedot" #: pretix/control/templates/pretixcontrol/order/refund_choose.html:37 msgid "Amount not refunded" @@ -13279,10 +13263,8 @@ msgstr "" #: pretix/control/templates/pretixcontrol/order/refund_choose.html:38 #: pretix/control/templates/pretixcontrol/order/refund_choose.html:85 -#, fuzzy -#| msgid "Refund order" msgid "Refund amount" -msgstr "Hyvitä tilaus" +msgstr "Hyvitettävä summa" #: pretix/control/templates/pretixcontrol/order/refund_choose.html:66 msgid "Full amount" @@ -13293,16 +13275,12 @@ msgid "This payment method does not support automatic refunds." msgstr "Tätä maksutapa ei tue automaattisia hyvityksiä." #: pretix/control/templates/pretixcontrol/order/refund_choose.html:78 -#, fuzzy -#| msgid "Refund order" msgid "Refund to a different payment method" -msgstr "Hyvitä tilaus" +msgstr "Hyvitä eri maksutavalle" #: pretix/control/templates/pretixcontrol/order/refund_choose.html:84 -#, fuzzy -#| msgid "Question options" msgid "Recipient / options" -msgstr "Kysymysvaihtoehdot" +msgstr "Vastaanottaja / vaihtoehdot" #: pretix/control/templates/pretixcontrol/order/refund_choose.html:112 msgid "Transfer to other order" @@ -14500,16 +14478,12 @@ msgid "Times" msgstr "" #: pretix/control/templates/pretixcontrol/subevents/bulk.html:339 -#, fuzzy -#| msgid "Start of presale" msgid "Start of first slot" -msgstr "Ennakkomyynnin alku" +msgstr "" #: pretix/control/templates/pretixcontrol/subevents/bulk.html:345 -#, fuzzy -#| msgid "End of presale" msgid "End of time slots" -msgstr "Ennakkomyynnin loppu" +msgstr "" #: pretix/control/templates/pretixcontrol/subevents/bulk.html:351 msgid "Length of slots" @@ -14525,11 +14499,8 @@ msgid "Break between slots" msgstr "" #: pretix/control/templates/pretixcontrol/subevents/bulk.html:370 -#, fuzzy -#| msgctxt "payment_state" -#| msgid "created" msgid "Create" -msgstr "luotu" +msgstr "Luo" #: pretix/control/templates/pretixcontrol/subevents/bulk.html:377 msgid "Add a single time slot" @@ -16187,10 +16158,8 @@ msgid "The selected exporter was not found." msgstr "" #: pretix/control/views/orders.py:2117 -#, fuzzy -#| msgid "We are processing your request …" msgid "There was a problem processing your input:" -msgstr "Pyyntöäsi käsitellään …" +msgstr "Ongelma syötteen käsittelyssä:" #: pretix/control/views/orders.py:2213 msgid "All orders have been canceled." @@ -16949,17 +16918,12 @@ msgid "Can only create a bank transfer refund from an existing payment." msgstr "" #: pretix/plugins/banktransfer/payment.py:349 -#, fuzzy -#| msgid "Price (optional)" msgid "BIC (optional)" -msgstr "Hinta (valinnainen)" +msgstr "BIC (valinnainen)" #: pretix/plugins/banktransfer/payment.py:388 -#, fuzzy -#| msgid "Your order has been placed successfully. See below for details." msgid "Your input was invalid, please see below for details." -msgstr "" -"Tilauksesi on vastaanotettu onnistuneesti. Katso tilauksen tiedot alta." +msgstr "Epäkelpo syöte, katso tiedot alta." #: pretix/plugins/banktransfer/refund_export.py:25 #: pretix/plugins/banktransfer/templates/pretixplugins/banktransfer/control.html:5 @@ -20213,10 +20177,8 @@ msgid "You chose an invalid cancellation fee." msgstr "" #: pretix/presale/views/order.py:869 -#, fuzzy -#| msgid "Any customer" msgid "Canceled by customer" -msgstr "Kaikki asiakkaat" +msgstr "Asiakkaan peruuttama" #: pretix/presale/views/order.py:880 msgid "The cancellation has been requested." diff --git a/src/pretix/settings.py b/src/pretix/settings.py index 2b86c8d0a..f53d82502 100644 --- a/src/pretix/settings.py +++ b/src/pretix/settings.py @@ -720,6 +720,8 @@ BOOTSTRAP3 = { 'default': 'bootstrap3.renderers.FieldRenderer', 'inline': 'bootstrap3.renderers.InlineFieldRenderer', 'control': 'pretix.control.forms.renderers.ControlFieldRenderer', + 'bulkedit': 'pretix.control.forms.renderers.BulkEditFieldRenderer', + 'bulkedit_inline': 'pretix.control.forms.renderers.InlineBulkEditFieldRenderer', 'checkout': 'pretix.presale.forms.renderers.CheckoutFieldRenderer', }, } @@ -758,3 +760,6 @@ OAUTH2_PROVIDER = { COUNTRIES_OVERRIDE = { 'XK': _('Kosovo'), } + +DATA_UPLOAD_MAX_NUMBER_FIELDS = 25000 +DATA_UPLOAD_MAX_MEMORY_SIZE = 10 * 1024 * 1024 # 10 MB diff --git a/src/pretix/static/pretixbase/js/asynctask.js b/src/pretix/static/pretixbase/js/asynctask.js index 4ec8ec867..34c630f08 100644 --- a/src/pretix/static/pretixbase/js/asynctask.js +++ b/src/pretix/static/pretixbase/js/asynctask.js @@ -71,6 +71,7 @@ function async_task_check_error(jqXHR, textStatus, errorThrown) { jqXHR.responseText.indexOf(" 0) { // This is some kind of 500/404/403 page, show it in an overlay $("body").data('ajaxing', false); @@ -146,11 +147,20 @@ function async_task_error(jqXHR, textStatus, errorThrown) { if (respdom.filter('form') && (respdom.filter('.has-error') || respdom.filter('.alert-danger'))) { // This is a failed form validation, let's just use it waitingDialog.hide(); - $("body").html(jqXHR.responseText.substring( - jqXHR.responseText.indexOf(" 0) { waitingDialog.hide(); ajaxErrDialog.show(c.first().html()); diff --git a/src/pretix/static/pretixbase/js/details.js b/src/pretix/static/pretixbase/js/details.js index 3cc8fbcc8..eadd8a4a5 100644 --- a/src/pretix/static/pretixbase/js/details.js +++ b/src/pretix/static/pretixbase/js/details.js @@ -1,11 +1,8 @@ /*global $ */ -$(function () { - "use strict"; - +setup_collapsible_details = function (el) { var isOpera = Object.prototype.toString.call(window.opera) == '[object Opera]'; - - $("details summary, details summary a[data-toggle=variations]").click(function (e) { + el.find("details summary, details summary a[data-toggle=variations]").click(function (e) { if (this.tagName !== "A" && $(e.target).closest("a").length > 0) { return true; } @@ -44,7 +41,12 @@ $(function () { $detailsNotSummary = $details.children(':not(summary)'); $details.prop('open', typeof $details.attr('open') == 'string'); if (!$details.prop('open')) { - $detailsNotSummary.hide(); + if ($details.find(".has-error, .alert-danger").length) { + $details.addClass("details-open"); + $details.prop('open', true); + } else { + $detailsNotSummary.hide(); + } } else { $details.addClass("details-open"); } @@ -55,4 +57,10 @@ $(function () { return false; }); }); +}; + +$(function () { + "use strict"; + + setup_collapsible_details($("body")); }); diff --git a/src/pretix/static/pretixcontrol/js/ui/checkinrules.js b/src/pretix/static/pretixcontrol/js/ui/checkinrules.js index 7bb72f325..aa95debd3 100644 --- a/src/pretix/static/pretixcontrol/js/ui/checkinrules.js +++ b/src/pretix/static/pretixcontrol/js/ui/checkinrules.js @@ -362,6 +362,9 @@ $(document).ready(function () { } else { this.$set(this.rule[this.operator], 1, time); } + if (event.target.value === "custom") { + this.$set(this.rule[this.operator], 2, 0); + } }, setTimeValue: function (val) { console.log(val); diff --git a/src/pretix/static/pretixcontrol/js/ui/main.js b/src/pretix/static/pretixcontrol/js/ui/main.js index 092f3f83d..73b6d3bce 100644 --- a/src/pretix/static/pretixcontrol/js/ui/main.js +++ b/src/pretix/static/pretixcontrol/js/ui/main.js @@ -546,6 +546,25 @@ var form_handlers = function (el) { ); }); + el.find(".bulk-edit-field-group").each(function () { + var $checkbox = $(this).find("input[type=checkbox][name=_bulk]"); + var $content = $(this).find(".field-content"); + var $fields = $content.find("input, select, textarea, button"); + + var update = function () { + var isChecked = $checkbox.prop("checked"); + $content.toggleClass("enabled", isChecked); + $fields.attr("tabIndex", isChecked ? 0 : -1); + } + $content.on("focusin change click", function () { + if ($checkbox.prop("checked")) return; + $checkbox.prop("checked", true); + update(); + }); + $checkbox.on('change', update) + update(); + }); + el.find("input[name*=question], select[name*=question]").change(questions_toggle_dependent); questions_toggle_dependent(); }; @@ -563,7 +582,6 @@ $(function () { } ); $("[data-formset]").on("formAdded", "div", function (event) { - console.log("formAdded") form_handlers($(event.target)); }); $(document).on("click", ".variations .variations-select-all", function (e) { @@ -672,29 +690,104 @@ $(function () { // Tables with bulk selection, e.g. subevent list $("input[data-toggle-table]").each(function (ev) { var $toggle = $(this); - - var update = function () { - var all_true = true; - var all_false = true; - $toggle.closest("table").find("td:first-child input[type=checkbox]").each(function () { - if ($(this).prop("checked")) { - all_false = false; - } else { - all_true = false; - } - }); - if (all_true) { - $toggle.prop("checked", true).prop("indeterminate", false); - } else if (all_false) { - $toggle.prop("checked", false).prop("indeterminate", false); - } else { - $toggle.prop("checked", false).prop("indeterminate", true); + var $table = $toggle.closest("table"); + var $selectAll = $table.find(".table-select-all"); + var $rows = $table.find("tbody tr"); + var $checkboxes = $rows.find("td:first-child input[type=checkbox]"); + var firstIndex, lastIndex, selectionChecked, onChangeSelectionHappened = false; + var updateSelection = function(a, b, checked) { + if (a > b) { + //[a, b] = [b, a];// ES6 not ready yet for pretix + var tmp = a; + a = b; + b = tmp; + } + for (var i = a; i <= b; i++) { + var checkbox = $checkboxes.get(i); + if (!checkbox.hasAttribute("data-inital")) checkbox.setAttribute("data-inital", checkbox.checked); + if (checked === undefined || checked === null) checkbox.checked = checkbox.getAttribute("data-inital") === "true"; + else checkbox.checked = checked; } }; + var onChangeSelection = function(ev) { + onChangeSelectionHappened = true; - $(this).closest("table").find("td:first-child input[type=checkbox]").change(update); - $(this).change(function (ev) { - $(this).closest("table").find("td:first-child input[type=checkbox]").prop("checked", $(this).prop("checked")); + var row = ev.target.closest("tr"); + var currentIndex = 0; + while(row = row.previousSibling) { + if (row.tagName) currentIndex++; + } + var dCurrent = currentIndex - firstIndex; + var dLast = lastIndex - firstIndex; + if (dCurrent*dLast < 0) { + // direction of selection changed => reset all previously selected + updateSelection(lastIndex, firstIndex); + } + else if (Math.abs(dCurrent) < Math.abs(dLast)) { + // selection distance decreased => reset unselected + updateSelection(currentIndex, lastIndex); + } + lastIndex = currentIndex; + updateSelection(firstIndex, currentIndex, selectionChecked); + + ev.preventDefault(); + }; + $table.on("pointerdown", function(ev) { + if (!ev.target.closest("td:first-child")) return; + var row = ev.target.closest("tr"); + selectionChecked = !row.querySelector("td:first-child input").checked; + + firstIndex = 0; + while(row = row.previousSibling) { + if (row.tagName) firstIndex++; + } + lastIndex = firstIndex; + + ev.preventDefault(); + $rows.on("pointerenter", onChangeSelection); + + $(document).one("pointerup", function(ev) { + if (onChangeSelectionHappened) { + ev.preventDefault(); + onChangeSelectionHappened = false; + $checkboxes.removeAttr("data-inital"); + + update(); + } + $rows.off("pointerenter", onChangeSelection); + }); + }); + + + var update = function () { + var all_same; + var checkboxes = $checkboxes.toArray(); + var i = checkboxes.length; + while (i--) { + if (all_same === undefined) { + all_same = checkboxes[i].checked; + continue; + } + if (all_same != checkboxes[i].checked) { + $toggle.prop("checked", false).prop("indeterminate", true).trigger("change"); + return; + } + } + $toggle.prop("checked", all_same).prop("indeterminate", false).trigger("change"); + }; + + var debounceUpdate; + $checkboxes.change(function() { + //$(this).closest("tr").toggleClass("warning", this.checked); + // when changing the $toggle’s checked-property, lots of change events + // get triggered => debounce + if (debounceUpdate) window.clearTimeout(debounceUpdate); + debounceUpdate = window.setTimeout(update, 10); + }); + $toggle.change(function (ev) { + if (!this.indeterminate) $checkboxes.prop("checked", this.checked);//.trigger("change"); + $selectAll.toggleClass("hidden", !this.checked).prop("hidden", !this.checked); + if (!this.checked) $selectAll.find("input").prop("checked", false); }); }); diff --git a/src/pretix/static/pretixcontrol/scss/_forms.scss b/src/pretix/static/pretixcontrol/scss/_forms.scss index 18279f046..d79fe15a7 100644 --- a/src/pretix/static/pretixcontrol/scss/_forms.scss +++ b/src/pretix/static/pretixcontrol/scss/_forms.scss @@ -613,3 +613,41 @@ table td > .checkbox input[type="checkbox"] { border-bottom: 1px solid $input-border; padding-bottom: 5px; } + + +.batch-select-label { + display: block; + width: 100%; + height: 1.5em; + cursor: pointer; +} + +.bulk-edit-field-group { + .field-toggle { + font-weight: normal; + display: inline-block; + background: $gray-lighter; + padding: 2px 8px 4px; + border-top-left-radius: $border-radius-base; + border-top-right-radius: $border-radius-base; + margin-bottom: 0; + input { + position: relative; + top: 2px; + } + } + .field-content { + border: 2px solid $gray-lighter; + padding: 15px; + opacity: 0.5; + .datepickerfield::placeholder, .timepickerfield::placeholder { + opacity: 0; + } + &.enabled { + opacity: 1; + .datepickerfield::placeholder, .timepickerfield::placeholder { + opacity: 1; + } + } + } +} diff --git a/src/tests/api/test_subevents.py b/src/tests/api/test_subevents.py index f7692af91..1c0043fd8 100644 --- a/src/tests/api/test_subevents.py +++ b/src/tests/api/test_subevents.py @@ -72,6 +72,7 @@ TEST_SUBEVENT_RES = { 'date_to': None, 'date_admission': None, 'name': {'en': 'Foobar'}, + 'frontpage_text': None, 'date_from': '2017-12-27T10:00:00Z', 'presale_end': None, 'seating_plan': None, diff --git a/src/tests/base/__init__.py b/src/tests/base/__init__.py index 1fbe32533..abdd76fba 100644 --- a/src/tests/base/__init__.py +++ b/src/tests/base/__init__.py @@ -34,16 +34,32 @@ def extract_form_fields(soup): if field['type'] in ('checkbox', 'radio'): if field.has_attr('checked') and field.has_attr('name'): - data[field['name']] = field.get('value', 'on') + if field['name'] in data: + if not isinstance(data[field['name']], list): + data[field['name']] = [data[field['name']]] + data[field['name']].append(field.get('value', 'on')) + else: + data[field['name']] = field.get('value', 'on') continue elif field.has_attr('name'): # single element name/value fields - data[field['name']] = field.get('value', '') + value = field.get('value', '') + if field['name'] in data: + if not isinstance(data[field['name']], list): + data[field['name']] = [data[field['name']]] + data[field['name']].append(value) + else: + data[field['name']] = value continue # textareas for textarea in soup.findAll('textarea'): - data[textarea['name']] = textarea.text or '' + if textarea['name'] in data: + if not isinstance(data[textarea['name']], list): + data[textarea['name']] = [data[textarea['name']]] + data[textarea['name']].append(textarea.text or '') + else: + data[textarea['name']] = textarea.text or '' # select fields for select in soup.find_all('select'): @@ -66,6 +82,11 @@ def extract_form_fields(soup): else: value = [option['value'] for option in selected_options] - data[select['name']] = value + if select['name'] in data: + if not isinstance(data[select['name']], list): + data[select['name']] = [data[select['name']]] + data[select['name']].append(value) + else: + data[select['name']] = value return data diff --git a/src/tests/control/test_events.py b/src/tests/control/test_events.py index 69caa09e6..25cbbd3e2 100644 --- a/src/tests/control/test_events.py +++ b/src/tests/control/test_events.py @@ -9,10 +9,7 @@ from i18nfield.strings import LazyI18nString from pytz import timezone from tests.base import SoupTest, extract_form_fields -from pretix.base.models import ( - Event, Order, OrderPosition, Organizer, SubEvent, Team, User, -) -from pretix.base.models.items import SubEventItem +from pretix.base.models import Event, Order, Organizer, Team, User from pretix.testutils.mock import mocker_context @@ -996,646 +993,6 @@ class EventsTest(SoupTest): assert doc.select(".has-error") -class SubEventsTest(SoupTest): - @scopes_disabled() - def setUp(self): - super().setUp() - self.user = User.objects.create_user('dummy@dummy.dummy', 'dummy') - self.orga1 = Organizer.objects.create(name='CCC', slug='ccc') - self.event1 = Event.objects.create( - organizer=self.orga1, name='30C3', slug='30c3', - date_from=datetime.datetime(2013, 12, 26, tzinfo=datetime.timezone.utc), - plugins='pretix.plugins.banktransfer,tests.testdummy', - has_subevents=True - ) - - t = Team.objects.create(organizer=self.orga1, can_create_events=True, can_change_event_settings=True, - can_change_items=True) - t.members.add(self.user) - t.limit_events.add(self.event1) - self.ticket = self.event1.items.create(name='Early-bird ticket', - category=None, default_price=23, - admission=True) - - self.client.login(email='dummy@dummy.dummy', password='dummy') - - self.subevent1 = self.event1.subevents.create(name='SE1', date_from=now()) - self.subevent2 = self.event1.subevents.create(name='SE2', date_from=now()) - - def test_list(self): - doc = self.get_doc('/control/event/ccc/30c3/subevents/') - tabletext = doc.select("#page-wrapper .table")[0].text - self.assertIn("SE1", tabletext) - - def test_create(self): - doc = self.get_doc('/control/event/ccc/30c3/subevents/add') - assert doc.select("input[name=quotas-TOTAL_FORMS]") - doc = self.post_doc('/control/event/ccc/30c3/subevents/add', { - 'name_0': 'SE2', - 'active': 'on', - 'date_from_0': '2017-07-01', - 'date_from_1': '10:00:00', - 'date_to_0': '2017-07-01', - 'date_to_1': '12:00:00', - 'location_0': 'Hamburg', - 'presale_start_0': '2017-06-20', - 'presale_start_1': '10:00:00', - 'checkinlist_set-TOTAL_FORMS': '1', - 'checkinlist_set-INITIAL_FORMS': '0', - 'checkinlist_set-MIN_NUM_FORMS': '0', - 'checkinlist_set-MAX_NUM_FORMS': '1000', - 'checkinlist_set-0-name': 'Default', - 'checkinlist_set-0-all_products': 'on', - 'quotas-TOTAL_FORMS': '1', - 'quotas-INITIAL_FORMS': '0', - 'quotas-MIN_NUM_FORMS': '0', - 'quotas-MAX_NUM_FORMS': '1000', - 'quotas-0-name': 'Q1', - 'quotas-0-size': '50', - 'quotas-0-itemvars': str(self.ticket.pk), - 'item-%d-price' % self.ticket.pk: '12' - }) - assert doc.select(".alert-success") - with scopes_disabled(): - se = self.event1.subevents.first() - assert str(se.name) == "SE2" - assert se.active - assert se.date_from.isoformat() == "2017-07-01T10:00:00+00:00" - assert se.date_to.isoformat() == "2017-07-01T12:00:00+00:00" - assert str(se.location) == "Hamburg" - assert se.presale_start.isoformat() == "2017-06-20T10:00:00+00:00" - assert not se.presale_end - assert se.quotas.count() == 1 - q = se.quotas.last() - assert q.name == "Q1" - assert q.size == 50 - assert list(q.items.all()) == [self.ticket] - sei = SubEventItem.objects.get(subevent=se, item=self.ticket) - assert sei.price == 12 - assert se.checkinlist_set.count() == 1 - - def test_modify(self): - doc = self.get_doc('/control/event/ccc/30c3/subevents/%d/' % self.subevent1.pk) - assert doc.select("input[name=quotas-TOTAL_FORMS]") - doc = self.post_doc('/control/event/ccc/30c3/subevents/%d/' % self.subevent1.pk, { - 'name_0': 'SE2', - 'active': 'on', - 'date_from_0': '2017-07-01', - 'date_from_1': '10:00:00', - 'date_to_0': '2017-07-01', - 'date_to_1': '12:00:00', - 'location_0': 'Hamburg', - 'presale_start_0': '2017-06-20', - 'presale_start_1': '10:00:00', - 'quotas-TOTAL_FORMS': '1', - 'quotas-INITIAL_FORMS': '0', - 'quotas-MIN_NUM_FORMS': '0', - 'quotas-MAX_NUM_FORMS': '1000', - 'quotas-0-name': 'Q1', - 'quotas-0-size': '50', - 'quotas-0-itemvars': str(self.ticket.pk), - 'checkinlist_set-TOTAL_FORMS': '1', - 'checkinlist_set-INITIAL_FORMS': '0', - 'checkinlist_set-MIN_NUM_FORMS': '0', - 'checkinlist_set-MAX_NUM_FORMS': '1000', - 'checkinlist_set-0-name': 'Default', - 'checkinlist_set-0-all_products': 'on', - 'item-%d-price' % self.ticket.pk: '12' - }) - assert doc.select(".alert-success") - self.subevent1.refresh_from_db() - se = self.subevent1 - assert str(se.name) == "SE2" - assert se.active - assert se.date_from.isoformat() == "2017-07-01T10:00:00+00:00" - assert se.date_to.isoformat() == "2017-07-01T12:00:00+00:00" - assert str(se.location) == "Hamburg" - assert se.presale_start.isoformat() == "2017-06-20T10:00:00+00:00" - assert not se.presale_end - with scopes_disabled(): - assert se.quotas.count() == 1 - q = se.quotas.last() - assert q.name == "Q1" - assert q.size == 50 - assert list(q.items.all()) == [self.ticket] - sei = SubEventItem.objects.get(subevent=se, item=self.ticket) - assert sei.price == 12 - assert se.checkinlist_set.count() == 1 - - def test_delete(self): - doc = self.get_doc('/control/event/ccc/30c3/subevents/%d/delete' % self.subevent1.pk) - assert doc.select("button") - doc = self.post_doc('/control/event/ccc/30c3/subevents/%d/delete' % self.subevent1.pk, {}) - assert doc.select(".alert-success") - # deleting the second event - doc = self.post_doc('/control/event/ccc/30c3/subevents/%d/delete' % self.subevent2.pk, {}) - assert doc.select(".alert-success") - with scopes_disabled(): - assert not SubEvent.objects.filter(pk=self.subevent2.pk).exists() - assert not SubEvent.objects.filter(pk=self.subevent1.pk).exists() - - def test_delete_with_orders(self): - with scopes_disabled(): - o = Order.objects.create( - code='FOO', event=self.event1, email='dummy@dummy.test', - status=Order.STATUS_PENDING, - datetime=now(), expires=now() + datetime.timedelta(days=10), - total=14, locale='en' - ) - OrderPosition.objects.create( - order=o, - item=self.ticket, - subevent=self.subevent1, - price=Decimal("14"), - ) - doc = self.get_doc('/control/event/ccc/30c3/subevents/%d/delete' % self.subevent1.pk, follow=True) - assert doc.select(".alert-danger") - doc = self.post_doc('/control/event/ccc/30c3/subevents/%d/delete' % self.subevent1.pk, {}, follow=True) - assert doc.select(".alert-danger") - with scopes_disabled(): - assert self.event1.subevents.filter(pk=self.subevent1.pk).exists() - - def test_create_bulk(self): - with scopes_disabled(): - self.event1.subevents.all().delete() - self.event1.settings.timezone = 'Europe/Berlin' - - doc = self.get_doc('/control/event/ccc/30c3/subevents/bulk_add') - assert doc.select("input[name=rruleformset-TOTAL_FORMS]") - doc = self.post_doc('/control/event/ccc/30c3/subevents/bulk_add', { - 'rruleformset-TOTAL_FORMS': '1', - 'rruleformset-INITIAL_FORMS': '0', - 'rruleformset-MIN_NUM_FORMS': '0', - 'rruleformset-MAX_NUM_FORMS': '1000', - 'rruleformset-0-interval': '1', - 'rruleformset-0-freq': 'yearly', - 'rruleformset-0-dtstart': '2018-04-03', - 'rruleformset-0-yearly_same': 'on', - 'rruleformset-0-yearly_bysetpos': '1', - 'rruleformset-0-yearly_byweekday': 'MO', - 'rruleformset-0-yearly_bymonth': '1', - 'rruleformset-0-monthly_same': 'on', - 'rruleformset-0-monthly_bysetpos': '1', - 'rruleformset-0-monthly_byweekday': 'MO', - 'rruleformset-0-end': 'count', - 'rruleformset-0-count': '10', - 'rruleformset-0-until': '2019-04-03', - 'timeformset-TOTAL_FORMS': '1', - 'timeformset-INITIAL_FORMS': '0', - 'timeformset-MIN_NUM_FORMS': '1', - 'timeformset-MAX_NUM_FORMS': '1000', - 'timeformset-0-time_from': '13:29:31', - 'timeformset-0-time_to': '15:29:31', - 'name_0': 'Foo', - 'active': 'on', - 'location_0': 'Loc', - 'time_admission': '', - 'frontpage_text_0': '', - 'rel_presale_start_0': 'unset', - 'rel_presale_start_1': '', - 'rel_presale_start_2': '1', - 'rel_presale_start_3': 'date_from', - 'rel_presale_start_4': '', - 'rel_presale_end_1': '', - 'rel_presale_end_0': 'relative', - 'rel_presale_end_2': '1', - 'rel_presale_end_3': 'date_from', - 'rel_presale_end_4': '13:29:31', - 'quotas-TOTAL_FORMS': '1', - 'quotas-INITIAL_FORMS': '0', - 'quotas-MIN_NUM_FORMS': '0', - 'quotas-MAX_NUM_FORMS': '1000', - 'quotas-0-id': '', - 'quotas-0-name': 'Bar', - 'quotas-0-size': '12', - 'quotas-0-itemvars': str(self.ticket.pk), - 'item-%d-price' % self.ticket.pk: '16', - 'checkinlist_set-TOTAL_FORMS': '1', - 'checkinlist_set-INITIAL_FORMS': '0', - 'checkinlist_set-MIN_NUM_FORMS': '0', - 'checkinlist_set-MAX_NUM_FORMS': '1000', - 'checkinlist_set-0-id': '', - 'checkinlist_set-0-name': 'Foo', - 'checkinlist_set-0-limit_products': str(self.ticket.pk), - }) - assert doc.select(".alert-success") - with scopes_disabled(): - ses = list(self.event1.subevents.order_by('date_from')) - assert len(ses) == 10 - - assert str(ses[0].name) == "Foo" - assert ses[0].date_from.isoformat() == "2018-04-03T11:29:31+00:00" - assert ses[0].date_to.isoformat() == "2018-04-03T13:29:31+00:00" - assert not ses[0].presale_start - assert ses[0].presale_end.isoformat() == "2018-04-02T11:29:31+00:00" - with scopes_disabled(): - assert ses[0].quotas.count() == 1 - assert list(ses[0].quotas.first().items.all()) == [self.ticket] - assert SubEventItem.objects.get(subevent=ses[0], item=self.ticket).price == 16 - assert ses[0].checkinlist_set.count() == 1 - - assert str(ses[1].name) == "Foo" - assert ses[1].date_from.isoformat() == "2019-04-03T11:29:31+00:00" - assert ses[1].date_to.isoformat() == "2019-04-03T13:29:31+00:00" - assert not ses[1].presale_start - assert ses[1].presale_end.isoformat() == "2019-04-02T11:29:31+00:00" - with scopes_disabled(): - assert ses[1].quotas.count() == 1 - assert list(ses[1].quotas.first().items.all()) == [self.ticket] - assert SubEventItem.objects.get(subevent=ses[0], item=self.ticket).price == 16 - assert ses[1].checkinlist_set.count() == 1 - - assert ses[-1].date_from.isoformat() == "2027-04-03T11:29:31+00:00" - - def test_create_bulk_daily_interval(self): - with scopes_disabled(): - self.event1.subevents.all().delete() - self.event1.settings.timezone = 'Europe/Berlin' - - doc = self.get_doc('/control/event/ccc/30c3/subevents/bulk_add') - assert doc.select("input[name=rruleformset-TOTAL_FORMS]") - doc = self.post_doc('/control/event/ccc/30c3/subevents/bulk_add', { - 'rruleformset-TOTAL_FORMS': '1', - 'rruleformset-INITIAL_FORMS': '0', - 'rruleformset-MIN_NUM_FORMS': '0', - 'rruleformset-MAX_NUM_FORMS': '1000', - 'rruleformset-0-interval': '2', - 'rruleformset-0-freq': 'daily', - 'rruleformset-0-dtstart': '2018-04-03', - 'rruleformset-0-yearly_same': 'on', - 'rruleformset-0-yearly_bysetpos': '1', - 'rruleformset-0-yearly_byweekday': 'MO', - 'rruleformset-0-yearly_bymonth': '1', - 'rruleformset-0-monthly_same': 'on', - 'rruleformset-0-monthly_bysetpos': '1', - 'rruleformset-0-monthly_byweekday': 'MO', - 'rruleformset-0-end': 'until', - 'rruleformset-0-count': '10', - 'rruleformset-0-until': '2019-04-03', - 'timeformset-TOTAL_FORMS': '1', - 'timeformset-INITIAL_FORMS': '0', - 'timeformset-MIN_NUM_FORMS': '1', - 'timeformset-MAX_NUM_FORMS': '1000', - 'timeformset-0-time_from': '13:29:31', - 'timeformset-0-time_to': '15:29:31', - 'name_0': 'Foo', - 'active': 'on', - 'frontpage_text_0': '', - 'rel_presale_start_0': 'unset', - 'rel_presale_start_1': '', - 'rel_presale_start_2': '1', - 'rel_presale_start_3': 'date_from', - 'rel_presale_start_4': '', - 'rel_presale_end_1': '', - 'rel_presale_end_0': 'relative', - 'rel_presale_end_2': '1', - 'rel_presale_end_3': 'date_from', - 'rel_presale_end_4': '13:29:31', - 'quotas-TOTAL_FORMS': '1', - 'quotas-INITIAL_FORMS': '0', - 'quotas-MIN_NUM_FORMS': '1', - 'quotas-MAX_NUM_FORMS': '1000', - 'quotas-0-name': 'Q1', - 'quotas-0-size': '50', - 'quotas-0-itemvars': str(self.ticket.pk), - 'checkinlist_set-TOTAL_FORMS': '0', - 'checkinlist_set-INITIAL_FORMS': '0', - 'checkinlist_set-MIN_NUM_FORMS': '0', - 'checkinlist_set-MAX_NUM_FORMS': '1000', - }) - assert doc.select(".alert-success") - with scopes_disabled(): - ses = list(self.event1.subevents.order_by('date_from')) - assert len(ses) == 183 - - assert ses[0].date_from.isoformat() == "2018-04-03T11:29:31+00:00" - assert ses[110].date_from.isoformat() == "2018-11-09T12:29:31+00:00" # DST :) - assert ses[-1].date_from.isoformat() == "2019-04-02T11:29:31+00:00" - - def test_create_bulk_daily_interval_multiple_times(self): - with scopes_disabled(): - self.event1.subevents.all().delete() - self.event1.settings.timezone = 'Europe/Berlin' - - doc = self.get_doc('/control/event/ccc/30c3/subevents/bulk_add') - assert doc.select("input[name=rruleformset-TOTAL_FORMS]") - doc = self.post_doc('/control/event/ccc/30c3/subevents/bulk_add', { - 'rruleformset-TOTAL_FORMS': '1', - 'rruleformset-INITIAL_FORMS': '0', - 'rruleformset-MIN_NUM_FORMS': '0', - 'rruleformset-MAX_NUM_FORMS': '1000', - 'rruleformset-0-interval': '2', - 'rruleformset-0-freq': 'daily', - 'rruleformset-0-dtstart': '2018-04-03', - 'rruleformset-0-yearly_same': 'on', - 'rruleformset-0-yearly_bysetpos': '1', - 'rruleformset-0-yearly_byweekday': 'MO', - 'rruleformset-0-yearly_bymonth': '1', - 'rruleformset-0-monthly_same': 'on', - 'rruleformset-0-monthly_bysetpos': '1', - 'rruleformset-0-monthly_byweekday': 'MO', - 'rruleformset-0-end': 'until', - 'rruleformset-0-count': '10', - 'rruleformset-0-until': '2019-04-03', - 'timeformset-TOTAL_FORMS': '2', - 'timeformset-INITIAL_FORMS': '0', - 'timeformset-MIN_NUM_FORMS': '1', - 'timeformset-MAX_NUM_FORMS': '1000', - 'timeformset-0-time_from': '13:29:31', - 'timeformset-0-time_to': '15:29:31', - 'timeformset-1-time_from': '15:29:31', - 'timeformset-1-time_to': '17:29:31', - 'name_0': 'Foo', - 'active': 'on', - 'frontpage_text_0': '', - 'rel_presale_start_0': 'unset', - 'rel_presale_start_1': '', - 'rel_presale_start_2': '1', - 'rel_presale_start_3': 'date_from', - 'rel_presale_start_4': '', - 'rel_presale_end_1': '', - 'rel_presale_end_0': 'relative', - 'rel_presale_end_2': '1', - 'rel_presale_end_3': 'date_from', - 'rel_presale_end_4': '13:29:31', - 'quotas-TOTAL_FORMS': '1', - 'quotas-INITIAL_FORMS': '0', - 'quotas-MIN_NUM_FORMS': '0', - 'quotas-MAX_NUM_FORMS': '1000', - 'quotas-0-name': 'Q1', - 'quotas-0-size': '50', - 'quotas-0-itemvars': str(self.ticket.pk), - 'checkinlist_set-TOTAL_FORMS': '0', - 'checkinlist_set-INITIAL_FORMS': '0', - 'checkinlist_set-MIN_NUM_FORMS': '0', - 'checkinlist_set-MAX_NUM_FORMS': '1000', - }) - assert doc.select(".alert-success") - with scopes_disabled(): - ses = list(self.event1.subevents.order_by('date_from')) - assert len(ses) == 183 * 2 - - assert ses[0].date_from.isoformat() == "2018-04-03T11:29:31+00:00" - assert ses[1].date_from.isoformat() == "2018-04-03T13:29:31+00:00" - assert ses[220].date_from.isoformat() == "2018-11-09T12:29:31+00:00" # DST :) - assert ses[-1].date_from.isoformat() == "2019-04-02T13:29:31+00:00" - - def test_create_bulk_exclude(self): - with scopes_disabled(): - self.event1.subevents.all().delete() - self.event1.settings.timezone = 'Europe/Berlin' - - doc = self.get_doc('/control/event/ccc/30c3/subevents/bulk_add') - assert doc.select("input[name=rruleformset-TOTAL_FORMS]") - doc = self.post_doc('/control/event/ccc/30c3/subevents/bulk_add', { - 'rruleformset-TOTAL_FORMS': '2', - 'rruleformset-INITIAL_FORMS': '0', - 'rruleformset-MIN_NUM_FORMS': '0', - 'rruleformset-MAX_NUM_FORMS': '1000', - 'rruleformset-0-interval': '1', - 'rruleformset-0-freq': 'daily', - 'rruleformset-0-dtstart': '2018-04-03', - 'rruleformset-0-yearly_same': 'on', - 'rruleformset-0-yearly_bysetpos': '1', - 'rruleformset-0-yearly_byweekday': 'MO', - 'rruleformset-0-yearly_bymonth': '1', - 'rruleformset-0-monthly_same': 'on', - 'rruleformset-0-monthly_bysetpos': '1', - 'rruleformset-0-monthly_byweekday': 'MO', - 'rruleformset-0-end': 'until', - 'rruleformset-0-count': '10', - 'rruleformset-0-until': '2019-04-03', - 'rruleformset-1-interval': '1', - 'rruleformset-1-freq': 'weekly', - 'rruleformset-1-dtstart': '2018-04-03', - 'rruleformset-1-yearly_same': 'on', - 'rruleformset-1-yearly_bysetpos': '1', - 'rruleformset-1-yearly_byweekday': 'MO', - 'rruleformset-1-yearly_bymonth': '1', - 'rruleformset-1-monthly_same': 'on', - 'rruleformset-1-monthly_bysetpos': '1', - 'rruleformset-1-monthly_byweekday': 'MO', - 'rruleformset-1-weekly_byweekday': 'MO', - 'rruleformset-1-end': 'until', - 'rruleformset-1-count': '10', - 'rruleformset-1-until': '2019-04-03', - 'rruleformset-1-exclude': 'on', - 'timeformset-TOTAL_FORMS': '1', - 'timeformset-INITIAL_FORMS': '0', - 'timeformset-MIN_NUM_FORMS': '1', - 'timeformset-MAX_NUM_FORMS': '1000', - 'timeformset-0-time_from': '13:29:31', - 'timeformset-0-time_to': '15:29:31', - 'name_0': 'Foo', - 'active': 'on', - 'frontpage_text_0': '', - 'rel_presale_start_0': 'unset', - 'rel_presale_start_1': '', - 'rel_presale_start_2': '1', - 'rel_presale_start_3': 'date_from', - 'rel_presale_start_4': '', - 'rel_presale_end_1': '', - 'rel_presale_end_0': 'relative', - 'rel_presale_end_2': '1', - 'rel_presale_end_3': 'date_from', - 'rel_presale_end_4': '13:29:31', - 'quotas-TOTAL_FORMS': '1', - 'quotas-INITIAL_FORMS': '0', - 'quotas-MIN_NUM_FORMS': '0', - 'quotas-MAX_NUM_FORMS': '1000', - 'quotas-0-name': 'Q1', - 'quotas-0-size': '50', - 'quotas-0-itemvars': str(self.ticket.pk), - 'checkinlist_set-TOTAL_FORMS': '0', - 'checkinlist_set-INITIAL_FORMS': '0', - 'checkinlist_set-MIN_NUM_FORMS': '0', - 'checkinlist_set-MAX_NUM_FORMS': '1000', - }) - assert doc.select(".alert-success") - with scopes_disabled(): - ses = list(self.event1.subevents.order_by('date_from')) - assert len(ses) == 314 - - assert ses[0].date_from.isoformat() == "2018-04-03T11:29:31+00:00" - assert ses[5].date_from.isoformat() == "2018-04-08T11:29:31+00:00" - assert ses[6].date_from.isoformat() == "2018-04-10T11:29:31+00:00" - - def test_create_bulk_monthly_interval(self): - with scopes_disabled(): - self.event1.subevents.all().delete() - self.event1.settings.timezone = 'Europe/Berlin' - - doc = self.post_doc('/control/event/ccc/30c3/subevents/bulk_add', { - 'rruleformset-TOTAL_FORMS': '1', - 'rruleformset-INITIAL_FORMS': '0', - 'rruleformset-MIN_NUM_FORMS': '0', - 'rruleformset-MAX_NUM_FORMS': '1000', - 'rruleformset-0-interval': '1', - 'rruleformset-0-freq': 'monthly', - 'rruleformset-0-dtstart': '2018-04-03', - 'rruleformset-0-yearly_same': 'on', - 'rruleformset-0-yearly_bysetpos': '1', - 'rruleformset-0-yearly_byweekday': 'MO', - 'rruleformset-0-yearly_bymonth': '1', - 'rruleformset-0-monthly_same': 'off', - 'rruleformset-0-monthly_bysetpos': '-1', - 'rruleformset-0-monthly_byweekday': 'MO,TU,WE,TH,FR', - 'rruleformset-0-weekly_byweekday': 'TH', - 'rruleformset-0-end': 'until', - 'rruleformset-0-count': '10', - 'rruleformset-0-until': '2019-04-03', - 'timeformset-TOTAL_FORMS': '1', - 'timeformset-INITIAL_FORMS': '0', - 'timeformset-MIN_NUM_FORMS': '1', - 'timeformset-MAX_NUM_FORMS': '1000', - 'timeformset-0-time_from': '13:29:31', - 'timeformset-0-time_to': '15:29:31', - 'name_0': 'Foo', - 'active': 'on', - 'frontpage_text_0': '', - 'rel_presale_start_0': 'unset', - 'rel_presale_start_1': '', - 'rel_presale_start_2': '1', - 'rel_presale_start_3': 'date_from', - 'rel_presale_start_4': '', - 'rel_presale_end_0': 'unset', - 'rel_presale_end_1': '', - 'rel_presale_end_2': '1', - 'rel_presale_end_3': 'date_from', - 'rel_presale_end_4': '13:29:31', - 'quotas-TOTAL_FORMS': '1', - 'quotas-INITIAL_FORMS': '0', - 'quotas-MIN_NUM_FORMS': '0', - 'quotas-MAX_NUM_FORMS': '1000', - 'quotas-0-name': 'Q1', - 'quotas-0-size': '50', - 'quotas-0-itemvars': str(self.ticket.pk), - 'checkinlist_set-TOTAL_FORMS': '0', - 'checkinlist_set-INITIAL_FORMS': '0', - 'checkinlist_set-MIN_NUM_FORMS': '0', - 'checkinlist_set-MAX_NUM_FORMS': '1000', - }) - assert doc.select(".alert-success") - with scopes_disabled(): - ses = list(self.event1.subevents.order_by('date_from')) - assert len(ses) == 12 - - assert ses[0].date_from.isoformat() == "2018-04-30T11:29:31+00:00" - assert ses[1].date_from.isoformat() == "2018-05-31T11:29:31+00:00" - assert ses[-1].date_from.isoformat() == "2019-03-29T12:29:31+00:00" - - def test_create_bulk_weekly_interval(self): - with scopes_disabled(): - self.event1.subevents.all().delete() - self.event1.settings.timezone = 'Europe/Berlin' - - doc = self.post_doc('/control/event/ccc/30c3/subevents/bulk_add', { - 'rruleformset-TOTAL_FORMS': '1', - 'rruleformset-INITIAL_FORMS': '0', - 'rruleformset-MIN_NUM_FORMS': '0', - 'rruleformset-MAX_NUM_FORMS': '1000', - 'rruleformset-0-interval': '1', - 'rruleformset-0-freq': 'weekly', - 'rruleformset-0-dtstart': '2018-04-03', - 'rruleformset-0-yearly_same': 'on', - 'rruleformset-0-yearly_bysetpos': '1', - 'rruleformset-0-yearly_byweekday': 'MO', - 'rruleformset-0-yearly_bymonth': '1', - 'rruleformset-0-monthly_same': 'on', - 'rruleformset-0-monthly_bysetpos': '-1', - 'rruleformset-0-monthly_byweekday': 'MO,TU,WE,TH,FR', - 'rruleformset-0-weekly_byweekday': 'TH', - 'rruleformset-0-end': 'until', - 'rruleformset-0-count': '10', - 'rruleformset-0-until': '2019-04-03', - 'timeformset-TOTAL_FORMS': '1', - 'timeformset-INITIAL_FORMS': '0', - 'timeformset-MIN_NUM_FORMS': '1', - 'timeformset-MAX_NUM_FORMS': '1000', - 'timeformset-0-time_from': '13:29:31', - 'timeformset-0-time_to': '15:29:31', - 'name_0': 'Foo', - 'active': 'on', - 'frontpage_text_0': '', - 'rel_presale_start_0': 'unset', - 'rel_presale_start_1': '', - 'rel_presale_start_2': '1', - 'rel_presale_start_3': 'date_from', - 'rel_presale_start_4': '', - 'rel_presale_end_0': 'unset', - 'rel_presale_end_1': '', - 'rel_presale_end_2': '1', - 'rel_presale_end_3': 'date_from', - 'rel_presale_end_4': '13:29:31', - 'quotas-TOTAL_FORMS': '1', - 'quotas-INITIAL_FORMS': '0', - 'quotas-MIN_NUM_FORMS': '0', - 'quotas-MAX_NUM_FORMS': '1000', - 'quotas-0-name': 'Q1', - 'quotas-0-size': '50', - 'quotas-0-itemvars': str(self.ticket.pk), - 'checkinlist_set-TOTAL_FORMS': '0', - 'checkinlist_set-INITIAL_FORMS': '0', - 'checkinlist_set-MIN_NUM_FORMS': '0', - 'checkinlist_set-MAX_NUM_FORMS': '1000', - }) - assert doc.select(".alert-success") - with scopes_disabled(): - ses = list(self.event1.subevents.order_by('date_from')) - assert len(ses) == 52 - - assert ses[0].date_from.isoformat() == "2018-04-05T11:29:31+00:00" - assert ses[1].date_from.isoformat() == "2018-04-12T11:29:31+00:00" - assert ses[-1].date_from.isoformat() == "2019-03-28T12:29:31+00:00" - - def test_delete_bulk(self): - self.subevent2.active = True - self.subevent2.save() - with scopes_disabled(): - o = Order.objects.create( - code='FOO', event=self.event1, email='dummy@dummy.test', - status=Order.STATUS_PENDING, - datetime=now(), expires=now() + datetime.timedelta(days=10), - total=14, locale='en' - ) - OrderPosition.objects.create( - order=o, - item=self.ticket, - subevent=self.subevent1, - price=Decimal("14"), - ) - doc = self.post_doc('/control/event/ccc/30c3/subevents/bulk_action', { - 'subevent': [str(self.subevent1.pk), str(self.subevent2.pk)], - 'action': 'delete_confirm' - }, follow=True) - assert doc.select(".alert-success") - with scopes_disabled(): - assert not self.event1.subevents.filter(pk=self.subevent2.pk).exists() - assert self.event1.subevents.get(pk=self.subevent1.pk).active is False - - def test_disable_bulk(self): - self.subevent2.active = True - self.subevent2.save() - doc = self.post_doc('/control/event/ccc/30c3/subevents/bulk_action', { - 'subevent': str(self.subevent2.pk), - 'action': 'disable' - }, follow=True) - assert doc.select(".alert-success") - with scopes_disabled(): - assert self.event1.subevents.get(pk=self.subevent2.pk).active is False - - def test_enable_bulk(self): - self.subevent2.active = False - self.subevent2.save() - doc = self.post_doc('/control/event/ccc/30c3/subevents/bulk_action', { - 'subevent': str(self.subevent2.pk), - 'action': 'enable' - }, follow=True) - assert doc.select(".alert-success") - with scopes_disabled(): - assert self.event1.subevents.get(pk=self.subevent2.pk).active is True - - class EventDeletionTest(SoupTest): @scopes_disabled() def setUp(self): diff --git a/src/tests/control/test_subevents.py b/src/tests/control/test_subevents.py new file mode 100644 index 000000000..be98d4a60 --- /dev/null +++ b/src/tests/control/test_subevents.py @@ -0,0 +1,1045 @@ +import datetime +from decimal import Decimal + +from django.utils.timezone import now +from django_scopes import scopes_disabled +from tests.base import SoupTest, extract_form_fields + +from pretix.base.models import ( + Event, Order, OrderPosition, Organizer, SubEvent, Team, User, +) +from pretix.base.models.items import SubEventItem + + +class SubEventsTest(SoupTest): + @scopes_disabled() + def setUp(self): + super().setUp() + self.user = User.objects.create_user('dummy@dummy.dummy', 'dummy') + self.orga1 = Organizer.objects.create(name='CCC', slug='ccc') + self.event1 = Event.objects.create( + organizer=self.orga1, name='30C3', slug='30c3', + date_from=datetime.datetime(2013, 12, 26, tzinfo=datetime.timezone.utc), + plugins='pretix.plugins.banktransfer,tests.testdummy', + has_subevents=True + ) + + t = Team.objects.create(organizer=self.orga1, can_create_events=True, can_change_event_settings=True, + can_change_items=True) + t.members.add(self.user) + t.limit_events.add(self.event1) + self.ticket = self.event1.items.create(name='Early-bird ticket', + category=None, default_price=23, + admission=True) + + self.client.login(email='dummy@dummy.dummy', password='dummy') + + self.subevent1 = self.event1.subevents.create(name='SE1', date_from=now()) + self.subevent2 = self.event1.subevents.create(name='SE2', date_from=now()) + + def test_list(self): + doc = self.get_doc('/control/event/ccc/30c3/subevents/') + tabletext = doc.select("#page-wrapper .table")[0].text + self.assertIn("SE1", tabletext) + + def test_create(self): + doc = self.get_doc('/control/event/ccc/30c3/subevents/add') + assert doc.select("input[name=quotas-TOTAL_FORMS]") + doc = self.post_doc('/control/event/ccc/30c3/subevents/add', { + 'name_0': 'SE2', + 'active': 'on', + 'date_from_0': '2017-07-01', + 'date_from_1': '10:00:00', + 'date_to_0': '2017-07-01', + 'date_to_1': '12:00:00', + 'location_0': 'Hamburg', + 'presale_start_0': '2017-06-20', + 'presale_start_1': '10:00:00', + 'checkinlist_set-TOTAL_FORMS': '1', + 'checkinlist_set-INITIAL_FORMS': '0', + 'checkinlist_set-MIN_NUM_FORMS': '0', + 'checkinlist_set-MAX_NUM_FORMS': '1000', + 'checkinlist_set-0-name': 'Default', + 'checkinlist_set-0-all_products': 'on', + 'quotas-TOTAL_FORMS': '1', + 'quotas-INITIAL_FORMS': '0', + 'quotas-MIN_NUM_FORMS': '0', + 'quotas-MAX_NUM_FORMS': '1000', + 'quotas-0-name': 'Q1', + 'quotas-0-size': '50', + 'quotas-0-itemvars': str(self.ticket.pk), + 'item-%d-price' % self.ticket.pk: '12' + }) + assert doc.select(".alert-success") + with scopes_disabled(): + se = self.event1.subevents.first() + assert str(se.name) == "SE2" + assert se.active + assert se.date_from.isoformat() == "2017-07-01T10:00:00+00:00" + assert se.date_to.isoformat() == "2017-07-01T12:00:00+00:00" + assert str(se.location) == "Hamburg" + assert se.presale_start.isoformat() == "2017-06-20T10:00:00+00:00" + assert not se.presale_end + assert se.quotas.count() == 1 + q = se.quotas.last() + assert q.name == "Q1" + assert q.size == 50 + assert list(q.items.all()) == [self.ticket] + sei = SubEventItem.objects.get(subevent=se, item=self.ticket) + assert sei.price == 12 + assert se.checkinlist_set.count() == 1 + + def test_modify(self): + doc = self.get_doc('/control/event/ccc/30c3/subevents/%d/' % self.subevent1.pk) + assert doc.select("input[name=quotas-TOTAL_FORMS]") + doc = self.post_doc('/control/event/ccc/30c3/subevents/%d/' % self.subevent1.pk, { + 'name_0': 'SE2', + 'active': 'on', + 'date_from_0': '2017-07-01', + 'date_from_1': '10:00:00', + 'date_to_0': '2017-07-01', + 'date_to_1': '12:00:00', + 'location_0': 'Hamburg', + 'presale_start_0': '2017-06-20', + 'presale_start_1': '10:00:00', + 'quotas-TOTAL_FORMS': '1', + 'quotas-INITIAL_FORMS': '0', + 'quotas-MIN_NUM_FORMS': '0', + 'quotas-MAX_NUM_FORMS': '1000', + 'quotas-0-name': 'Q1', + 'quotas-0-size': '50', + 'quotas-0-itemvars': str(self.ticket.pk), + 'checkinlist_set-TOTAL_FORMS': '1', + 'checkinlist_set-INITIAL_FORMS': '0', + 'checkinlist_set-MIN_NUM_FORMS': '0', + 'checkinlist_set-MAX_NUM_FORMS': '1000', + 'checkinlist_set-0-name': 'Default', + 'checkinlist_set-0-all_products': 'on', + 'item-%d-price' % self.ticket.pk: '12' + }) + assert doc.select(".alert-success") + self.subevent1.refresh_from_db() + se = self.subevent1 + assert str(se.name) == "SE2" + assert se.active + assert se.date_from.isoformat() == "2017-07-01T10:00:00+00:00" + assert se.date_to.isoformat() == "2017-07-01T12:00:00+00:00" + assert str(se.location) == "Hamburg" + assert se.presale_start.isoformat() == "2017-06-20T10:00:00+00:00" + assert not se.presale_end + with scopes_disabled(): + assert se.quotas.count() == 1 + q = se.quotas.last() + assert q.name == "Q1" + assert q.size == 50 + assert list(q.items.all()) == [self.ticket] + sei = SubEventItem.objects.get(subevent=se, item=self.ticket) + assert sei.price == 12 + assert se.checkinlist_set.count() == 1 + + def test_delete(self): + doc = self.get_doc('/control/event/ccc/30c3/subevents/%d/delete' % self.subevent1.pk) + assert doc.select("button") + doc = self.post_doc('/control/event/ccc/30c3/subevents/%d/delete' % self.subevent1.pk, {}) + assert doc.select(".alert-success") + # deleting the second event + doc = self.post_doc('/control/event/ccc/30c3/subevents/%d/delete' % self.subevent2.pk, {}) + assert doc.select(".alert-success") + with scopes_disabled(): + assert not SubEvent.objects.filter(pk=self.subevent2.pk).exists() + assert not SubEvent.objects.filter(pk=self.subevent1.pk).exists() + + def test_delete_with_orders(self): + with scopes_disabled(): + o = Order.objects.create( + code='FOO', event=self.event1, email='dummy@dummy.test', + status=Order.STATUS_PENDING, + datetime=now(), expires=now() + datetime.timedelta(days=10), + total=14, locale='en' + ) + OrderPosition.objects.create( + order=o, + item=self.ticket, + subevent=self.subevent1, + price=Decimal("14"), + ) + doc = self.get_doc('/control/event/ccc/30c3/subevents/%d/delete' % self.subevent1.pk, follow=True) + assert doc.select(".alert-danger") + doc = self.post_doc('/control/event/ccc/30c3/subevents/%d/delete' % self.subevent1.pk, {}, follow=True) + assert doc.select(".alert-danger") + with scopes_disabled(): + assert self.event1.subevents.filter(pk=self.subevent1.pk).exists() + + def test_create_bulk(self): + with scopes_disabled(): + self.event1.subevents.all().delete() + self.event1.settings.timezone = 'Europe/Berlin' + + doc = self.get_doc('/control/event/ccc/30c3/subevents/bulk_add') + assert doc.select("input[name=rruleformset-TOTAL_FORMS]") + doc = self.post_doc('/control/event/ccc/30c3/subevents/bulk_add', { + 'rruleformset-TOTAL_FORMS': '1', + 'rruleformset-INITIAL_FORMS': '0', + 'rruleformset-MIN_NUM_FORMS': '0', + 'rruleformset-MAX_NUM_FORMS': '1000', + 'rruleformset-0-interval': '1', + 'rruleformset-0-freq': 'yearly', + 'rruleformset-0-dtstart': '2018-04-03', + 'rruleformset-0-yearly_same': 'on', + 'rruleformset-0-yearly_bysetpos': '1', + 'rruleformset-0-yearly_byweekday': 'MO', + 'rruleformset-0-yearly_bymonth': '1', + 'rruleformset-0-monthly_same': 'on', + 'rruleformset-0-monthly_bysetpos': '1', + 'rruleformset-0-monthly_byweekday': 'MO', + 'rruleformset-0-end': 'count', + 'rruleformset-0-count': '10', + 'rruleformset-0-until': '2019-04-03', + 'timeformset-TOTAL_FORMS': '1', + 'timeformset-INITIAL_FORMS': '0', + 'timeformset-MIN_NUM_FORMS': '1', + 'timeformset-MAX_NUM_FORMS': '1000', + 'timeformset-0-time_from': '13:29:31', + 'timeformset-0-time_to': '15:29:31', + 'name_0': 'Foo', + 'active': 'on', + 'location_0': 'Loc', + 'time_admission': '', + 'frontpage_text_0': '', + 'rel_presale_start_0': 'unset', + 'rel_presale_start_1': '', + 'rel_presale_start_2': '1', + 'rel_presale_start_3': 'date_from', + 'rel_presale_start_4': '', + 'rel_presale_end_1': '', + 'rel_presale_end_0': 'relative', + 'rel_presale_end_2': '1', + 'rel_presale_end_3': 'date_from', + 'rel_presale_end_4': '13:29:31', + 'quotas-TOTAL_FORMS': '1', + 'quotas-INITIAL_FORMS': '0', + 'quotas-MIN_NUM_FORMS': '0', + 'quotas-MAX_NUM_FORMS': '1000', + 'quotas-0-id': '', + 'quotas-0-name': 'Bar', + 'quotas-0-size': '12', + 'quotas-0-itemvars': str(self.ticket.pk), + 'item-%d-price' % self.ticket.pk: '16', + 'checkinlist_set-TOTAL_FORMS': '1', + 'checkinlist_set-INITIAL_FORMS': '0', + 'checkinlist_set-MIN_NUM_FORMS': '0', + 'checkinlist_set-MAX_NUM_FORMS': '1000', + 'checkinlist_set-0-id': '', + 'checkinlist_set-0-name': 'Foo', + 'checkinlist_set-0-limit_products': str(self.ticket.pk), + }) + assert doc.select(".alert-success") + with scopes_disabled(): + ses = list(self.event1.subevents.order_by('date_from')) + assert len(ses) == 10 + + assert str(ses[0].name) == "Foo" + assert ses[0].date_from.isoformat() == "2018-04-03T11:29:31+00:00" + assert ses[0].date_to.isoformat() == "2018-04-03T13:29:31+00:00" + assert not ses[0].presale_start + assert ses[0].presale_end.isoformat() == "2018-04-02T11:29:31+00:00" + with scopes_disabled(): + assert ses[0].quotas.count() == 1 + assert list(ses[0].quotas.first().items.all()) == [self.ticket] + assert SubEventItem.objects.get(subevent=ses[0], item=self.ticket).price == 16 + assert ses[0].checkinlist_set.count() == 1 + + assert str(ses[1].name) == "Foo" + assert ses[1].date_from.isoformat() == "2019-04-03T11:29:31+00:00" + assert ses[1].date_to.isoformat() == "2019-04-03T13:29:31+00:00" + assert not ses[1].presale_start + assert ses[1].presale_end.isoformat() == "2019-04-02T11:29:31+00:00" + with scopes_disabled(): + assert ses[1].quotas.count() == 1 + assert list(ses[1].quotas.first().items.all()) == [self.ticket] + assert SubEventItem.objects.get(subevent=ses[0], item=self.ticket).price == 16 + assert ses[1].checkinlist_set.count() == 1 + + assert ses[-1].date_from.isoformat() == "2027-04-03T11:29:31+00:00" + + def test_create_bulk_daily_interval(self): + with scopes_disabled(): + self.event1.subevents.all().delete() + self.event1.settings.timezone = 'Europe/Berlin' + + doc = self.get_doc('/control/event/ccc/30c3/subevents/bulk_add') + assert doc.select("input[name=rruleformset-TOTAL_FORMS]") + doc = self.post_doc('/control/event/ccc/30c3/subevents/bulk_add', { + 'rruleformset-TOTAL_FORMS': '1', + 'rruleformset-INITIAL_FORMS': '0', + 'rruleformset-MIN_NUM_FORMS': '0', + 'rruleformset-MAX_NUM_FORMS': '1000', + 'rruleformset-0-interval': '2', + 'rruleformset-0-freq': 'daily', + 'rruleformset-0-dtstart': '2018-04-03', + 'rruleformset-0-yearly_same': 'on', + 'rruleformset-0-yearly_bysetpos': '1', + 'rruleformset-0-yearly_byweekday': 'MO', + 'rruleformset-0-yearly_bymonth': '1', + 'rruleformset-0-monthly_same': 'on', + 'rruleformset-0-monthly_bysetpos': '1', + 'rruleformset-0-monthly_byweekday': 'MO', + 'rruleformset-0-end': 'until', + 'rruleformset-0-count': '10', + 'rruleformset-0-until': '2019-04-03', + 'timeformset-TOTAL_FORMS': '1', + 'timeformset-INITIAL_FORMS': '0', + 'timeformset-MIN_NUM_FORMS': '1', + 'timeformset-MAX_NUM_FORMS': '1000', + 'timeformset-0-time_from': '13:29:31', + 'timeformset-0-time_to': '15:29:31', + 'name_0': 'Foo', + 'active': 'on', + 'frontpage_text_0': '', + 'rel_presale_start_0': 'unset', + 'rel_presale_start_1': '', + 'rel_presale_start_2': '1', + 'rel_presale_start_3': 'date_from', + 'rel_presale_start_4': '', + 'rel_presale_end_1': '', + 'rel_presale_end_0': 'relative', + 'rel_presale_end_2': '1', + 'rel_presale_end_3': 'date_from', + 'rel_presale_end_4': '13:29:31', + 'quotas-TOTAL_FORMS': '1', + 'quotas-INITIAL_FORMS': '0', + 'quotas-MIN_NUM_FORMS': '1', + 'quotas-MAX_NUM_FORMS': '1000', + 'quotas-0-name': 'Q1', + 'quotas-0-size': '50', + 'quotas-0-itemvars': str(self.ticket.pk), + 'checkinlist_set-TOTAL_FORMS': '0', + 'checkinlist_set-INITIAL_FORMS': '0', + 'checkinlist_set-MIN_NUM_FORMS': '0', + 'checkinlist_set-MAX_NUM_FORMS': '1000', + }) + assert doc.select(".alert-success") + with scopes_disabled(): + ses = list(self.event1.subevents.order_by('date_from')) + assert len(ses) == 183 + + assert ses[0].date_from.isoformat() == "2018-04-03T11:29:31+00:00" + assert ses[110].date_from.isoformat() == "2018-11-09T12:29:31+00:00" # DST :) + assert ses[-1].date_from.isoformat() == "2019-04-02T11:29:31+00:00" + + def test_create_bulk_daily_interval_multiple_times(self): + with scopes_disabled(): + self.event1.subevents.all().delete() + self.event1.settings.timezone = 'Europe/Berlin' + + doc = self.get_doc('/control/event/ccc/30c3/subevents/bulk_add') + assert doc.select("input[name=rruleformset-TOTAL_FORMS]") + doc = self.post_doc('/control/event/ccc/30c3/subevents/bulk_add', { + 'rruleformset-TOTAL_FORMS': '1', + 'rruleformset-INITIAL_FORMS': '0', + 'rruleformset-MIN_NUM_FORMS': '0', + 'rruleformset-MAX_NUM_FORMS': '1000', + 'rruleformset-0-interval': '2', + 'rruleformset-0-freq': 'daily', + 'rruleformset-0-dtstart': '2018-04-03', + 'rruleformset-0-yearly_same': 'on', + 'rruleformset-0-yearly_bysetpos': '1', + 'rruleformset-0-yearly_byweekday': 'MO', + 'rruleformset-0-yearly_bymonth': '1', + 'rruleformset-0-monthly_same': 'on', + 'rruleformset-0-monthly_bysetpos': '1', + 'rruleformset-0-monthly_byweekday': 'MO', + 'rruleformset-0-end': 'until', + 'rruleformset-0-count': '10', + 'rruleformset-0-until': '2019-04-03', + 'timeformset-TOTAL_FORMS': '2', + 'timeformset-INITIAL_FORMS': '0', + 'timeformset-MIN_NUM_FORMS': '1', + 'timeformset-MAX_NUM_FORMS': '1000', + 'timeformset-0-time_from': '13:29:31', + 'timeformset-0-time_to': '15:29:31', + 'timeformset-1-time_from': '15:29:31', + 'timeformset-1-time_to': '17:29:31', + 'name_0': 'Foo', + 'active': 'on', + 'frontpage_text_0': '', + 'rel_presale_start_0': 'unset', + 'rel_presale_start_1': '', + 'rel_presale_start_2': '1', + 'rel_presale_start_3': 'date_from', + 'rel_presale_start_4': '', + 'rel_presale_end_1': '', + 'rel_presale_end_0': 'relative', + 'rel_presale_end_2': '1', + 'rel_presale_end_3': 'date_from', + 'rel_presale_end_4': '13:29:31', + 'quotas-TOTAL_FORMS': '1', + 'quotas-INITIAL_FORMS': '0', + 'quotas-MIN_NUM_FORMS': '0', + 'quotas-MAX_NUM_FORMS': '1000', + 'quotas-0-name': 'Q1', + 'quotas-0-size': '50', + 'quotas-0-itemvars': str(self.ticket.pk), + 'checkinlist_set-TOTAL_FORMS': '0', + 'checkinlist_set-INITIAL_FORMS': '0', + 'checkinlist_set-MIN_NUM_FORMS': '0', + 'checkinlist_set-MAX_NUM_FORMS': '1000', + }) + assert doc.select(".alert-success") + with scopes_disabled(): + ses = list(self.event1.subevents.order_by('date_from')) + assert len(ses) == 183 * 2 + + assert ses[0].date_from.isoformat() == "2018-04-03T11:29:31+00:00" + assert ses[1].date_from.isoformat() == "2018-04-03T13:29:31+00:00" + assert ses[220].date_from.isoformat() == "2018-11-09T12:29:31+00:00" # DST :) + assert ses[-1].date_from.isoformat() == "2019-04-02T13:29:31+00:00" + + def test_create_bulk_exclude(self): + with scopes_disabled(): + self.event1.subevents.all().delete() + self.event1.settings.timezone = 'Europe/Berlin' + + doc = self.get_doc('/control/event/ccc/30c3/subevents/bulk_add') + assert doc.select("input[name=rruleformset-TOTAL_FORMS]") + doc = self.post_doc('/control/event/ccc/30c3/subevents/bulk_add', { + 'rruleformset-TOTAL_FORMS': '2', + 'rruleformset-INITIAL_FORMS': '0', + 'rruleformset-MIN_NUM_FORMS': '0', + 'rruleformset-MAX_NUM_FORMS': '1000', + 'rruleformset-0-interval': '1', + 'rruleformset-0-freq': 'daily', + 'rruleformset-0-dtstart': '2018-04-03', + 'rruleformset-0-yearly_same': 'on', + 'rruleformset-0-yearly_bysetpos': '1', + 'rruleformset-0-yearly_byweekday': 'MO', + 'rruleformset-0-yearly_bymonth': '1', + 'rruleformset-0-monthly_same': 'on', + 'rruleformset-0-monthly_bysetpos': '1', + 'rruleformset-0-monthly_byweekday': 'MO', + 'rruleformset-0-end': 'until', + 'rruleformset-0-count': '10', + 'rruleformset-0-until': '2019-04-03', + 'rruleformset-1-interval': '1', + 'rruleformset-1-freq': 'weekly', + 'rruleformset-1-dtstart': '2018-04-03', + 'rruleformset-1-yearly_same': 'on', + 'rruleformset-1-yearly_bysetpos': '1', + 'rruleformset-1-yearly_byweekday': 'MO', + 'rruleformset-1-yearly_bymonth': '1', + 'rruleformset-1-monthly_same': 'on', + 'rruleformset-1-monthly_bysetpos': '1', + 'rruleformset-1-monthly_byweekday': 'MO', + 'rruleformset-1-weekly_byweekday': 'MO', + 'rruleformset-1-end': 'until', + 'rruleformset-1-count': '10', + 'rruleformset-1-until': '2019-04-03', + 'rruleformset-1-exclude': 'on', + 'timeformset-TOTAL_FORMS': '1', + 'timeformset-INITIAL_FORMS': '0', + 'timeformset-MIN_NUM_FORMS': '1', + 'timeformset-MAX_NUM_FORMS': '1000', + 'timeformset-0-time_from': '13:29:31', + 'timeformset-0-time_to': '15:29:31', + 'name_0': 'Foo', + 'active': 'on', + 'frontpage_text_0': '', + 'rel_presale_start_0': 'unset', + 'rel_presale_start_1': '', + 'rel_presale_start_2': '1', + 'rel_presale_start_3': 'date_from', + 'rel_presale_start_4': '', + 'rel_presale_end_1': '', + 'rel_presale_end_0': 'relative', + 'rel_presale_end_2': '1', + 'rel_presale_end_3': 'date_from', + 'rel_presale_end_4': '13:29:31', + 'quotas-TOTAL_FORMS': '1', + 'quotas-INITIAL_FORMS': '0', + 'quotas-MIN_NUM_FORMS': '0', + 'quotas-MAX_NUM_FORMS': '1000', + 'quotas-0-name': 'Q1', + 'quotas-0-size': '50', + 'quotas-0-itemvars': str(self.ticket.pk), + 'checkinlist_set-TOTAL_FORMS': '0', + 'checkinlist_set-INITIAL_FORMS': '0', + 'checkinlist_set-MIN_NUM_FORMS': '0', + 'checkinlist_set-MAX_NUM_FORMS': '1000', + }) + assert doc.select(".alert-success") + with scopes_disabled(): + ses = list(self.event1.subevents.order_by('date_from')) + assert len(ses) == 314 + + assert ses[0].date_from.isoformat() == "2018-04-03T11:29:31+00:00" + assert ses[5].date_from.isoformat() == "2018-04-08T11:29:31+00:00" + assert ses[6].date_from.isoformat() == "2018-04-10T11:29:31+00:00" + + def test_create_bulk_monthly_interval(self): + with scopes_disabled(): + self.event1.subevents.all().delete() + self.event1.settings.timezone = 'Europe/Berlin' + + doc = self.post_doc('/control/event/ccc/30c3/subevents/bulk_add', { + 'rruleformset-TOTAL_FORMS': '1', + 'rruleformset-INITIAL_FORMS': '0', + 'rruleformset-MIN_NUM_FORMS': '0', + 'rruleformset-MAX_NUM_FORMS': '1000', + 'rruleformset-0-interval': '1', + 'rruleformset-0-freq': 'monthly', + 'rruleformset-0-dtstart': '2018-04-03', + 'rruleformset-0-yearly_same': 'on', + 'rruleformset-0-yearly_bysetpos': '1', + 'rruleformset-0-yearly_byweekday': 'MO', + 'rruleformset-0-yearly_bymonth': '1', + 'rruleformset-0-monthly_same': 'off', + 'rruleformset-0-monthly_bysetpos': '-1', + 'rruleformset-0-monthly_byweekday': 'MO,TU,WE,TH,FR', + 'rruleformset-0-weekly_byweekday': 'TH', + 'rruleformset-0-end': 'until', + 'rruleformset-0-count': '10', + 'rruleformset-0-until': '2019-04-03', + 'timeformset-TOTAL_FORMS': '1', + 'timeformset-INITIAL_FORMS': '0', + 'timeformset-MIN_NUM_FORMS': '1', + 'timeformset-MAX_NUM_FORMS': '1000', + 'timeformset-0-time_from': '13:29:31', + 'timeformset-0-time_to': '15:29:31', + 'name_0': 'Foo', + 'active': 'on', + 'frontpage_text_0': '', + 'rel_presale_start_0': 'unset', + 'rel_presale_start_1': '', + 'rel_presale_start_2': '1', + 'rel_presale_start_3': 'date_from', + 'rel_presale_start_4': '', + 'rel_presale_end_0': 'unset', + 'rel_presale_end_1': '', + 'rel_presale_end_2': '1', + 'rel_presale_end_3': 'date_from', + 'rel_presale_end_4': '13:29:31', + 'quotas-TOTAL_FORMS': '1', + 'quotas-INITIAL_FORMS': '0', + 'quotas-MIN_NUM_FORMS': '0', + 'quotas-MAX_NUM_FORMS': '1000', + 'quotas-0-name': 'Q1', + 'quotas-0-size': '50', + 'quotas-0-itemvars': str(self.ticket.pk), + 'checkinlist_set-TOTAL_FORMS': '0', + 'checkinlist_set-INITIAL_FORMS': '0', + 'checkinlist_set-MIN_NUM_FORMS': '0', + 'checkinlist_set-MAX_NUM_FORMS': '1000', + }) + assert doc.select(".alert-success") + with scopes_disabled(): + ses = list(self.event1.subevents.order_by('date_from')) + assert len(ses) == 12 + + assert ses[0].date_from.isoformat() == "2018-04-30T11:29:31+00:00" + assert ses[1].date_from.isoformat() == "2018-05-31T11:29:31+00:00" + assert ses[-1].date_from.isoformat() == "2019-03-29T12:29:31+00:00" + + def test_create_bulk_weekly_interval(self): + with scopes_disabled(): + self.event1.subevents.all().delete() + self.event1.settings.timezone = 'Europe/Berlin' + + doc = self.post_doc('/control/event/ccc/30c3/subevents/bulk_add', { + 'rruleformset-TOTAL_FORMS': '1', + 'rruleformset-INITIAL_FORMS': '0', + 'rruleformset-MIN_NUM_FORMS': '0', + 'rruleformset-MAX_NUM_FORMS': '1000', + 'rruleformset-0-interval': '1', + 'rruleformset-0-freq': 'weekly', + 'rruleformset-0-dtstart': '2018-04-03', + 'rruleformset-0-yearly_same': 'on', + 'rruleformset-0-yearly_bysetpos': '1', + 'rruleformset-0-yearly_byweekday': 'MO', + 'rruleformset-0-yearly_bymonth': '1', + 'rruleformset-0-monthly_same': 'on', + 'rruleformset-0-monthly_bysetpos': '-1', + 'rruleformset-0-monthly_byweekday': 'MO,TU,WE,TH,FR', + 'rruleformset-0-weekly_byweekday': 'TH', + 'rruleformset-0-end': 'until', + 'rruleformset-0-count': '10', + 'rruleformset-0-until': '2019-04-03', + 'timeformset-TOTAL_FORMS': '1', + 'timeformset-INITIAL_FORMS': '0', + 'timeformset-MIN_NUM_FORMS': '1', + 'timeformset-MAX_NUM_FORMS': '1000', + 'timeformset-0-time_from': '13:29:31', + 'timeformset-0-time_to': '15:29:31', + 'name_0': 'Foo', + 'active': 'on', + 'frontpage_text_0': '', + 'rel_presale_start_0': 'unset', + 'rel_presale_start_1': '', + 'rel_presale_start_2': '1', + 'rel_presale_start_3': 'date_from', + 'rel_presale_start_4': '', + 'rel_presale_end_0': 'unset', + 'rel_presale_end_1': '', + 'rel_presale_end_2': '1', + 'rel_presale_end_3': 'date_from', + 'rel_presale_end_4': '13:29:31', + 'quotas-TOTAL_FORMS': '1', + 'quotas-INITIAL_FORMS': '0', + 'quotas-MIN_NUM_FORMS': '0', + 'quotas-MAX_NUM_FORMS': '1000', + 'quotas-0-name': 'Q1', + 'quotas-0-size': '50', + 'quotas-0-itemvars': str(self.ticket.pk), + 'checkinlist_set-TOTAL_FORMS': '0', + 'checkinlist_set-INITIAL_FORMS': '0', + 'checkinlist_set-MIN_NUM_FORMS': '0', + 'checkinlist_set-MAX_NUM_FORMS': '1000', + }) + assert doc.select(".alert-success") + with scopes_disabled(): + ses = list(self.event1.subevents.order_by('date_from')) + assert len(ses) == 52 + + assert ses[0].date_from.isoformat() == "2018-04-05T11:29:31+00:00" + assert ses[1].date_from.isoformat() == "2018-04-12T11:29:31+00:00" + assert ses[-1].date_from.isoformat() == "2019-03-28T12:29:31+00:00" + + def test_delete_bulk(self): + self.subevent2.active = True + self.subevent2.save() + with scopes_disabled(): + o = Order.objects.create( + code='FOO', event=self.event1, email='dummy@dummy.test', + status=Order.STATUS_PENDING, + datetime=now(), expires=now() + datetime.timedelta(days=10), + total=14, locale='en' + ) + OrderPosition.objects.create( + order=o, + item=self.ticket, + subevent=self.subevent1, + price=Decimal("14"), + ) + doc = self.post_doc('/control/event/ccc/30c3/subevents/bulk_action', { + 'subevent': [str(self.subevent1.pk), str(self.subevent2.pk)], + 'action': 'delete_confirm' + }, follow=True) + assert doc.select(".alert-success") + with scopes_disabled(): + assert not self.event1.subevents.filter(pk=self.subevent2.pk).exists() + assert self.event1.subevents.get(pk=self.subevent1.pk).active is False + + def test_delete_bulk_by_query(self): + doc = self.post_doc('/control/event/ccc/30c3/subevents/bulk_action', { + '__ALL': 'on', + 'filter-query': 'SE2', + 'action': 'delete_confirm' + }, follow=True) + assert doc.select(".alert-success") + with scopes_disabled(): + assert not self.event1.subevents.filter(pk=self.subevent2.pk).exists() + assert self.event1.subevents.filter(pk=self.subevent1.pk).exists() + + def test_disable_bulk(self): + self.subevent2.active = True + self.subevent2.save() + doc = self.post_doc('/control/event/ccc/30c3/subevents/bulk_action', { + 'subevent': str(self.subevent2.pk), + 'action': 'disable' + }, follow=True) + assert doc.select(".alert-success") + with scopes_disabled(): + assert self.event1.subevents.get(pk=self.subevent2.pk).active is False + + def test_enable_bulk(self): + self.subevent2.active = False + self.subevent2.save() + doc = self.post_doc('/control/event/ccc/30c3/subevents/bulk_action', { + 'subevent': str(self.subevent2.pk), + 'action': 'enable' + }, follow=True) + assert doc.select(".alert-success") + with scopes_disabled(): + assert self.event1.subevents.get(pk=self.subevent2.pk).active is True + + def test_edit_bulk_scalar_change(self): + doc = self.post_doc('/control/event/ccc/30c3/subevents/bulk_edit', { + '__ALL': 'on', + }, follow=True) + fields = extract_form_fields(doc) + assert not fields.get('bulkedit-name_0') + fields.update({ + '_bulk': ['bulkeditname'], + 'bulkedit-name_0': 'SEFOO', + }) + doc = self.post_doc('/control/event/ccc/30c3/subevents/bulk_edit', fields, follow=True) + assert doc.select(".alert-success") + with scopes_disabled(): + assert str(self.event1.subevents.get(pk=self.subevent1.pk).name) == 'SEFOO' + assert str(self.event1.subevents.get(pk=self.subevent2.pk).name) == 'SEFOO' + + def test_edit_bulk_scalar_keep_mixed(self): + doc = self.post_doc('/control/event/ccc/30c3/subevents/bulk_edit', { + '__ALL': 'on', + }, follow=True) + fields = extract_form_fields(doc) + assert not fields.get('bulkedit-name_0') + fields.update({ + '_bulk': ['bulkeditlocation'], + 'bulkedit-name_0': 'SEFOO', + }) + doc = self.post_doc('/control/event/ccc/30c3/subevents/bulk_edit', fields, follow=True) + assert doc.select(".alert-success") + with scopes_disabled(): + assert str(self.event1.subevents.get(pk=self.subevent1.pk).name) == 'SE1' + assert str(self.event1.subevents.get(pk=self.subevent2.pk).name) == 'SE2' + + def test_edit_bulk_meta(self): + prop1 = self.orga1.meta_properties.create(name="Prop1") + prop2 = self.orga1.meta_properties.create(name="Prop2") + prop2.subevent_values.create(subevent=self.subevent1, value="Bla") + prop2.subevent_values.create(subevent=self.subevent2, value="Bla") + doc = self.post_doc('/control/event/ccc/30c3/subevents/bulk_edit', { + '__ALL': 'on', + }, follow=True) + fields = extract_form_fields(doc) + assert not fields.get('prop-{}-value'.format(prop1.pk)) + assert fields.get('prop-{}-value'.format(prop2.pk)) == 'Bla' + fields.update({ + '_bulk': ['prop-{}value'.format(prop1.pk), 'prop-{}value'.format(prop2.pk)], + 'prop-{}-value'.format(prop1.pk): 'Bla', + 'prop-{}-value'.format(prop2.pk): '', + }) + doc = self.post_doc('/control/event/ccc/30c3/subevents/bulk_edit', fields, follow=True) + assert doc.select(".alert-success") + with scopes_disabled(): + assert self.event1.subevents.get(pk=self.subevent1.pk).meta_data == {'Prop1': 'Bla', 'Prop2': ''} + assert self.event1.subevents.get(pk=self.subevent2.pk).meta_data == {'Prop1': 'Bla', 'Prop2': ''} + + def test_edit_bulk_day_both_same_before(self): + with scopes_disabled(): + self.subevent1.date_from = datetime.datetime(2013, 12, 26, 9, 0, 0, tzinfo=datetime.timezone.utc) + self.subevent1.save() + self.subevent2.date_from = datetime.datetime(2013, 12, 26, 11, 0, 0, tzinfo=datetime.timezone.utc) + self.subevent2.save() + + doc = self.post_doc('/control/event/ccc/30c3/subevents/bulk_edit', { + '__ALL': 'on', + }, follow=True) + fields = extract_form_fields(doc) + assert fields.get('bulkedit-date_from_day') == '2013-12-26' + fields.update({ + '_bulk': ['bulkeditdate_from_day'], + 'bulkedit-date_from_day': '2013-12-27', + }) + doc = self.post_doc('/control/event/ccc/30c3/subevents/bulk_edit', fields, follow=True) + assert doc.select(".alert-success") + with scopes_disabled(): + self.subevent1.refresh_from_db() + self.subevent2.refresh_from_db() + assert self.subevent1.date_from == datetime.datetime(2013, 12, 27, 9, 0, 0, tzinfo=datetime.timezone.utc) + assert self.subevent2.date_from == datetime.datetime(2013, 12, 27, 11, 0, 0, tzinfo=datetime.timezone.utc) + + def test_edit_bulk_day_both_different_before(self): + with scopes_disabled(): + self.subevent1.date_from = datetime.datetime(2013, 12, 26, 9, 0, 0, tzinfo=datetime.timezone.utc) + self.subevent1.save() + self.subevent2.date_from = datetime.datetime(2013, 12, 27, 11, 0, 0, tzinfo=datetime.timezone.utc) + self.subevent2.save() + + doc = self.post_doc('/control/event/ccc/30c3/subevents/bulk_edit', { + '__ALL': 'on', + }, follow=True) + fields = extract_form_fields(doc) + assert not fields.get('bulkedit-date_from_day') + fields.update({ + '_bulk': ['bulkeditdate_from_day'], + 'bulkedit-date_from_day': '2013-12-27', + }) + doc = self.post_doc('/control/event/ccc/30c3/subevents/bulk_edit', fields, follow=True) + assert doc.select(".alert-success") + with scopes_disabled(): + self.subevent1.refresh_from_db() + self.subevent2.refresh_from_db() + assert self.subevent1.date_from == datetime.datetime(2013, 12, 27, 9, 0, 0, tzinfo=datetime.timezone.utc) + assert self.subevent2.date_from == datetime.datetime(2013, 12, 27, 11, 0, 0, tzinfo=datetime.timezone.utc) + + def test_edit_bulk_day_unset_before(self): + doc = self.post_doc('/control/event/ccc/30c3/subevents/bulk_edit', { + '__ALL': 'on', + }, follow=True) + fields = extract_form_fields(doc) + assert not fields.get('bulkedit-date_to_day') + fields.update({ + '_bulk': ['bulkeditdate_to_day'], + 'bulkedit-date_to_day': '2013-12-27', + }) + doc = self.post_doc('/control/event/ccc/30c3/subevents/bulk_edit', fields, follow=True) + assert doc.select(".alert-success") + with scopes_disabled(): + self.subevent1.refresh_from_db() + self.subevent2.refresh_from_db() + assert self.subevent1.date_to == datetime.datetime(2013, 12, 27, 0, 0, 0, tzinfo=datetime.timezone.utc) + assert self.subevent2.date_to == datetime.datetime(2013, 12, 27, 0, 0, 0, tzinfo=datetime.timezone.utc) + + def test_edit_bulk_day_unset(self): + with scopes_disabled(): + self.subevent1.date_to = datetime.datetime(2013, 12, 26, 9, 0, 0, tzinfo=datetime.timezone.utc) + self.subevent1.save() + self.subevent2.date_to = datetime.datetime(2013, 12, 27, 11, 0, 0, tzinfo=datetime.timezone.utc) + self.subevent2.save() + + doc = self.post_doc('/control/event/ccc/30c3/subevents/bulk_edit', { + '__ALL': 'on', + }, follow=True) + fields = extract_form_fields(doc) + assert not fields.get('bulkedit-date_to_day') + fields.update({ + '_bulk': ['bulkeditdate_to_day'], + }) + doc = self.post_doc('/control/event/ccc/30c3/subevents/bulk_edit', fields, follow=True) + assert doc.select(".alert-success") + with scopes_disabled(): + self.subevent1.refresh_from_db() + self.subevent2.refresh_from_db() + assert self.subevent1.date_to is None + assert self.subevent2.date_to is None + + def test_edit_bulk_time_both_same_before(self): + with scopes_disabled(): + self.subevent1.date_to = datetime.datetime(2013, 12, 26, 9, 0, 0, tzinfo=datetime.timezone.utc) + self.subevent1.save() + self.subevent2.date_to = datetime.datetime(2013, 12, 27, 9, 0, 0, tzinfo=datetime.timezone.utc) + self.subevent2.save() + + doc = self.post_doc('/control/event/ccc/30c3/subevents/bulk_edit', { + '__ALL': 'on', + }, follow=True) + fields = extract_form_fields(doc) + assert fields.get('bulkedit-date_to_time') == '09:00:00' + fields.update({ + '_bulk': ['bulkeditdate_to_time'], + 'bulkedit-date_to_time': '10:00:00', + }) + doc = self.post_doc('/control/event/ccc/30c3/subevents/bulk_edit', fields, follow=True) + assert doc.select(".alert-success") + with scopes_disabled(): + self.subevent1.refresh_from_db() + self.subevent2.refresh_from_db() + assert self.subevent1.date_to == datetime.datetime(2013, 12, 26, 10, 0, 0, tzinfo=datetime.timezone.utc) + assert self.subevent2.date_to == datetime.datetime(2013, 12, 27, 10, 0, 0, tzinfo=datetime.timezone.utc) + + def test_edit_bulk_time_both_different_before(self): + with scopes_disabled(): + self.subevent1.date_to = datetime.datetime(2013, 12, 26, 9, 0, 0, tzinfo=datetime.timezone.utc) + self.subevent1.save() + self.subevent2.date_to = datetime.datetime(2013, 12, 27, 11, 0, 0, tzinfo=datetime.timezone.utc) + self.subevent2.save() + + doc = self.post_doc('/control/event/ccc/30c3/subevents/bulk_edit', { + '__ALL': 'on', + }, follow=True) + fields = extract_form_fields(doc) + assert not fields.get('bulkedit-date_to_time') + fields.update({ + '_bulk': ['bulkeditdate_to_time'], + 'bulkedit-date_to_time': '10:00:00', + }) + doc = self.post_doc('/control/event/ccc/30c3/subevents/bulk_edit', fields, follow=True) + assert doc.select(".alert-success") + with scopes_disabled(): + self.subevent1.refresh_from_db() + self.subevent2.refresh_from_db() + assert self.subevent1.date_to == datetime.datetime(2013, 12, 26, 10, 0, 0, tzinfo=datetime.timezone.utc) + assert self.subevent2.date_to == datetime.datetime(2013, 12, 27, 10, 0, 0, tzinfo=datetime.timezone.utc) + + def test_edit_bulk_time_unset_before(self): + with scopes_disabled(): + self.subevent1.date_from = datetime.datetime(2013, 12, 26, 9, 0, 0, tzinfo=datetime.timezone.utc) + self.subevent1.save() + self.subevent2.date_from = datetime.datetime(2013, 12, 27, 11, 0, 0, tzinfo=datetime.timezone.utc) + self.subevent2.save() + + doc = self.post_doc('/control/event/ccc/30c3/subevents/bulk_edit', { + '__ALL': 'on', + }, follow=True) + fields = extract_form_fields(doc) + assert not fields.get('bulkedit-date_to_time') + fields.update({ + '_bulk': ['bulkeditdate_to_time'], + 'bulkedit-date_to_time': '17:00:00', + }) + doc = self.post_doc('/control/event/ccc/30c3/subevents/bulk_edit', fields, follow=True) + assert doc.select(".alert-success") + with scopes_disabled(): + self.subevent1.refresh_from_db() + self.subevent2.refresh_from_db() + assert self.subevent1.date_to == datetime.datetime(2013, 12, 26, 17, 0, 0, tzinfo=datetime.timezone.utc) + assert self.subevent2.date_to == datetime.datetime(2013, 12, 27, 17, 0, 0, tzinfo=datetime.timezone.utc) + + def test_edit_bulk_price(self): + sei1 = SubEventItem.objects.create(subevent=self.subevent1, item=self.ticket, price=Decimal('4.00')) + sei2 = SubEventItem.objects.create(subevent=self.subevent2, item=self.ticket, price=Decimal('4.00')) + doc = self.post_doc('/control/event/ccc/30c3/subevents/bulk_edit', { + '__ALL': 'on', + }, follow=True) + fields = extract_form_fields(doc) + assert fields.get('item-{}-price'.format(self.ticket.id)) == '4.00' + fields.update({ + '_bulk': ['item-{}price'.format(self.ticket.id)], + 'item-{}-price'.format(self.ticket.id): '5.00', + }) + doc = self.post_doc('/control/event/ccc/30c3/subevents/bulk_edit', fields, follow=True) + assert doc.select(".alert-success") + with scopes_disabled(): + sei1.refresh_from_db() + sei2.refresh_from_db() + assert sei1.price == Decimal('5.00') + assert sei2.price == Decimal('5.00') + + def test_edit_bulk_quotas_add_and_edit(self): + doc = self.post_doc('/control/event/ccc/30c3/subevents/bulk_edit', { + '__ALL': 'on', + }, follow=True) + fields = extract_form_fields(doc) + fields.update({ + '_bulk': ['__quotas'], + 'quotas-TOTAL_FORMS': '2', + 'quotas-INITIAL_FORMS': '0', + 'quotas-MIN_NUM_FORMS': '0', + 'quotas-MAX_NUM_FORMS': '1000', + 'quotas-0-name': 'Q1', + 'quotas-0-size': '50', + 'quotas-0-itemvars': str(self.ticket.pk), + 'quotas-1-name': 'Q2', + 'quotas-1-size': '25', + 'quotas-1-itemvars': str(self.ticket.pk), + }) + doc = self.post_doc('/control/event/ccc/30c3/subevents/bulk_edit', fields, follow=True) + assert doc.select(".alert-success") + with scopes_disabled(): + for se in [self.subevent1, self.subevent2]: + q = se.quotas.first() + assert q.name == 'Q1' + assert q.size == 50 + assert list(q.items.all()) == [self.ticket] + q = se.quotas.last() + assert q.name == 'Q2' + assert q.size == 25 + assert list(q.items.all()) == [self.ticket] + + doc = self.post_doc('/control/event/ccc/30c3/subevents/bulk_edit', { + '__ALL': 'on', + }, follow=True) + fields = extract_form_fields(doc) + fields.update({ + '_bulk': ['__quotas'], + 'quotas-0-size': '25', + 'quotas-1-size': '50', + }) + doc = self.post_doc('/control/event/ccc/30c3/subevents/bulk_edit', fields, follow=True) + assert doc.select(".alert-success") + with scopes_disabled(): + for se in [self.subevent1, self.subevent2]: + q = se.quotas.get(name='Q1') + assert q.size == 25 + assert list(q.items.all()) == [self.ticket] + q = se.quotas.get(name='Q2') + assert q.size == 50 + assert list(q.items.all()) == [self.ticket] + + doc = self.post_doc('/control/event/ccc/30c3/subevents/bulk_edit', { + '__ALL': 'on', + }, follow=True) + fields = extract_form_fields(doc) + fields.update({ + '_bulk': ['__quotas'], + 'quotas-1-DELETE': 'on', + }) + doc = self.post_doc('/control/event/ccc/30c3/subevents/bulk_edit', fields, follow=True) + assert doc.select(".alert-success") + with scopes_disabled(): + for se in [self.subevent1, self.subevent2]: + assert se.quotas.count() == 1 + + def test_edit_bulk_quotas_mixed_replace(self): + with scopes_disabled(): + self.subevent1.quotas.create(event=self.event1, name="Q1", size=20) + self.subevent2.quotas.create(event=self.event1, name="Q2", size=40) + doc = self.post_doc('/control/event/ccc/30c3/subevents/bulk_edit', { + '__ALL': 'on', + }, follow=True) + fields = extract_form_fields(doc) + assert fields['quotas-TOTAL_FORMS'] == '0' + fields.update({ + '_bulk': ['_invalid_'], + }) + doc = self.post_doc('/control/event/ccc/30c3/subevents/bulk_edit', fields, follow=True) + assert doc.select(".alert-success") + with scopes_disabled(): + assert self.subevent1.quotas.get().size == 20 + assert self.subevent2.quotas.get().size == 40 + + fields.update({ + '_bulk': ['__quotas'], + 'quotas-TOTAL_FORMS': '1', + 'quotas-INITIAL_FORMS': '0', + 'quotas-MIN_NUM_FORMS': '0', + 'quotas-MAX_NUM_FORMS': '1000', + 'quotas-0-name': 'Q1', + 'quotas-0-size': '100', + 'quotas-0-itemvars': str(self.ticket.pk), + }) + doc = self.post_doc('/control/event/ccc/30c3/subevents/bulk_edit', fields, follow=True) + assert doc.select(".alert-success") + with scopes_disabled(): + assert self.subevent1.quotas.get().size == 100 + assert self.subevent2.quotas.get().size == 100 + + def test_edit_bulk_lists_add_and_edit(self): + doc = self.post_doc('/control/event/ccc/30c3/subevents/bulk_edit', { + '__ALL': 'on', + }, follow=True) + fields = extract_form_fields(doc) + fields.update({ + '_bulk': ['__checkinlists'], + 'checkinlist_set-TOTAL_FORMS': '2', + 'checkinlist_set-INITIAL_FORMS': '0', + 'checkinlist_set-MIN_NUM_FORMS': '0', + 'checkinlist_set-MAX_NUM_FORMS': '1000', + 'checkinlist_set-0-name': 'Q1', + 'checkinlist_set-0-limit_products': str(self.ticket.pk), + 'checkinlist_set-1-name': 'Q2', + 'checkinlist_set-1-limit_products': str(self.ticket.pk), + }) + doc = self.post_doc('/control/event/ccc/30c3/subevents/bulk_edit', fields, follow=True) + assert doc.select(".alert-success") + with scopes_disabled(): + for se in [self.subevent1, self.subevent2]: + q = se.checkinlist_set.get(name='Q1') + assert list(q.limit_products.all()) == [self.ticket] + q = se.checkinlist_set.get(name='Q2') + assert list(q.limit_products.all()) == [self.ticket] + + doc = self.post_doc('/control/event/ccc/30c3/subevents/bulk_edit', { + '__ALL': 'on', + }, follow=True) + fields = extract_form_fields(doc) + fields.update({ + '_bulk': ['__checkinlists'], + 'checkinlist_set-1-DELETE': 'on', + }) + doc = self.post_doc('/control/event/ccc/30c3/subevents/bulk_edit', fields, follow=True) + assert doc.select(".alert-success") + with scopes_disabled(): + for se in [self.subevent1, self.subevent2]: + assert se.checkinlist_set.count() == 1 + + def test_edit_bulk_lists_keep_mixed(self): + with scopes_disabled(): + self.subevent1.checkinlist_set.create(event=self.event1, name="C1") + self.subevent2.checkinlist_set.create(event=self.event1, name="C2") + doc = self.post_doc('/control/event/ccc/30c3/subevents/bulk_edit', { + '__ALL': 'on', + }, follow=True) + fields = extract_form_fields(doc) + assert 'checkinlist_set-TOTAL_FORMS' not in fields diff --git a/src/tests/control/test_vouchers.py b/src/tests/control/test_vouchers.py index 2e8322401..58a3346d7 100644 --- a/src/tests/control/test_vouchers.py +++ b/src/tests/control/test_vouchers.py @@ -93,9 +93,9 @@ class VoucherFormTest(SoupTestMixin, TransactionTestCase): doc = self.client.get('/control/event/%s/%s/vouchers/?download=yes' % (self.orga.slug, self.event.slug)) assert doc.content.decode().strip() == '"Voucher code","Valid until","Product","Reserve quota",' \ '"Bypass quota","Price effect","Value","Tag","Redeemed",' \ - '"Maximum usages","Seat"' \ + '"Maximum usages","Seat","Comment"' \ '\r\n"ABCDEFG","","Early-bird ticket","No","No","No effect","","","0",' \ - '"1",""' + '"1","",""' def test_filter_status_valid(self): with scopes_disabled():