Merge branch 'master' of github.com:tixl/tixl

Conflicts:
	src/locale/de/LC_MESSAGES/django.po
This commit is contained in:
Raphael Michel
2015-01-07 17:02:03 +01:00
72 changed files with 2187 additions and 1240 deletions

1
.gitignore vendored
View File

@@ -14,3 +14,4 @@ htmlcov/
.ropeproject .ropeproject
__pycache__/ __pycache__/
_static/ _static/
.idea

View File

@@ -1,6 +1,7 @@
language: python language: python
python: python:
- "3.2" - "3.2"
- "3.3"
- "3.4" - "3.4"
install: install:
- pip install -q -r src/requirements.txt - pip install -q -r src/requirements.txt
@@ -13,3 +14,8 @@ script:
- coverage run manage.py test - coverage run manage.py test
after_success: after_success:
- coveralls - coveralls
addons:
sauce_connect:
username: "tixl"
access_key:
secure: "a0NUwGs2jHci0hIg3jySZLkfljv6FP33fZxAyi2gKeaxcVC+a/AailSnUgDoyVWxPr0JnkLvdFcxzDBgrQ1TLsgpRDSXnc1nIGsaHjgvVGSJ1hKACYtO/9QH+dgaaHEsIsHHbvGdnjwjrX8AZtDnkcRk1T3Skj8kUCniaU39w38="

View File

@@ -1,5 +1,5 @@
API details Plugin API
=========== ==========
Contents: Contents:

View File

@@ -66,12 +66,12 @@ It is sent out with several keyword arguments:
keys and the ``PropertyValue`` objects are values. If an ``ItemVariation`` object keys and the ``PropertyValue`` objects are values. If an ``ItemVariation`` object
exists, it is available in the dictionary via the special key ``'variation'``. If exists, it is available in the dictionary via the special key ``'variation'``. If
the item does not have any properties, the list will contain exactly one empty the item does not have any properties, the list will contain exactly one empty
dictionary. Please not: this is *not* the list of all possible variations, this is dictionary. Please note: this is *not* the list of all possible variations, this is
only the list of all variations the frontend likes to determine the status for. only the list of all variations the frontend likes to determine the status for.
Technically, you won't get ``dict`` objects but ``tixlbase.types.VariationDict`` Technically, you won't get ``dict`` objects but ``tixlbase.types.VariationDict``
objects, which behave exactly the same but add some extra methods. objects, which behave exactly the same but add some extra methods.
``context`` ``context``
A yet-to-defined context object containing information about the user and the order A yet-to-be-defined context object containing information about the user and the order
process. This is required to implement coupon-systems or similar restrictions. process. This is required to implement coupon-systems or similar restrictions.
``cache`` ``cache``
An object very similar to Django's own caching API (see tip below) An object very similar to Django's own caching API (see tip below)
@@ -118,7 +118,7 @@ In our example, the implementation could look like this::
# Fetch all restriction objects applied to this item # Fetch all restriction objects applied to this item
restrictions = list(TimeRestriction.objects.filter( restrictions = list(TimeRestriction.objects.filter(
items__in=(item,), item=item,
).prefetch_related('variations')) ).prefetch_related('variations'))
# If we do not know anything about this item, we are done here. # If we do not know anything about this item, we are done here.
@@ -185,8 +185,7 @@ In our example, the implementation could look like this::
if 'variation' not in v or v['variation'] not in applied_to: if 'variation' not in v or v['variation'] not in applied_to:
continue continue
if (restriction.timeframe_from <= now() if restriction.timeframe_from <= now() <= restriction.timeframe_to:
and restriction.timeframe_to >= now()):
# Selling this item is currently possible # Selling this item is currently possible
available = True available = True
# If multiple time frames are currently active, make sure to # If multiple time frames are currently active, make sure to
@@ -213,4 +212,83 @@ In our example, the implementation could look like this::
If you do not copy down to the ``dict`` objects, you will run into If you do not copy down to the ``dict`` objects, you will run into
interference problems with other plugins. interference problems with other plugins.
Control interface formsets
^^^^^^^^^^^^^^^^^^^^^^^^^^
To make it possible for the event organizer to configure your restriction, there is a
'Restrictions' page in the item configuration. This page is able to show a formset for
each restriction plugin, but *you* are required to create this formset. This is why you
should listen to the the ``tixlcontrol.signals.restriction_formset`` signal.
Currently, the signal comes with only one keyword argument:
``item``
The instance of ``tixlbase.models.Item`` we want a formset for.
You are expected to return a dict containing the following items:
``formsetclass``
An inline formset class (not a formset object).
``prefix``
A unique prefix for your queryset.
``title``
A title for your formset (normally your plugin name)
Our time restriction example looks like this::
from django.utils.translation import ugettext_lazy as _
from django.dispatch import receiver
from django.forms.models import inlineformset_factory
from tixlcontrol.signals import restriction_formset
from tixlbase.models import Item
from tixlcontrol.views.forms import (
VariationsField, RestrictionInlineFormset, RestrictionForm
)
from .models import TimeRestriction
class TimeRestrictionForm(RestrictionForm):
class Meta:
model = TimeRestriction
localized_fields = '__all__'
fields = [
'variations',
'timeframe_from',
'timeframe_to',
'price',
]
@receiver(restriction_formset)
def formset_handler(sender, **kwargs):
formset = inlineformset_factory(
Item,
TimeRestriction,
formset=RestrictionInlineFormset,
form=TimeRestrictionForm,
can_order=False,
can_delete=True,
extra=0,
)
return {
'title': _('Restriction by time'),
'formsetclass': formset,
'prefix': 'timerestriction',
}
.. NOTE::
If you do use the ``RestrictionInlineFormset``, ``RestrictionForm`` and
``VariationsField`` classes in your implementation, we will do a lot of magic for you
to display the ``variations`` field in the form in a nice and consistent way. So please,
use these base classes and test carefully, if you make any changes to the behaviour
of this field.
.. _caching feature: https://docs.djangoproject.com/en/1.7/topics/cache/ .. _caching feature: https://docs.djangoproject.com/en/1.7/topics/cache/

View File

@@ -95,8 +95,8 @@ to do nearly anything, there are a few obvious examples:
a maximum number. You can use this either to stop selling tickets completely when your house a maximum number. You can use this either to stop selling tickets completely when your house
is full or for creating limited 'VIP tickets'. We'll come to this again later. is full or for creating limited 'VIP tickets'. We'll come to this again later.
* A more advanced example is a restriction by user, for example reduced ticket prices for * A more advanced example is a restriction by user, for example reduced ticket prices for
members who are members of a special group. users who are members of a special group.
* Arbitrary sophisticated features like coupon codes are also possible to be implemented using * Arbitrary sophisticated features like coupon codes can also be implemented using
this feature. this feature.
Any number of **restrictions** can be applied to the whole of a **item** or even to a specific Any number of **restrictions** can be applied to the whole of a **item** or even to a specific
@@ -139,7 +139,7 @@ special care in the implementation to never sell more tickets than allowed, even
* The same quota can apply to multiple items and one item can be affected by multiple quotas, to * The same quota can apply to multiple items and one item can be affected by multiple quotas, to
enable both of the following features at the same time: enable both of the following features at the same time:
* You'll want to make sure you never have more then X people at your event, so you'll create a quota * You'll want to make sure you never have more than X people at your event, so you'll create a quota
applying to all ticket items. applying to all ticket items.
* You want to reduce the first Y tickets in price, so you'll create a restriction which is bound by * You want to reduce the first Y tickets in price, so you'll create a restriction which is bound by
a quota of Y and reduces the price. a quota of Y and reduces the price.

View File

@@ -9,7 +9,7 @@ Technical goals
* Python 3.4 features may be used, Python 3.2 is an absolute requirement * Python 3.4 features may be used, Python 3.2 is an absolute requirement
* Use Django 1.7+ * Use Django 1.7+
* Be PEP-8 compliant * Be PEP-8 compliant
* Be fully internationalization, unicode and timezone aware * Be fully internationalized, unicode and timezone aware
* Use a fully documented and reproducible setup * Use a fully documented and reproducible setup
* Be fully tested by both unit and behaviour tests * Be fully tested by both unit and behaviour tests
* Use LessCSS * Use LessCSS

View File

@@ -7,7 +7,7 @@ Python source code
All the source code lives in ``src/``, which has several subdirectories. All the source code lives in ``src/``, which has several subdirectories.
tixl/ tixl/
This directory contains the basic Django settings and URL routing. It is This directory contains the basic Django settings and URL routing.
tixlbase/ tixlbase/
This is the django app containing all the models and methods which are This is the django app containing all the models and methods which are

View File

@@ -35,7 +35,7 @@ LESS stylesheets
* Indent your code with four spaces. * Indent your code with four spaces.
* Make use of the nesting feature of LESS to put your code in logical groups, but avoid using * Make use of the nesting feature of LESS to put your code in logical groups, but avoid using
more then three levels of nesting. more than three levels of nesting.
* Put spaces after ``:`` in declarations. * Put spaces after ``:`` in declarations.
* Put spaces before ``{`` in rulesets. * Put spaces before ``{`` in rulesets.
* When grouping selectors, use one line per selector. * When grouping selectors, use one line per selector.

View File

@@ -7,8 +7,8 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: 1\n" "Project-Id-Version: 1\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2014-10-07 18:27+0200\n" "POT-Creation-Date: 2014-10-18 17:35+0200\n"
"PO-Revision-Date: 2014-10-07 18:28+0100\n" "PO-Revision-Date: 2014-10-18 17:35+0100\n"
"Last-Translator: Raphael Michel <michel@rami.io>\n" "Last-Translator: Raphael Michel <michel@rami.io>\n"
"Language-Team: Raphael Michel <michel@rami.io>\n" "Language-Team: Raphael Michel <michel@rami.io>\n"
"Language: de\n" "Language: de\n"
@@ -74,6 +74,10 @@ msgstr "Nachname"
msgid "Is active" msgid "Is active"
msgstr "Ist aktiviert" msgstr "Ist aktiviert"
#: tixlbase/models.py:91
msgid "Is site admin"
msgstr "Ist Systemadministrator"
#: tixlbase/models.py:93 #: tixlbase/models.py:93
msgid "Date joined" msgid "Date joined"
msgstr "Registrierungsdatum" msgstr "Registrierungsdatum"
@@ -82,7 +86,11 @@ msgstr "Registrierungsdatum"
msgid "Language" msgid "Language"
msgstr "Sprache" msgstr "Sprache"
#: tixlbase/models.py:105 #: tixlbase/models.py:100
msgid "Timezone"
msgstr "Zeitzone"
#: tixlbase/models.py:105 tixlbase/models.py:817 tixlbase/models.py:903
msgid "User" msgid "User"
msgstr "Benutzer" msgstr "Benutzer"
@@ -90,7 +98,7 @@ msgstr "Benutzer"
msgid "Users" msgid "Users"
msgstr "Benutzer" msgstr "Benutzer"
#: tixlbase/models.py:152 tixlbase/models.py:222 #: tixlbase/models.py:152 tixlbase/models.py:222 tixlbase/models.py:755
msgid "Name" msgid "Name"
msgstr "Name" msgstr "Name"
@@ -222,9 +230,10 @@ msgstr ""
#: tixlbase/models.py:281 #: tixlbase/models.py:281
#: tixlcontrol/templates/tixlcontrol/event/settings_base.html:9 #: tixlcontrol/templates/tixlcontrol/event/settings_base.html:9
msgid "Plugins" msgid "Plugins"
msgstr "Plugins"
#: tixlbase/models.py:285 tixlbase/models.py:506 tixlbase/models.py:678 #: tixlbase/models.py:285 tixlbase/models.py:506 tixlbase/models.py:678
msgstr "Erweiterungen"
#: tixlbase/models.py:285 tixlbase/models.py:506 tixlbase/models.py:708
msgid "Event" msgid "Event"
msgstr "Veranstaltung" msgstr "Veranstaltung"
@@ -321,7 +330,7 @@ msgstr "Frage"
#: tixlbase/models.py:470 #: tixlbase/models.py:470
msgid "Question type" msgid "Question type"
msgstr "Fragentyp" msgstr "Art der Antwort"
#: tixlbase/models.py:474 #: tixlbase/models.py:474
msgid "Required question" msgid "Required question"
@@ -383,10 +392,9 @@ msgstr ""
#: tixlbase/models.py:560 #: tixlbase/models.py:560
msgid "The user will be asked to fill in answers for the selected questions" msgid "The user will be asked to fill in answers for the selected questions"
msgstr "" msgstr ""
"Der Käufer wird beim Kauf aufgefordert, Antworten für die ausgewählten " "Der Käuft wird beim Kauf gebeten, die ausgewählten Fragen zu beantworten"
"Fragen anzugeben."
#: tixlbase/models.py:566 tixlcontrol/templates/tixlcontrol/item/base.html:3 #: tixlbase/models.py:566 tixlbase/models.py:713 tixlbase/models.py:762
msgid "Item" msgid "Item"
msgstr "Produkt" msgstr "Produkt"
@@ -414,6 +422,100 @@ msgstr "Beschränkung"
msgid "Restrictions" msgid "Restrictions"
msgstr "Beschränkungen" msgstr "Beschränkungen"
#: tixlbase/models.py:758
msgid "Total capacity"
msgstr "Gesamtanzahl"
#: tixlbase/models.py:780
msgid "Quota"
msgstr "Kontingent"
#: tixlbase/models.py:781
msgid "Quotas"
msgstr "Kontingente"
#: tixlbase/models.py:800
msgid "pending"
msgstr "ausstehend"
#: tixlbase/models.py:801
msgid "paid"
msgstr "bezahlt"
#: tixlbase/models.py:802
msgid "expired"
msgstr "abgelaufen"
#: tixlbase/models.py:803
msgid "cancelled"
msgstr "storniert"
#: tixlbase/models.py:809
msgid "Status"
msgstr "Status"
#: tixlbase/models.py:821 tixlbase/models.py:923
msgid "Date"
msgstr "Datum"
#: tixlbase/models.py:824 tixlbase/models.py:926
msgid "Expiration date"
msgstr "Ablaufdatum"
#: tixlbase/models.py:827
msgid "Payment date"
msgstr "Zahlungsdatum"
#: tixlbase/models.py:830
msgid "Payment information"
msgstr "Zahlungsinformationen"
#: tixlbase/models.py:834
msgid "Total amount"
msgstr "Gesamtbetrag"
#: tixlbase/models.py:838 tixlbase/models.py:862
msgid "Order"
msgstr "Bestellung"
#: tixlbase/models.py:839
msgid "Orders"
msgstr "Bestellungen"
#: tixlbase/models.py:871 tixlbase/models.py:916
msgid "Variation"
msgstr "Variante"
#: tixlbase/models.py:875 tixlbase/models.py:920
#: tixlcontrol/templates/tixlcontrol/item/variations_1d.html:13
msgid "Price"
msgstr "Preis"
#: tixlbase/models.py:880
msgid "Answers"
msgstr "Antworten"
#: tixlbase/models.py:884
msgid "Order position"
msgstr "Bestelltes Produkt"
#: tixlbase/models.py:885
msgid "Order positions"
msgstr "Bestellzeile"
#: tixlbase/models.py:907
msgid "Session key"
msgstr "Sitzung"
#: tixlbase/models.py:930
msgid "Cart position"
msgstr "Produkt im Warenkorb"
#: tixlbase/models.py:931
msgid "Cart positions"
msgstr "Produkte im Warenkorb"
#: tixlcontrol/middleware.py:59 #: tixlcontrol/middleware.py:59
msgid "" msgid ""
"The selected event was not found or you have no permission to administrate " "The selected event was not found or you have no permission to administrate "
@@ -459,7 +561,7 @@ msgstr "Einstellungen"
#: tixlcontrol/templates/tixlcontrol/event/plugins.html:8 #: tixlcontrol/templates/tixlcontrol/event/plugins.html:8
msgid "Installed plugins" msgid "Installed plugins"
msgstr "Installierte Plugins" msgstr "Installierte Erweiterungen"
#: tixlcontrol/templates/tixlcontrol/event/plugins.html:11 #: tixlcontrol/templates/tixlcontrol/event/plugins.html:11
#: tixlcontrol/templates/tixlcontrol/event/settings.html:8 #: tixlcontrol/templates/tixlcontrol/event/settings.html:8
@@ -548,10 +650,6 @@ msgstr "Ende"
msgid "Modify item:" msgid "Modify item:"
msgstr "Produkt bearbeiten:" msgstr "Produkt bearbeiten:"
#: tixlcontrol/templates/tixlcontrol/item/base.html:8
#: tixlcontrol/templates/tixlcontrol/item/variations_1d.html:5
#: tixlcontrol/templates/tixlcontrol/item/variations_2d.html:5
#: tixlcontrol/templates/tixlcontrol/item/variations_nd.html:5
msgid "Variations" msgid "Variations"
msgstr "Varianten" msgstr "Varianten"
@@ -567,6 +665,16 @@ msgstr "Erweiterte Einstellungen"
msgid "Price" msgid "Price"
msgstr "Preis" msgstr "Preis"
#: tixlcontrol/templates/tixlcontrol/item/restrictions.html:28
msgid "Add a new restriction"
msgstr "Neue Beschränkung hinzufügen"
#: tixlcontrol/templates/tixlcontrol/item/variations_0d.html:6
msgid ""
"You have to define and select propreties to be able to configure variations."
msgstr ""
"Sie müssen Eigenschaften auswählen, um Varianten konfigurieren zu können."
#: tixlcontrol/templates/tixlcontrol/items/base.html:7 #: tixlcontrol/templates/tixlcontrol/items/base.html:7
msgid "Categories" msgid "Categories"
msgstr "Kategorien" msgstr "Kategorien"
@@ -663,8 +771,8 @@ msgid ""
"All answers to the question given by the buyers of the following tickets " "All answers to the question given by the buyers of the following tickets "
"will be <strong>permanently lost</strong>." "will be <strong>permanently lost</strong>."
msgstr "" msgstr ""
"Alle Antworten, die von Käufern der folgenden Tickets auf diese Frage " "Alle Antworten auf diese Frage werden <strong>unwiderruflich gelöscht</"
"gegeben wurden, sind <strong>unwiderruflich gelöscht</strong>." "strong>."
#: tixlcontrol/templates/tixlcontrol/items/questions.html:12 #: tixlcontrol/templates/tixlcontrol/items/questions.html:12
msgid "A new question has been created." msgid "A new question has been created."
@@ -695,12 +803,16 @@ msgstr ""
msgid "This account is inactive." msgid "This account is inactive."
msgstr "Dieses Konto ist deaktiviert." msgstr "Dieses Konto ist deaktiviert."
#: tixlcontrol/views/forms.py:130
msgid "not applicable"
msgstr "nicht anwendbar"
#: tixlplugins/timerestriction/__init__.py:8 #: tixlplugins/timerestriction/__init__.py:8
msgid "Time restriction" msgid "Time restriction"
msgstr "Zeitliche Beschränkung" msgstr "Zeitliche Beschränkung"
#: tixlplugins/timerestriction/__init__.py:12 #: tixlplugins/timerestriction/__init__.py:12
msgid "Restriciton by time" msgid "Restricition by time"
msgstr "Zeitliche Beschränkung" msgstr "Zeitliche Beschränkung"
#: tixlplugins/timerestriction/__init__.py:13 #: tixlplugins/timerestriction/__init__.py:13
@@ -712,8 +824,8 @@ msgid ""
"This plugin adds the possibility to restrict the sale of a given item or " "This plugin adds the possibility to restrict the sale of a given item or "
"variation to a certain timeframe or change its price during a certain period." "variation to a certain timeframe or change its price during a certain period."
msgstr "" msgstr ""
"Dieses Plugin ermöglicht es, den Verkauf eines Produktes auf bestimmte " "Dieses Plugin ermöglicht es, den Verkauf von Produkten auf einen gewissen "
"Zeiträume einzuschränken oder seinen Preis für einen bestimmten Zeitraum zu " "Zeitraum einzuschränken oder den Preis während eines gewissen Zeitraums zu "
"ändern." "ändern."
#: tixlplugins/timerestriction/models.py:15 #: tixlplugins/timerestriction/models.py:15
@@ -727,3 +839,10 @@ msgstr "Ende des Zeitraums"
#: tixlplugins/timerestriction/models.py:23 #: tixlplugins/timerestriction/models.py:23
msgid "Price in time frame" msgid "Price in time frame"
msgstr "Preis im Zeitraum" msgstr "Preis im Zeitraum"
#: tixlplugins/timerestriction/signals.py:140
msgid "Restriction by time"
msgstr "Zeitliche Beschränkung"
#~ msgid "Datetime"
#~ msgstr "Datum"

View File

@@ -3,6 +3,7 @@ Django>=1.7
pytz pytz
django-bootstrap3 django-bootstrap3
-e git+https://github.com/tixl/django-formset-js.git@master#egg=django-formset-js -e git+https://github.com/tixl/django-formset-js.git@master#egg=django-formset-js
-e git+https://github.com/tixl/cleanerversion.git@tixl#egg=cleanerversion
# Deployment / static file compilation requirements # Deployment / static file compilation requirements
django-compressor django-compressor
@@ -25,4 +26,7 @@ pep8-naming
flake8 flake8
coveralls coveralls
coverage coverage
selenium
PyVirtualDisplay
-e git+https://github.com/tixl/sauceclient.git@master#egg=sauceclient
travis

View File

@@ -3,6 +3,8 @@ import hashlib
from django.core.cache import caches from django.core.cache import caches
from tixlbase.models import Event
class EventRelatedCache: class EventRelatedCache:
""" """
@@ -17,12 +19,12 @@ class EventRelatedCache:
instantiate it as many times as you want. instantiate it as many times as you want.
""" """
def __init__(self, event, cache='default'): def __init__(self, event: Event, cache: str='default'):
self.cache = caches[cache] self.cache = caches[cache]
self.event = event self.event = event
self.prefixkey = 'event:%d' % self.event.pk self.prefixkey = 'event:%s' % self.event.pk
def _prefix_key(self, original_key): def _prefix_key(self, original_key: str) -> str:
# Race conditions can happen here, but should be very very rare. # Race conditions can happen here, but should be very very rare.
# We could only handle this by going _really_ lowlevel using # We could only handle this by going _really_ lowlevel using
# memcached's `add` keyword instead of `set`. # memcached's `add` keyword instead of `set`.
@@ -32,14 +34,15 @@ class EventRelatedCache:
if prefix is None: if prefix is None:
prefix = int(time.time()) prefix = int(time.time())
self.cache.set(self.prefixkey, prefix) self.cache.set(self.prefixkey, prefix)
key = 'event:%d:%d:%s' % (self.event.pk, prefix, original_key) key = 'event:%s:%d:%s' % (self.event.pk, prefix, original_key)
if len(key) > 200: # Hash long keys, as memcached has a length limit if len(key) > 200: # Hash long keys, as memcached has a length limit
# TODO: Use a more efficient, non-cryptographic hash algorithm # TODO: Use a more efficient, non-cryptographic hash algorithm
key = hashlib.sha256(key.encode("UTF-8")).hexdigest() key = hashlib.sha256(key.encode("UTF-8")).hexdigest()
return key return key
def _strip_prefix(self, key): @staticmethod
return key.split(":", maxsplit=3)[-1] if 'event:' in key else key def _strip_prefix(key: str) -> str:
return key.split(":", 3)[-1] if 'event:' in key else key
def clear(self): def clear(self):
try: try:
@@ -48,35 +51,35 @@ class EventRelatedCache:
prefix = int(time.time()) prefix = int(time.time())
self.cache.set(self.prefixkey, prefix) self.cache.set(self.prefixkey, prefix)
def set(self, key, value, timeout=3600): def set(self, key: str, value: str, timeout: int=3600):
return self.cache.set(self._prefix_key(key), value, timeout) return self.cache.set(self._prefix_key(key), value, timeout)
def get(self, key): def get(self, key: str) -> str:
return self.cache.get(self._prefix_key(key)) return self.cache.get(self._prefix_key(key))
def get_many(self, keys): def get_many(self, keys: "list[str]") -> "dict[str, str]":
values = self.cache.get_many([self._prefix_key(key) for key in keys]) values = self.cache.get_many([self._prefix_key(key) for key in keys])
newvalues = {} newvalues = {}
for k, v in values.items(): for k, v in values.items():
newvalues[self._strip_prefix(k)] = v newvalues[self._strip_prefix(k)] = v
return newvalues return newvalues
def set_many(self, values, timeout=3600): def set_many(self, values: "dict[str, str]", timeout=3600):
newvalues = {} newvalues = {}
for k, v in values.items(): for k, v in values.items():
newvalues[self._prefix_key(k)] = v newvalues[self._prefix_key(k)] = v
return self.cache.set_many(newvalues, timeout) return self.cache.set_many(newvalues, timeout)
def delete(self, key): # NOQA def delete(self, key: str): # NOQA
return self.cache.delete(self._prefix_key(key)) return self.cache.delete(self._prefix_key(key))
def delete_many(self, keys): # NOQA def delete_many(self, keys: "list[str]"): # NOQA
return self.cache.delete_many([self._prefix_key(key) for key in keys]) return self.cache.delete_many([self._prefix_key(key) for key in keys])
def incr(self, key, by=1): # NOQA def incr(self, key: str, by: int=1): # NOQA
return self.cache.incr(self._prefix_key(key), by) return self.cache.incr(self._prefix_key(key), by)
def decr(self, key, by=1): # NOQA def decr(self, key: str, by: int=1): # NOQA
return self.cache.decr(self._prefix_key(key), by) return self.cache.decr(self._prefix_key(key), by)
def close(self): # NOQA def close(self): # NOQA

15
src/tixlbase/forms.py Normal file
View File

@@ -0,0 +1,15 @@
from django.forms.models import ModelFormMetaclass, BaseModelForm
from django.utils import six
from versions.models import Versionable
class VersionedBaseModelForm(BaseModelForm):
def save(self, commit=True):
if self.instance.pk is not None and isinstance(self.instance, Versionable):
if self.has_changed():
self.instance = self.instance.clone()
super().save(commit)
class VersionedModelForm(six.with_metaclass(ModelFormMetaclass, VersionedBaseModelForm)):
pass

View File

@@ -7,8 +7,7 @@ from django.utils.translation.trans_real import (
get_supported_language_variant, get_supported_language_variant,
parse_accept_lang_header, parse_accept_lang_header,
language_code_re, language_code_re,
check_for_language, check_for_language
_supported
) )
from django.utils.translation import LANGUAGE_SESSION_KEY from django.utils.translation import LANGUAGE_SESSION_KEY
from django.utils import translation, timezone from django.utils import translation, timezone
@@ -17,6 +16,8 @@ from django.utils.cache import patch_vary_headers
from tixlbase.models import Event from tixlbase.models import Event
_supported = None
class LocaleMiddleware(BaseLocaleMiddleware): class LocaleMiddleware(BaseLocaleMiddleware):
@@ -29,7 +30,7 @@ class LocaleMiddleware(BaseLocaleMiddleware):
url = resolve(request.path_info) url = resolve(request.path_info)
if 'event' in url.kwargs and 'organizer' in url.kwargs: if 'event' in url.kwargs and 'organizer' in url.kwargs:
try: try:
request.event = Event.objects.get( request.event = Event.objects.current.get(
slug=url.kwargs['event'], slug=url.kwargs['event'],
organizer__slug=url.kwargs['organizer'], organizer__slug=url.kwargs['organizer'],
) )
@@ -61,7 +62,7 @@ class LocaleMiddleware(BaseLocaleMiddleware):
return response return response
def get_language_from_request(request): def get_language_from_request(request) -> str:
""" """
Analyzes the request to find what language the user wants the system to Analyzes the request to find what language the user wants the system to
show. Only languages listed in settings.LANGUAGES are taken into account. show. Only languages listed in settings.LANGUAGES are taken into account.

View File

@@ -2,8 +2,12 @@
from __future__ import unicode_literals from __future__ import unicode_literals
from django.db import models, migrations from django.db import models, migrations
import django.utils.timezone import versions.models
import django.core.validators
from django.conf import settings from django.conf import settings
import django.db.models.deletion
import django.utils.timezone
import tixlbase.models
class Migration(migrations.Migration): class Migration(migrations.Migration):
@@ -16,82 +20,432 @@ class Migration(migrations.Migration):
migrations.CreateModel( migrations.CreateModel(
name='User', name='User',
fields=[ fields=[
('id', models.AutoField(verbose_name='ID', primary_key=True, auto_created=True, serialize=False)), ('id', models.AutoField(serialize=False, auto_created=True, verbose_name='ID', primary_key=True)),
('password', models.CharField(verbose_name='password', max_length=128)), ('password', models.CharField(max_length=128, verbose_name='password')),
('last_login', models.DateTimeField(verbose_name='last login', default=django.utils.timezone.now)), ('last_login', models.DateTimeField(default=django.utils.timezone.now, verbose_name='last login')),
('is_superuser', models.BooleanField(verbose_name='superuser status', default=False, help_text='Designates that this user has all permissions without explicitly assigning them.')), ('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')),
('identifier', models.CharField(unique=True, max_length=255)), ('identifier', models.CharField(unique=True, max_length=255)),
('username', models.CharField(max_length=120)), ('username', models.CharField(max_length=120, blank=True, null=True, help_text='Letters, digits and @/./+/-/_ only.')),
('email', models.EmailField(blank=True, null=True, db_index=True, max_length=75)), ('email', models.EmailField(null=True, max_length=75, blank=True, db_index=True, verbose_name='E-mail')),
('is_active', models.BooleanField(default=True)), ('givenname', models.CharField(max_length=255, blank=True, null=True, verbose_name='Given name')),
('is_staff', models.BooleanField(default=False)), ('familyname', models.CharField(max_length=255, blank=True, null=True, verbose_name='Family name')),
('date_joined', models.DateTimeField(auto_now_add=True)), ('is_active', models.BooleanField(default=True, verbose_name='Is active')),
('is_staff', models.BooleanField(default=False, verbose_name='Is site admin')),
('date_joined', models.DateTimeField(verbose_name='Date joined', auto_now_add=True)),
('locale', models.CharField(max_length=50, choices=[('de', 'German'), ('en', 'English')], default='en', verbose_name='Language')),
('timezone', models.CharField(max_length=100, default='UTC', verbose_name='Timezone')),
], ],
options={ options={
'verbose_name_plural': 'Users',
'verbose_name': 'User',
},
bases=(models.Model,),
),
migrations.CreateModel(
name='CartPosition',
fields=[
('id', models.AutoField(serialize=False, auto_created=True, verbose_name='ID', primary_key=True)),
('session', models.CharField(max_length=255, blank=True, null=True, verbose_name='Session key')),
('total', models.DecimalField(decimal_places=2, max_digits=10, verbose_name='Price')),
('datetime', models.DateTimeField(verbose_name='Date')),
('expires', models.DateTimeField(verbose_name='Expiration date')),
],
options={
'verbose_name_plural': 'Cart positions',
'verbose_name': 'Cart position',
}, },
bases=(models.Model,), bases=(models.Model,),
), ),
migrations.CreateModel( migrations.CreateModel(
name='Event', name='Event',
fields=[ fields=[
('id', models.AutoField(verbose_name='ID', primary_key=True, auto_created=True, serialize=False)), ('id', models.CharField(max_length=36, primary_key=True, serialize=False)),
('name', models.CharField(max_length=200)), ('identity', models.CharField(max_length=36)),
('slug', models.CharField(db_index=True, max_length=50)), ('version_start_date', models.DateTimeField()),
('locale', models.CharField(max_length=10)), ('version_end_date', models.DateTimeField(blank=True, null=True, default=None)),
('currency', models.CharField(max_length=10)), ('version_birth_date', models.DateTimeField()),
('date_from', models.DateTimeField()), ('name', models.CharField(max_length=200, verbose_name='Name')),
('date_to', models.DateTimeField(blank=True, null=True)), ('slug', models.CharField(max_length=50, db_index=True, validators=[django.core.validators.RegexValidator(regex='^[a-zA-Z0-9.-]+$', message='The slug may only contain letters, numbers, dots and dashes.')], verbose_name='Slug', help_text='Should be short, only contain lowercase letters and numbers, and must be unique among your events. This is being used in addresses and bank transfer references.')),
('show_date_to', models.BooleanField(default=True)), ('locale', models.CharField(max_length=10, choices=[('de', 'German'), ('en', 'English')], verbose_name='Default locale')),
('show_times', models.BooleanField(default=True)), ('timezone', models.CharField(max_length=100, default='UTC', verbose_name='Default timezone')),
('presale_end', models.DateTimeField(blank=True, null=True)), ('currency', models.CharField(max_length=10, verbose_name='Default currency')),
('presale_start', models.DateTimeField(blank=True, null=True)), ('date_from', models.DateTimeField(verbose_name='Event start time')),
('payment_term_days', models.IntegerField(default=14)), ('date_to', models.DateTimeField(blank=True, null=True, verbose_name='Event end time')),
('payment_term_last', models.DateTimeField(blank=True, null=True)), ('show_date_to', models.BooleanField(default=True, help_text="If disabled, only event's start date will be displayed to the public.", verbose_name='Show event end date')),
('show_times', models.BooleanField(default=True, help_text="If disabled, the event's start and end date will be displayed without the time of day.", verbose_name='Show dates with time')),
('presale_end', models.DateTimeField(blank=True, null=True, help_text='No items will be sold after this date.', verbose_name='End of presale')),
('presale_start', models.DateTimeField(blank=True, null=True, help_text='No items will be sold before this date.', verbose_name='Start of presale')),
('payment_term_days', models.PositiveIntegerField(default=14, help_text='The number of days after placing an order the user has to pay to preserve his reservation.', verbose_name='Payment term in days')),
('payment_term_last', models.DateTimeField(blank=True, null=True, help_text='The last date any payments are accepted. This has precedence over the number of days configured above.', verbose_name='Last date of payments')),
('plugins', models.TextField(blank=True, null=True, verbose_name='Plugins')),
], ],
options={ options={
'verbose_name_plural': 'Events',
'ordering': ('date_from', 'name'), 'ordering': ('date_from', 'name'),
'verbose_name': 'Event',
},
bases=(models.Model,),
),
migrations.CreateModel(
name='EventPermission',
fields=[
('id', models.CharField(max_length=36, primary_key=True, serialize=False)),
('identity', models.CharField(max_length=36)),
('version_start_date', models.DateTimeField()),
('version_end_date', models.DateTimeField(blank=True, null=True, default=None)),
('version_birth_date', models.DateTimeField()),
('can_change_settings', models.BooleanField(default=True, verbose_name='Can change event settings')),
('can_change_items', models.BooleanField(default=True, verbose_name='Can change item settings')),
('event', versions.models.VersionedForeignKey(to='tixlbase.Event')),
('user', models.ForeignKey(related_name='event_perms', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name_plural': 'Event permissions',
'verbose_name': 'Event permission',
},
bases=(models.Model,),
),
migrations.CreateModel(
name='Item',
fields=[
('id', models.CharField(max_length=36, primary_key=True, serialize=False)),
('identity', models.CharField(max_length=36)),
('version_start_date', models.DateTimeField()),
('version_end_date', models.DateTimeField(blank=True, null=True, default=None)),
('version_birth_date', models.DateTimeField()),
('name', models.CharField(max_length=255, verbose_name='Item name')),
('active', models.BooleanField(default=True, verbose_name='Active')),
('deleted', models.BooleanField(default=False)),
('short_description', models.TextField(blank=True, null=True, help_text='This is shown below the item name in lists.', verbose_name='Short description')),
('long_description', models.TextField(blank=True, null=True, verbose_name='Long description')),
('default_price', models.DecimalField(decimal_places=2, max_digits=7, blank=True, null=True, verbose_name='Default price')),
('tax_rate', models.DecimalField(decimal_places=2, max_digits=7, blank=True, null=True, verbose_name='Taxes included in percent')),
],
options={
'verbose_name_plural': 'Items',
'verbose_name': 'Item',
},
bases=(models.Model,),
),
migrations.CreateModel(
name='ItemCategory',
fields=[
('id', models.CharField(max_length=36, primary_key=True, serialize=False)),
('identity', models.CharField(max_length=36)),
('version_start_date', models.DateTimeField()),
('version_end_date', models.DateTimeField(blank=True, null=True, default=None)),
('version_birth_date', models.DateTimeField()),
('name', models.CharField(max_length=255, verbose_name='Category name')),
('position', models.IntegerField(default=0)),
('event', versions.models.VersionedForeignKey(related_name='categories', to='tixlbase.Event')),
],
options={
'verbose_name_plural': 'Item categories',
'ordering': ('position',),
'verbose_name': 'Item category',
},
bases=(models.Model,),
),
migrations.CreateModel(
name='ItemVariation',
fields=[
('id', models.CharField(max_length=36, primary_key=True, serialize=False)),
('identity', models.CharField(max_length=36)),
('version_start_date', models.DateTimeField()),
('version_end_date', models.DateTimeField(blank=True, null=True, default=None)),
('version_birth_date', models.DateTimeField()),
('active', models.BooleanField(default=True, verbose_name='Active')),
('default_price', models.DecimalField(decimal_places=2, max_digits=7, blank=True, null=True, verbose_name='Default price')),
('item', versions.models.VersionedForeignKey(related_name='variations', to='tixlbase.Item')),
],
options={
'verbose_name_plural': 'Item variations',
'verbose_name': 'Item variation',
},
bases=(models.Model,),
),
migrations.CreateModel(
name='Order',
fields=[
('id', models.CharField(max_length=36, primary_key=True, serialize=False)),
('identity', models.CharField(max_length=36)),
('version_start_date', models.DateTimeField()),
('version_end_date', models.DateTimeField(blank=True, null=True, default=None)),
('version_birth_date', models.DateTimeField()),
('status', models.CharField(max_length=3, choices=[('p', 'pending'), ('n', 'paid'), ('e', 'expired'), ('c', 'cancelled')], verbose_name='Status')),
('datetime', models.DateTimeField(verbose_name='Date', auto_now_add=True)),
('expires', models.DateTimeField(verbose_name='Expiration date')),
('payment_date', models.DateTimeField(verbose_name='Payment date')),
('payment_info', models.TextField(verbose_name='Payment information')),
('total', models.DecimalField(decimal_places=2, max_digits=10, verbose_name='Total amount')),
('event', versions.models.VersionedForeignKey(verbose_name='Event', to='tixlbase.Event')),
('user', models.ForeignKey(null=True, verbose_name='User', blank=True, to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name_plural': 'Orders',
'verbose_name': 'Order',
},
bases=(models.Model,),
),
migrations.CreateModel(
name='OrderPosition',
fields=[
('id', models.AutoField(serialize=False, auto_created=True, verbose_name='ID', primary_key=True)),
('price', models.DecimalField(decimal_places=2, max_digits=10, verbose_name='Price')),
],
options={
'verbose_name_plural': 'Order positions',
'verbose_name': 'Order position',
}, },
bases=(models.Model,), bases=(models.Model,),
), ),
migrations.CreateModel( migrations.CreateModel(
name='Organizer', name='Organizer',
fields=[ fields=[
('id', models.AutoField(verbose_name='ID', primary_key=True, auto_created=True, serialize=False)), ('id', models.CharField(max_length=36, primary_key=True, serialize=False)),
('name', models.CharField(max_length=200)), ('identity', models.CharField(max_length=36)),
('slug', models.CharField(unique=True, db_index=True, max_length=50)), ('version_start_date', models.DateTimeField()),
('owner', models.ForeignKey(blank=True, null=True, to=settings.AUTH_USER_MODEL)), ('version_end_date', models.DateTimeField(blank=True, null=True, default=None)),
('version_birth_date', models.DateTimeField()),
('name', models.CharField(max_length=200, verbose_name='Name')),
('slug', models.CharField(unique=True, max_length=50, db_index=True, verbose_name='Slug')),
], ],
options={ options={
'verbose_name_plural': 'Organizers',
'ordering': ('name',), 'ordering': ('name',),
'verbose_name': 'Organizer',
}, },
bases=(models.Model,), bases=(models.Model,),
), ),
migrations.CreateModel(
name='OrganizerPermission',
fields=[
('id', models.CharField(max_length=36, primary_key=True, serialize=False)),
('identity', models.CharField(max_length=36)),
('version_start_date', models.DateTimeField()),
('version_end_date', models.DateTimeField(blank=True, null=True, default=None)),
('version_birth_date', models.DateTimeField()),
('can_create_events', models.BooleanField(default=True, verbose_name='Can create events')),
('organizer', versions.models.VersionedForeignKey(to='tixlbase.Organizer')),
('user', models.ForeignKey(related_name='organizer_perms', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name_plural': 'Organizer permissions',
'verbose_name': 'Organizer permission',
},
bases=(models.Model,),
),
migrations.CreateModel(
name='Property',
fields=[
('id', models.CharField(max_length=36, primary_key=True, serialize=False)),
('identity', models.CharField(max_length=36)),
('version_start_date', models.DateTimeField()),
('version_end_date', models.DateTimeField(blank=True, null=True, default=None)),
('version_birth_date', models.DateTimeField()),
('name', models.CharField(max_length=250, verbose_name='Property name')),
('event', versions.models.VersionedForeignKey(related_name='properties', to='tixlbase.Event')),
],
options={
'verbose_name_plural': 'Item properties',
'verbose_name': 'Item property',
},
bases=(models.Model,),
),
migrations.CreateModel(
name='PropertyValue',
fields=[
('id', models.CharField(max_length=36, primary_key=True, serialize=False)),
('identity', models.CharField(max_length=36)),
('version_start_date', models.DateTimeField()),
('version_end_date', models.DateTimeField(blank=True, null=True, default=None)),
('version_birth_date', models.DateTimeField()),
('value', models.CharField(max_length=250, verbose_name='Value')),
('position', models.IntegerField(default=0)),
('prop', versions.models.VersionedForeignKey(related_name='values', to='tixlbase.Property')),
],
options={
'verbose_name_plural': 'Property values',
'ordering': ('position',),
'verbose_name': 'Property value',
},
bases=(models.Model,),
),
migrations.CreateModel(
name='Question',
fields=[
('id', models.CharField(max_length=36, primary_key=True, serialize=False)),
('identity', models.CharField(max_length=36)),
('version_start_date', models.DateTimeField()),
('version_end_date', models.DateTimeField(blank=True, null=True, default=None)),
('version_birth_date', models.DateTimeField()),
('question', models.TextField(verbose_name='Question')),
('type', models.CharField(max_length=5, choices=[('N', 'Number'), ('S', 'Text (one line)'), ('T', 'Multiline text'), ('B', 'Yes/No')], verbose_name='Question type')),
('required', models.BooleanField(default=False, verbose_name='Required question')),
('event', versions.models.VersionedForeignKey(related_name='questions', to='tixlbase.Event')),
],
options={
'verbose_name_plural': 'Questions',
'verbose_name': 'Question',
},
bases=(models.Model,),
),
migrations.CreateModel(
name='QuestionAnswer',
fields=[
('id', models.CharField(max_length=36, primary_key=True, serialize=False)),
('identity', models.CharField(max_length=36)),
('version_start_date', models.DateTimeField()),
('version_end_date', models.DateTimeField(blank=True, null=True, default=None)),
('version_birth_date', models.DateTimeField()),
('answer', models.TextField()),
('cartposition', models.ForeignKey(null=True, to='tixlbase.CartPosition', blank=True)),
('orderposition', models.ForeignKey(null=True, to='tixlbase.OrderPosition', blank=True)),
('question', versions.models.VersionedForeignKey(to='tixlbase.Question')),
],
options={
'abstract': False,
},
bases=(models.Model,),
),
migrations.CreateModel(
name='Quota',
fields=[
('id', models.CharField(max_length=36, primary_key=True, serialize=False)),
('identity', models.CharField(max_length=36)),
('version_start_date', models.DateTimeField()),
('version_end_date', models.DateTimeField(blank=True, null=True, default=None)),
('version_birth_date', models.DateTimeField()),
('name', models.CharField(max_length=200, verbose_name='Name')),
('size', models.PositiveIntegerField(verbose_name='Total capacity')),
('event', versions.models.VersionedForeignKey(related_name='quotas', to='tixlbase.Event', verbose_name='Event')),
('items', versions.models.VersionedManyToManyField(blank=True, to='tixlbase.Item', verbose_name='Item')),
('lock_cache', models.ManyToManyField(blank=True, to='tixlbase.CartPosition')),
('order_cache', models.ManyToManyField(blank=True, to='tixlbase.OrderPosition')),
('variations', tixlbase.models.VariationsField(blank=True, to='tixlbase.ItemVariation', verbose_name='Variations')),
],
options={
'verbose_name_plural': 'Quotas',
'verbose_name': 'Quota',
},
bases=(models.Model,),
),
migrations.AlterUniqueTogether(
name='questionanswer',
unique_together=set([('id', 'identity')]),
),
migrations.AddField(
model_name='organizer',
name='permitted',
field=models.ManyToManyField(through='tixlbase.OrganizerPermission', related_name='organizers', to=settings.AUTH_USER_MODEL),
preserve_default=True,
),
migrations.AddField(
model_name='orderposition',
name='answers',
field=versions.models.VersionedManyToManyField(through='tixlbase.QuestionAnswer', to='tixlbase.Question', verbose_name='Answers'),
preserve_default=True,
),
migrations.AddField(
model_name='orderposition',
name='item',
field=versions.models.VersionedForeignKey(verbose_name='Item', to='tixlbase.Item'),
preserve_default=True,
),
migrations.AddField(
model_name='orderposition',
name='order',
field=versions.models.VersionedForeignKey(verbose_name='Order', to='tixlbase.Order'),
preserve_default=True,
),
migrations.AddField(
model_name='orderposition',
name='variation',
field=versions.models.VersionedForeignKey(null=True, verbose_name='Variation', blank=True, to='tixlbase.ItemVariation'),
preserve_default=True,
),
migrations.AddField(
model_name='itemvariation',
name='values',
field=versions.models.VersionedManyToManyField(related_name='variations', to='tixlbase.PropertyValue'),
preserve_default=True,
),
migrations.AddField(
model_name='item',
name='category',
field=versions.models.VersionedForeignKey(on_delete=django.db.models.deletion.PROTECT, null=True, related_name='items', verbose_name='Category', blank=True, to='tixlbase.ItemCategory'),
preserve_default=True,
),
migrations.AddField(
model_name='item',
name='event',
field=versions.models.VersionedForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='items', to='tixlbase.Event', verbose_name='Event'),
preserve_default=True,
),
migrations.AddField(
model_name='item',
name='properties',
field=versions.models.VersionedManyToManyField(blank=True, related_name='items', help_text="The selected properties will be available for the user to select. After saving this field, move to the 'Variations' tab to configure the details.", verbose_name='Properties', to='tixlbase.Property'),
preserve_default=True,
),
migrations.AddField(
model_name='item',
name='questions',
field=versions.models.VersionedManyToManyField(blank=True, related_name='items', help_text='The user will be asked to fill in answers for the selected questions', verbose_name='Questions', to='tixlbase.Question'),
preserve_default=True,
),
migrations.AddField( migrations.AddField(
model_name='event', model_name='event',
name='organizer', name='organizer',
field=models.ForeignKey(to='tixlbase.Organizer', related_name='events'), field=versions.models.VersionedForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='events', to='tixlbase.Organizer'),
preserve_default=True, preserve_default=True,
), ),
migrations.AlterUniqueTogether( migrations.AddField(
model_name='event',
name='permitted',
field=models.ManyToManyField(through='tixlbase.EventPermission', related_name='events', to=settings.AUTH_USER_MODEL),
preserve_default=True,
),
migrations.AddField(
model_name='cartposition',
name='event', name='event',
unique_together=set([('organizer', 'slug')]), field=versions.models.VersionedForeignKey(verbose_name='Event', to='tixlbase.Event'),
preserve_default=True,
),
migrations.AddField(
model_name='cartposition',
name='item',
field=versions.models.VersionedForeignKey(verbose_name='Item', to='tixlbase.Item'),
preserve_default=True,
),
migrations.AddField(
model_name='cartposition',
name='user',
field=models.ForeignKey(null=True, verbose_name='User', blank=True, to=settings.AUTH_USER_MODEL),
preserve_default=True,
),
migrations.AddField(
model_name='cartposition',
name='variation',
field=versions.models.VersionedForeignKey(null=True, verbose_name='Variation', blank=True, to='tixlbase.ItemVariation'),
preserve_default=True,
), ),
migrations.AddField( migrations.AddField(
model_name='user', model_name='user',
name='event', name='event',
field=models.ForeignKey(to='tixlbase.Event', blank=True, null=True, related_name='users'), field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, null=True, related_name='users', to='tixlbase.Event', blank=True),
preserve_default=True, preserve_default=True,
), ),
migrations.AddField( migrations.AddField(
model_name='user', model_name='user',
name='groups', name='groups',
field=models.ManyToManyField(verbose_name='groups', related_name='user_set', related_query_name='user', blank=True, to='auth.Group', help_text='The groups this user belongs to. A user will get all permissions granted to each of his/her group.'), field=models.ManyToManyField(related_name='user_set', help_text='The groups this user belongs to. A user will get all permissions granted to each of his/her group.', verbose_name='groups', related_query_name='user', blank=True, to='auth.Group'),
preserve_default=True, preserve_default=True,
), ),
migrations.AddField( migrations.AddField(
model_name='user', model_name='user',
name='user_permissions', name='user_permissions',
field=models.ManyToManyField(verbose_name='user permissions', related_name='user_set', related_query_name='user', blank=True, to='auth.Permission', help_text='Specific permissions for this user.'), field=models.ManyToManyField(related_name='user_set', help_text='Specific permissions for this user.', verbose_name='user permissions', related_query_name='user', blank=True, to='auth.Permission'),
preserve_default=True, preserve_default=True,
), ),
migrations.AlterUniqueTogether( migrations.AlterUniqueTogether(

View File

@@ -1,26 +0,0 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import models, migrations
class Migration(migrations.Migration):
dependencies = [
('tixlbase', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='user',
name='familyname',
field=models.CharField(blank=True, max_length=255, null=True),
preserve_default=True,
),
migrations.AddField(
model_name='user',
name='givenname',
field=models.CharField(blank=True, max_length=255, null=True),
preserve_default=True,
),
]

View File

@@ -1,19 +0,0 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import models, migrations
class Migration(migrations.Migration):
dependencies = [
('tixlbase', '0002_auto_20140910_1628'),
]
operations = [
migrations.AlterField(
model_name='user',
name='username',
field=models.CharField(blank=True, max_length=120, null=True, help_text='Letters, digits and @/./+/-/_ only.'),
),
]

View File

@@ -1,61 +0,0 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import models, migrations
from django.conf import settings
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('tixlbase', '0003_auto_20140910_1649'),
]
operations = [
migrations.CreateModel(
name='OrganizerPermission',
fields=[
('id', models.AutoField(auto_created=True, verbose_name='ID', serialize=False, primary_key=True)),
('can_create_events', models.BooleanField(default=True)),
('organizer', models.ForeignKey(to='tixlbase.Organizer', related_name='perms')),
('user', models.ForeignKey(to=settings.AUTH_USER_MODEL, related_name='organizer_perms')),
],
options={
},
bases=(models.Model,),
),
migrations.AlterUniqueTogether(
name='organizerpermission',
unique_together=set([('organizer', 'user')]),
),
migrations.RemoveField(
model_name='organizer',
name='owner',
),
migrations.AlterField(
model_name='event',
name='organizer',
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='events', to='tixlbase.Organizer'),
),
migrations.AlterField(
model_name='user',
name='email',
field=models.EmailField(null=True, blank=True, db_index=True, verbose_name='E-mail', max_length=75),
),
migrations.AlterField(
model_name='user',
name='event',
field=models.ForeignKey(null=True, blank=True, on_delete=django.db.models.deletion.PROTECT, related_name='users', to='tixlbase.Event'),
),
migrations.AlterField(
model_name='user',
name='familyname',
field=models.CharField(null=True, blank=True, verbose_name='Family name', max_length=255),
),
migrations.AlterField(
model_name='user',
name='givenname',
field=models.CharField(null=True, blank=True, verbose_name='Given name', max_length=255),
),
]

View File

@@ -1,48 +0,0 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import models, migrations
from django.conf import settings
class Migration(migrations.Migration):
dependencies = [
('tixlbase', '0004_auto_20140911_2037'),
]
operations = [
migrations.CreateModel(
name='EventPermission',
fields=[
('id', models.AutoField(auto_created=True, serialize=False, primary_key=True, verbose_name='ID')),
('can_change_settings', models.BooleanField(default=True)),
('organizer', models.ForeignKey(to='tixlbase.Event')),
('user', models.ForeignKey(to=settings.AUTH_USER_MODEL, related_name='event_perms')),
],
options={
},
bases=(models.Model,),
),
migrations.AlterUniqueTogether(
name='eventpermission',
unique_together=set([('organizer', 'user')]),
),
migrations.AddField(
model_name='event',
name='permitted',
field=models.ManyToManyField(to=settings.AUTH_USER_MODEL, related_name='events', through='tixlbase.EventPermission'),
preserve_default=True,
),
migrations.AddField(
model_name='organizer',
name='permitted',
field=models.ManyToManyField(to=settings.AUTH_USER_MODEL, related_name='organizers', through='tixlbase.OrganizerPermission'),
preserve_default=True,
),
migrations.AlterField(
model_name='organizerpermission',
name='organizer',
field=models.ForeignKey(to='tixlbase.Organizer'),
),
]

View File

@@ -1,138 +0,0 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import models, migrations
class Migration(migrations.Migration):
dependencies = [
('tixlbase', '0005_auto_20140911_2052'),
]
operations = [
migrations.AlterModelOptions(
name='event',
options={'verbose_name_plural': 'Events', 'verbose_name': 'Event', 'ordering': ('date_from', 'name')},
),
migrations.AlterModelOptions(
name='eventpermission',
options={'verbose_name_plural': 'Event permissions', 'verbose_name': 'Event permission'},
),
migrations.AlterModelOptions(
name='organizer',
options={'verbose_name_plural': 'Organizers', 'verbose_name': 'Organizer', 'ordering': ('name',)},
),
migrations.AlterModelOptions(
name='organizerpermission',
options={'verbose_name_plural': 'Organizer permissions', 'verbose_name': 'Organizer permission'},
),
migrations.AlterModelOptions(
name='user',
options={'verbose_name_plural': 'Users', 'verbose_name': 'User'},
),
migrations.RenameField(
model_name='eventpermission',
old_name='organizer',
new_name='event',
),
migrations.AlterField(
model_name='event',
name='currency',
field=models.CharField(max_length=10, verbose_name='Default currency'),
),
migrations.AlterField(
model_name='event',
name='date_from',
field=models.DateTimeField(verbose_name='Event start time'),
),
migrations.AlterField(
model_name='event',
name='date_to',
field=models.DateTimeField(blank=True, null=True, verbose_name='Event end time'),
),
migrations.AlterField(
model_name='event',
name='locale',
field=models.CharField(max_length=10, verbose_name='Default locale', choices=[('de', 'German'), ('en', 'English')]),
),
migrations.AlterField(
model_name='event',
name='name',
field=models.CharField(max_length=200, verbose_name='Name'),
),
migrations.AlterField(
model_name='event',
name='payment_term_days',
field=models.IntegerField(verbose_name='Payment term in days', default=14),
),
migrations.AlterField(
model_name='event',
name='payment_term_last',
field=models.DateTimeField(blank=True, null=True, verbose_name='Last date of payments'),
),
migrations.AlterField(
model_name='event',
name='presale_end',
field=models.DateTimeField(blank=True, null=True, verbose_name='End of presale'),
),
migrations.AlterField(
model_name='event',
name='presale_start',
field=models.DateTimeField(blank=True, null=True, verbose_name='Start of presale'),
),
migrations.AlterField(
model_name='event',
name='show_date_to',
field=models.BooleanField(verbose_name='Show event end date', default=True),
),
migrations.AlterField(
model_name='event',
name='show_times',
field=models.BooleanField(verbose_name='Show dates with time', default=True),
),
migrations.AlterField(
model_name='event',
name='slug',
field=models.CharField(db_index=True, max_length=50, verbose_name='Slug'),
),
migrations.AlterField(
model_name='eventpermission',
name='can_change_settings',
field=models.BooleanField(verbose_name='Can change event settings', default=True),
),
migrations.AlterField(
model_name='organizer',
name='name',
field=models.CharField(max_length=200, verbose_name='Name'),
),
migrations.AlterField(
model_name='organizer',
name='slug',
field=models.CharField(db_index=True, max_length=50, verbose_name='Slug', unique=True),
),
migrations.AlterField(
model_name='organizerpermission',
name='can_create_events',
field=models.BooleanField(verbose_name='Can create events', default=True),
),
migrations.AlterField(
model_name='user',
name='date_joined',
field=models.DateTimeField(verbose_name='Date joined', auto_now_add=True),
),
migrations.AlterField(
model_name='user',
name='is_active',
field=models.BooleanField(verbose_name='Is active', default=True),
),
migrations.AlterField(
model_name='user',
name='is_staff',
field=models.BooleanField(verbose_name='Is site admin', default=False),
),
migrations.AlterUniqueTogether(
name='eventpermission',
unique_together=set([('event', 'user')]),
),
]

View File

@@ -1,26 +0,0 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import models, migrations
class Migration(migrations.Migration):
dependencies = [
('tixlbase', '0006_auto_20140912_1855'),
]
operations = [
migrations.AddField(
model_name='user',
name='locale',
field=models.CharField(choices=[('de', 'German'), ('en', 'English')], max_length=50, default='en'),
preserve_default=True,
),
migrations.AddField(
model_name='user',
name='timezone',
field=models.CharField(max_length=100, default='UTC'),
preserve_default=True,
),
]

View File

@@ -1,30 +0,0 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import models, migrations
class Migration(migrations.Migration):
dependencies = [
('tixlbase', '0007_auto_20140914_1301'),
]
operations = [
migrations.AddField(
model_name='event',
name='timezone',
field=models.CharField(max_length=100, default='UTC', verbose_name='Default timezone'),
preserve_default=True,
),
migrations.AlterField(
model_name='user',
name='locale',
field=models.CharField(max_length=50, verbose_name='Language', default='en', choices=[('de', 'German'), ('en', 'English')]),
),
migrations.AlterField(
model_name='user',
name='timezone',
field=models.CharField(max_length=100, default='UTC', verbose_name='Timezone'),
),
]

View File

@@ -1,144 +0,0 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import models, migrations
import django.core.validators
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('tixlbase', '0008_auto_20140914_1304'),
]
operations = [
migrations.CreateModel(
name='Item',
fields=[
('id', models.AutoField(primary_key=True, serialize=False, auto_created=True, verbose_name='ID')),
('name', models.CharField(max_length=255, verbose_name='Item name')),
('active', models.BooleanField(default=True)),
('deleted', models.BooleanField(default=False)),
('short_description', models.TextField(help_text='This is shown below the item name in lists.', blank=True, null=True, verbose_name='Short description')),
('long_description', models.TextField(blank=True, null=True, verbose_name='Long description')),
('default_price', models.DecimalField(decimal_places=2, blank=True, max_digits=7, null=True, verbose_name='Default price')),
('tax_rate', models.DecimalField(decimal_places=2, blank=True, max_digits=7, null=True, verbose_name='Included taxes in percent')),
],
options={
'verbose_name_plural': 'Items',
'verbose_name': 'Item',
},
bases=(models.Model,),
),
migrations.CreateModel(
name='ItemCategory',
fields=[
('id', models.AutoField(primary_key=True, serialize=False, auto_created=True, verbose_name='ID')),
('name', models.CharField(max_length=255, verbose_name='Category name')),
('position', models.IntegerField(blank=True, null=True)),
('event', models.ForeignKey(to='tixlbase.Event')),
],
options={
'verbose_name_plural': 'Item categories',
'ordering': ('position',),
'verbose_name': 'Item category',
},
bases=(models.Model,),
),
migrations.CreateModel(
name='ItemFlavor',
fields=[
('id', models.AutoField(primary_key=True, serialize=False, auto_created=True, verbose_name='ID')),
('active', models.BooleanField(default=True)),
('default_price', models.DecimalField(decimal_places=2, blank=True, max_digits=7, null=True, verbose_name='Default price')),
('item', models.ForeignKey(to='tixlbase.Item', related_name='flavors')),
],
options={
},
bases=(models.Model,),
),
migrations.CreateModel(
name='Property',
fields=[
('id', models.AutoField(primary_key=True, serialize=False, auto_created=True, verbose_name='ID')),
('name', models.CharField(max_length=250, verbose_name='Property name')),
('event', models.ForeignKey(to='tixlbase.Event')),
],
options={
'verbose_name_plural': 'Item properties',
'verbose_name': 'Item property',
},
bases=(models.Model,),
),
migrations.CreateModel(
name='PropertyValue',
fields=[
('id', models.AutoField(primary_key=True, serialize=False, auto_created=True, verbose_name='ID')),
('value', models.CharField(max_length=250, verbose_name='Value')),
('prop', models.ForeignKey(to='tixlbase.Property', related_name='values')),
],
options={
},
bases=(models.Model,),
),
migrations.AddField(
model_name='itemflavor',
name='prop',
field=models.ManyToManyField(related_name='values', to='tixlbase.PropertyValue'),
preserve_default=True,
),
migrations.AddField(
model_name='item',
name='category',
field=models.ForeignKey(blank=True, to='tixlbase.ItemCategory', on_delete=django.db.models.deletion.PROTECT, null=True),
preserve_default=True,
),
migrations.AddField(
model_name='item',
name='event',
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='tixlbase.Event'),
preserve_default=True,
),
migrations.AddField(
model_name='item',
name='properties',
field=models.ManyToManyField(related_name='items', to='tixlbase.Property'),
preserve_default=True,
),
migrations.AlterField(
model_name='event',
name='payment_term_days',
field=models.IntegerField(help_text='The number of days after placing an order the user has to pay to preserve his reservation.', default=14, verbose_name='Payment term in days'),
),
migrations.AlterField(
model_name='event',
name='payment_term_last',
field=models.DateTimeField(help_text='The last date any payments are accepted. This has precedence over the number of days configured above.', blank=True, null=True, verbose_name='Last date of payments'),
),
migrations.AlterField(
model_name='event',
name='presale_end',
field=models.DateTimeField(help_text='No items will be sold after this date.', blank=True, null=True, verbose_name='End of presale'),
),
migrations.AlterField(
model_name='event',
name='presale_start',
field=models.DateTimeField(help_text='No items will be sold before this date.', blank=True, null=True, verbose_name='Start of presale'),
),
migrations.AlterField(
model_name='event',
name='show_date_to',
field=models.BooleanField(help_text="If disabled, only event's start date will be displayed to the public.", default=True, verbose_name='Show event end date'),
),
migrations.AlterField(
model_name='event',
name='show_times',
field=models.BooleanField(help_text="If disabled, the event's start and end date will be displayed without the time of day.", default=True, verbose_name='Show dates with time'),
),
migrations.AlterField(
model_name='event',
name='slug',
field=models.CharField(db_index=True, help_text='Should be short, only contain lowercase letters and numbers, and must be unique among your events. This is being used in addresses and bank transfer references.', validators=[django.core.validators.RegexValidator(message='The slug may only contain letters, numbers, dots and dashes.', regex='^[a-zA-Z0-9.-]+$')], verbose_name='Slug', max_length=50),
),
]

View File

@@ -1,30 +0,0 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import models, migrations
class Migration(migrations.Migration):
dependencies = [
('tixlbase', '0009_auto_20140916_2120'),
]
operations = [
migrations.RemoveField(
model_name='itemflavor',
name='prop',
),
migrations.AddField(
model_name='eventpermission',
name='can_change_items',
field=models.BooleanField(verbose_name='Can change item settings', default=True),
preserve_default=True,
),
migrations.AddField(
model_name='itemflavor',
name='values',
field=models.ManyToManyField(to='tixlbase.PropertyValue', related_name='flavors'),
preserve_default=True,
),
]

View File

@@ -1,49 +0,0 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import models, migrations
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('tixlbase', '0010_auto_20140927_1006'),
]
operations = [
migrations.CreateModel(
name='ItemVariation',
fields=[
('id', models.AutoField(serialize=False, primary_key=True, verbose_name='ID', auto_created=True)),
('active', models.BooleanField(default=True)),
('default_price', models.DecimalField(max_digits=7, decimal_places=2, blank=True, null=True, verbose_name='Default price')),
('item', models.ForeignKey(related_name='variations', to='tixlbase.Item')),
('values', models.ManyToManyField(related_name='variations', to='tixlbase.PropertyValue')),
],
options={
},
bases=(models.Model,),
),
migrations.RemoveField(
model_name='itemflavor',
name='item',
),
migrations.RemoveField(
model_name='itemflavor',
name='values',
),
migrations.DeleteModel(
name='ItemFlavor',
),
migrations.AlterField(
model_name='item',
name='category',
field=models.ForeignKey(null=True, blank=True, on_delete=django.db.models.deletion.PROTECT, related_name='items', to='tixlbase.ItemCategory'),
),
migrations.AlterField(
model_name='item',
name='event',
field=models.ForeignKey(to='tixlbase.Event', on_delete=django.db.models.deletion.PROTECT, related_name='items'),
),
]

View File

@@ -1,66 +0,0 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import models, migrations
import django.db.models.deletion
def setposition(apps, schema_editor):
ItemCategory = apps.get_model("tixlbase", "ItemCategory")
for cat in ItemCategory.objects.all():
cat.position = 0
cat.save()
class Migration(migrations.Migration):
dependencies = [
('tixlbase', '0011_auto_20140927_1013'),
]
operations = [
migrations.RunPython(setposition),
migrations.AlterField(
model_name='item',
name='active',
field=models.BooleanField(default=True, verbose_name='Active'),
),
migrations.AlterField(
model_name='item',
name='category',
field=models.ForeignKey(blank=True, null=True, verbose_name='Category', related_name='items', to='tixlbase.ItemCategory', on_delete=django.db.models.deletion.PROTECT),
),
migrations.AlterField(
model_name='item',
name='event',
field=models.ForeignKey(to='tixlbase.Event', related_name='items', verbose_name='Event', on_delete=django.db.models.deletion.PROTECT),
),
migrations.AlterField(
model_name='item',
name='properties',
field=models.ManyToManyField(to='tixlbase.Property', help_text="The selected properties will be available for the user to select. After saving this field, move to the 'Variations' tab to configure the details.", blank=True, verbose_name='Properties', related_name='items'),
),
migrations.AlterField(
model_name='item',
name='tax_rate',
field=models.DecimalField(max_digits=7, verbose_name='Taxes included in percent', blank=True, null=True, decimal_places=2),
),
migrations.AlterField(
model_name='itemcategory',
name='event',
field=models.ForeignKey(related_name='categories', to='tixlbase.Event'),
),
migrations.AlterField(
model_name='itemcategory',
name='position',
field=models.IntegerField(default=0),
),
migrations.AlterField(
model_name='itemvariation',
name='active',
field=models.BooleanField(default=True, verbose_name='Active'),
),
migrations.AlterField(
model_name='property',
name='event',
field=models.ForeignKey(related_name='properties', to='tixlbase.Event'),
),
]

View File

@@ -1,20 +0,0 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import models, migrations
class Migration(migrations.Migration):
dependencies = [
('tixlbase', '0012_auto_20140929_1935'),
]
operations = [
migrations.AddField(
model_name='propertyvalue',
name='position',
field=models.IntegerField(default=0),
preserve_default=True,
),
]

View File

@@ -1,43 +0,0 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import models, migrations
class Migration(migrations.Migration):
dependencies = [
('tixlbase', '0013_propertyvalue_position'),
]
operations = [
migrations.CreateModel(
name='Question',
fields=[
('id', models.AutoField(primary_key=True, auto_created=True, verbose_name='ID', serialize=False)),
('question', models.TextField(verbose_name='Question')),
('type', models.CharField(choices=[('N', 'Number'), ('S', 'Text (one line)'), ('T', 'Multiline text'), ('B', 'Yes/No')], max_length=5, verbose_name='Question type')),
('required', models.BooleanField(default=False, verbose_name='Required question')),
('event', models.ForeignKey(to='tixlbase.Event', related_name='events')),
],
options={
'verbose_name': 'Question',
'verbose_name_plural': 'Questions',
},
bases=(models.Model,),
),
migrations.AlterModelOptions(
name='itemvariation',
options={'verbose_name': 'Item variation', 'verbose_name_plural': 'Item variations'},
),
migrations.AlterModelOptions(
name='propertyvalue',
options={'ordering': ('position',), 'verbose_name': 'Property value', 'verbose_name_plural': 'Property values'},
),
migrations.AddField(
model_name='item',
name='questions',
field=models.ManyToManyField(to='tixlbase.Question', related_name='questions', blank=True, verbose_name='Questions', help_text='The user will be asked to fill in answers for the selected questions'),
preserve_default=True,
),
]

View File

@@ -1,24 +0,0 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import models, migrations
class Migration(migrations.Migration):
dependencies = [
('tixlbase', '0014_auto_20141005_1037'),
]
operations = [
migrations.AlterField(
model_name='item',
name='questions',
field=models.ManyToManyField(blank=True, related_name='items', verbose_name='Questions', help_text='The user will be asked to fill in answers for the selected questions', to='tixlbase.Question'),
),
migrations.AlterField(
model_name='question',
name='event',
field=models.ForeignKey(to='tixlbase.Event', related_name='questions'),
),
]

View File

@@ -1,20 +0,0 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import models, migrations
class Migration(migrations.Migration):
dependencies = [
('tixlbase', '0015_auto_20141006_2205'),
]
operations = [
migrations.AddField(
model_name='event',
name='plugins',
field=models.TextField(blank=True, verbose_name='Plugins', null=True),
preserve_default=True,
),
]

View File

@@ -6,6 +6,7 @@ from django.contrib.auth.models import AbstractBaseUser, BaseUserManager, Permis
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from django.template.defaultfilters import date as _date from django.template.defaultfilters import date as _date
from django.core.validators import RegexValidator from django.core.validators import RegexValidator
from versions.models import Versionable, VersionedForeignKey, VersionedManyToManyField
from tixlbase.types import VariationDict from tixlbase.types import VariationDict
@@ -88,7 +89,7 @@ class User(AbstractBaseUser, PermissionsMixin):
is_active = models.BooleanField(default=True, is_active = models.BooleanField(default=True,
verbose_name=_('Is active')) verbose_name=_('Is active'))
is_staff = models.BooleanField(default=False, is_staff = models.BooleanField(default=False,
verbose_name=('Is site admin')) verbose_name=_('Is site admin'))
date_joined = models.DateTimeField(auto_now_add=True, date_joined = models.DateTimeField(auto_now_add=True,
verbose_name=_('Date joined')) verbose_name=_('Date joined'))
locale = models.CharField(max_length=50, locale = models.CharField(max_length=50,
@@ -97,7 +98,7 @@ class User(AbstractBaseUser, PermissionsMixin):
verbose_name=_('Language')) verbose_name=_('Language'))
timezone = models.CharField(max_length=100, timezone = models.CharField(max_length=100,
default=settings.TIME_ZONE, default=settings.TIME_ZONE,
verbose_name=('Timezone')) verbose_name=_('Timezone'))
objects = UserManager() objects = UserManager()
@@ -119,7 +120,7 @@ class User(AbstractBaseUser, PermissionsMixin):
self.identifier = self.identifier.lower() self.identifier = self.identifier.lower()
super().save(*args, **kwargs) super().save(*args, **kwargs)
def get_short_name(self): def get_short_name(self) -> str:
if self.givenname: if self.givenname:
return self.givenname return self.givenname
elif self.familyname: elif self.familyname:
@@ -127,7 +128,7 @@ class User(AbstractBaseUser, PermissionsMixin):
else: else:
return self.username return self.username
def get_full_name(self): def get_full_name(self) -> str:
if self.givenname and not self.familyname: if self.givenname and not self.familyname:
return self.givenname return self.givenname
elif not self.givenname and self.familyname: elif not self.givenname and self.familyname:
@@ -141,7 +142,7 @@ class User(AbstractBaseUser, PermissionsMixin):
return self.username return self.username
class Organizer(models.Model): class Organizer(Versionable):
""" """
This model represents an entity organizing events, like a company. This model represents an entity organizing events, like a company.
Any organizer has a unique slug, which is a short name (alphanumeric, Any organizer has a unique slug, which is a short name (alphanumeric,
@@ -165,13 +166,13 @@ class Organizer(models.Model):
return self.name return self.name
class OrganizerPermission(models.Model): class OrganizerPermission(Versionable):
""" """
The relation between an Organizer and an User who has permissions to The relation between an Organizer and an User who has permissions to
access an organizer profile. access an organizer profile.
""" """
organizer = models.ForeignKey(Organizer) organizer = VersionedForeignKey(Organizer)
user = models.ForeignKey(User, related_name="organizer_perms") user = models.ForeignKey(User, related_name="organizer_perms")
can_create_events = models.BooleanField( can_create_events = models.BooleanField(
default=True, default=True,
@@ -181,7 +182,6 @@ class OrganizerPermission(models.Model):
class Meta: class Meta:
verbose_name = _("Organizer permission") verbose_name = _("Organizer permission")
verbose_name_plural = _("Organizer permissions") verbose_name_plural = _("Organizer permissions")
unique_together = (("organizer", "user"),)
def __str__(self): def __str__(self):
return _("%(name)s on %(object)s") % { return _("%(name)s on %(object)s") % {
@@ -190,7 +190,7 @@ class OrganizerPermission(models.Model):
} }
class Event(models.Model): class Event(Versionable):
""" """
This model represents an event. An event is anything you can buy This model represents an event. An event is anything you can buy
tickets for. It belongs to one orgnaizer and has a name and a slug, tickets for. It belongs to one orgnaizer and has a name and a slug,
@@ -216,8 +216,8 @@ class Event(models.Model):
matter when they were ordered (and thus, ignoring payment_term_days). matter when they were ordered (and thus, ignoring payment_term_days).
""" """
organizer = models.ForeignKey(Organizer, related_name="events", organizer = VersionedForeignKey(Organizer, related_name="events",
on_delete=models.PROTECT) on_delete=models.PROTECT)
name = models.CharField(max_length=200, name = models.CharField(max_length=200,
verbose_name=_("Name")) verbose_name=_("Name"))
slug = models.CharField( slug = models.CharField(
@@ -266,7 +266,7 @@ class Event(models.Model):
verbose_name=_("Start of presale"), verbose_name=_("Start of presale"),
help_text=_("No items will be sold before this date."), help_text=_("No items will be sold before this date."),
) )
payment_term_days = models.IntegerField( payment_term_days = models.PositiveIntegerField(
default=14, default=14,
verbose_name=_("Payment term in days"), verbose_name=_("Payment term in days"),
help_text=_("The number of days after placing an order the user has to pay to preserve his reservation."), help_text=_("The number of days after placing an order the user has to pay to preserve his reservation."),
@@ -284,7 +284,7 @@ class Event(models.Model):
class Meta: class Meta:
verbose_name = _("Event") verbose_name = _("Event")
verbose_name_plural = _("Events") verbose_name_plural = _("Events")
unique_together = (("organizer", "slug"),) # unique_together = (("organizer", "slug"),) # TODO: Enforce manually
ordering = ("date_from", "name") ordering = ("date_from", "name")
def __str__(self): def __str__(self):
@@ -295,18 +295,18 @@ class Event(models.Model):
self.get_cache().clear() self.get_cache().clear()
return obj return obj
def get_plugins(self): def get_plugins(self) -> "list[str]":
if self.plugins is None: if self.plugins is None:
return [] return []
return self.plugins.split(",") return self.plugins.split(",")
def get_date_from_display(self): def get_date_from_display(self) -> str:
return _date( return _date(
self.date_from, self.date_from,
"DATETIME_FORMAT" if self.show_times else "DATE_FORMAT" "DATETIME_FORMAT" if self.show_times else "DATE_FORMAT"
) )
def get_date_to_display(self): def get_date_to_display(self) -> str:
if not self.show_date_to: if not self.show_date_to:
return "" return ""
return _date( return _date(
@@ -314,18 +314,18 @@ class Event(models.Model):
"DATETIME_FORMAT" if self.show_times else "DATE_FORMAT" "DATETIME_FORMAT" if self.show_times else "DATE_FORMAT"
) )
def get_cache(self): def get_cache(self) -> "tixlbase.cache.EventRelatedCache":
from tixlbase.cache import EventRelatedCache from tixlbase.cache import EventRelatedCache
return EventRelatedCache(self) return EventRelatedCache(self)
class EventPermission(models.Model): class EventPermission(Versionable):
""" """
The relation between an Event and an User who has permissions to The relation between an Event and an User who has permissions to
access an event. access an event.
""" """
event = models.ForeignKey(Event) event = VersionedForeignKey(Event)
user = models.ForeignKey(User, related_name="event_perms") user = models.ForeignKey(User, related_name="event_perms")
can_change_settings = models.BooleanField( can_change_settings = models.BooleanField(
default=True, default=True,
@@ -339,7 +339,6 @@ class EventPermission(models.Model):
class Meta: class Meta:
verbose_name = _("Event permission") verbose_name = _("Event permission")
verbose_name_plural = _("Event permissions") verbose_name_plural = _("Event permissions")
unique_together = (("event", "user"),)
def __str__(self): def __str__(self):
return _("%(name)s on %(object)s") % { return _("%(name)s on %(object)s") % {
@@ -348,11 +347,11 @@ class EventPermission(models.Model):
} }
class ItemCategory(models.Model): class ItemCategory(Versionable):
""" """
Items can be sorted into categories Items can be sorted into categories
""" """
event = models.ForeignKey( event = VersionedForeignKey(
Event, Event,
on_delete=models.CASCADE, on_delete=models.CASCADE,
related_name='categories', related_name='categories',
@@ -373,20 +372,25 @@ class ItemCategory(models.Model):
def __str__(self): def __str__(self):
return self.name return self.name
def save(self, *args, **kwargs): def delete(self, *args, **kwargs):
super().delete(*args, **kwargs)
if self.event:
self.event.get_cache().clear()
def save(self, *args, **kwargs):
super().save(*args, **kwargs)
if self.event: if self.event:
self.event.get_cache().clear() self.event.get_cache().clear()
return super().save(*args, **kwargs)
class Property(models.Model): class Property(Versionable):
""" """
A property is a modifier which can be applied to an A property is a modifier which can be applied to an
Item. For example 'Size' would be a property associated Item. For example 'Size' would be a property associated
with the item 'T-Shirt'. with the item 'T-Shirt'.
""" """
event = models.ForeignKey( event = VersionedForeignKey(
Event, Event,
related_name="properties", related_name="properties",
) )
@@ -402,19 +406,24 @@ class Property(models.Model):
def __str__(self): def __str__(self):
return self.name return self.name
def save(self, *args, **kwargs): def delete(self, *args, **kwargs):
super().delete(*args, **kwargs)
if self.event:
self.event.get_cache().clear()
def save(self, *args, **kwargs):
super().save(*args, **kwargs)
if self.event: if self.event:
self.event.get_cache().clear() self.event.get_cache().clear()
return super().save(*args, **kwargs)
class PropertyValue(models.Model): class PropertyValue(Versionable):
""" """
A value of a property. If the property would be 'T-Shirt size', A value of a property. If the property would be 'T-Shirt size',
this could be 'M' or 'L' this could be 'M' or 'L'
""" """
prop = models.ForeignKey( prop = VersionedForeignKey(
Property, Property,
on_delete=models.CASCADE, on_delete=models.CASCADE,
related_name="values" related_name="values"
@@ -435,13 +444,18 @@ class PropertyValue(models.Model):
def __str__(self): def __str__(self):
return "%s: %s" % (self.prop.name, self.value) return "%s: %s" % (self.prop.name, self.value)
def save(self, *args, **kwargs): def delete(self, *args, **kwargs):
super().delete(*args, **kwargs)
if self.prop:
self.prop.event.get_cache().clear()
def save(self, *args, **kwargs):
super().save(*args, **kwargs)
if self.prop: if self.prop:
self.prop.event.get_cache().clear() self.prop.event.get_cache().clear()
return super().save(*args, **kwargs)
class Question(models.Model): class Question(Versionable):
""" """
A question is an input field that can be used to extend a ticket A question is an input field that can be used to extend a ticket
by custom information, e.g. "Attendee name" or "Attendee age". by custom information, e.g. "Attendee name" or "Attendee age".
@@ -457,7 +471,7 @@ class Question(models.Model):
(TYPE_BOOLEAN, _("Yes/No")), (TYPE_BOOLEAN, _("Yes/No")),
) )
event = models.ForeignKey( event = VersionedForeignKey(
Event, Event,
related_name="questions", related_name="questions",
) )
@@ -481,13 +495,18 @@ class Question(models.Model):
def __str__(self): def __str__(self):
return self.question return self.question
def save(self, *args, **kwargs): def delete(self, *args, **kwargs):
super().delete(*args, **kwargs)
if self.event:
self.event.get_cache().clear()
def save(self, *args, **kwargs):
super().save(*args, **kwargs)
if self.event: if self.event:
self.event.get_cache().clear() self.event.get_cache().clear()
return super().save(*args, **kwargs)
class Item(models.Model): class Item(Versionable):
""" """
An item is a thing which can be sold. It belongs to an An item is a thing which can be sold. It belongs to an
event and may or may not belong to a category. event and may or may not belong to a category.
@@ -499,13 +518,13 @@ class Item(models.Model):
inconsistencies. Instead, they have an attribute "deleted". inconsistencies. Instead, they have an attribute "deleted".
Deleted items will not be shown anywhere. Deleted items will not be shown anywhere.
""" """
event = models.ForeignKey( event = VersionedForeignKey(
Event, Event,
on_delete=models.PROTECT, on_delete=models.PROTECT,
related_name="items", related_name="items",
verbose_name=_("Event"), verbose_name=_("Event"),
) )
category = models.ForeignKey( category = VersionedForeignKey(
ItemCategory, ItemCategory,
on_delete=models.PROTECT, on_delete=models.PROTECT,
related_name="items", related_name="items",
@@ -540,7 +559,7 @@ class Item(models.Model):
verbose_name=_("Taxes included in percent"), verbose_name=_("Taxes included in percent"),
max_digits=7, decimal_places=2 max_digits=7, decimal_places=2
) )
properties = models.ManyToManyField( properties = VersionedManyToManyField(
Property, Property,
related_name='items', related_name='items',
verbose_name=_("Properties"), verbose_name=_("Properties"),
@@ -551,7 +570,7 @@ class Item(models.Model):
+ '\'Variations\' tab to configure the details.' + '\'Variations\' tab to configure the details.'
) )
) )
questions = models.ManyToManyField( questions = VersionedManyToManyField(
Question, Question,
related_name='items', related_name='items',
verbose_name=_("Questions"), verbose_name=_("Questions"),
@@ -570,16 +589,18 @@ class Item(models.Model):
return self.name return self.name
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
super().save(*args, **kwargs)
if self.event: if self.event:
self.event.get_cache().clear() self.event.get_cache().clear()
return super().save(*args, **kwargs)
def delete(self): def delete(self):
self.deleted = True self.deleted = True
self.active = False self.active = False
return super().save() super().save()
if self.event:
self.event.get_cache().clear()
def get_all_variations(self): def get_all_variations(self, use_cache: bool=False) -> "list[VariationDict]":
""" """
This method returns a list containing all variations of this This method returns a list containing all variations of this
item. The list contains one VariationDict per variation, where item. The list contains one VariationDict per variation, where
@@ -590,35 +611,39 @@ class Item(models.Model):
VariationDicts differ from dicts only by specifying some extra VariationDicts differ from dicts only by specifying some extra
methods. methods.
""" """
all_variations = self.variations.all().prefetch_related("values") if use_cache and hasattr(self, '_get_all_variations_cache'):
all_properties = self.properties.all().prefetch_related("values") return self._get_all_variations_cache
all_variations = self.variations.current.all().prefetch_related("values")
all_properties = self.properties.current.all().prefetch_related("values")
variations_cache = {} variations_cache = {}
for var in all_variations: for var in all_variations:
key = [] key = []
for v in var.values.all(): for v in var.values.current.all():
key.append((v.prop_id, v.pk)) key.append((v.prop_id, v.identity))
key = tuple(sorted(key)) key = tuple(sorted(key))
variations_cache[key] = var variations_cache[key] = var
result = [] result = []
for comb in product(*[prop.values.all() for prop in all_properties]): for comb in product(*[prop.values.current.all() for prop in all_properties]):
if len(comb) == 0: if len(comb) == 0:
result.append(VariationDict()) result.append(VariationDict())
continue continue
key = [] key = []
var = VariationDict() var = VariationDict()
for v in comb: for v in comb:
key.append((v.prop.pk, v.pk)) key.append((v.prop.identity, v.identity))
var[v.prop.pk] = v var[v.prop.identity] = v
key = tuple(sorted(key)) key = tuple(sorted(key))
if key in variations_cache: if key in variations_cache:
var['variation'] = variations_cache[key] var['variation'] = variations_cache[key]
result.append(var) result.append(var)
self._get_all_variations_cache = result
return result return result
class ItemVariation(models.Model): class ItemVariation(Versionable):
""" """
A variation is an item combined with values for all properties A variation is an item combined with values for all properties
associated with the item. For example, if your item is 'T-Shirt' associated with the item. For example, if your item is 'T-Shirt'
@@ -637,11 +662,11 @@ class ItemVariation(models.Model):
Restrictions can be not only set to items but also directly to variations. Restrictions can be not only set to items but also directly to variations.
""" """
item = models.ForeignKey( item = VersionedForeignKey(
Item, Item,
related_name='variations' related_name='variations'
) )
values = models.ManyToManyField( values = VersionedManyToManyField(
PropertyValue, PropertyValue,
related_name='variations', related_name='variations',
) )
@@ -659,30 +684,65 @@ class ItemVariation(models.Model):
verbose_name = _("Item variation") verbose_name = _("Item variation")
verbose_name_plural = _("Item variations") verbose_name_plural = _("Item variations")
def save(self, *args, **kwargs): def delete(self, *args, **kwargs):
super().delete(*args, **kwargs)
if self.item:
self.item.event.get_cache().clear()
def save(self, *args, **kwargs):
super().save(*args, **kwargs)
if self.item: if self.item:
self.item.event.get_cache().clear() self.item.event.get_cache().clear()
return super().save(*args, **kwargs)
class BaseRestriction(models.Model): class VariationsField(VersionedManyToManyField):
"""
This is a ManyToManyField using the tixlcontrol.views.forms.VariationsField
form field by default.
"""
def formfield(self, **kwargs):
from tixlcontrol.views.forms import VariationsField as FVariationsField
from django.db.models.fields.related import RelatedField
defaults = {
'form_class': FVariationsField,
# We don't need a queryset
'queryset': ItemVariation.objects.none(),
}
defaults.update(kwargs)
# If initial is passed in, it's a list of related objects, but the
# MultipleChoiceField takes a list of IDs.
if defaults.get('initial') is not None:
initial = defaults['initial']
if callable(initial):
initial = initial()
defaults['initial'] = [i.identity for i in initial]
# Skip ManyToManyField in dependency chain
return super(RelatedField, self).formfield(**defaults)
class BaseRestriction(Versionable):
""" """
A restriction is the abstract concept of a rule that limits the availability A restriction is the abstract concept of a rule that limits the availability
of Items or ItemVariations. This model is just an abstract base class to be of Items or ItemVariations. This model is just an abstract base class to be
extended by restriction plugins. extended by restriction plugins.
""" """
event = models.ForeignKey( event = VersionedForeignKey(
Event, Event,
on_delete=models.CASCADE, on_delete=models.CASCADE,
related_name="restrictions_%(app_label)s_%(class)s", related_name="restrictions_%(app_label)s_%(class)s",
verbose_name=_("Event"), verbose_name=_("Event"),
) )
items = models.ManyToManyField( item = VersionedForeignKey(
Item, Item,
blank=True, null=True,
verbose_name=_("Item"),
related_name="restrictions_%(app_label)s_%(class)s", related_name="restrictions_%(app_label)s_%(class)s",
) )
variations = models.ManyToManyField( variations = VariationsField(
ItemVariation, 'tixlbase.ItemVariation',
blank=True,
verbose_name=_("Variations"),
related_name="restrictions_%(app_label)s_%(class)s", related_name="restrictions_%(app_label)s_%(class)s",
) )
@@ -691,7 +751,221 @@ class BaseRestriction(models.Model):
verbose_name = _("Restriction") verbose_name = _("Restriction")
verbose_name_plural = _("Restrictions") verbose_name_plural = _("Restrictions")
def save(self, *args, **kwargs): def delete(self, *args, **kwargs):
super().delete(*args, **kwargs)
if self.event: if self.event:
self.event.get_cache().clear() self.event.get_cache().clear()
return super().save(*args, **kwargs)
def save(self, *args, **kwargs):
super().save(*args, **kwargs)
if self.event:
self.event.get_cache().clear()
class Quota(Versionable):
"""
A quota is a "pool of tickets". It is there to limit the number of items
of a certain type to be sold. For example, you could have a quota of 500
applied to all your items (because you only have that much space in your
building), and also a quota of 100 applied to the VIP tickets for
exclusivity. In this case, no more than 500 tickets will be sold in total
and no more than 100 of them will be VIP tickets (but 450 normal and 50
VIP tickets will be fine).
As always, a quota can not only be tied to an item, but also to a specific
variation. We follow the general rule here: If there are no variations
speficied, the quota applies to all of them, and if there are variations
specified, the quota applies to those.
This object holds two fields, "order_cache" and "lock_cache", which are
implementation specific and are considered private. It is planned that they
are being used as a fallback solution if redis is not available.
"""
event = VersionedForeignKey(
Event,
on_delete=models.CASCADE,
related_name="quotas",
verbose_name=_("Event"),
)
name = models.CharField(
max_length=200,
verbose_name=_("Name")
)
size = models.PositiveIntegerField(
verbose_name=_("Total capacity")
)
items = VersionedManyToManyField(
Item,
verbose_name=_("Item"),
blank=True
)
variations = VariationsField(
ItemVariation,
blank=True,
verbose_name=_("Variations")
)
order_cache = models.ManyToManyField(
'OrderPosition',
blank=True
)
lock_cache = models.ManyToManyField(
'CartPosition',
blank=True
)
class Meta:
verbose_name = _("Quota")
verbose_name_plural = _("Quotas")
def __str__(self):
return self.name
class Order(Versionable):
"""
An order is created when a user clicks 'buy' on his cart. It holds
several OrderPositions and is connected to an user. It has an
expiration date: If items run out of capacity, orders which are over
their expiration date might be cancelled.
Important: An order holds its total monetary value, as an order is a
piece of 'history' and must not change due to a change in item prices.
"""
STATUS_PENDING = "n"
STATUS_PAID = "p"
STATUS_EXPIRED = "e"
STATUS_CANCELLED = "c"
STATUS_CHOICE = (
(STATUS_PAID, _("pending")),
(STATUS_PENDING, _("paid")),
(STATUS_EXPIRED, _("expired")),
(STATUS_CANCELLED, _("cancelled")),
)
status = models.CharField(
max_length=3,
choices=STATUS_CHOICE,
verbose_name=_("Status")
)
event = VersionedForeignKey(
Event,
verbose_name=_("Event")
)
user = models.ForeignKey(
User, null=True, blank=True,
verbose_name=_("User")
)
datetime = models.DateTimeField(
auto_now_add=True,
verbose_name=_("Date")
)
expires = models.DateTimeField(
verbose_name=_("Expiration date")
)
payment_date = models.DateTimeField(
verbose_name=_("Payment date")
)
payment_info = models.TextField(
verbose_name=_("Payment information")
)
total = models.DecimalField(
decimal_places=2, max_digits=10,
verbose_name=_("Total amount")
)
class Meta:
verbose_name = _("Order")
verbose_name_plural = _("Orders")
class QuestionAnswer(Versionable):
"""
The answer to a Question, connected to an OrderPosition or CartPosition
"""
orderposition = models.ForeignKey('OrderPosition', null=True, blank=True)
cartposition = models.ForeignKey('CartPosition', null=True, blank=True)
question = VersionedForeignKey(Question)
answer = models.TextField()
class OrderPosition(models.Model):
"""
An OrderPosition is one line of an order, representing one ordered items
of a specified type (or variation).
Important: An OrderPosition holds its total monetary value, as an order is a
piece of 'history' and must not change due to a change in item prices.
"""
order = VersionedForeignKey(
Order,
verbose_name=_("Order")
)
item = VersionedForeignKey(
Item,
verbose_name=_("Item")
)
variation = VersionedForeignKey(
ItemVariation,
null=True, blank=True,
verbose_name=_("Variation")
)
price = models.DecimalField(
decimal_places=2, max_digits=10,
verbose_name=_("Price")
)
answers = VersionedManyToManyField(
Question,
through=QuestionAnswer,
verbose_name=_("Answers")
)
class Meta:
verbose_name = _("Order position")
verbose_name_plural = _("Order positions")
class CartPosition(models.Model):
"""
A cart position is similar to a order line, except that it is not
yet part of a binding order but just placed by some user in his or
her cart. It therefore normally has a much shorter expiration time
than an ordered position, but still blocks an item in the quota pool
as we do not want to throw out users while they're clicking through
the checkout process.
"""
event = VersionedForeignKey(
Event,
verbose_name=_("Event")
)
user = models.ForeignKey(
User, null=True, blank=True,
verbose_name=_("User")
)
session = models.CharField(
max_length=255, null=True, blank=True,
verbose_name=_("Session key")
)
item = VersionedForeignKey(
Item,
verbose_name=_("Item")
)
variation = VersionedForeignKey(
ItemVariation,
null=True, blank=True,
verbose_name=_("Variation")
)
total = models.DecimalField(
decimal_places=2, max_digits=10,
verbose_name=_("Price")
)
datetime = models.DateTimeField(
verbose_name=_("Date")
)
expires = models.DateTimeField(
verbose_name=_("Expiration date")
)
class Meta:
verbose_name = _("Cart position")
verbose_name_plural = _("Cart positions")

View File

@@ -10,7 +10,7 @@ class PluginType(Enum):
RESTRICTION = 1 RESTRICTION = 1
def get_all_plugins(): def get_all_plugins() -> "class":
plugins = [] plugins = []
for app in apps.get_app_configs(): for app in apps.get_app_configs():
if hasattr(app, 'TixlPluginMeta'): if hasattr(app, 'TixlPluginMeta'):

View File

@@ -0,0 +1,98 @@
import os
import sys
from django.test import LiveServerTestCase
from selenium import webdriver
RUN_LOCAL = ('SAUCE_USERNAME' not in os.environ)
if RUN_LOCAL:
# could add Chrome, PhantomJS etc... here
BROWSERS = ['Chrome', 'Firefox']
else:
from sauceclient import SauceClient
USERNAME = os.environ.get('SAUCE_USERNAME')
ACCESS_KEY = os.environ.get('SAUCE_ACCESS_KEY')
sauce = SauceClient(USERNAME, ACCESS_KEY)
BROWSERS = [
{"platform": "Mac OS X 10.9",
"browserName": "chrome",
"version": "35"},
{"platform": "Windows 8.1",
"browserName": "internet explorer",
"version": "11"},
{"platform": "Linux",
"browserName": "firefox",
"version": "29"}]
def on_platforms():
if RUN_LOCAL:
def decorator(base_class):
module = sys.modules[base_class.__module__].__dict__
for i, platform in enumerate(BROWSERS):
d = dict(base_class.__dict__)
d['browser'] = platform
name = "%s_%s" % (base_class.__name__, i + 1)
module[name] = type(name, (base_class,), d)
pass
return decorator
def decorator(base_class):
module = sys.modules[base_class.__module__].__dict__
for i, platform in enumerate(BROWSERS):
d = dict(base_class.__dict__)
d['desired_capabilities'] = platform
name = "%s_%s" % (base_class.__name__, i + 1)
module[name] = type(name, (base_class,), d)
return decorator
class BrowserTest(LiveServerTestCase):
def setUp(self):
if RUN_LOCAL:
self.setUpLocal()
else:
self.setUpSauce()
def tearDown(self):
if RUN_LOCAL:
self.tearDownLocal()
else:
self.tearDownSauce()
def setUpSauce(self):
if 'TRAVIS_JOB_NUMBER' in os.environ:
self.desired_capabilities['tunnel-identifier'] = \
os.environ['TRAVIS_JOB_NUMBER']
self.desired_capabilities['build'] = os.environ['TRAVIS_BUILD_NUMBER']
self.desired_capabilities['tags'] = \
[os.environ['TRAVIS_PYTHON_VERSION'], 'CI']
self.desired_capabilities['name'] = self.id()
sauce_url = "http://%s:%s@ondemand.saucelabs.com:80/wd/hub"
self.driver = webdriver.Remote(
desired_capabilities=self.desired_capabilities,
command_executor=sauce_url % (USERNAME, ACCESS_KEY)
)
self.driver.implicitly_wait(5)
def setUpLocal(self):
self.driver = getattr(webdriver, self.browser)()
self.driver.implicitly_wait(3)
def tearDownLocal(self):
self.driver.quit()
def tearDownSauce(self):
print("\nLink to your job: \n "
"https://saucelabs.com/jobs/%s \n" % self.driver.session_id)
try:
if sys.exc_info() == (None, None, None):
sauce.jobs.update_job(self.driver.session_id, passed=True)
else:
sauce.jobs.update_job(self.driver.session_id, passed=False)
finally:
self.driver.quit()

View File

@@ -40,7 +40,6 @@ class ItemVariationsTest(TestCase):
for vd in variations: for vd in variations:
for i, v in vd.relevant_items(): for i, v in vd.relevant_items():
self.assertIs(type(i), int)
self.assertIs(type(v), PropertyValue) self.assertIs(type(v), PropertyValue)
for v in vd.relevant_values(): for v in vd.relevant_values():

View File

@@ -5,42 +5,53 @@ class VariationDict(dict):
returned by ``Item.get_all_variations()`` to avoid duplicate code in the returned by ``Item.get_all_variations()`` to avoid duplicate code in the
code calling this method. code calling this method.
""" """
IGNORE_KEYS = ('variation', 'key')
def relevant_items(self): def relevant_items(self) -> "list[(str, PropertyValue)]":
""" """
Iterate over all items with numeric keys. Iterate over all items with numeric keys.
This is in use because the variation dictionaries use property ids This is in use because the variation dictionaries use property ids
as key and have some special keys like 'variation'. as key and have some special keys like 'variation'.
""" """
for i in self.items(): return (i for i in self.items() if i[0] not in self.IGNORE_KEYS)
if type(i[0]) is int:
yield i
def relevant_values(self): def relevant_values(self) -> "list[PropertyValue]":
""" """
Iterate over all values with numeric keys. Iterate over all values with numeric keys.
This is in use because the variation dictionaries use property ids This is in use because the variation dictionaries use property ids
as key and have some special keys like 'variation'. as key and have some special keys like 'variation'.
""" """
for i in self.items(): return (i[1] for i in self.items() if i[0] not in self.IGNORE_KEYS)
if type(i[0]) is int:
yield i[1]
def identify(self): def identify(self) -> str:
""" """
Build an identifier for this dict. This can be any string used to Build a simple and unique identifier for this dict. This can be any string
compare one VariationDict to others. used to compare one VariationDict to others.
In the current implementation, it is a string containing a list of In the current implementation, it is a string containing a list of
the PropertyValue id's, sorted by the Property id's and is therefore the PropertyValue id's, sorted by the Property id's and is therefore
unique among one item. unique among one item.
""" """
order_key = lambda i: i[0] order_key = lambda i: i[0]
return ",".join([ return ",".join((
str(v[1].pk) for v in sorted(self.relevant_items(), key=order_key) str(v[1].pk) for v in sorted(self.relevant_items(), key=order_key)
]) ))
def key(self) -> str:
"""
Build an identifier for this dict which exactly specifies the combination
for this variation without any doubt. This can be used to "talk" about a
variation in network communication.
In the current implementation, it is a string containing a list of
the propertyid:valueid tuples without any specific order and is therefore
not useful to compare two VariationDicts for equality.
"""
return ",".join((
str(v[0]) + ":" + str(v[1].pk) for v in self.relevant_items()
))
def __eq__(self, other): def __eq__(self, other):
if type(other) is type(self): if type(other) is type(self):
@@ -48,7 +59,7 @@ class VariationDict(dict):
else: else:
return super().__eq__(other) return super().__eq__(other)
def ordered_values(self): def ordered_values(self) -> "list[ItemVariation]":
""" """
Returns a list of values ordered by their keys Returns a list of values ordered by their keys
""" """
@@ -60,7 +71,7 @@ class VariationDict(dict):
) )
] ]
def copy(self): def copy(self) -> "VariationDict":
""" """
Return a one-level deep copy of this object (create a new Return a one-level deep copy of this object (create a new
VariationDict but make a shallow copy of the dict inside it). VariationDict but make a shallow copy of the dict inside it).

View File

@@ -46,11 +46,11 @@ class PermissionMiddleware:
return redirect_to_login( return redirect_to_login(
path, resolved_login_url, REDIRECT_FIELD_NAME) path, resolved_login_url, REDIRECT_FIELD_NAME)
request.user.events_cache = request.user.events.order_by( request.user.events_cache = request.user.events.current.order_by(
"organizer", "date_from").prefetch_related("organizer") "organizer", "date_from").prefetch_related("organizer")
if 'event.' in url_name and 'event' in url.kwargs: if 'event.' in url_name and 'event' in url.kwargs:
try: try:
request.event = Event.objects.get( request.event = Event.objects.current.get(
slug=url.kwargs['event'], slug=url.kwargs['event'],
permitted__id__exact=request.user.id, permitted__id__exact=request.user.id,
organizer__slug=url.kwargs['organizer'], organizer__slug=url.kwargs['organizer'],

View File

@@ -0,0 +1,6 @@
from tixlbase.signals import EventPluginSignal
restriction_formset = EventPluginSignal(
providing_args=["item"]
)

View File

@@ -1,6 +1,8 @@
$(function() { "use strict";
$("[data-formset]").formset({ $(function () {
animateForms: true, $("[data-formset]").formset({
reorderMode: 'animate', animateForms: true,
}); reorderMode: 'animate'
});
$('.collapse').collapse();
}); });

View File

@@ -28,3 +28,40 @@ td > .form-group > .checkbox {
.form-plugins .panel-title { .form-plugins .panel-title {
line-height: 34px; line-height: 34px;
} }
.restriction-formset .variations label {
margin: 0;
}
.submit-group {
margin: 15px 0 0 0 !important;
padding: 15px;
background: #eeeeee;
text-align: right;
.btn-save {
.btn-lg;
}
.btn-cancel {
.pull-left;
.btn-lg;
}
}
.container ul.nav-pills {
margin: 20px 0;
}
.variation-matrix {
td .form-group, .checkbox {
margin: 0;
}
}
@media (min-width: @screen-sm-min) {
.variation-matrix > tbody > tr > td {
line-height: 34px;
input[type=checkbox] {
margin-top: 10px;
}
}
}

View File

@@ -16,6 +16,6 @@
</ul> </ul>
</li> </li>
<li {% if url_name == "event.index" %}class="active"{% endif %}><a href="{% url 'control:event.index' organizer=request.event.organizer.slug event=request.event.slug %}">{% trans "Dashboard" %}</a></li> <li {% if url_name == "event.index" %}class="active"{% endif %}><a href="{% url 'control:event.index' organizer=request.event.organizer.slug event=request.event.slug %}">{% trans "Dashboard" %}</a></li>
<li {% if url_name == "event.settings" %}class="active"{% endif %}><a href="{% url 'control:event.settings' organizer=request.event.organizer.slug event=request.event.slug %}">{% trans "Settings" %}</a></li> <li {% if "event.settings" in url_name %}class="active"{% endif %}><a href="{% url 'control:event.settings' organizer=request.event.organizer.slug event=request.event.slug %}">{% trans "Settings" %}</a></li>
<li {% if "event.item" in url_name %}class="active"{% endif %}><a href="{% url 'control:event.items' organizer=request.event.organizer.slug event=request.event.slug %}">{% trans "Items" %}</a></li> <li {% if "event.item" in url_name %}class="active"{% endif %}><a href="{% url 'control:event.items' organizer=request.event.organizer.slug event=request.event.slug %}">{% trans "Items" %}</a></li>
{% endblock %} {% endblock %}

View File

@@ -34,12 +34,10 @@
{% bootstrap_field form.payment_term_days layout="horizontal" %} {% bootstrap_field form.payment_term_days layout="horizontal" %}
{% bootstrap_field form.payment_term_last layout="horizontal" %} {% bootstrap_field form.payment_term_last layout="horizontal" %}
</fieldset> </fieldset>
<div class="form-group"> <div class="form-group submit-group">
<div class="col-sm-offset-2 col-sm-10"> <button type="submit" class="btn btn-primary btn-save">
<button type="submit" class="btn btn-primary"> {% trans "Save" %}
{% trans "Save" %} </button>
</button>
</div>
</div> </div>
</form> </form>
{% endblock %} {% endblock %}

View File

@@ -4,8 +4,9 @@
{% block content %} {% block content %}
<h1>{% trans "Modify item:" %} {{ item.name }}</h1> <h1>{% trans "Modify item:" %} {{ item.name }}</h1>
<ul class="nav nav-pills"> <ul class="nav nav-pills">
<li {% if "event.item" == url_name %}class="active"{% endif %}><a href="{% url 'control:event.item' organizer=request.event.organizer.slug event=request.event.slug item=item.pk %}">{% trans "General information" %}</a></li> <li {% if "event.item" == url_name %}class="active"{% endif %}><a href="{% url 'control:event.item' organizer=request.event.organizer.slug event=request.event.slug item=item.identity %}">{% trans "General information" %}</a></li>
<li {% if "event.item.variations" == url_name %}class="active"{% endif %}><a href="{% url 'control:event.item.variations' organizer=request.event.organizer.slug event=request.event.slug item=item.pk %}">{% trans "Variations" %}</a></li> <li {% if "event.item.variations" == url_name %}class="active"{% endif %}><a href="{% url 'control:event.item.variations' organizer=request.event.organizer.slug event=request.event.slug item=item.identity %}">{% trans "Variations" %}</a></li>
<li {% if "event.item.restrictions" == url_name %}class="active"{% endif %}><a href="{% url 'control:event.item.restrictions' organizer=request.event.organizer.slug event=request.event.slug item=item.identity %}">{% trans "Restrictions" %}</a></li>
</ul> </ul>
{% block inside %} {% block inside %}
{% endblock %} {% endblock %}

View File

@@ -2,8 +2,6 @@
{% load i18n %} {% load i18n %}
{% load bootstrap3 %} {% load bootstrap3 %}
{% block inside %} {% block inside %}
<h2>{% trans "General information" %}</h2>
{% if "success" in request.GET %} {% if "success" in request.GET %}
<div class="alert alert-success"> <div class="alert alert-success">
{% trans "Your changes have been saved." %} {% trans "Your changes have been saved." %}
@@ -29,12 +27,10 @@
{% bootstrap_field form.properties layout="horizontal" %} {% bootstrap_field form.properties layout="horizontal" %}
{% bootstrap_field form.questions layout="horizontal" %} {% bootstrap_field form.questions layout="horizontal" %}
</fieldset> </fieldset>
<div class="form-group"> <div class="form-group submit-group">
<div class="col-sm-offset-2 col-sm-10"> <button type="submit" class="btn btn-primary btn-save">
<button type="submit" class="btn btn-primary"> {% trans "Save" %}
{% trans "Save" %} </button>
</button>
</div>
</div> </div>
</form> </form>
{% endblock %} {% endblock %}

View File

@@ -0,0 +1,66 @@
{% extends "tixlcontrol/item/base.html" %}
{% load i18n %}
{% load bootstrap3 %}
{% load formset_tags %}
{% block inside %}
<form action="" method="post">
{% csrf_token %}
{% for set in formsets %}
<fieldset>
<legend>{{ set.title }}</legend>
<div data-formset class="restriction-formset" data-formset-prefix="{{ set.formset.prefix }}">
<div data-formset-body class="panel-group collapse" id="accordion_{{ set.formset.prefix }}">
{{ set.formset.management_form }}
{% for f in set.formset %}
<div class="panel panel-default" data-formset-form>
<div class="panel-heading">
<h4 class="panel-title">
<a data-toggle="collapse" data-parent="#accordion"
href="#collapse{{ f.prefix }}">
Test
</a>
</h4>
</div>
<div id="collapse{{ f.prefix }}" class="panel-collapse collapse in">
<div class="panel-body">
<div class="form-horizontal">
{% bootstrap_form f layout="horizontal" field_class="col-md-10" %}
</div>
</div>
</div>
</div>
{% endfor %}
</div>
<script type="form-template" data-formset-empty-form>
{% escapescript %}
<div class="panel panel-default" data-formset-form>
<div class="panel-heading">
<h4 class="panel-title">
<a data-toggle="collapse" data-parent="#accordion" href="#collapse__prefix__">
{% trans "New restriction" %}
</a>
</h4>
</div>
<div id="collapse__prefix__" class="panel-collapse collapse in">
<div class="panel-body">
<div class="form-horizontal">
{% bootstrap_form set.formset.initialized_empty_form layout="horizontal" field_class="col-md-10" %}
</div>
</div>
</div>
</div>
{% endescapescript %}
</script>
<button type="button" class="btn btn-default" data-formset-add><i
class="fa fa-plus"></i> {% trans "Add a new restriction" %}</button>
</div>
</fieldset>
{% endfor %}
<div class="form-group submit-group">
<button type="submit" class="btn btn-primary btn-save">
{% trans "Save" %}
</button>
</div>
</form>
{% endblock %}

View File

@@ -0,0 +1,7 @@
{% extends "tixlcontrol/item/base.html" %}
{% load i18n %}
{% load bootstrap3 %}
{% block inside %}
<em>{% trans "You have to define and select propreties to be able to configure variations." %}
{% endblock %}

View File

@@ -2,7 +2,6 @@
{% load i18n %} {% load i18n %}
{% load bootstrap3 %} {% load bootstrap3 %}
{% block inside %} {% block inside %}
<h2>{% trans "Variations" %}</h2>
<form action="" method="post"> <form action="" method="post">
{% csrf_token %} {% csrf_token %}
<table class="table"> <table class="table">
@@ -24,12 +23,10 @@
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
<div class="form-group"> <div class="form-group submit-group">
<div class="col-sm-12"> <button type="submit" class="btn btn-primary btn-save">
<button type="submit" class="btn btn-primary"> {% trans "Save" %}
{% trans "Save" %} </button>
</button>
</div>
</div> </div>
</form> </form>

View File

@@ -1,41 +0,0 @@
{% extends "tixlcontrol/item/base.html" %}
{% load i18n %}
{% load bootstrap3 %}
{% block inside %}
<h2>{% trans "Variations" %}</h2>
<form action="" method="post">
{% csrf_token %}
<table class="table">
<thead>
<tr>
<th></th>
{% for val in properties.1.values.all %}
<th>{{ val.value }}</th>
{% endfor %}
</tr>
</thead>
<tbody>
{% for sub in forms %}
<tr>
<td>{{ sub.row }}</td>
{% for form in sub.forms %}
<td>
{% bootstrap_field form.active layout='inline' %}
{% bootstrap_field form.default_price layout='inline' %}
{{ form.default_price.errors }}
</td>
{% endfor %}
</tr>
{% endfor %}
</tbody>
</table>
<div class="form-group">
<div class="col-sm-12">
<button type="submit" class="btn btn-primary">
{% trans "Save" %}
</button>
</div>
</div>
</form>
{% endblock %}

View File

@@ -2,43 +2,48 @@
{% load i18n %} {% load i18n %}
{% load bootstrap3 %} {% load bootstrap3 %}
{% block inside %} {% block inside %}
<h2>{% trans "Variations" %}</h2> <form action="" method="post">
<form action="" method="post"> {% csrf_token %}
{% csrf_token %} {% for major in forms %}
{% for major in forms %} {% if major.row %}
<h3>{{ major.row }}</h3> <h3>{{ major.row }}</h3>
<table class="table"> {% endif %}
<thead> <table class="table variation-matrix">
<tr> <thead>
<th></th> <tr>
{% for val in properties.1.values.all %} <th></th>
<th>{{ val.value }}</th> {% for val in properties.1.values.all %}
{% endfor %} <th>{{ val.value }}</th>
</tr> {% endfor %}
</thead> </tr>
<tbody> </thead>
{% for sub in major.forms %} <tbody>
<tr> {% for sub in major.forms %}
<td>{{ sub.row }}</td> <tr>
{% for form in sub.forms %} <td>{{ sub.row.value }}</td>
<td> {% for form in sub.forms %}
{% bootstrap_field form.active layout='inline' %} <td>
{% bootstrap_field form.default_price layout='inline' %} <div class="row">
{{ form.default_price.errors }} <div class="col-sm-5">
</td> {% bootstrap_field form.active layout='inline' %}
{% endfor %} </div>
</tr> <div class="col-sm-7">
{% endfor %} {% bootstrap_field form.default_price layout='inline' %}
</tbody> </div>
</table> </div>
{% endfor %} {{ form.default_price.errors }}
<div class="form-group"> </td>
<div class="col-sm-12"> {% endfor %}
<button type="submit" class="btn btn-primary"> </tr>
{% trans "Save" %} {% endfor %}
</button> </tbody>
</div> </table>
</div> {% endfor %}
</form> <div class="form-group submit-group">
<button type="submit" class="btn btn-primary btn-save">
{% trans "Save" %}
</button>
</div>
</form>
{% endblock %} {% endblock %}

View File

@@ -7,6 +7,7 @@
<li {% if "event.items.categories" in url_name %}class="active"{% endif %}><a href="{% url 'control:event.items.categories' organizer=request.event.organizer.slug event=request.event.slug %}">{% trans "Categories" %}</a></li> <li {% if "event.items.categories" in url_name %}class="active"{% endif %}><a href="{% url 'control:event.items.categories' organizer=request.event.organizer.slug event=request.event.slug %}">{% trans "Categories" %}</a></li>
<li {% if "event.items.properties" in url_name %}class="active"{% endif %}><a href="{% url 'control:event.items.properties' organizer=request.event.organizer.slug event=request.event.slug %}">{% trans "Properties" %}</a></li> <li {% if "event.items.properties" in url_name %}class="active"{% endif %}><a href="{% url 'control:event.items.properties' organizer=request.event.organizer.slug event=request.event.slug %}">{% trans "Properties" %}</a></li>
<li {% if "event.items.questions" in url_name %}class="active"{% endif %}><a href="{% url 'control:event.items.questions' organizer=request.event.organizer.slug event=request.event.slug %}">{% trans "Questions" %}</a></li> <li {% if "event.items.questions" in url_name %}class="active"{% endif %}><a href="{% url 'control:event.items.questions' organizer=request.event.organizer.slug event=request.event.slug %}">{% trans "Questions" %}</a></li>
<li {% if "event.items.quotas" in url_name %}class="active"{% endif %}><a href="{% url 'control:event.items.quotas' organizer=request.event.organizer.slug event=request.event.slug %}">{% trans "Quotas" %}</a></li>
</ul> </ul>
{% block inside %} {% block inside %}
{% endblock %} {% endblock %}

View File

@@ -30,12 +30,12 @@
<tbody> <tbody>
{% for c in categories %} {% for c in categories %}
<tr> <tr>
<td><strong><a href="{% url "control:event.items.categories.edit" organizer=request.event.organizer.slug event=request.event.slug category=c.pk %}">{{ c.name }}</a></strong></td> <td><strong><a href="{% url "control:event.items.categories.edit" organizer=request.event.organizer.slug event=request.event.slug category=c.identity %}">{{ c.name }}</a></strong></td>
<td> <td>
<a href="{% url "control:event.items.categories.up" organizer=request.event.organizer.slug event=request.event.slug category=c.pk %}" class="btn btn-default btn-sm {% if forloop.counter0 == 0 %}disabled{% endif %}"><i class="fa fa-arrow-up"></i></a> <a href="{% url "control:event.items.categories.up" organizer=request.event.organizer.slug event=request.event.slug category=c.identity %}" class="btn btn-default btn-sm {% if forloop.counter0 == 0 %}disabled{% endif %}"><i class="fa fa-arrow-up"></i></a>
<a href="{% url "control:event.items.categories.down" organizer=request.event.organizer.slug event=request.event.slug category=c.pk %}" class="btn btn-default btn-sm {% if forloop.revcounter0 == 0 %}disabled{% endif %}"><i class="fa fa-arrow-down"></i></a> <a href="{% url "control:event.items.categories.down" organizer=request.event.organizer.slug event=request.event.slug category=c.identity %}" class="btn btn-default btn-sm {% if forloop.revcounter0 == 0 %}disabled{% endif %}"><i class="fa fa-arrow-down"></i></a>
</td> </td>
<td class="text-right"><a href="{% url "control:event.items.categories.delete" organizer=request.event.organizer.slug event=request.event.slug category=c.pk %}"" class="btn btn-danger btn-sm"><i class="fa fa-trash"></i></a></td> <td class="text-right"><a href="{% url "control:event.items.categories.delete" organizer=request.event.organizer.slug event=request.event.slug category=c.identity %}" class="btn btn-danger btn-sm"><i class="fa fa-trash"></i></a></td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>

View File

@@ -15,12 +15,10 @@
<legend>{% trans "General information" %}</legend> <legend>{% trans "General information" %}</legend>
{% bootstrap_field form.name layout="horizontal" %} {% bootstrap_field form.name layout="horizontal" %}
</fieldset> </fieldset>
<div class="form-group"> <div class="form-group submit-group">
<div class="col-sm-offset-2 col-sm-10"> <button type="submit" class="btn btn-primary btn-save">
<button type="submit" class="btn btn-primary"> {% trans "Save" %}
{% trans "Save" %} </button>
</button>
</div>
</div> </div>
</form> </form>
{% endblock %} {% endblock %}

View File

@@ -6,16 +6,14 @@
<h1>{% trans "Delete item category" %}</h1> <h1>{% trans "Delete item category" %}</h1>
<form action="" method="post" class="form-horizontal"> <form action="" method="post" class="form-horizontal">
{% csrf_token %} {% csrf_token %}
<p>{% blocktrans %}Are you sure you want to the category <strong>{{ category.name }}</strong>?{% endblocktrans %}</p> <p>{% blocktrans %}Are you sure you want to delete the category <strong>{{ category.name }}</strong>?{% endblocktrans %}</p>
<div class="form-group"> <div class="form-group submit-group">
<div class="col-sm-offset-2 col-sm-10"> <a href="{% url "control:event.items.categories" organizer=request.event.organizer.slug event=request.event.slug %}" class="btn btn-default btn-cancel">
<button type="submit" class="btn btn-primary"> {% trans "Cancel" %}
{% trans "Confirm" %} </a>
</button> <button type="submit" class="btn btn-danger btn-save">
<a href="{% url "control:event.items.categories" organizer=request.event.organizer.slug event=request.event.slug %}" class="btn btn-default"> {% trans "Delete" %}
{% trans "Cancel" %} </button>
</a>
</div>
</div> </div>
</form> </form>
{% endblock %} {% endblock %}

View File

@@ -13,7 +13,8 @@
<tbody> <tbody>
{% for i in items %} {% for i in items %}
<tr> <tr>
<td><strong><a href="{% url "control:event.item" organizer=request.event.organizer.slug event=request.event.slug item=i.pk %}">{{ i.name }}</a></strong></td> <td><strong><a href="
{% url "control:event.item" organizer=request.event.organizer.slug event=request.event.slug item=i.identity %}">{{ i.name }}</a></strong></td>
<td>{{ i.category }}</td> <td>{{ i.category }}</td>
</tr> </tr>
{% endfor %} {% endfor %}

View File

@@ -29,8 +29,10 @@
<tbody> <tbody>
{% for p in properties %} {% for p in properties %}
<tr> <tr>
<td><strong><a href="{% url "control:event.items.properties.edit" organizer=request.event.organizer.slug event=request.event.slug property=p.pk %}">{{ p.name }}</a></strong></td> <td><strong><a href="
<td class="text-right"><a href="{% url "control:event.items.properties.delete" organizer=request.event.organizer.slug event=request.event.slug property=p.pk %}"" class="btn btn-danger btn-sm"><i class="fa fa-trash"></i></a></td> {% url "control:event.items.properties.edit" organizer=request.event.organizer.slug event=request.event.slug property=p.identity %}">{{ p.name }}</a></strong></td>
<td class="text-right"><a href="
{% url "control:event.items.properties.delete" organizer=request.event.organizer.slug event=request.event.slug property=p.identity %}" class="btn btn-danger btn-sm"><i class="fa fa-trash"></i></a></td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>

View File

@@ -24,14 +24,14 @@
{% for f in formset %} {% for f in formset %}
<div class="form-group" data-formset-form> <div class="form-group" data-formset-form>
{{ f.id }} {{ f.id }}
<div class="col-sm-10"> <div class="col-sm-9">
{% bootstrap_field f.value form_group_class="" layout="inline" %} {% bootstrap_field f.value form_group_class="" layout="inline" %}
</div> </div>
<div class="sr-only"> <div class="sr-only">
{% bootstrap_field f.ORDER form_group_class="" layout="inline" %} {% bootstrap_field f.ORDER form_group_class="" layout="inline" %}
{% bootstrap_field f.DELETE form_group_class="" layout="inline" %} {% bootstrap_field f.DELETE form_group_class="" layout="inline" %}
</div> </div>
<div class="col-sm-2 text-right"> <div class="col-sm-3 text-right">
<button type="button" class="btn btn-default" data-formset-move-up-button><i class="fa fa-arrow-up"></i></button> <button type="button" class="btn btn-default" data-formset-move-up-button><i class="fa fa-arrow-up"></i></button>
<button type="button" class="btn btn-default" data-formset-move-down-button><i class="fa fa-arrow-down"></i></button> <button type="button" class="btn btn-default" data-formset-move-down-button><i class="fa fa-arrow-down"></i></button>
<button type="button" class="btn btn-danger" data-formset-delete-button><i class="fa fa-trash"></i></button> <button type="button" class="btn btn-danger" data-formset-delete-button><i class="fa fa-trash"></i></button>
@@ -43,14 +43,14 @@
{% escapescript %} {% escapescript %}
<div class="form-group" data-formset-form> <div class="form-group" data-formset-form>
{{ formset.empty_form.id }} {{ formset.empty_form.id }}
<div class="col-sm-10"> <div class="col-sm-9">
{% bootstrap_field formset.empty_form.value form_group_class="" layout="inline" %} {% bootstrap_field formset.empty_form.value form_group_class="" layout="inline" %}
</div> </div>
<div class="sr-only"> <div class="sr-only">
{% bootstrap_field formset.empty_form.ORDER form_group_class="" layout="inline" %} {% bootstrap_field formset.empty_form.ORDER form_group_class="" layout="inline" %}
{% bootstrap_field formset.empty_form.DELETE form_group_class="" layout="inline" %} {% bootstrap_field formset.empty_form.DELETE form_group_class="" layout="inline" %}
</div> </div>
<div class="col-sm-2 text-right"> <div class="col-sm-3 text-right">
<button type="button" class="btn btn-default" data-formset-move-up-button><i class="fa fa-arrow-up"></i></button> <button type="button" class="btn btn-default" data-formset-move-up-button><i class="fa fa-arrow-up"></i></button>
<button type="button" class="btn btn-default" data-formset-move-down-button><i class="fa fa-arrow-down"></i></button> <button type="button" class="btn btn-default" data-formset-move-down-button><i class="fa fa-arrow-down"></i></button>
<button type="button" class="btn btn-danger" data-formset-delete-button><i class="fa fa-trash"></i></button> <button type="button" class="btn btn-danger" data-formset-delete-button><i class="fa fa-trash"></i></button>
@@ -61,12 +61,10 @@
<button type="button" class="btn btn-default" data-formset-add><i class="fa fa-plus"></i> {% trans "Add a new value" %}</button> <button type="button" class="btn btn-default" data-formset-add><i class="fa fa-plus"></i> {% trans "Add a new value" %}</button>
</div> </div>
</fieldset> </fieldset>
<div class="form-group"> <div class="form-group submit-group">
<div class="col-sm-offset-2 col-sm-10"> <button type="submit" class="btn btn-primary btn-save">
<button type="submit" class="btn btn-primary"> {% trans "Save" %}
{% trans "Save" %} </button>
</button>
</div>
</div> </div>
</form> </form>
{% endblock %} {% endblock %}

View File

@@ -15,16 +15,14 @@
<form action="" method="post" class="form-horizontal"> <form action="" method="post" class="form-horizontal">
{% csrf_token %} {% csrf_token %}
<p>{% blocktrans %}Are you sure you want to the property <strong>{{ property }}</strong>?{% endblocktrans %}</p> <p>{% blocktrans %}Are you sure you want to the property <strong>{{ property }}</strong>?{% endblocktrans %}</p>
<div class="form-group"> <div class="form-group submit-group">
<div class="col-sm-offset-2 col-sm-10"> <a href="{% url "control:event.items.properties" organizer=request.event.organizer.slug event=request.event.slug %}" class="btn btn-default btn-cancel">
<button type="submit" class="btn btn-primary"> {% trans "Cancel" %}
{% trans "Confirm" %} </a>
</button> <button type="submit" class="btn btn-danger btn-save">
<a href="{% url "control:event.items.properties" organizer=request.event.organizer.slug event=request.event.slug %}" class="btn btn-default"> {% trans "Delete" %}
{% trans "Cancel" %} </button>
</a> </div>
</div>
</div>
</form> </form>
{% endif %} {% endif %}
{% endblock %} {% endblock %}

View File

@@ -17,12 +17,10 @@
{% bootstrap_field form.type layout="horizontal" %} {% bootstrap_field form.type layout="horizontal" %}
{% bootstrap_field form.required layout="horizontal" %} {% bootstrap_field form.required layout="horizontal" %}
</fieldset> </fieldset>
<div class="form-group"> <div class="form-group submit-group">
<div class="col-sm-offset-2 col-sm-10"> <button type="submit" class="btn btn-primary btn-save">
<button type="submit" class="btn btn-primary"> {% trans "Save" %}
{% trans "Save" %} </button>
</button>
</div>
</div> </div>
</form> </form>
{% endblock %} {% endblock %}

View File

@@ -8,20 +8,18 @@
{% csrf_token %} {% csrf_token %}
<p>{% blocktrans %}Are you sure you want to the question <strong>{{ question }}</strong>?{% endblocktrans %}</p> <p>{% blocktrans %}Are you sure you want to the question <strong>{{ question }}</strong>?{% endblocktrans %}</p>
{% if dependent|length > 0 %} {% if dependent|length > 0 %}
<p>{% blocktrans %}All answers to the question given by the buyers of the following tickets will be <strong>permanently lost</strong>.{% endblocktrans %}</p> <p>{% blocktrans %}All answers to the question given by the buyers of the following tickets will be <strong>lost</strong>.{% endblocktrans %}</p>
{% for item in dependent %} {% for item in dependent %}
<li><a href="{% url "control:event.item" organizer=request.event.organizer.slug event=request.event.slug item=item.pk %}">{{ item.name }}</a></li> <li><a href="{% url "control:event.item" organizer=request.event.organizer.slug event=request.event.slug item=item.pk %}">{{ item.name }}</a></li>
{% endfor %} {% endfor %}
{% endif %} {% endif %}
<div class="form-group"> <div class="form-group submit-group">
<div class="col-sm-offset-2 col-sm-10"> <a href="{% url "control:event.items.questions" organizer=request.event.organizer.slug event=request.event.slug %}" class="btn btn-default btn-cancel">
<button type="submit" class="btn btn-primary"> {% trans "Cancel" %}
{% trans "Confirm" %} </a>
</button> <button type="submit" class="btn btn-danger btn-save">
<a href="{% url "control:event.items.properties" organizer=request.event.organizer.slug event=request.event.slug %}" class="btn btn-default"> {% trans "Delete" %}
{% trans "Cancel" %} </button>
</a>
</div>
</div> </div>
</form> </form>
{% endblock %} {% endblock %}

View File

@@ -30,9 +30,11 @@
<tbody> <tbody>
{% for q in questions %} {% for q in questions %}
<tr> <tr>
<td><strong><a href="{% url "control:event.items.questions.edit" organizer=request.event.organizer.slug event=request.event.slug question=q.pk %}">{{ q.question }}</a></strong></td> <td><strong><a href="
{% url "control:event.items.questions.edit" organizer=request.event.organizer.slug event=request.event.slug question=q.identity %}">{{ q.question }}</a></strong></td>
<td>{{ q.get_type_display }}</td> <td>{{ q.get_type_display }}</td>
<td class="text-right"><a href="{% url "control:event.items.questions.delete" organizer=request.event.organizer.slug event=request.event.slug question=q.pk %}"" class="btn btn-danger btn-sm"><i class="fa fa-trash"></i></a></td> <td class="text-right"><a href="
{% url "control:event.items.questions.delete" organizer=request.event.organizer.slug event=request.event.slug question=q.identity %}" class="btn btn-danger btn-sm"><i class="fa fa-trash"></i></a></td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>

View File

@@ -0,0 +1,25 @@
{% extends "tixlcontrol/items/base.html" %}
{% load i18n %}
{% load bootstrap3 %}
{% block title %}{% trans "Quota" %}{% endblock %}
{% block inside %}
<h1>{% trans "Quota" %}</h1>
<form action="" method="post" class="form-horizontal">
{% csrf_token %}
{% if "success" in request.GET %}
<div class="alert alert-success">
{% trans "Your changes have been saved." %}
</div>
{% endif %}
<fieldset>
<legend>{% trans "General information" %}</legend>
{% bootstrap_field form.name layout="horizontal" %}
{% bootstrap_field form.size layout="horizontal" %}
</fieldset>
<div class="form-group submit-group">
<button type="submit" class="btn btn-primary btn-save">
{% trans "Save" %}
</button>
</div>
</form>
{% endblock %}

View File

@@ -0,0 +1,25 @@
{% extends "tixlcontrol/items/base.html" %}
{% load i18n %}
{% load bootstrap3 %}
{% block title %}{% trans "Delete quota" %}{% endblock %}
{% block inside %}
<h1>{% trans "Delete quota" %}</h1>
<form action="" method="post" class="form-horizontal">
{% csrf_token %}
<p>{% blocktrans %}Are you sure you want to delete the quota <strong>{{ quota }}</strong>?{% endblocktrans %}</p>
{% if dependent|length > 0 %}
<p>{% blocktrans %}The following items might be no longer available for sale:{% endblocktrans %}</p>
{% for item in dependent %}
<li><a href="{% url "control:event.item" organizer=request.event.organizer.slug event=request.event.slug item=item.pk %}">{{ item.name }}</a></li>
{% endfor %}
{% endif %}
<div class="form-group submit-group">
<a href="{% url "control:event.items.quotas" organizer=request.event.organizer.slug event=request.event.slug %}" class="btn btn-default btn-cancel">
{% trans "Cancel" %}
</a>
<button type="submit" class="btn btn-danger btn-save">
{% trans "Delete" %}
</button>
</div>
</form>
{% endblock %}

View File

@@ -0,0 +1,44 @@
{% extends "tixlcontrol/items/base.html" %}
{% load i18n %}
{% block title %}{% trans "Quotas" %}{% endblock %}
{% block inside %}
<h1>{% trans "Quotas" %}</h1>
{% if "updated" in request.GET %}
<div class="alert alert-success">
{% trans "Your changes have been saved." %}
</div>
{% elif "created" in request.GET %}
<div class="alert alert-success">
{% trans "A new quota has been created." %}
</div>
{% elif "deleted" in request.GET %}
<div class="alert alert-success">
{% trans "The quota has been deleted." %}
</div>
{% endif %}
<p>
<a href="{% url "control:event.items.quotas.add" organizer=request.event.organizer.slug event=request.event.slug %}" class="btn btn-default"><i class="fa fa-plus"></i> {% trans "Create a new quota" %}</a>
</p>
<table class="table table-hover">
<thead>
<tr>
<th>{% trans "Quota name" %}</th>
<th>{% trans "Items" %}</th>
<th>{% trans "Total capacity" %}</th>
<th>{% trans "Capacity left" %}</th>
<th></th>
</tr>
</thead>
<tbody>
{% for q in quotas %}
<tr>
<td><strong><a href="{% url "control:event.items.quotas.edit" organizer=request.event.organizer.slug event=request.event.slug quota=q.identity %}">{{ q.name }}</a></strong></td>
<td></td>
<td>{{ q.size }}</td>
<td></td>
<td class="text-right"><a href="{% url "control:event.items.quotas.delete" organizer=request.event.organizer.slug event=request.event.slug quota=q.identity %}" class="btn btn-danger btn-sm"><i class="fa fa-trash"></i></a></td>
</tr>
{% endfor %}
</tbody>
</table>
{% endblock %}

View File

@@ -1,6 +1,36 @@
from django.test import TestCase, Client from django.test import TestCase, Client, LiveServerTestCase
from selenium import webdriver
from tixlbase.models import User from tixlbase.models import User
from tixlbase.tests import BrowserTest, on_platforms
@on_platforms()
class LoginFormBrowserTest(BrowserTest):
def setUp(self):
super().setUp()
self.user = User.objects.create_user('dummy@dummy.dummy', 'dummy@dummy.dummy', 'dummy')
def test_login(self):
self.driver.implicitly_wait(10)
self.driver.get('%s%s' % (self.live_server_url, '/control/login'))
username_input = self.driver.find_element_by_name("email")
username_input.send_keys('dummy@dummy.dummy')
password_input = self.driver.find_element_by_name("password")
password_input.send_keys('dummy')
self.driver.find_element_by_css_selector('button[type="submit"]').click()
self.driver.find_element_by_class_name("navbar-right")
def test_login_fail(self):
self.driver.implicitly_wait(10)
self.driver.get('%s%s' % (self.live_server_url, '/control/login'))
username_input = self.driver.find_element_by_name("email")
username_input.send_keys('dummy@dummy.dummy')
password_input = self.driver.find_element_by_name("password")
password_input.send_keys('wrong')
self.driver.find_element_by_css_selector('button[type="submit"]').click()
self.driver.find_element_by_class_name("alert-danger")
class LoginFormTest(TestCase): class LoginFormTest(TestCase):

View File

@@ -21,22 +21,28 @@ urlpatterns += patterns(
url(r'^settings/$', event.EventUpdate.as_view(), name='event.settings'), url(r'^settings/$', event.EventUpdate.as_view(), name='event.settings'),
url(r'^settings/plugins$', event.EventPlugins.as_view(), name='event.settings.plugins'), url(r'^settings/plugins$', event.EventPlugins.as_view(), name='event.settings.plugins'),
url(r'^items/$', item.ItemList.as_view(), name='event.items'), url(r'^items/$', item.ItemList.as_view(), name='event.items'),
url(r'^items/(?P<item>\d+)/$', item.ItemUpdateGeneral.as_view(), name='event.item'), url(r'^items/(?P<item>[0-9a-f-]+)/$', item.ItemUpdateGeneral.as_view(), name='event.item'),
url(r'^items/(?P<item>\d+)/variations$', item.ItemVariations.as_view(), name='event.item.variations'), url(r'^items/(?P<item>[0-9a-f-]+)/variations$', item.ItemVariations.as_view(), name='event.item.variations'),
url(r'^items/(?P<item>[0-9a-f-]+)/restrictions$', item.ItemRestrictions.as_view(), name='event.item.restrictions'),
url(r'^categories/$', item.CategoryList.as_view(), name='event.items.categories'), url(r'^categories/$', item.CategoryList.as_view(), name='event.items.categories'),
url(r'^categories/(?P<category>\d+)/delete$', item.CategoryDelete.as_view(), name='event.items.categories.delete'), url(r'^categories/(?P<category>[0-9a-f-]+)/delete$', item.CategoryDelete.as_view(), name='event.items.categories.delete'),
url(r'^categories/(?P<category>\d+)/up$', item.category_move_up, name='event.items.categories.up'), url(r'^categories/(?P<category>[0-9a-f-]+)/up$', item.category_move_up, name='event.items.categories.up'),
url(r'^categories/(?P<category>\d+)/down$', item.category_move_down, name='event.items.categories.down'), url(r'^categories/(?P<category>[0-9a-f-]+)/down$', item.category_move_down, name='event.items.categories.down'),
url(r'^categories/(?P<category>\d+)/$', item.CategoryUpdate.as_view(), name='event.items.categories.edit'), url(r'^categories/(?P<category>[0-9a-f-]+)/$', item.CategoryUpdate.as_view(), name='event.items.categories.edit'),
url(r'^categories/add$', item.CategoryCreate.as_view(), name='event.items.categories.add'), url(r'^categories/add$', item.CategoryCreate.as_view(), name='event.items.categories.add'),
url(r'^questions/$', item.QuestionList.as_view(), name='event.items.questions'), url(r'^questions/$', item.QuestionList.as_view(), name='event.items.questions'),
url(r'^questions/(?P<question>\d+)/delete$', item.QuestionDelete.as_view(), name='event.items.questions.delete'), url(r'^questions/(?P<question>[0-9a-f-]+)/delete$', item.QuestionDelete.as_view(), name='event.items.questions.delete'),
url(r'^questions/(?P<question>\d+)/$', item.QuestionUpdate.as_view(), name='event.items.questions.edit'), url(r'^questions/(?P<question>[0-9a-f-]+)/$', item.QuestionUpdate.as_view(), name='event.items.questions.edit'),
url(r'^questions/add$', item.QuestionCreate.as_view(), name='event.items.questions.add'), url(r'^questions/add$', item.QuestionCreate.as_view(), name='event.items.questions.add'),
url(r'^properties/$', item.PropertyList.as_view(), name='event.items.properties'), url(r'^properties/$', item.PropertyList.as_view(), name='event.items.properties'),
url(r'^properties/(?P<property>\d+)/$', item.PropertyUpdate.as_view(), name='event.items.properties.edit'), url(r'^properties/(?P<property>[0-9a-f-]+)/$', item.PropertyUpdate.as_view(), name='event.items.properties.edit'),
url(r'^properties/(?P<property>\d+)/delete$', item.PropertyDelete.as_view(), name='event.items.properties.delete'), url(r'^properties/(?P<property>[0-9a-f-]+)/delete$', item.PropertyDelete.as_view(), name='event.items.properties.delete'),
url(r'^properties/add$', item.PropertyCreate.as_view(), name='event.items.properties.add'), url(r'^properties/add$', item.PropertyCreate.as_view(), name='event.items.properties.add'),
url(r'^quotas/$', item.QuotaList.as_view(), name='event.items.quotas'),
url(r'^quotas/(?P<quota>[0-9a-f-]+)/$', item.QuotaUpdate.as_view(), name='event.items.quotas.edit'),
url(r'^quotas/(?P<quota>[0-9a-f-]+)/delete$', item.QuotaDelete.as_view(),
name='event.items.quotas.delete'),
url(r'^quotas/add$', item.QuotaCreate.as_view(), name='event.items.quotas.add'),
) )
)) ))
) )

View File

@@ -7,12 +7,13 @@ from django.utils.translation import ugettext_lazy as _
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
from pytz import common_timezones from pytz import common_timezones
from tixlbase.forms import VersionedModelForm
from tixlbase.models import Event from tixlbase.models import Event
from tixlcontrol.permissions import EventPermissionRequiredMixin from tixlcontrol.permissions import EventPermissionRequiredMixin
class EventUpdateForm(forms.ModelForm): class EventUpdateForm(VersionedModelForm):
timezone = forms.ChoiceField( timezone = forms.ChoiceField(
choices=((a, a) for a in common_timezones), choices=((a, a) for a in common_timezones),
@@ -52,10 +53,10 @@ class EventUpdate(EventPermissionRequiredMixin, UpdateView):
template_name = 'tixlcontrol/event/settings.html' template_name = 'tixlcontrol/event/settings.html'
permission = 'can_change_settings' permission = 'can_change_settings'
def get_object(self, queryset=None): def get_object(self, queryset=None) -> Event:
return self.request.event return self.request.event
def get_success_url(self): def get_success_url(self) -> str:
return reverse('control:event.settings', kwargs={ return reverse('control:event.settings', kwargs={
'organizer': self.get_object().organizer.slug, 'organizer': self.get_object().organizer.slug,
'event': self.get_object().slug, 'event': self.get_object().slug,
@@ -72,7 +73,7 @@ class EventPlugins(EventPermissionRequiredMixin, TemplateView, SingleObjectMixin
def get_object(self, queryset=None): def get_object(self, queryset=None):
return self.request.event return self.request.event
def get_context_data(self, *args, **kwargs): def get_context_data(self, *args, **kwargs) -> dict:
from tixlbase.plugins import get_all_plugins from tixlbase.plugins import get_all_plugins
context = super().get_context_data(*args, **kwargs) context = super().get_context_data(*args, **kwargs)
context['plugins'] = [p for p in get_all_plugins() if not p.name.startswith('.')] context['plugins'] = [p for p in get_all_plugins() if not p.name.startswith('.')]
@@ -98,7 +99,7 @@ class EventPlugins(EventPermissionRequiredMixin, TemplateView, SingleObjectMixin
self.object.save() self.object.save()
return redirect(self.get_success_url()) return redirect(self.get_success_url())
def get_success_url(self): def get_success_url(self) -> str:
return reverse('control:event.settings.plugins', kwargs={ return reverse('control:event.settings.plugins', kwargs={
'organizer': self.get_object().organizer.slug, 'organizer': self.get_object().organizer.slug,
'event': self.get_object().slug, 'event': self.get_object().slug,

View File

@@ -1,15 +1,25 @@
from itertools import product
from django import forms from django import forms
from django.core.exceptions import ValidationError
from django.db import transaction, IntegrityError
from django.forms.widgets import flatatt
from django.utils.encoding import force_text
from django.utils.html import format_html
from django.utils.safestring import mark_safe
from django.utils.translation import ugettext as _
from tixlbase.forms import VersionedModelForm
from tixlbase.models import ItemVariation, PropertyValue, Item
class TolerantFormsetModelForm(forms.ModelForm): class TolerantFormsetModelForm(VersionedModelForm):
def has_changed(self) -> bool:
def has_changed(self):
""" """
Returns True if data differs from initial. Contrary to the default Returns True if data differs from initial. Contrary to the default
implementation, the ORDER field is being ignored. implementation, the ORDER field is being ignored.
""" """
for name, field in self.fields.items(): for name, field in self.fields.items():
if name == 'ORDER': if name == 'ORDER' or name == 'id':
continue continue
prefixed_name = self.add_prefix(name) prefixed_name = self.add_prefix(name)
data_value = field.widget.value_from_datadict(self.data, self.files, prefixed_name) data_value = field.widget.value_from_datadict(self.data, self.files, prefixed_name)
@@ -27,6 +37,325 @@ class TolerantFormsetModelForm(forms.ModelForm):
# Always assume data has changed if validation fails. # Always assume data has changed if validation fails.
self._changed_data.append(name) self._changed_data.append(name)
continue continue
# We're using a private API of Django here. This is not nice, but no problem as it seems
# like this will become a public API in future Django.
if field._has_changed(initial_value, data_value): if field._has_changed(initial_value, data_value):
return True return True
return False return False
class RestrictionForm(TolerantFormsetModelForm):
"""
The restriction form provides useful functionality for all forms
representing a restriction instance. To be concret, this form does
the necessary magic to make the 'variations' field work correctly
and look beautiful.
"""
def __init__(self, *args, **kwargs):
if 'item' in kwargs:
self.item = kwargs['item']
del kwargs['item']
super().__init__(*args, **kwargs)
if 'variations' in self.fields and isinstance(self.fields['variations'], VariationsField):
self.fields['variations'].set_item(self.item)
class RestrictionInlineFormset(forms.BaseInlineFormSet):
"""
This is the base class you should use for any formset you return
from a ``restriction_formset`` signal receiver that contains
RestrictionForm objects as its forms, as it correcly handles the
necessary item parameter for the RestrictionForm. While this could
be achieved with a regular formset, this also adds a
``initialized_empty_form`` method which is the only way to correctly
render a working empty form for a JavaScript-enabled restriction formset.
"""
def __init__(self, data=None, files=None, instance=None,
save_as_new=False, prefix=None, queryset=None, **kwargs):
super().__init__(
data, files, instance, save_as_new, prefix, queryset, **kwargs
)
if isinstance(self.instance, Item):
self.queryset = self.queryset.as_of().prefetch_related("variations")
def initialized_empty_form(self):
form = self.form(
auto_id=self.auto_id,
prefix=self.add_prefix('__prefix__'),
empty_permitted=True,
item=self.instance
)
self.add_fields(form, None)
return form
def _construct_form(self, i, **kwargs):
kwargs['item'] = self.instance
return super()._construct_form(i, **kwargs)
class Meta:
exclude = ['item']
class VariationsFieldRenderer(forms.widgets.CheckboxFieldRenderer):
def __init__(self, name, value, attrs, choices):
self.name = name
self.value = value
self.attrs = attrs
self.choices = choices
def render(self):
"""
Outputs a grid for this set of choice fields.
"""
if len(self.choices) == 0:
raise ValueError("Can't handle empty lists")
variations = []
for key, value in self.choices:
value['key'] = key
variations.append(value)
properties = [v.prop for v in variations[0].relevant_values()]
dimension = len(properties)
id_ = self.attrs.get('id', None)
start_tag = format_html('<div class="variations" id="{0}">', id_) if id_ else '<div class="variations">'
output = [start_tag]
# TODO: This is very duplicate to tixlcontrol.views.item.ItemVariations.get_forms()
# Find a common abstraction to avoid the repetition.
if dimension == 0:
output.append(format_html('<em>{0}</em>', _("not applicable")))
elif dimension == 1:
output.append('<ul>')
for i, variation in enumerate(variations):
final_attrs = dict(
self.attrs.copy(), type=self.choice_input_class.input_type,
name=self.name, value=variation['key']
)
if variation['key'] in self.value:
final_attrs['checked'] = 'checked'
w = self.choice_input_class(
self.name, self.value, self.attrs.copy(),
(variation['key'], variation[properties[0].identity].value),
i
)
output.append(format_html('<li>{0}</li>', force_text(w)))
output.append('</ul>')
elif dimension >= 2:
# prop1 is the property on all the grid's y-axes
prop1 = properties[0]
prop1v = list(prop1.values.current.all())
# prop2 is the property on all the grid's x-axes
prop2 = properties[1]
prop2v = list(prop2.values.current.all())
# Given an iterable of PropertyValue objects, this will return a
# list of their primary keys, ordered by the primary keys of the
# properties they belong to EXCEPT the value for the property prop2.
# We'll see later why we need this.
selector = lambda values: [
v.identity for v in sorted(values, key=lambda v: v.prop.identity)
if v.prop.identity != prop2.identity
]
# Given a list of variations, this will sort them by their position
# on the x-axis
sort = lambda v: v[prop2.identity].identity
# We now iterate over the cartesian product of all the other
# properties which are NOT on the axes of the grid because we
# create one grid for any combination of them.
for gridrow in product(*[prop.values.current.all() for prop in properties[2:]]):
if len(gridrow) > 0:
output.append('<strong>')
output.append(", ".join([value.value for value in gridrow]))
output.append('</strong>')
output.append('<table class="table"><thead><tr><th></th>')
for val2 in prop2v:
output.append(format_html('<th>{0}</th>', val2.value))
output.append('</thead><tbody>')
for val1 in prop1v:
output.append(format_html('<tr><th>{0}</th>', val1.value))
# We are now inside one of the rows of the grid and have to
# select the variations to display in this row. In order to
# achieve this, we use the 'selector' lambda defined above.
# It gives us a normalized, comparable version of a set of
# PropertyValue objects. In this case, we compute the
# selector of our row as the selector of the sum of the
# values defining our grind and the value defining our row.
selection = selector(gridrow + (val1,))
# We now iterate over all variations who generate the same
# selector as 'selection'.
filtered = [v for v in variations if selector(v.relevant_values()) == selection]
for variation in sorted(filtered, key=sort):
final_attrs = dict(
self.attrs.copy(), type=self.choice_input_class.input_type,
name=self.name, value=variation['key']
)
if variation['key'] in self.value:
final_attrs['checked'] = 'checked'
output.append(format_html('<td><label><input{0} /></label></td>', flatatt(final_attrs)))
output.append('</td>')
output.append('</tbody></table>')
output.append('</div>')
return mark_safe('\n'.join(output))
class VariationsCheckboxRenderer(VariationsFieldRenderer):
choice_input_class = forms.widgets.CheckboxChoiceInput
class VariationsSelectMultiple(forms.CheckboxSelectMultiple):
renderer = VariationsCheckboxRenderer
_empty_value = []
class VariationsField(forms.ModelMultipleChoiceField):
"""
This form field is intended to be used to let the user select a
variation of a certain item, for example in a restriction plugin.
As this field expects the non-standard keyword parameter ``item``
at initialization time, this is field is normally named ``variations``
and lives inside a ``tixlcontrol.views.forms.RestrictionForm``, which
does some magic to provide this parameter.
"""
def __init__(self, *args, item=None, **kwargs):
self.item = item
if 'widget' not in args or kwargs['widget'] is None:
kwargs['widget'] = VariationsSelectMultiple
super().__init__(*args, **kwargs)
def set_item(self, item: Item):
self.item = item
self._set_choices(self._get_choices())
def _get_choices(self) -> "list[(str, VariationDict)]":
"""
We can't use a normal QuerySet as there theoretically might be
two types of variations: Some who already have a ItemVariation
object associated with tham and some who don't. We therefore use
the item's ``get_all_variations`` method. In the first case, we
use the ItemVariation objects primary key as our choice, key,
in the latter case we use a string constructed from the values
(see VariationDict.key() for implementation details).
"""
if self.item is None:
return ()
variations = self.item.get_all_variations(use_cache=True)
return (
(
v['variation'].identity if 'variation' in v else v.key(),
v
) for v in variations
)
def clean(self, value: "list[int]"):
"""
At cleaning time, we have to clean up the mess we produced with our
_get_choices implementation. In the case of ItemVariation object ids
we don't to anything to them, but if one of the selected items is a
list of PropertyValue objects (see _get_choices), we need to create
a new ItemVariation object for this combination and then add this to
our list of selected items.
"""
if self.item is None:
raise ValueError(
"VariationsField object was not properly initialized. Please"
"use a tixlcontrol.views.forms.RestrictionForm form instead of"
"a plain Django ModelForm"
)
# Standard validation foo
if self.required and not value:
raise ValidationError(self.error_messages['required'], code='required')
elif not self.required and not value:
return self.queryset.none()
if not isinstance(value, (list, tuple)):
raise ValidationError(self.error_messages['list'], code='list')
# Build up a cache of variations having an ItemVariation object
# For implementation details, see ItemVariation.get_all_variations()
# which uses a very similar method
all_variations = self.item.variations.all().prefetch_related("values")
variations_cache = {}
for var in all_variations:
key = []
for v in var.values.all():
key.append((v.prop_id, v.identity))
key = tuple(sorted(key))
variations_cache[key] = var.identity
cleaned_value = []
# Wrap this in a transaction to prevent strange database state if we
# get a ValidationError half-way through
with transaction.atomic():
for pk in value:
if ":" in pk:
# A combination of PropertyValues was given
# Hash the combination in the same way as in our cache above
key = []
for pair in pk.split(","):
key.append(tuple([i for i in pair.split(":")]))
key = tuple(sorted(key))
if key in variations_cache:
# An ItemVariation object already exists for this variation,
# so use this. (This might occur if the variation object was
# created _after_ the user loaded the form but _before_ he
# submitted it.)
cleaned_value.append(str(variations_cache[key]))
continue
# No ItemVariation present, create one!
var = ItemVariation()
var.item_id = self.item.identity
var.save()
# Add the values to the ItemVariation object
for pair in pk.split(","):
prop, value = pair.split(":")
try:
var.values.add(
PropertyValue.objects.current.get(
identity=value,
prop_id=prop
)
)
except PropertyValue.DoesNotExist:
raise ValidationError(
self.error_messages['invalid_pk_value'],
code='invalid_pk_value',
params={'pk': value},
)
variations_cache[key] = var.identity
cleaned_value.append(str(var.identity))
else:
# An ItemVariation id was given
cleaned_value.append(pk)
qs = self.item.variations.current.filter(identity__in=cleaned_value)
# Re-check for consistency
pks = set(force_text(getattr(o, "identity")) for o in qs)
for val in cleaned_value:
if force_text(val) not in pks:
raise ValidationError(
self.error_messages['invalid_choice'],
code='invalid_choice',
params={'value': val},
)
# Since this overrides the inherited ModelChoiceField.clean
# we run custom validators here
self.run_validators(cleaned_value)
return qs
choices = property(_get_choices, forms.ChoiceField._set_choices)

View File

@@ -1,4 +1,5 @@
from itertools import product from itertools import product
from django.db import transaction
from django.views.generic import ListView from django.views.generic import ListView
from django.views.generic.edit import CreateView, UpdateView, DeleteView from django.views.generic.edit import CreateView, UpdateView, DeleteView
@@ -6,13 +7,16 @@ from django.views.generic.base import TemplateView
from django.views.generic.detail import SingleObjectMixin from django.views.generic.detail import SingleObjectMixin
from django.core.urlresolvers import resolve, reverse from django.core.urlresolvers import resolve, reverse
from django.http import HttpResponseRedirect, HttpResponseForbidden from django.http import HttpResponseRedirect, HttpResponseForbidden
from django import forms
from django.shortcuts import redirect from django.shortcuts import redirect
from django.forms.models import inlineformset_factory from django.forms.models import inlineformset_factory
from tixlbase.forms import VersionedModelForm
from tixlbase.models import Item, ItemCategory, Property, ItemVariation, PropertyValue, Question from tixlbase.models import (
Item, ItemCategory, Property, ItemVariation, PropertyValue, Question, Quota
)
from tixlcontrol.permissions import EventPermissionRequiredMixin, event_permission_required from tixlcontrol.permissions import EventPermissionRequiredMixin, event_permission_required
from tixlcontrol.views.forms import TolerantFormsetModelForm from tixlcontrol.views.forms import TolerantFormsetModelForm
from tixlcontrol.signals import restriction_formset
class ItemList(ListView): class ItemList(ListView):
@@ -21,12 +25,12 @@ class ItemList(ListView):
template_name = 'tixlcontrol/items/index.html' template_name = 'tixlcontrol/items/index.html'
def get_queryset(self): def get_queryset(self):
return Item.objects.filter( return Item.objects.current.filter(
event=self.request.event event=self.request.event
).prefetch_related("category") ).prefetch_related("category")
class CategoryForm(forms.ModelForm): class CategoryForm(VersionedModelForm):
class Meta: class Meta:
model = ItemCategory model = ItemCategory
@@ -45,13 +49,15 @@ class CategoryDelete(EventPermissionRequiredMixin, DeleteView):
def get_object(self, queryset=None): def get_object(self, queryset=None):
url = resolve(self.request.path_info) url = resolve(self.request.path_info)
return self.request.event.categories.get( return self.request.event.categories.current.get(
id=url.kwargs['category'] identity=url.kwargs['category']
) )
def delete(self, request, *args, **kwargs): def delete(self, request, *args, **kwargs):
self.object = self.get_object() self.object = self.get_object()
self.object.items.update(category=None) for item in self.object.items.current.all():
item.category = None
item.save()
success_url = self.get_success_url() success_url = self.get_success_url()
self.object.delete() self.object.delete()
return HttpResponseRedirect(success_url) return HttpResponseRedirect(success_url)
@@ -72,8 +78,8 @@ class CategoryUpdate(EventPermissionRequiredMixin, UpdateView):
def get_object(self, queryset=None): def get_object(self, queryset=None):
url = resolve(self.request.path_info) url = resolve(self.request.path_info)
return self.request.event.categories.get( return self.request.event.categories.current.get(
id=url.kwargs['category'] identity=url.kwargs['category']
) )
def get_success_url(self): def get_success_url(self):
@@ -107,14 +113,14 @@ class CategoryList(ListView):
template_name = 'tixlcontrol/items/categories.html' template_name = 'tixlcontrol/items/categories.html'
def get_queryset(self): def get_queryset(self):
return self.request.event.categories.all() return self.request.event.categories.current.all()
def category_move(request, organizer, event, category, up=True): def category_move(request, organizer, event, category, up=True):
category = request.event.categories.get( category = request.event.categories.current.get(
id=category identity=category
) )
categories = list(request.event.categories.order_by("position")) categories = list(request.event.categories.current.order_by("position"))
index = categories.index(category) index = categories.index(category)
if index != 0 and up: if index != 0 and up:
@@ -152,12 +158,12 @@ class PropertyList(ListView):
template_name = 'tixlcontrol/items/properties.html' template_name = 'tixlcontrol/items/properties.html'
def get_queryset(self): def get_queryset(self):
return Property.objects.filter( return Property.objects.current.filter(
event=self.request.event event=self.request.event
) )
class PropertyForm(forms.ModelForm): class PropertyForm(VersionedModelForm):
class Meta: class Meta:
model = Property model = Property
localized_fields = '__all__' localized_fields = '__all__'
@@ -184,8 +190,8 @@ class PropertyUpdate(EventPermissionRequiredMixin, UpdateView):
def get_object(self, queryset=None): def get_object(self, queryset=None):
url = resolve(self.request.path_info) url = resolve(self.request.path_info)
return self.request.event.properties.get( return self.request.event.properties.current.get(
id=url.kwargs['property'] identity=url.kwargs['property']
) )
def get_success_url(self): def get_success_url(self):
@@ -203,7 +209,9 @@ class PropertyUpdate(EventPermissionRequiredMixin, UpdateView):
can_order=True, can_order=True,
extra=0, extra=0,
) )
formset = formsetclass(**self.get_form_kwargs()) kwargs = self.get_form_kwargs()
kwargs['queryset'] = self.object.values.current.all()
formset = formsetclass(**kwargs)
return formset return formset
def get_context_data(self, *args, **kwargs): def get_context_data(self, *args, **kwargs):
@@ -212,9 +220,15 @@ class PropertyUpdate(EventPermissionRequiredMixin, UpdateView):
return context return context
def form_valid(self, form, formset): def form_valid(self, form, formset):
for f in formset.deleted_forms:
f.instance.delete()
f.instance.pk = None
for i, f in enumerate(formset.ordered_forms): for i, f in enumerate(formset.ordered_forms):
if f.instance.pk is not None:
f.instance = f.instance.clone()
f.instance.position = i f.instance.position = i
formset.save() f.instance.save()
return super().form_valid(form) return super().form_valid(form)
def post(self, request, *args, **kwargs): def post(self, request, *args, **kwargs):
@@ -285,18 +299,18 @@ class PropertyDelete(EventPermissionRequiredMixin, DeleteView):
def get_context_data(self, *args, **kwargs): def get_context_data(self, *args, **kwargs):
context = super().get_context_data(*args, **kwargs) context = super().get_context_data(*args, **kwargs)
context['dependent'] = self.get_object().items.all() context['dependent'] = self.get_object().items.current.all()
context['possible'] = self.is_allowed() context['possible'] = self.is_allowed()
return context return context
def is_allowed(self): def is_allowed(self):
return self.get_object().items.count() == 0 return self.get_object().items.current.count() == 0
def get_object(self, queryset=None): def get_object(self, queryset=None):
if not hasattr(self, 'object') or not self.object: if not hasattr(self, 'object') or not self.object:
url = resolve(self.request.path_info) url = resolve(self.request.path_info)
self.object = self.request.event.properties.get( self.object = self.request.event.properties.current.get(
id=url.kwargs['property'] identity=url.kwargs['property']
) )
return self.object return self.object
@@ -321,10 +335,10 @@ class QuestionList(ListView):
template_name = 'tixlcontrol/items/questions.html' template_name = 'tixlcontrol/items/questions.html'
def get_queryset(self): def get_queryset(self):
return self.request.event.questions.all() return self.request.event.questions.current.all()
class QuestionForm(forms.ModelForm): class QuestionForm(VersionedModelForm):
class Meta: class Meta:
model = Question model = Question
@@ -344,18 +358,17 @@ class QuestionDelete(EventPermissionRequiredMixin, DeleteView):
def get_object(self, queryset=None): def get_object(self, queryset=None):
url = resolve(self.request.path_info) url = resolve(self.request.path_info)
return self.request.event.questions.get( return self.request.event.questions.current.get(
id=url.kwargs['question'] identity=url.kwargs['question']
) )
def get_context_data(self, *args, **kwargs): def get_context_data(self, *args, **kwargs):
context = super().get_context_data(*args, **kwargs) context = super().get_context_data(*args, **kwargs)
context['dependent'] = list(self.get_object().items.all()) context['dependent'] = list(self.get_object().items.current.all())
return context return context
def delete(self, request, *args, **kwargs): def delete(self, request, *args, **kwargs):
self.object = self.get_object() self.object = self.get_object()
self.object.items.update(category=None)
success_url = self.get_success_url() success_url = self.get_success_url()
self.object.delete() self.object.delete()
return HttpResponseRedirect(success_url) return HttpResponseRedirect(success_url)
@@ -376,8 +389,8 @@ class QuestionUpdate(EventPermissionRequiredMixin, UpdateView):
def get_object(self, queryset=None): def get_object(self, queryset=None):
url = resolve(self.request.path_info) url = resolve(self.request.path_info)
return self.request.event.questions.get( return self.request.event.questions.current.get(
id=url.kwargs['question'] identity=url.kwargs['question']
) )
def get_success_url(self): def get_success_url(self):
@@ -405,13 +418,117 @@ class QuestionCreate(EventPermissionRequiredMixin, CreateView):
return super().form_valid(form) return super().form_valid(form)
class ItemUpdateFormGeneral(forms.ModelForm): class QuotaList(ListView):
model = Quota
context_object_name = 'quotas'
template_name = 'tixlcontrol/items/quotas.html'
def get_queryset(self):
return Quota.objects.current.filter(
event=self.request.event
)
class QuotaForm(VersionedModelForm):
class Meta:
model = Quota
localized_fields = '__all__'
fields = [
'name',
'size',
]
class QuotaCreate(EventPermissionRequiredMixin, CreateView):
model = Quota
form_class = QuotaForm
template_name = 'tixlcontrol/items/quota.html'
permission = 'can_change_items'
context_object_name = 'quota'
def get_success_url(self):
return reverse('control:event.items.quotas', kwargs={
'organizer': self.request.event.organizer.slug,
'event': self.request.event.slug,
}) + '?created=true'
def form_valid(self, form):
form.instance.event = self.request.event
return super().form_valid(form)
class QuotaUpdate(EventPermissionRequiredMixin, UpdateView):
model = Quota
form_class = QuotaForm
template_name = 'tixlcontrol/items/quota.html'
permission = 'can_change_items'
context_object_name = 'quota'
def get_object(self, queryset=None):
url = resolve(self.request.path_info)
return self.request.event.quotas.current.get(
identity=url.kwargs['quota']
)
def get_success_url(self):
return reverse('control:event.items.quotas', kwargs={
'organizer': self.request.event.organizer.slug,
'event': self.request.event.slug,
}) + '?updated=true'
class QuotaDelete(EventPermissionRequiredMixin, DeleteView):
model = Quota
template_name = 'tixlcontrol/items/quota_delete.html'
permission = 'can_change_items'
context_object_name = 'quota'
def get_object(self, queryset=None):
url = resolve(self.request.path_info)
return self.request.event.quotas.current.get(
identity=url.kwargs['quota']
)
def get_context_data(self, *args, **kwargs):
context = super().get_context_data(*args, **kwargs)
context['dependent'] = list(self.get_object().items.current.all())
return context
def delete(self, request, *args, **kwargs):
self.object = self.get_object()
success_url = self.get_success_url()
self.object.delete()
return HttpResponseRedirect(success_url)
def get_success_url(self):
return reverse('control:event.items.quotas', kwargs={
'organizer': self.request.event.organizer.slug,
'event': self.request.event.slug,
}) + '?deleted=true'
class ItemDetailMixin(SingleObjectMixin):
model = Item
context_object_name = 'item'
def get_object(self, queryset=None):
if not hasattr(self, 'object') or not self.object:
url = resolve(self.request.path_info)
self.item = self.request.event.items.current.get(
identity=url.kwargs['item']
)
self.object = self.item
return self.object
class ItemUpdateFormGeneral(VersionedModelForm):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.fields['category'].queryset = self.instance.event.categories.all() self.fields['category'].queryset = self.instance.event.categories.current.all()
self.fields['properties'].queryset = self.instance.event.properties.all() self.fields['properties'].queryset = self.instance.event.properties.current.all()
self.fields['questions'].queryset = self.instance.event.questions.all() self.fields['questions'].queryset = self.instance.event.questions.current.all()
class Meta: class Meta:
model = Item model = Item
@@ -429,28 +546,20 @@ class ItemUpdateFormGeneral(forms.ModelForm):
] ]
class ItemUpdateGeneral(EventPermissionRequiredMixin, UpdateView): class ItemUpdateGeneral(ItemDetailMixin, EventPermissionRequiredMixin, UpdateView):
model = Item
form_class = ItemUpdateFormGeneral form_class = ItemUpdateFormGeneral
template_name = 'tixlcontrol/item/index.html' template_name = 'tixlcontrol/item/index.html'
permission = 'can_change_items' permission = 'can_change_items'
context_object_name = 'item'
def get_object(self, queryset=None):
url = resolve(self.request.path_info)
return self.request.event.items.get(
id=url.kwargs['item']
)
def get_success_url(self): def get_success_url(self):
return reverse('control:event.item', kwargs={ return reverse('control:event.item', kwargs={
'organizer': self.request.event.organizer.slug, 'organizer': self.request.event.organizer.slug,
'event': self.request.event.slug, 'event': self.request.event.slug,
'item': self.get_object().pk, 'item': self.get_object().identity,
}) + '?success=true' }) + '?success=true'
class ItemVariationForm(forms.ModelForm): class ItemVariationForm(VersionedModelForm):
class Meta: class Meta:
model = ItemVariation model = ItemVariation
@@ -461,10 +570,8 @@ class ItemVariationForm(forms.ModelForm):
] ]
class ItemVariations(EventPermissionRequiredMixin, TemplateView, SingleObjectMixin): class ItemVariations(ItemDetailMixin, EventPermissionRequiredMixin, TemplateView):
model = Item
context_object_name = 'item'
permission = 'can_change_items' permission = 'can_change_items'
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
@@ -482,13 +589,16 @@ class ItemVariations(EventPermissionRequiredMixin, TemplateView, SingleObjectMix
form = ItemVariationForm( form = ItemVariationForm(
data, data,
instance=variation['variation'], instance=variation['variation'],
prefix=",".join([str(i.pk) for i in values]), prefix=",".join([str(i.identity) for i in values]),
) )
else: else:
inst = ItemVariation(item=self.object)
inst.item_id = self.object.identity
inst.creation = True
form = ItemVariationForm( form = ItemVariationForm(
data, data,
instance=ItemVariation(item=self.object), instance=inst,
prefix=",".join([str(i.pk) for i in values]), prefix=",".join([str(i.identity) for i in values]),
) )
form.values = values form.values = values
return form return form
@@ -518,34 +628,8 @@ class ItemVariations(EventPermissionRequiredMixin, TemplateView, SingleObjectMix
forms.append(form) forms.append(form)
forms_flat = forms forms_flat = forms
elif self.dimension == 2: elif self.dimension >= 2:
# For two-dimensional structures we have a grid of forms # For 2 or more dimensional structures we display a list of grids
# prop1 is the property on the grid's y-axis
prop1 = self.properties[0]
# prop2 is the property on the grid's x-axis
prop2 = self.properties[1]
# Given a list of variations, this will sort them by their position
# on the x-axis
sort = lambda v: v[prop2.pk].pk
for val1 in prop1.values.all():
formrow = []
# We are now inside a grid row. We iterate over all variations
# which belong in this row and create forms for them. In order
# to achieve this, we select all variation dictionaries which
# have the same value for prop1 as our row does and sort them
# by their value for prop2.
filtered = [v for v in variations if v[prop1.pk].pk == val1.pk]
for variation in sorted(filtered, key=sort):
form = self.get_form(variation, data)
formrow.append(form)
forms_flat.append(form)
forms.append({'row': val1.value, 'forms': formrow})
elif self.dimension > 2:
# For 3 or more dimensional structures we display a list of grids
# of forms # of forms
# prop1 is the property on all the grid's y-axes # prop1 is the property on all the grid's y-axes
@@ -558,20 +642,20 @@ class ItemVariations(EventPermissionRequiredMixin, TemplateView, SingleObjectMix
# properties they belong to EXCEPT the value for the property prop2. # properties they belong to EXCEPT the value for the property prop2.
# We'll see later why we need this. # We'll see later why we need this.
selector = lambda values: [ selector = lambda values: [
v.pk for v in sorted(values, key=lambda v: v.prop.pk) v.identity for v in sorted(values, key=lambda v: v.prop.identity)
if v.prop.pk != prop2.pk if v.prop.identity != prop2.identity
] ]
# Given a list of variations, this will sort them by their position # Given a list of variations, this will sort them by their position
# on the x-axis # on the x-axis
sort = lambda v: v[prop2.pk].pk sort = lambda v: v[prop2.identity].identity
# We now iterate over the cartesian product of all the other # We now iterate over the cartesian product of all the other
# properties which are NOT on the axes of the grid because we # properties which are NOT on the axes of the grid because we
# create one grid for any combination of them. # create one grid for any combination of them.
for gridrow in product(*[prop.values.all() for prop in self.properties[2:]]): for gridrow in product(*[prop.values.current.all() for prop in self.properties[2:]]):
grids = [] grids = []
for val1 in prop1.values.all(): for val1 in prop1.values.current.all():
formrow = [] formrow = []
# We are now inside one of the rows of the grid and have to # We are now inside one of the rows of the grid and have to
# select the variations to display in this row. In order to # select the variations to display in this row. In order to
@@ -597,7 +681,7 @@ class ItemVariations(EventPermissionRequiredMixin, TemplateView, SingleObjectMix
def main(self, request, *args, **kwargs): def main(self, request, *args, **kwargs):
self.object = self.get_object() self.object = self.get_object()
self.properties = list(self.object.properties.all().prefetch_related("values")) self.properties = list(self.object.properties.current.all().prefetch_related("values"))
self.dimension = len(self.properties) self.dimension = len(self.properties)
self.forms, self.forms_flat = self.get_forms() self.forms, self.forms_flat = self.get_forms()
@@ -609,29 +693,24 @@ class ItemVariations(EventPermissionRequiredMixin, TemplateView, SingleObjectMix
def post(self, request, *args, **kwargs): def post(self, request, *args, **kwargs):
self.main(request, *args, **kwargs) self.main(request, *args, **kwargs)
context = self.get_context_data(object=self.object) context = self.get_context_data(object=self.object)
for form in self.forms_flat: with transaction.atomic():
if form.is_valid(): for form in self.forms_flat:
if form.instance.pk is None: if form.is_valid() and form.has_changed():
form.save()
form.instance.values.add(*form.values)
else:
form.save() form.save()
if hasattr(form.instance, 'creation') and form.instance.creation:
# We need this special 'creation' field set to true in get_form
# for newly created items as cleanerversion does already set the
# primary key in its post_init hook
form.instance.values.add(*form.values)
# TODO: Redirect to success message
return self.render_to_response(context) return self.render_to_response(context)
def get_object(self, queryset=None):
if not self.item:
url = resolve(self.request.path_info)
self.item = self.request.event.items.get(
id=url.kwargs['item']
)
return self.item
def get_template_names(self): def get_template_names(self):
if self.dimension == 1: if self.dimension == 0:
return ['tixlcontrol/item/variations_0d.html']
elif self.dimension == 1:
return ['tixlcontrol/item/variations_1d.html'] return ['tixlcontrol/item/variations_1d.html']
elif self.dimension == 2: elif self.dimension >= 2:
return ['tixlcontrol/item/variations_2d.html']
elif self.dimension > 2:
return ['tixlcontrol/item/variations_nd.html'] return ['tixlcontrol/item/variations_nd.html']
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
@@ -639,3 +718,64 @@ class ItemVariations(EventPermissionRequiredMixin, TemplateView, SingleObjectMix
context['forms'] = self.forms context['forms'] = self.forms
context['properties'] = self.properties context['properties'] = self.properties
return context return context
class ItemRestrictions(ItemDetailMixin, EventPermissionRequiredMixin, TemplateView):
permission = 'can_change_items'
template_name = 'tixlcontrol/item/restrictions.html'
def get_formsets(self):
responses = restriction_formset.send(self.object.event, item=self.object)
formsets = []
for receiver, response in responses:
response['formset'] = response['formsetclass'](
self.request.POST if self.request.method == 'POST' else None,
instance=self.object,
prefix=response['prefix'],
)
formsets.append(response)
return formsets
def main(self, request, *args, **kwargs):
self.object = self.get_object()
self.request = request
self.formsets = self.get_formsets()
def get(self, request, *args, **kwargs):
self.main(request, *args, **kwargs)
context = self.get_context_data(object=self.object)
return self.render_to_response(context)
def post(self, request, *args, **kwargs):
self.main(request, *args, **kwargs)
valid = True
for f in self.formsets:
valid &= f['formset'].is_valid()
if valid:
for f in self.formsets:
for form in f['formset']:
if 'DELETE' in form.cleaned_data and form.cleaned_data['DELETE'] is True:
if form.instance.pk is None:
continue
form.instance.delete()
else:
form.instance.event = request.event
form.instance.item = self.object
form.save()
return redirect(self.get_success_url())
else:
context = self.get_context_data(object=self.object)
return self.render_to_response(context)
def get_context_data(self, *args, **kwargs):
context = super().get_context_data(*args, **kwargs)
context['formsets'] = self.formsets
return context
def get_success_url(self):
return reverse('control:event.item.restrictions', kwargs={
'organizer': self.request.event.organizer.slug,
'event': self.request.event.slug,
'item': self.object.identity
}) + '?success=true'

View File

@@ -10,7 +10,7 @@ class EventList(ListView):
template_name = 'tixlcontrol/events/index.html' template_name = 'tixlcontrol/events/index.html'
def get_queryset(self): def get_queryset(self):
return Event.objects.filter( return Event.objects.current.filter(
permitted__id__exact=self.request.user.pk permitted__id__exact=self.request.user.pk
).prefetch_related( ).prefetch_related(
"organizer", "organizer",

View File

@@ -9,7 +9,7 @@ class TimeRestrictionApp(AppConfig):
class TixlPluginMeta: class TixlPluginMeta:
type = PluginType.RESTRICTION type = PluginType.RESTRICTION
name = _("Restriciton by time") name = _("Restricition by time")
author = _("the tixl team") author = _("the tixl team")
version = '1.0.0' version = '1.0.0'
description = _("This plugin adds the possibility to restrict the sale " + description = _("This plugin adds the possibility to restrict the sale " +

View File

@@ -2,30 +2,36 @@
from __future__ import unicode_literals from __future__ import unicode_literals
from django.db import models, migrations from django.db import models, migrations
import tixlbase.models
import versions.models
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('tixlbase', '0015_auto_20141006_2205'), ('tixlbase', '0001_initial'),
] ]
operations = [ operations = [
migrations.CreateModel( migrations.CreateModel(
name='TimeRestriction', name='TimeRestriction',
fields=[ fields=[
('id', models.AutoField(primary_key=True, serialize=False, verbose_name='ID', auto_created=True)), ('id', models.CharField(serialize=False, primary_key=True, max_length=36)),
('identity', models.CharField(max_length=36)),
('version_start_date', models.DateTimeField()),
('version_end_date', models.DateTimeField(null=True, blank=True, default=None)),
('version_birth_date', models.DateTimeField()),
('timeframe_from', models.DateTimeField(verbose_name='Start of time frame')), ('timeframe_from', models.DateTimeField(verbose_name='Start of time frame')),
('timeframe_to', models.DateTimeField(verbose_name='End of time frame')), ('timeframe_to', models.DateTimeField(verbose_name='End of time frame')),
('price', models.DecimalField(max_digits=7, verbose_name='Price in time frame', decimal_places=2, null=True, blank=True)), ('price', models.DecimalField(null=True, blank=True, verbose_name='Price in time frame', max_digits=7, decimal_places=2)),
('event', models.ForeignKey(related_name='restrictions_timerestriction_timerestriction', to='tixlbase.Event', verbose_name='Event')), ('event', versions.models.VersionedForeignKey(to='tixlbase.Event', related_name='restrictions_timerestriction_timerestriction', verbose_name='Event')),
('items', models.ManyToManyField(to='tixlbase.Item', related_name='restrictions_timerestriction_timerestriction')), ('item', versions.models.VersionedForeignKey(to='tixlbase.Item', blank=True, null=True, related_name='restrictions_timerestriction_timerestriction', verbose_name='Item')),
('variations', models.ManyToManyField(to='tixlbase.ItemVariation', related_name='restrictions_timerestriction_timerestriction')), ('variations', tixlbase.models.VariationsField(to='tixlbase.ItemVariation', blank=True, verbose_name='Variations', related_name='restrictions_timerestriction_timerestriction')),
], ],
options={ options={
'abstract': False,
'verbose_name_plural': 'Restrictions',
'verbose_name': 'Restriction', 'verbose_name': 'Restriction',
'verbose_name_plural': 'Restrictions',
'abstract': False,
}, },
bases=(models.Model,), bases=(models.Model,),
), ),

View File

@@ -1,7 +1,12 @@
from django.dispatch import receiver from django.dispatch import receiver
from django.utils.timezone import now from django.utils.timezone import now
from django.utils.translation import ugettext_lazy as _
from django.forms.models import inlineformset_factory
from tixlbase.signals import determine_availability from tixlbase.signals import determine_availability
from tixlbase.models import Item
from tixlcontrol.views.forms import VariationsField, RestrictionInlineFormset, RestrictionForm
from tixlcontrol.signals import restriction_formset
from .models import TimeRestriction from .models import TimeRestriction
@@ -15,8 +20,8 @@ def availability_handler(sender, **kwargs):
context = kwargs['context'] # NOQA context = kwargs['context'] # NOQA
# Fetch all restriction objects applied to this item # Fetch all restriction objects applied to this item
restrictions = list(TimeRestriction.objects.filter( restrictions = list(TimeRestriction.objects.current.filter(
items__in=(item,), item=item,
).prefetch_related('variations')) ).prefetch_related('variations'))
# If we do not know anything about this item, we are done here. # If we do not know anything about this item, we are done here.
@@ -57,8 +62,8 @@ def availability_handler(sender, **kwargs):
price = None price = None
# Make up some unique key for this variation # Make up some unique key for this variation
cachekey = 'timerestriction:%d:%s' % ( cachekey = 'timerestriction:%s:%s' % (
item.pk, item.identity,
v.identify(), v.identify(),
) )
@@ -74,7 +79,7 @@ def availability_handler(sender, **kwargs):
# Walk through all restriction objects applied to this item # Walk through all restriction objects applied to this item
for restriction in restrictions: for restriction in restrictions:
applied_to = list(restriction.variations.all()) applied_to = list(restriction.variations.current.all())
# Only take this restriction into consideration if it either # Only take this restriction into consideration if it either
# is directly applied to this variation OR is applied to all # is directly applied to this variation OR is applied to all
@@ -83,8 +88,7 @@ def availability_handler(sender, **kwargs):
if 'variation' not in v or v['variation'] not in applied_to: if 'variation' not in v or v['variation'] not in applied_to:
continue continue
if (restriction.timeframe_from <= now() if restriction.timeframe_from <= now() <= restriction.timeframe_to:
and restriction.timeframe_to >= now()):
# Selling this item is currently possible # Selling this item is currently possible
available = True available = True
# If multiple time frames are currently active, make sure to # If multiple time frames are currently active, make sure to
@@ -105,3 +109,35 @@ def availability_handler(sender, **kwargs):
) )
return variations return variations
class TimeRestrictionForm(RestrictionForm):
class Meta:
model = TimeRestriction
localized_fields = '__all__'
fields = [
'variations',
'timeframe_from',
'timeframe_to',
'price',
]
@receiver(restriction_formset)
def formset_handler(sender, **kwargs):
formset = inlineformset_factory(
Item,
TimeRestriction,
formset=RestrictionInlineFormset,
form=TimeRestrictionForm,
can_order=False,
can_delete=True,
extra=0,
)
return {
'title': _('Restriction by time'),
'formsetclass': formset,
'prefix': 'timerestriction',
}

View File

@@ -46,7 +46,8 @@ class TimeRestrictionTest(TestCase):
event=self.event, event=self.event,
price=12 price=12
) )
r.items.add(self.item) r.item = self.item
r.save()
result = signals.availability_handler( result = signals.availability_handler(
self.event, item=self.item, self.event, item=self.item,
variations=self.item.get_all_variations(), variations=self.item.get_all_variations(),
@@ -64,7 +65,8 @@ class TimeRestrictionTest(TestCase):
event=self.event, event=self.event,
price=12 price=12
) )
r.items.add(self.item) r.item = self.item
r.save()
result = signals.availability_handler( result = signals.availability_handler(
self.event, item=self.item, self.event, item=self.item,
variations=self.item.get_all_variations(), variations=self.item.get_all_variations(),
@@ -91,7 +93,8 @@ class TimeRestrictionTest(TestCase):
event=self.event, event=self.event,
price=12 price=12
) )
r.items.add(self.item) r.item = self.item
r.save()
result = signals.availability_handler( result = signals.availability_handler(
self.event, item=self.item, self.event, item=self.item,
variations=self.item.get_all_variations(), variations=self.item.get_all_variations(),
@@ -108,14 +111,16 @@ class TimeRestrictionTest(TestCase):
event=self.event, event=self.event,
price=12 price=12
) )
r1.items.add(self.item) r1.item = self.item
r1.save()
r2 = TimeRestriction.objects.create( r2 = TimeRestriction.objects.create(
timeframe_from=now() - timedelta(days=3), timeframe_from=now() - timedelta(days=3),
timeframe_to=now() + timedelta(days=5), timeframe_to=now() + timedelta(days=5),
event=self.event, event=self.event,
price=8 price=8
) )
r2.items.add(self.item) r2.item = self.item
r2.save()
result = signals.availability_handler( result = signals.availability_handler(
self.event, item=self.item, self.event, item=self.item,
variations=self.item.get_all_variations(), variations=self.item.get_all_variations(),
@@ -133,14 +138,16 @@ class TimeRestrictionTest(TestCase):
event=self.event, event=self.event,
price=12 price=12
) )
r1.items.add(self.item) r1.item = self.item
r1.save()
r2 = TimeRestriction.objects.create( r2 = TimeRestriction.objects.create(
timeframe_from=now() + timedelta(days=1), timeframe_from=now() + timedelta(days=1),
timeframe_to=now() + timedelta(days=7), timeframe_to=now() + timedelta(days=7),
event=self.event, event=self.event,
price=8 price=8
) )
r2.items.add(self.item) r2.item = self.item
r2.save()
result = signals.availability_handler( result = signals.availability_handler(
self.event, item=self.item, self.event, item=self.item,
variations=self.item.get_all_variations(), variations=self.item.get_all_variations(),
@@ -158,14 +165,16 @@ class TimeRestrictionTest(TestCase):
event=self.event, event=self.event,
price=12 price=12
) )
r1.items.add(self.item) r1.item = self.item
r1.save()
r2 = TimeRestriction.objects.create( r2 = TimeRestriction.objects.create(
timeframe_from=now() + timedelta(days=4), timeframe_from=now() + timedelta(days=4),
timeframe_to=now() + timedelta(days=7), timeframe_to=now() + timedelta(days=7),
event=self.event, event=self.event,
price=8 price=8
) )
r2.items.add(self.item) r2.item = self.item
r2.save()
result = signals.availability_handler( result = signals.availability_handler(
self.event, item=self.item, self.event, item=self.item,
variations=self.item.get_all_variations(), variations=self.item.get_all_variations(),
@@ -183,14 +192,16 @@ class TimeRestrictionTest(TestCase):
event=self.event, event=self.event,
price=12 price=12
) )
r1.items.add(self.item) r1.item = self.item
r1.save()
r2 = TimeRestriction.objects.create( r2 = TimeRestriction.objects.create(
timeframe_from=now() + timedelta(days=4), timeframe_from=now() + timedelta(days=4),
timeframe_to=now() + timedelta(days=7), timeframe_to=now() + timedelta(days=7),
event=self.event, event=self.event,
price=8 price=8
) )
r2.items.add(self.item) r2.item = self.item
r2.save()
result = signals.availability_handler( result = signals.availability_handler(
self.event, item=self.item, self.event, item=self.item,
variations=self.item.get_all_variations(), variations=self.item.get_all_variations(),
@@ -213,7 +224,8 @@ class TimeRestrictionTest(TestCase):
event=self.event, event=self.event,
price=12 price=12
) )
r1.items.add(self.item) r1.item = self.item
r1.save()
r1.variations.add(v1) r1.variations.add(v1)
result = signals.availability_handler( result = signals.availability_handler(
self.event, item=self.item, self.event, item=self.item,
@@ -241,14 +253,16 @@ class TimeRestrictionTest(TestCase):
event=self.event, event=self.event,
price=12 price=12
) )
r1.items.add(self.item) r1.item = self.item
r1.save()
r2 = TimeRestriction.objects.create( r2 = TimeRestriction.objects.create(
timeframe_from=now() - timedelta(days=5), timeframe_from=now() - timedelta(days=5),
timeframe_to=now() + timedelta(days=1), timeframe_to=now() + timedelta(days=1),
event=self.event, event=self.event,
price=8 price=8
) )
r2.items.add(self.item) r2.item = self.item
r2.save()
r2.variations.add(v1) r2.variations.add(v1)
r3 = TimeRestriction.objects.create( r3 = TimeRestriction.objects.create(
timeframe_from=now() - timedelta(days=5), timeframe_from=now() - timedelta(days=5),
@@ -256,7 +270,8 @@ class TimeRestrictionTest(TestCase):
event=self.event, event=self.event,
price=10 price=10
) )
r3.items.add(self.item) r3.item = self.item
r3.save()
r3.variations.add(v2) r3.variations.add(v2)
result = signals.availability_handler( result = signals.availability_handler(
self.event, item=self.item, self.event, item=self.item,
@@ -285,7 +300,8 @@ class TimeRestrictionTest(TestCase):
event=self.event, event=self.event,
price=12 price=12
) )
r1.items.add(self.item) r1.item = self.item
r1.save()
r1.variations.add(v1) r1.variations.add(v1)
r2 = TimeRestriction.objects.create( r2 = TimeRestriction.objects.create(
timeframe_from=now() - timedelta(days=5), timeframe_from=now() - timedelta(days=5),
@@ -293,7 +309,8 @@ class TimeRestrictionTest(TestCase):
event=self.event, event=self.event,
price=8 price=8
) )
r2.items.add(self.item) r2.item = self.item
r2.save()
r2.variations.add(v1) r2.variations.add(v1)
r3 = TimeRestriction.objects.create( r3 = TimeRestriction.objects.create(
timeframe_from=now() - timedelta(days=5), timeframe_from=now() - timedelta(days=5),
@@ -301,7 +318,8 @@ class TimeRestrictionTest(TestCase):
event=self.event, event=self.event,
price=8 price=8
) )
r3.items.add(self.item) r3.item = self.item
r3.save()
r3.variations.add(v2) r3.variations.add(v2)
result = signals.availability_handler( result = signals.availability_handler(
self.event, item=self.item, self.event, item=self.item,