diff --git a/.gitignore b/.gitignore index 984568a3b..fc6eb78d0 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,4 @@ htmlcov/ .ropeproject __pycache__/ _static/ +.idea diff --git a/.travis.yml b/.travis.yml index e32c2edb2..e58767a9e 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,6 +1,7 @@ language: python python: - "3.2" + - "3.3" - "3.4" install: - pip install -q -r src/requirements.txt @@ -13,3 +14,8 @@ script: - coverage run manage.py test after_success: - coveralls +addons: + sauce_connect: + username: "tixl" + access_key: + secure: "a0NUwGs2jHci0hIg3jySZLkfljv6FP33fZxAyi2gKeaxcVC+a/AailSnUgDoyVWxPr0JnkLvdFcxzDBgrQ1TLsgpRDSXnc1nIGsaHjgvVGSJ1hKACYtO/9QH+dgaaHEsIsHHbvGdnjwjrX8AZtDnkcRk1T3Skj8kUCniaU39w38=" diff --git a/doc/development/api/index.rst b/doc/development/api/index.rst index d693800a3..aa9eca294 100644 --- a/doc/development/api/index.rst +++ b/doc/development/api/index.rst @@ -1,5 +1,5 @@ -API details -=========== +Plugin API +========== Contents: diff --git a/doc/development/api/restriction.rst b/doc/development/api/restriction.rst index 87998c7fe..55fe771d6 100644 --- a/doc/development/api/restriction.rst +++ b/doc/development/api/restriction.rst @@ -66,12 +66,12 @@ It is sent out with several keyword arguments: keys and the ``PropertyValue`` objects are values. If an ``ItemVariation`` object 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 - 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. Technically, you won't get ``dict`` objects but ``tixlbase.types.VariationDict`` objects, which behave exactly the same but add some extra methods. ``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. ``cache`` 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 restrictions = list(TimeRestriction.objects.filter( - items__in=(item,), + item=item, ).prefetch_related('variations')) # 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: continue - if (restriction.timeframe_from <= now() - and restriction.timeframe_to >= now()): + if restriction.timeframe_from <= now() <= restriction.timeframe_to: # Selling this item is currently possible available = True # 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 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/ diff --git a/doc/development/concepts.rst b/doc/development/concepts.rst index afb9ff901..364c3060e 100644 --- a/doc/development/concepts.rst +++ b/doc/development/concepts.rst @@ -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 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 - members who are members of a special group. -* Arbitrary sophisticated features like coupon codes are also possible to be implemented using + users who are members of a special group. +* Arbitrary sophisticated features like coupon codes can also be implemented using this feature. 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 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. * 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. diff --git a/doc/development/goals.rst b/doc/development/goals.rst index 431b78c12..92d60f0a3 100644 --- a/doc/development/goals.rst +++ b/doc/development/goals.rst @@ -9,7 +9,7 @@ Technical goals * Python 3.4 features may be used, Python 3.2 is an absolute requirement * Use Django 1.7+ * 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 * Be fully tested by both unit and behaviour tests * Use LessCSS diff --git a/doc/development/structure.rst b/doc/development/structure.rst index 64ade215d..7a7e06b78 100644 --- a/doc/development/structure.rst +++ b/doc/development/structure.rst @@ -7,7 +7,7 @@ Python source code All the source code lives in ``src/``, which has several subdirectories. tixl/ - This directory contains the basic Django settings and URL routing. It is + This directory contains the basic Django settings and URL routing. tixlbase/ This is the django app containing all the models and methods which are diff --git a/doc/development/style.rst b/doc/development/style.rst index 9070ffe0f..2f915ab52 100644 --- a/doc/development/style.rst +++ b/doc/development/style.rst @@ -35,7 +35,7 @@ LESS stylesheets * Indent your code with four spaces. * 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 before ``{`` in rulesets. * When grouping selectors, use one line per selector. diff --git a/src/locale/de/LC_MESSAGES/django.po b/src/locale/de/LC_MESSAGES/django.po index f148dda60..c7fc0aa01 100644 --- a/src/locale/de/LC_MESSAGES/django.po +++ b/src/locale/de/LC_MESSAGES/django.po @@ -7,8 +7,8 @@ msgid "" msgstr "" "Project-Id-Version: 1\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2014-10-07 18:27+0200\n" -"PO-Revision-Date: 2014-10-07 18:28+0100\n" +"POT-Creation-Date: 2014-10-18 17:35+0200\n" +"PO-Revision-Date: 2014-10-18 17:35+0100\n" "Last-Translator: Raphael Michel \n" "Language-Team: Raphael Michel \n" "Language: de\n" @@ -74,6 +74,10 @@ msgstr "Nachname" msgid "Is active" msgstr "Ist aktiviert" +#: tixlbase/models.py:91 +msgid "Is site admin" +msgstr "Ist Systemadministrator" + #: tixlbase/models.py:93 msgid "Date joined" msgstr "Registrierungsdatum" @@ -82,7 +86,11 @@ msgstr "Registrierungsdatum" msgid "Language" 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" msgstr "Benutzer" @@ -90,7 +98,7 @@ msgstr "Benutzer" msgid "Users" msgstr "Benutzer" -#: tixlbase/models.py:152 tixlbase/models.py:222 +#: tixlbase/models.py:152 tixlbase/models.py:222 tixlbase/models.py:755 msgid "Name" msgstr "Name" @@ -222,9 +230,10 @@ msgstr "" #: tixlbase/models.py:281 #: tixlcontrol/templates/tixlcontrol/event/settings_base.html:9 msgid "Plugins" -msgstr "Plugins" - #: 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" msgstr "Veranstaltung" @@ -321,7 +330,7 @@ msgstr "Frage" #: tixlbase/models.py:470 msgid "Question type" -msgstr "Fragentyp" +msgstr "Art der Antwort" #: tixlbase/models.py:474 msgid "Required question" @@ -383,10 +392,9 @@ msgstr "" #: tixlbase/models.py:560 msgid "The user will be asked to fill in answers for the selected questions" msgstr "" -"Der Käufer wird beim Kauf aufgefordert, Antworten für die ausgewählten " -"Fragen anzugeben." +"Der Käuft wird beim Kauf gebeten, die ausgewählten Fragen zu beantworten" -#: 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" msgstr "Produkt" @@ -414,6 +422,100 @@ msgstr "Beschränkung" msgid "Restrictions" 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 msgid "" "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 msgid "Installed plugins" -msgstr "Installierte Plugins" +msgstr "Installierte Erweiterungen" #: tixlcontrol/templates/tixlcontrol/event/plugins.html:11 #: tixlcontrol/templates/tixlcontrol/event/settings.html:8 @@ -548,10 +650,6 @@ msgstr "Ende" msgid "Modify item:" 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" msgstr "Varianten" @@ -567,6 +665,16 @@ msgstr "Erweiterte Einstellungen" msgid "Price" 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 msgid "Categories" msgstr "Kategorien" @@ -663,8 +771,8 @@ msgid "" "All answers to the question given by the buyers of the following tickets " "will be permanently lost." msgstr "" -"Alle Antworten, die von Käufern der folgenden Tickets auf diese Frage " -"gegeben wurden, sind unwiderruflich gelöscht." +"Alle Antworten auf diese Frage werden unwiderruflich gelöscht." #: tixlcontrol/templates/tixlcontrol/items/questions.html:12 msgid "A new question has been created." @@ -695,12 +803,16 @@ msgstr "" msgid "This account is inactive." msgstr "Dieses Konto ist deaktiviert." +#: tixlcontrol/views/forms.py:130 +msgid "not applicable" +msgstr "nicht anwendbar" + #: tixlplugins/timerestriction/__init__.py:8 msgid "Time restriction" msgstr "Zeitliche Beschränkung" #: tixlplugins/timerestriction/__init__.py:12 -msgid "Restriciton by time" +msgid "Restricition by time" msgstr "Zeitliche Beschränkung" #: tixlplugins/timerestriction/__init__.py:13 @@ -712,8 +824,8 @@ msgid "" "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." msgstr "" -"Dieses Plugin ermöglicht es, den Verkauf eines Produktes auf bestimmte " -"Zeiträume einzuschränken oder seinen Preis für einen bestimmten Zeitraum zu " +"Dieses Plugin ermöglicht es, den Verkauf von Produkten auf einen gewissen " +"Zeitraum einzuschränken oder den Preis während eines gewissen Zeitraums zu " "ändern." #: tixlplugins/timerestriction/models.py:15 @@ -727,3 +839,10 @@ msgstr "Ende des Zeitraums" #: tixlplugins/timerestriction/models.py:23 msgid "Price in time frame" msgstr "Preis im Zeitraum" + +#: tixlplugins/timerestriction/signals.py:140 +msgid "Restriction by time" +msgstr "Zeitliche Beschränkung" + +#~ msgid "Datetime" +#~ msgstr "Datum" diff --git a/src/requirements.txt b/src/requirements.txt index c880ade6b..8038937b1 100644 --- a/src/requirements.txt +++ b/src/requirements.txt @@ -3,6 +3,7 @@ Django>=1.7 pytz django-bootstrap3 -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 django-compressor @@ -25,4 +26,7 @@ pep8-naming flake8 coveralls coverage - +selenium +PyVirtualDisplay +-e git+https://github.com/tixl/sauceclient.git@master#egg=sauceclient +travis diff --git a/src/tixlbase/cache.py b/src/tixlbase/cache.py index d9f635f90..c84369b25 100644 --- a/src/tixlbase/cache.py +++ b/src/tixlbase/cache.py @@ -3,6 +3,8 @@ import hashlib from django.core.cache import caches +from tixlbase.models import Event + class EventRelatedCache: """ @@ -17,12 +19,12 @@ class EventRelatedCache: 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.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. # We could only handle this by going _really_ lowlevel using # memcached's `add` keyword instead of `set`. @@ -32,14 +34,15 @@ class EventRelatedCache: if prefix is None: prefix = int(time.time()) 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 # TODO: Use a more efficient, non-cryptographic hash algorithm key = hashlib.sha256(key.encode("UTF-8")).hexdigest() return key - def _strip_prefix(self, key): - return key.split(":", maxsplit=3)[-1] if 'event:' in key else key + @staticmethod + def _strip_prefix(key: str) -> str: + return key.split(":", 3)[-1] if 'event:' in key else key def clear(self): try: @@ -48,35 +51,35 @@ class EventRelatedCache: prefix = int(time.time()) 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) - def get(self, key): + def get(self, key: str) -> str: 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]) newvalues = {} for k, v in values.items(): newvalues[self._strip_prefix(k)] = v return newvalues - def set_many(self, values, timeout=3600): + def set_many(self, values: "dict[str, str]", timeout=3600): newvalues = {} for k, v in values.items(): newvalues[self._prefix_key(k)] = v 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)) - 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]) - 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) - 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) def close(self): # NOQA diff --git a/src/tixlbase/forms.py b/src/tixlbase/forms.py new file mode 100644 index 000000000..ae46dc43d --- /dev/null +++ b/src/tixlbase/forms.py @@ -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 diff --git a/src/tixlbase/middleware.py b/src/tixlbase/middleware.py index 7967e1664..40200069d 100644 --- a/src/tixlbase/middleware.py +++ b/src/tixlbase/middleware.py @@ -7,8 +7,7 @@ from django.utils.translation.trans_real import ( get_supported_language_variant, parse_accept_lang_header, language_code_re, - check_for_language, - _supported + check_for_language ) from django.utils.translation import LANGUAGE_SESSION_KEY from django.utils import translation, timezone @@ -17,6 +16,8 @@ from django.utils.cache import patch_vary_headers from tixlbase.models import Event +_supported = None + class LocaleMiddleware(BaseLocaleMiddleware): @@ -29,7 +30,7 @@ class LocaleMiddleware(BaseLocaleMiddleware): url = resolve(request.path_info) if 'event' in url.kwargs and 'organizer' in url.kwargs: try: - request.event = Event.objects.get( + request.event = Event.objects.current.get( slug=url.kwargs['event'], organizer__slug=url.kwargs['organizer'], ) @@ -61,7 +62,7 @@ class LocaleMiddleware(BaseLocaleMiddleware): 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 show. Only languages listed in settings.LANGUAGES are taken into account. diff --git a/src/tixlbase/migrations/0001_initial.py b/src/tixlbase/migrations/0001_initial.py index d435f936d..2cf93ab48 100644 --- a/src/tixlbase/migrations/0001_initial.py +++ b/src/tixlbase/migrations/0001_initial.py @@ -2,8 +2,12 @@ from __future__ import unicode_literals from django.db import models, migrations -import django.utils.timezone +import versions.models +import django.core.validators from django.conf import settings +import django.db.models.deletion +import django.utils.timezone +import tixlbase.models class Migration(migrations.Migration): @@ -16,82 +20,432 @@ class Migration(migrations.Migration): migrations.CreateModel( name='User', fields=[ - ('id', models.AutoField(verbose_name='ID', primary_key=True, auto_created=True, serialize=False)), - ('password', models.CharField(verbose_name='password', max_length=128)), - ('last_login', models.DateTimeField(verbose_name='last login', default=django.utils.timezone.now)), - ('is_superuser', models.BooleanField(verbose_name='superuser status', default=False, help_text='Designates that this user has all permissions without explicitly assigning them.')), + ('id', models.AutoField(serialize=False, auto_created=True, verbose_name='ID', primary_key=True)), + ('password', models.CharField(max_length=128, verbose_name='password')), + ('last_login', models.DateTimeField(default=django.utils.timezone.now, verbose_name='last login')), + ('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)), - ('username', models.CharField(max_length=120)), - ('email', models.EmailField(blank=True, null=True, db_index=True, max_length=75)), - ('is_active', models.BooleanField(default=True)), - ('is_staff', models.BooleanField(default=False)), - ('date_joined', models.DateTimeField(auto_now_add=True)), + ('username', models.CharField(max_length=120, blank=True, null=True, help_text='Letters, digits and @/./+/-/_ only.')), + ('email', models.EmailField(null=True, max_length=75, blank=True, db_index=True, verbose_name='E-mail')), + ('givenname', models.CharField(max_length=255, blank=True, null=True, verbose_name='Given name')), + ('familyname', models.CharField(max_length=255, blank=True, null=True, verbose_name='Family name')), + ('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={ + '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,), ), migrations.CreateModel( name='Event', fields=[ - ('id', models.AutoField(verbose_name='ID', primary_key=True, auto_created=True, serialize=False)), - ('name', models.CharField(max_length=200)), - ('slug', models.CharField(db_index=True, max_length=50)), - ('locale', models.CharField(max_length=10)), - ('currency', models.CharField(max_length=10)), - ('date_from', models.DateTimeField()), - ('date_to', models.DateTimeField(blank=True, null=True)), - ('show_date_to', models.BooleanField(default=True)), - ('show_times', models.BooleanField(default=True)), - ('presale_end', models.DateTimeField(blank=True, null=True)), - ('presale_start', models.DateTimeField(blank=True, null=True)), - ('payment_term_days', models.IntegerField(default=14)), - ('payment_term_last', models.DateTimeField(blank=True, null=True)), + ('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')), + ('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.')), + ('locale', models.CharField(max_length=10, choices=[('de', 'German'), ('en', 'English')], verbose_name='Default locale')), + ('timezone', models.CharField(max_length=100, default='UTC', verbose_name='Default timezone')), + ('currency', models.CharField(max_length=10, verbose_name='Default currency')), + ('date_from', models.DateTimeField(verbose_name='Event start time')), + ('date_to', models.DateTimeField(blank=True, null=True, verbose_name='Event end time')), + ('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={ + 'verbose_name_plural': 'Events', '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,), ), migrations.CreateModel( name='Organizer', fields=[ - ('id', models.AutoField(verbose_name='ID', primary_key=True, auto_created=True, serialize=False)), - ('name', models.CharField(max_length=200)), - ('slug', models.CharField(unique=True, db_index=True, max_length=50)), - ('owner', models.ForeignKey(blank=True, null=True, to=settings.AUTH_USER_MODEL)), + ('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')), + ('slug', models.CharField(unique=True, max_length=50, db_index=True, verbose_name='Slug')), ], options={ + 'verbose_name_plural': 'Organizers', 'ordering': ('name',), + 'verbose_name': 'Organizer', }, 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( model_name='event', 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, ), - 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', - 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( model_name='user', 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, ), migrations.AddField( model_name='user', 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, ), migrations.AddField( model_name='user', 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, ), migrations.AlterUniqueTogether( diff --git a/src/tixlbase/migrations/0002_auto_20140910_1628.py b/src/tixlbase/migrations/0002_auto_20140910_1628.py deleted file mode 100644 index d08eaa8e2..000000000 --- a/src/tixlbase/migrations/0002_auto_20140910_1628.py +++ /dev/null @@ -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, - ), - ] diff --git a/src/tixlbase/migrations/0003_auto_20140910_1649.py b/src/tixlbase/migrations/0003_auto_20140910_1649.py deleted file mode 100644 index 826d1a1c9..000000000 --- a/src/tixlbase/migrations/0003_auto_20140910_1649.py +++ /dev/null @@ -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.'), - ), - ] diff --git a/src/tixlbase/migrations/0004_auto_20140911_2037.py b/src/tixlbase/migrations/0004_auto_20140911_2037.py deleted file mode 100644 index be2571dd5..000000000 --- a/src/tixlbase/migrations/0004_auto_20140911_2037.py +++ /dev/null @@ -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), - ), - ] diff --git a/src/tixlbase/migrations/0005_auto_20140911_2052.py b/src/tixlbase/migrations/0005_auto_20140911_2052.py deleted file mode 100644 index e17580d85..000000000 --- a/src/tixlbase/migrations/0005_auto_20140911_2052.py +++ /dev/null @@ -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'), - ), - ] diff --git a/src/tixlbase/migrations/0006_auto_20140912_1855.py b/src/tixlbase/migrations/0006_auto_20140912_1855.py deleted file mode 100644 index 8546e2fd7..000000000 --- a/src/tixlbase/migrations/0006_auto_20140912_1855.py +++ /dev/null @@ -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')]), - ), - ] diff --git a/src/tixlbase/migrations/0007_auto_20140914_1301.py b/src/tixlbase/migrations/0007_auto_20140914_1301.py deleted file mode 100644 index 2f12faca7..000000000 --- a/src/tixlbase/migrations/0007_auto_20140914_1301.py +++ /dev/null @@ -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, - ), - ] diff --git a/src/tixlbase/migrations/0008_auto_20140914_1304.py b/src/tixlbase/migrations/0008_auto_20140914_1304.py deleted file mode 100644 index 6f75d8051..000000000 --- a/src/tixlbase/migrations/0008_auto_20140914_1304.py +++ /dev/null @@ -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'), - ), - ] diff --git a/src/tixlbase/migrations/0009_auto_20140916_2120.py b/src/tixlbase/migrations/0009_auto_20140916_2120.py deleted file mode 100644 index 82612f4f1..000000000 --- a/src/tixlbase/migrations/0009_auto_20140916_2120.py +++ /dev/null @@ -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), - ), - ] diff --git a/src/tixlbase/migrations/0010_auto_20140927_1006.py b/src/tixlbase/migrations/0010_auto_20140927_1006.py deleted file mode 100644 index e119fbdc3..000000000 --- a/src/tixlbase/migrations/0010_auto_20140927_1006.py +++ /dev/null @@ -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, - ), - ] diff --git a/src/tixlbase/migrations/0011_auto_20140927_1013.py b/src/tixlbase/migrations/0011_auto_20140927_1013.py deleted file mode 100644 index 96b2a0d5b..000000000 --- a/src/tixlbase/migrations/0011_auto_20140927_1013.py +++ /dev/null @@ -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'), - ), - ] diff --git a/src/tixlbase/migrations/0012_auto_20140929_1935.py b/src/tixlbase/migrations/0012_auto_20140929_1935.py deleted file mode 100644 index 527a88195..000000000 --- a/src/tixlbase/migrations/0012_auto_20140929_1935.py +++ /dev/null @@ -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'), - ), - ] diff --git a/src/tixlbase/migrations/0013_propertyvalue_position.py b/src/tixlbase/migrations/0013_propertyvalue_position.py deleted file mode 100644 index 77b5091e9..000000000 --- a/src/tixlbase/migrations/0013_propertyvalue_position.py +++ /dev/null @@ -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, - ), - ] diff --git a/src/tixlbase/migrations/0014_auto_20141005_1037.py b/src/tixlbase/migrations/0014_auto_20141005_1037.py deleted file mode 100644 index 98694c186..000000000 --- a/src/tixlbase/migrations/0014_auto_20141005_1037.py +++ /dev/null @@ -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, - ), - ] diff --git a/src/tixlbase/migrations/0015_auto_20141006_2205.py b/src/tixlbase/migrations/0015_auto_20141006_2205.py deleted file mode 100644 index 5dee309cf..000000000 --- a/src/tixlbase/migrations/0015_auto_20141006_2205.py +++ /dev/null @@ -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'), - ), - ] diff --git a/src/tixlbase/migrations/0016_event_plugins.py b/src/tixlbase/migrations/0016_event_plugins.py deleted file mode 100644 index 753cc19e3..000000000 --- a/src/tixlbase/migrations/0016_event_plugins.py +++ /dev/null @@ -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, - ), - ] diff --git a/src/tixlbase/models.py b/src/tixlbase/models.py index 8cb83f1a6..b09b570d9 100644 --- a/src/tixlbase/models.py +++ b/src/tixlbase/models.py @@ -6,6 +6,7 @@ from django.contrib.auth.models import AbstractBaseUser, BaseUserManager, Permis from django.utils.translation import ugettext_lazy as _ from django.template.defaultfilters import date as _date from django.core.validators import RegexValidator +from versions.models import Versionable, VersionedForeignKey, VersionedManyToManyField from tixlbase.types import VariationDict @@ -88,7 +89,7 @@ class User(AbstractBaseUser, PermissionsMixin): is_active = models.BooleanField(default=True, verbose_name=_('Is active')) is_staff = models.BooleanField(default=False, - verbose_name=('Is site admin')) + verbose_name=_('Is site admin')) date_joined = models.DateTimeField(auto_now_add=True, verbose_name=_('Date joined')) locale = models.CharField(max_length=50, @@ -97,7 +98,7 @@ class User(AbstractBaseUser, PermissionsMixin): verbose_name=_('Language')) timezone = models.CharField(max_length=100, default=settings.TIME_ZONE, - verbose_name=('Timezone')) + verbose_name=_('Timezone')) objects = UserManager() @@ -119,7 +120,7 @@ class User(AbstractBaseUser, PermissionsMixin): self.identifier = self.identifier.lower() super().save(*args, **kwargs) - def get_short_name(self): + def get_short_name(self) -> str: if self.givenname: return self.givenname elif self.familyname: @@ -127,7 +128,7 @@ class User(AbstractBaseUser, PermissionsMixin): else: return self.username - def get_full_name(self): + def get_full_name(self) -> str: if self.givenname and not self.familyname: return self.givenname elif not self.givenname and self.familyname: @@ -141,7 +142,7 @@ class User(AbstractBaseUser, PermissionsMixin): return self.username -class Organizer(models.Model): +class Organizer(Versionable): """ This model represents an entity organizing events, like a company. Any organizer has a unique slug, which is a short name (alphanumeric, @@ -165,13 +166,13 @@ class Organizer(models.Model): return self.name -class OrganizerPermission(models.Model): +class OrganizerPermission(Versionable): """ The relation between an Organizer and an User who has permissions to access an organizer profile. """ - organizer = models.ForeignKey(Organizer) + organizer = VersionedForeignKey(Organizer) user = models.ForeignKey(User, related_name="organizer_perms") can_create_events = models.BooleanField( default=True, @@ -181,7 +182,6 @@ class OrganizerPermission(models.Model): class Meta: verbose_name = _("Organizer permission") verbose_name_plural = _("Organizer permissions") - unique_together = (("organizer", "user"),) def __str__(self): 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 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). """ - organizer = models.ForeignKey(Organizer, related_name="events", - on_delete=models.PROTECT) + organizer = VersionedForeignKey(Organizer, related_name="events", + on_delete=models.PROTECT) name = models.CharField(max_length=200, verbose_name=_("Name")) slug = models.CharField( @@ -266,7 +266,7 @@ class Event(models.Model): verbose_name=_("Start of presale"), help_text=_("No items will be sold before this date."), ) - payment_term_days = models.IntegerField( + payment_term_days = models.PositiveIntegerField( default=14, 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."), @@ -284,7 +284,7 @@ class Event(models.Model): class Meta: verbose_name = _("Event") verbose_name_plural = _("Events") - unique_together = (("organizer", "slug"),) + # unique_together = (("organizer", "slug"),) # TODO: Enforce manually ordering = ("date_from", "name") def __str__(self): @@ -295,18 +295,18 @@ class Event(models.Model): self.get_cache().clear() return obj - def get_plugins(self): + def get_plugins(self) -> "list[str]": if self.plugins is None: return [] return self.plugins.split(",") - def get_date_from_display(self): + def get_date_from_display(self) -> str: return _date( self.date_from, "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: return "" return _date( @@ -314,18 +314,18 @@ class Event(models.Model): "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 return EventRelatedCache(self) -class EventPermission(models.Model): +class EventPermission(Versionable): """ The relation between an Event and an User who has permissions to access an event. """ - event = models.ForeignKey(Event) + event = VersionedForeignKey(Event) user = models.ForeignKey(User, related_name="event_perms") can_change_settings = models.BooleanField( default=True, @@ -339,7 +339,6 @@ class EventPermission(models.Model): class Meta: verbose_name = _("Event permission") verbose_name_plural = _("Event permissions") - unique_together = (("event", "user"),) def __str__(self): 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 """ - event = models.ForeignKey( + event = VersionedForeignKey( Event, on_delete=models.CASCADE, related_name='categories', @@ -373,20 +372,25 @@ class ItemCategory(models.Model): def __str__(self): 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: 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 Item. For example 'Size' would be a property associated with the item 'T-Shirt'. """ - event = models.ForeignKey( + event = VersionedForeignKey( Event, related_name="properties", ) @@ -402,19 +406,24 @@ class Property(models.Model): def __str__(self): 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: 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', this could be 'M' or 'L' """ - prop = models.ForeignKey( + prop = VersionedForeignKey( Property, on_delete=models.CASCADE, related_name="values" @@ -435,13 +444,18 @@ class PropertyValue(models.Model): def __str__(self): 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: 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 by custom information, e.g. "Attendee name" or "Attendee age". @@ -457,7 +471,7 @@ class Question(models.Model): (TYPE_BOOLEAN, _("Yes/No")), ) - event = models.ForeignKey( + event = VersionedForeignKey( Event, related_name="questions", ) @@ -481,13 +495,18 @@ class Question(models.Model): def __str__(self): 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: 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 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". Deleted items will not be shown anywhere. """ - event = models.ForeignKey( + event = VersionedForeignKey( Event, on_delete=models.PROTECT, related_name="items", verbose_name=_("Event"), ) - category = models.ForeignKey( + category = VersionedForeignKey( ItemCategory, on_delete=models.PROTECT, related_name="items", @@ -540,7 +559,7 @@ class Item(models.Model): verbose_name=_("Taxes included in percent"), max_digits=7, decimal_places=2 ) - properties = models.ManyToManyField( + properties = VersionedManyToManyField( Property, related_name='items', verbose_name=_("Properties"), @@ -551,7 +570,7 @@ class Item(models.Model): + '\'Variations\' tab to configure the details.' ) ) - questions = models.ManyToManyField( + questions = VersionedManyToManyField( Question, related_name='items', verbose_name=_("Questions"), @@ -570,16 +589,18 @@ class Item(models.Model): return self.name def save(self, *args, **kwargs): + super().save(*args, **kwargs) if self.event: self.event.get_cache().clear() - return super().save(*args, **kwargs) def delete(self): self.deleted = True 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 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 methods. """ - all_variations = self.variations.all().prefetch_related("values") - all_properties = self.properties.all().prefetch_related("values") + if use_cache and hasattr(self, '_get_all_variations_cache'): + 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 = {} for var in all_variations: key = [] - for v in var.values.all(): - key.append((v.prop_id, v.pk)) + for v in var.values.current.all(): + key.append((v.prop_id, v.identity)) key = tuple(sorted(key)) variations_cache[key] = var 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: result.append(VariationDict()) continue key = [] var = VariationDict() for v in comb: - key.append((v.prop.pk, v.pk)) - var[v.prop.pk] = v + key.append((v.prop.identity, v.identity)) + var[v.prop.identity] = v key = tuple(sorted(key)) if key in variations_cache: var['variation'] = variations_cache[key] result.append(var) + self._get_all_variations_cache = result return result -class ItemVariation(models.Model): +class ItemVariation(Versionable): """ A variation is an item combined with values for all properties 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. """ - item = models.ForeignKey( + item = VersionedForeignKey( Item, related_name='variations' ) - values = models.ManyToManyField( + values = VersionedManyToManyField( PropertyValue, related_name='variations', ) @@ -659,30 +684,65 @@ class ItemVariation(models.Model): verbose_name = _("Item variation") 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: 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 of Items or ItemVariations. This model is just an abstract base class to be extended by restriction plugins. """ - event = models.ForeignKey( + event = VersionedForeignKey( Event, on_delete=models.CASCADE, related_name="restrictions_%(app_label)s_%(class)s", verbose_name=_("Event"), ) - items = models.ManyToManyField( + item = VersionedForeignKey( Item, + blank=True, null=True, + verbose_name=_("Item"), related_name="restrictions_%(app_label)s_%(class)s", ) - variations = models.ManyToManyField( - ItemVariation, + variations = VariationsField( + 'tixlbase.ItemVariation', + blank=True, + verbose_name=_("Variations"), related_name="restrictions_%(app_label)s_%(class)s", ) @@ -691,7 +751,221 @@ class BaseRestriction(models.Model): verbose_name = _("Restriction") verbose_name_plural = _("Restrictions") - def save(self, *args, **kwargs): + def delete(self, *args, **kwargs): + super().delete(*args, **kwargs) if self.event: 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") diff --git a/src/tixlbase/plugins.py b/src/tixlbase/plugins.py index 23a0ce33d..ffe7856fd 100644 --- a/src/tixlbase/plugins.py +++ b/src/tixlbase/plugins.py @@ -10,7 +10,7 @@ class PluginType(Enum): RESTRICTION = 1 -def get_all_plugins(): +def get_all_plugins() -> "class": plugins = [] for app in apps.get_app_configs(): if hasattr(app, 'TixlPluginMeta'): diff --git a/src/tixlbase/tests/__init__.py b/src/tixlbase/tests/__init__.py index e69de29bb..9821ebac7 100644 --- a/src/tixlbase/tests/__init__.py +++ b/src/tixlbase/tests/__init__.py @@ -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() diff --git a/src/tixlbase/tests/test_models.py b/src/tixlbase/tests/test_models.py index 433510768..90d5ac463 100644 --- a/src/tixlbase/tests/test_models.py +++ b/src/tixlbase/tests/test_models.py @@ -40,7 +40,6 @@ class ItemVariationsTest(TestCase): for vd in variations: for i, v in vd.relevant_items(): - self.assertIs(type(i), int) self.assertIs(type(v), PropertyValue) for v in vd.relevant_values(): diff --git a/src/tixlbase/types.py b/src/tixlbase/types.py index 4f222f5b5..6b229831c 100644 --- a/src/tixlbase/types.py +++ b/src/tixlbase/types.py @@ -5,42 +5,53 @@ class VariationDict(dict): returned by ``Item.get_all_variations()`` to avoid duplicate code in the 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. This is in use because the variation dictionaries use property ids as key and have some special keys like 'variation'. """ - for i in self.items(): - if type(i[0]) is int: - yield i + return (i for i in self.items() if i[0] not in self.IGNORE_KEYS) - def relevant_values(self): + def relevant_values(self) -> "list[PropertyValue]": """ Iterate over all values with numeric keys. This is in use because the variation dictionaries use property ids as key and have some special keys like 'variation'. """ - for i in self.items(): - if type(i[0]) is int: - yield i[1] + return (i[1] for i in self.items() if i[0] not in self.IGNORE_KEYS) - def identify(self): + def identify(self) -> str: """ - Build an identifier for this dict. This can be any string used to - compare one VariationDict to others. + Build a simple and unique identifier for this dict. This can be any string + used to compare one VariationDict to others. 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 unique among one item. """ order_key = lambda i: i[0] - return ",".join([ + return ",".join(( 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): if type(other) is type(self): @@ -48,7 +59,7 @@ class VariationDict(dict): else: return super().__eq__(other) - def ordered_values(self): + def ordered_values(self) -> "list[ItemVariation]": """ 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 VariationDict but make a shallow copy of the dict inside it). diff --git a/src/tixlcontrol/middleware.py b/src/tixlcontrol/middleware.py index 4f0f39f67..b38fa2fd5 100644 --- a/src/tixlcontrol/middleware.py +++ b/src/tixlcontrol/middleware.py @@ -46,11 +46,11 @@ class PermissionMiddleware: return redirect_to_login( 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") if 'event.' in url_name and 'event' in url.kwargs: try: - request.event = Event.objects.get( + request.event = Event.objects.current.get( slug=url.kwargs['event'], permitted__id__exact=request.user.id, organizer__slug=url.kwargs['organizer'], diff --git a/src/tixlcontrol/signals.py b/src/tixlcontrol/signals.py new file mode 100644 index 000000000..2d660d986 --- /dev/null +++ b/src/tixlcontrol/signals.py @@ -0,0 +1,6 @@ +from tixlbase.signals import EventPluginSignal + + +restriction_formset = EventPluginSignal( + providing_args=["item"] +) diff --git a/src/tixlcontrol/static/tixlcontrol/js/ui/main.js b/src/tixlcontrol/static/tixlcontrol/js/ui/main.js index f771defe2..85623f2d6 100644 --- a/src/tixlcontrol/static/tixlcontrol/js/ui/main.js +++ b/src/tixlcontrol/static/tixlcontrol/js/ui/main.js @@ -1,6 +1,8 @@ -$(function() { - $("[data-formset]").formset({ - animateForms: true, - reorderMode: 'animate', - }); +"use strict"; +$(function () { + $("[data-formset]").formset({ + animateForms: true, + reorderMode: 'animate' + }); + $('.collapse').collapse(); }); diff --git a/src/tixlcontrol/static/tixlcontrol/less/forms.less b/src/tixlcontrol/static/tixlcontrol/less/forms.less index 60c772ca7..33ee8299d 100644 --- a/src/tixlcontrol/static/tixlcontrol/less/forms.less +++ b/src/tixlcontrol/static/tixlcontrol/less/forms.less @@ -28,3 +28,40 @@ td > .form-group > .checkbox { .form-plugins .panel-title { 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; + } + } +} \ No newline at end of file diff --git a/src/tixlcontrol/templates/tixlcontrol/event/base.html b/src/tixlcontrol/templates/tixlcontrol/event/base.html index dce9aff9a..95db6fd9b 100644 --- a/src/tixlcontrol/templates/tixlcontrol/event/base.html +++ b/src/tixlcontrol/templates/tixlcontrol/event/base.html @@ -16,6 +16,6 @@
  • {% trans "Dashboard" %}
  • -
  • {% trans "Settings" %}
  • +
  • {% trans "Settings" %}
  • {% trans "Items" %}
  • {% endblock %} diff --git a/src/tixlcontrol/templates/tixlcontrol/event/settings.html b/src/tixlcontrol/templates/tixlcontrol/event/settings.html index abce92f02..7049c51b9 100644 --- a/src/tixlcontrol/templates/tixlcontrol/event/settings.html +++ b/src/tixlcontrol/templates/tixlcontrol/event/settings.html @@ -34,12 +34,10 @@ {% bootstrap_field form.payment_term_days layout="horizontal" %} {% bootstrap_field form.payment_term_last layout="horizontal" %} -
    -
    - -
    +
    +
    {% endblock %} diff --git a/src/tixlcontrol/templates/tixlcontrol/item/base.html b/src/tixlcontrol/templates/tixlcontrol/item/base.html index 6f388a890..7a7aa2c3d 100644 --- a/src/tixlcontrol/templates/tixlcontrol/item/base.html +++ b/src/tixlcontrol/templates/tixlcontrol/item/base.html @@ -4,8 +4,9 @@ {% block content %}

    {% trans "Modify item:" %} {{ item.name }}

    {% block inside %} {% endblock %} diff --git a/src/tixlcontrol/templates/tixlcontrol/item/index.html b/src/tixlcontrol/templates/tixlcontrol/item/index.html index 88c31ded4..4b9d43886 100644 --- a/src/tixlcontrol/templates/tixlcontrol/item/index.html +++ b/src/tixlcontrol/templates/tixlcontrol/item/index.html @@ -2,8 +2,6 @@ {% load i18n %} {% load bootstrap3 %} {% block inside %} -

    {% trans "General information" %}

    - {% if "success" in request.GET %}
    {% trans "Your changes have been saved." %} @@ -29,12 +27,10 @@ {% bootstrap_field form.properties layout="horizontal" %} {% bootstrap_field form.questions layout="horizontal" %} -
    -
    - -
    +
    +
    {% endblock %} diff --git a/src/tixlcontrol/templates/tixlcontrol/item/restrictions.html b/src/tixlcontrol/templates/tixlcontrol/item/restrictions.html new file mode 100644 index 000000000..07b16e09d --- /dev/null +++ b/src/tixlcontrol/templates/tixlcontrol/item/restrictions.html @@ -0,0 +1,66 @@ +{% extends "tixlcontrol/item/base.html" %} +{% load i18n %} +{% load bootstrap3 %} +{% load formset_tags %} +{% block inside %} +
    + {% csrf_token %} + {% for set in formsets %} +
    + {{ set.title }} +
    +
    + {{ set.formset.management_form }} + {% for f in set.formset %} +
    +
    +

    + + Test + +

    +
    +
    +
    +
    + {% bootstrap_form f layout="horizontal" field_class="col-md-10" %} +
    +
    +
    +
    + {% endfor %} +
    + + +
    +
    + {% endfor %} +
    + +
    +
    + +{% endblock %} diff --git a/src/tixlcontrol/templates/tixlcontrol/item/variations_0d.html b/src/tixlcontrol/templates/tixlcontrol/item/variations_0d.html new file mode 100644 index 000000000..489d2051d --- /dev/null +++ b/src/tixlcontrol/templates/tixlcontrol/item/variations_0d.html @@ -0,0 +1,7 @@ +{% extends "tixlcontrol/item/base.html" %} +{% load i18n %} +{% load bootstrap3 %} +{% block inside %} + {% trans "You have to define and select propreties to be able to configure variations." %} + +{% endblock %} diff --git a/src/tixlcontrol/templates/tixlcontrol/item/variations_1d.html b/src/tixlcontrol/templates/tixlcontrol/item/variations_1d.html index a8321e696..480d42d3d 100644 --- a/src/tixlcontrol/templates/tixlcontrol/item/variations_1d.html +++ b/src/tixlcontrol/templates/tixlcontrol/item/variations_1d.html @@ -2,7 +2,6 @@ {% load i18n %} {% load bootstrap3 %} {% block inside %} -

    {% trans "Variations" %}

    {% csrf_token %} @@ -24,12 +23,10 @@ {% endfor %}
    -
    -
    - -
    +
    +
    diff --git a/src/tixlcontrol/templates/tixlcontrol/item/variations_2d.html b/src/tixlcontrol/templates/tixlcontrol/item/variations_2d.html deleted file mode 100644 index da8164632..000000000 --- a/src/tixlcontrol/templates/tixlcontrol/item/variations_2d.html +++ /dev/null @@ -1,41 +0,0 @@ -{% extends "tixlcontrol/item/base.html" %} -{% load i18n %} -{% load bootstrap3 %} -{% block inside %} -

    {% trans "Variations" %}

    -
    - {% csrf_token %} - - - - - {% for val in properties.1.values.all %} - - {% endfor %} - - - - {% for sub in forms %} - - - {% for form in sub.forms %} - - {% endfor %} - - {% endfor %} - -
    {{ val.value }}
    {{ sub.row }} - {% bootstrap_field form.active layout='inline' %} - {% bootstrap_field form.default_price layout='inline' %} - {{ form.default_price.errors }} -
    -
    -
    - -
    -
    -
    - -{% endblock %} diff --git a/src/tixlcontrol/templates/tixlcontrol/item/variations_nd.html b/src/tixlcontrol/templates/tixlcontrol/item/variations_nd.html index 6682d550d..40422df7c 100644 --- a/src/tixlcontrol/templates/tixlcontrol/item/variations_nd.html +++ b/src/tixlcontrol/templates/tixlcontrol/item/variations_nd.html @@ -2,43 +2,48 @@ {% load i18n %} {% load bootstrap3 %} {% block inside %} -

    {% trans "Variations" %}

    -
    - {% csrf_token %} - {% for major in forms %} -

    {{ major.row }}

    - - - - - {% for val in properties.1.values.all %} - - {% endfor %} - - - - {% for sub in major.forms %} - - - {% for form in sub.forms %} - - {% endfor %} - - {% endfor %} - -
    {{ val.value }}
    {{ sub.row }} - {% bootstrap_field form.active layout='inline' %} - {% bootstrap_field form.default_price layout='inline' %} - {{ form.default_price.errors }} -
    - {% endfor %} -
    -
    - -
    -
    -
    - +
    + {% csrf_token %} + {% for major in forms %} + {% if major.row %} +

    {{ major.row }}

    + {% endif %} + + + + + {% for val in properties.1.values.all %} + + {% endfor %} + + + + {% for sub in major.forms %} + + + {% for form in sub.forms %} + + {% endfor %} + + {% endfor %} + +
    {{ val.value }}
    {{ sub.row.value }} +
    +
    + {% bootstrap_field form.active layout='inline' %} +
    +
    + {% bootstrap_field form.default_price layout='inline' %} +
    +
    + {{ form.default_price.errors }} +
    + {% endfor %} +
    + +
    +
    + {% endblock %} diff --git a/src/tixlcontrol/templates/tixlcontrol/items/base.html b/src/tixlcontrol/templates/tixlcontrol/items/base.html index 499d305f6..c636133df 100644 --- a/src/tixlcontrol/templates/tixlcontrol/items/base.html +++ b/src/tixlcontrol/templates/tixlcontrol/items/base.html @@ -7,6 +7,7 @@
  • {% trans "Categories" %}
  • {% trans "Properties" %}
  • {% trans "Questions" %}
  • +
  • {% trans "Quotas" %}
  • {% block inside %} {% endblock %} diff --git a/src/tixlcontrol/templates/tixlcontrol/items/categories.html b/src/tixlcontrol/templates/tixlcontrol/items/categories.html index f5168b54d..728a564da 100644 --- a/src/tixlcontrol/templates/tixlcontrol/items/categories.html +++ b/src/tixlcontrol/templates/tixlcontrol/items/categories.html @@ -30,12 +30,12 @@ {% for c in categories %} - {{ c.name }} + {{ c.name }} - - + + - + {% endfor %} diff --git a/src/tixlcontrol/templates/tixlcontrol/items/category.html b/src/tixlcontrol/templates/tixlcontrol/items/category.html index 95c49466c..3e2a8d595 100644 --- a/src/tixlcontrol/templates/tixlcontrol/items/category.html +++ b/src/tixlcontrol/templates/tixlcontrol/items/category.html @@ -15,12 +15,10 @@ {% trans "General information" %} {% bootstrap_field form.name layout="horizontal" %} -
    -
    - -
    +
    +
    {% endblock %} diff --git a/src/tixlcontrol/templates/tixlcontrol/items/category_delete.html b/src/tixlcontrol/templates/tixlcontrol/items/category_delete.html index 8dcef68d1..af6d03be0 100644 --- a/src/tixlcontrol/templates/tixlcontrol/items/category_delete.html +++ b/src/tixlcontrol/templates/tixlcontrol/items/category_delete.html @@ -6,16 +6,14 @@

    {% trans "Delete item category" %}

    {% csrf_token %} -

    {% blocktrans %}Are you sure you want to the category {{ category.name }}?{% endblocktrans %}

    -
    -
    - - - {% trans "Cancel" %} - -
    +

    {% blocktrans %}Are you sure you want to delete the category {{ category.name }}?{% endblocktrans %}

    +
    + + {% trans "Cancel" %} + +
    {% endblock %} diff --git a/src/tixlcontrol/templates/tixlcontrol/items/index.html b/src/tixlcontrol/templates/tixlcontrol/items/index.html index 66ab70fff..4459df7dd 100644 --- a/src/tixlcontrol/templates/tixlcontrol/items/index.html +++ b/src/tixlcontrol/templates/tixlcontrol/items/index.html @@ -13,7 +13,8 @@ {% for i in items %} - {{ i.name }} + {{ i.name }} {{ i.category }} {% endfor %} diff --git a/src/tixlcontrol/templates/tixlcontrol/items/properties.html b/src/tixlcontrol/templates/tixlcontrol/items/properties.html index 57cc9c1dd..c6bafd55f 100644 --- a/src/tixlcontrol/templates/tixlcontrol/items/properties.html +++ b/src/tixlcontrol/templates/tixlcontrol/items/properties.html @@ -29,8 +29,10 @@ {% for p in properties %} - {{ p.name }} - + {{ p.name }} + {% endfor %} diff --git a/src/tixlcontrol/templates/tixlcontrol/items/property.html b/src/tixlcontrol/templates/tixlcontrol/items/property.html index 4a17a3420..70db5c7a4 100644 --- a/src/tixlcontrol/templates/tixlcontrol/items/property.html +++ b/src/tixlcontrol/templates/tixlcontrol/items/property.html @@ -24,14 +24,14 @@ {% for f in formset %}
    {{ f.id }} -
    +
    {% bootstrap_field f.value form_group_class="" layout="inline" %}
    {% bootstrap_field f.ORDER form_group_class="" layout="inline" %} {% bootstrap_field f.DELETE form_group_class="" layout="inline" %}
    -
    +
    @@ -43,14 +43,14 @@ {% escapescript %}
    {{ formset.empty_form.id }} -
    +
    {% bootstrap_field formset.empty_form.value 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" %}
    -
    +
    @@ -61,12 +61,10 @@
    -
    -
    - -
    +
    +
    {% endblock %} diff --git a/src/tixlcontrol/templates/tixlcontrol/items/property_delete.html b/src/tixlcontrol/templates/tixlcontrol/items/property_delete.html index 3973942e6..c6f3ef822 100644 --- a/src/tixlcontrol/templates/tixlcontrol/items/property_delete.html +++ b/src/tixlcontrol/templates/tixlcontrol/items/property_delete.html @@ -15,16 +15,14 @@
    {% csrf_token %}

    {% blocktrans %}Are you sure you want to the property {{ property }}?{% endblocktrans %}

    -
    -
    - - - {% trans "Cancel" %} - -
    -
    +
    + + {% trans "Cancel" %} + + +
    {% endif %} {% endblock %} diff --git a/src/tixlcontrol/templates/tixlcontrol/items/question.html b/src/tixlcontrol/templates/tixlcontrol/items/question.html index a7877527b..d7bb84f75 100644 --- a/src/tixlcontrol/templates/tixlcontrol/items/question.html +++ b/src/tixlcontrol/templates/tixlcontrol/items/question.html @@ -17,12 +17,10 @@ {% bootstrap_field form.type layout="horizontal" %} {% bootstrap_field form.required layout="horizontal" %} -
    -
    - -
    +
    +
    {% endblock %} diff --git a/src/tixlcontrol/templates/tixlcontrol/items/question_delete.html b/src/tixlcontrol/templates/tixlcontrol/items/question_delete.html index 0674c40f2..762d3ae3f 100644 --- a/src/tixlcontrol/templates/tixlcontrol/items/question_delete.html +++ b/src/tixlcontrol/templates/tixlcontrol/items/question_delete.html @@ -8,20 +8,18 @@ {% csrf_token %}

    {% blocktrans %}Are you sure you want to the question {{ question }}?{% endblocktrans %}

    {% if dependent|length > 0 %} -

    {% blocktrans %}All answers to the question given by the buyers of the following tickets will be permanently lost.{% endblocktrans %}

    +

    {% blocktrans %}All answers to the question given by the buyers of the following tickets will be lost.{% endblocktrans %}

    {% for item in dependent %}
  • {{ item.name }}
  • {% endfor %} {% endif %} -
    -
    - - - {% trans "Cancel" %} - -
    +
    + + {% trans "Cancel" %} + +
    {% endblock %} diff --git a/src/tixlcontrol/templates/tixlcontrol/items/questions.html b/src/tixlcontrol/templates/tixlcontrol/items/questions.html index b655071be..3c949f5fc 100644 --- a/src/tixlcontrol/templates/tixlcontrol/items/questions.html +++ b/src/tixlcontrol/templates/tixlcontrol/items/questions.html @@ -30,9 +30,11 @@ {% for q in questions %} - {{ q.question }} + {{ q.question }} {{ q.get_type_display }} - + {% endfor %} diff --git a/src/tixlcontrol/templates/tixlcontrol/items/quota.html b/src/tixlcontrol/templates/tixlcontrol/items/quota.html new file mode 100644 index 000000000..9599952ea --- /dev/null +++ b/src/tixlcontrol/templates/tixlcontrol/items/quota.html @@ -0,0 +1,25 @@ +{% extends "tixlcontrol/items/base.html" %} +{% load i18n %} +{% load bootstrap3 %} +{% block title %}{% trans "Quota" %}{% endblock %} +{% block inside %} +

    {% trans "Quota" %}

    +
    + {% csrf_token %} + {% if "success" in request.GET %} +
    + {% trans "Your changes have been saved." %} +
    + {% endif %} +
    + {% trans "General information" %} + {% bootstrap_field form.name layout="horizontal" %} + {% bootstrap_field form.size layout="horizontal" %} +
    +
    + +
    +
    +{% endblock %} diff --git a/src/tixlcontrol/templates/tixlcontrol/items/quota_delete.html b/src/tixlcontrol/templates/tixlcontrol/items/quota_delete.html new file mode 100644 index 000000000..2e33ff06d --- /dev/null +++ b/src/tixlcontrol/templates/tixlcontrol/items/quota_delete.html @@ -0,0 +1,25 @@ +{% extends "tixlcontrol/items/base.html" %} +{% load i18n %} +{% load bootstrap3 %} +{% block title %}{% trans "Delete quota" %}{% endblock %} +{% block inside %} +

    {% trans "Delete quota" %}

    +
    + {% csrf_token %} +

    {% blocktrans %}Are you sure you want to delete the quota {{ quota }}?{% endblocktrans %}

    + {% if dependent|length > 0 %} +

    {% blocktrans %}The following items might be no longer available for sale:{% endblocktrans %}

    + {% for item in dependent %} +
  • {{ item.name }}
  • + {% endfor %} + {% endif %} +
    + + {% trans "Cancel" %} + + +
    +
    +{% endblock %} diff --git a/src/tixlcontrol/templates/tixlcontrol/items/quotas.html b/src/tixlcontrol/templates/tixlcontrol/items/quotas.html new file mode 100644 index 000000000..e0c2d473a --- /dev/null +++ b/src/tixlcontrol/templates/tixlcontrol/items/quotas.html @@ -0,0 +1,44 @@ +{% extends "tixlcontrol/items/base.html" %} +{% load i18n %} +{% block title %}{% trans "Quotas" %}{% endblock %} +{% block inside %} +

    {% trans "Quotas" %}

    + {% if "updated" in request.GET %} +
    + {% trans "Your changes have been saved." %} +
    + {% elif "created" in request.GET %} +
    + {% trans "A new quota has been created." %} +
    + {% elif "deleted" in request.GET %} +
    + {% trans "The quota has been deleted." %} +
    + {% endif %} +

    + {% trans "Create a new quota" %} +

    + + + + + + + + + + + + {% for q in quotas %} + + + + + + + + {% endfor %} + +
    {% trans "Quota name" %}{% trans "Items" %}{% trans "Total capacity" %}{% trans "Capacity left" %}
    {{ q.name }}{{ q.size }}
    +{% endblock %} diff --git a/src/tixlcontrol/tests/test_auth.py b/src/tixlcontrol/tests/test_auth.py index 677eb9a5e..4df288e05 100644 --- a/src/tixlcontrol/tests/test_auth.py +++ b/src/tixlcontrol/tests/test_auth.py @@ -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.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): diff --git a/src/tixlcontrol/urls.py b/src/tixlcontrol/urls.py index fdd058507..a4fd4cd10 100644 --- a/src/tixlcontrol/urls.py +++ b/src/tixlcontrol/urls.py @@ -21,22 +21,28 @@ urlpatterns += patterns( url(r'^settings/$', event.EventUpdate.as_view(), name='event.settings'), 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/(?P\d+)/$', item.ItemUpdateGeneral.as_view(), name='event.item'), - url(r'^items/(?P\d+)/variations$', item.ItemVariations.as_view(), name='event.item.variations'), + url(r'^items/(?P[0-9a-f-]+)/$', item.ItemUpdateGeneral.as_view(), name='event.item'), + url(r'^items/(?P[0-9a-f-]+)/variations$', item.ItemVariations.as_view(), name='event.item.variations'), + url(r'^items/(?P[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/(?P\d+)/delete$', item.CategoryDelete.as_view(), name='event.items.categories.delete'), - url(r'^categories/(?P\d+)/up$', item.category_move_up, name='event.items.categories.up'), - url(r'^categories/(?P\d+)/down$', item.category_move_down, name='event.items.categories.down'), - url(r'^categories/(?P\d+)/$', item.CategoryUpdate.as_view(), name='event.items.categories.edit'), + url(r'^categories/(?P[0-9a-f-]+)/delete$', item.CategoryDelete.as_view(), name='event.items.categories.delete'), + url(r'^categories/(?P[0-9a-f-]+)/up$', item.category_move_up, name='event.items.categories.up'), + url(r'^categories/(?P[0-9a-f-]+)/down$', item.category_move_down, name='event.items.categories.down'), + url(r'^categories/(?P[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'^questions/$', item.QuestionList.as_view(), name='event.items.questions'), - url(r'^questions/(?P\d+)/delete$', item.QuestionDelete.as_view(), name='event.items.questions.delete'), - url(r'^questions/(?P\d+)/$', item.QuestionUpdate.as_view(), name='event.items.questions.edit'), + url(r'^questions/(?P[0-9a-f-]+)/delete$', item.QuestionDelete.as_view(), name='event.items.questions.delete'), + url(r'^questions/(?P[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'^properties/$', item.PropertyList.as_view(), name='event.items.properties'), - url(r'^properties/(?P\d+)/$', item.PropertyUpdate.as_view(), name='event.items.properties.edit'), - url(r'^properties/(?P\d+)/delete$', item.PropertyDelete.as_view(), name='event.items.properties.delete'), + url(r'^properties/(?P[0-9a-f-]+)/$', item.PropertyUpdate.as_view(), name='event.items.properties.edit'), + url(r'^properties/(?P[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'^quotas/$', item.QuotaList.as_view(), name='event.items.quotas'), + url(r'^quotas/(?P[0-9a-f-]+)/$', item.QuotaUpdate.as_view(), name='event.items.quotas.edit'), + url(r'^quotas/(?P[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'), ) )) ) diff --git a/src/tixlcontrol/views/event.py b/src/tixlcontrol/views/event.py index c3617ff76..63e7f16f5 100644 --- a/src/tixlcontrol/views/event.py +++ b/src/tixlcontrol/views/event.py @@ -7,12 +7,13 @@ from django.utils.translation import ugettext_lazy as _ from django.core.urlresolvers import reverse from pytz import common_timezones +from tixlbase.forms import VersionedModelForm from tixlbase.models import Event from tixlcontrol.permissions import EventPermissionRequiredMixin -class EventUpdateForm(forms.ModelForm): +class EventUpdateForm(VersionedModelForm): timezone = forms.ChoiceField( choices=((a, a) for a in common_timezones), @@ -52,10 +53,10 @@ class EventUpdate(EventPermissionRequiredMixin, UpdateView): template_name = 'tixlcontrol/event/settings.html' permission = 'can_change_settings' - def get_object(self, queryset=None): + def get_object(self, queryset=None) -> Event: return self.request.event - def get_success_url(self): + def get_success_url(self) -> str: return reverse('control:event.settings', kwargs={ 'organizer': self.get_object().organizer.slug, 'event': self.get_object().slug, @@ -72,7 +73,7 @@ class EventPlugins(EventPermissionRequiredMixin, TemplateView, SingleObjectMixin def get_object(self, queryset=None): 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 context = super().get_context_data(*args, **kwargs) 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() return redirect(self.get_success_url()) - def get_success_url(self): + def get_success_url(self) -> str: return reverse('control:event.settings.plugins', kwargs={ 'organizer': self.get_object().organizer.slug, 'event': self.get_object().slug, diff --git a/src/tixlcontrol/views/forms.py b/src/tixlcontrol/views/forms.py index 176a67c8e..dd8fb80b4 100644 --- a/src/tixlcontrol/views/forms.py +++ b/src/tixlcontrol/views/forms.py @@ -1,15 +1,25 @@ +from itertools import product 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): - - def has_changed(self): +class TolerantFormsetModelForm(VersionedModelForm): + def has_changed(self) -> bool: """ Returns True if data differs from initial. Contrary to the default implementation, the ORDER field is being ignored. """ for name, field in self.fields.items(): - if name == 'ORDER': + if name == 'ORDER' or name == 'id': continue prefixed_name = self.add_prefix(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. self._changed_data.append(name) 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): return True 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('
    ', id_) if id_ else '
    ' + 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('{0}', _("not applicable"))) + elif dimension == 1: + output.append('
      ') + 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('
    • {0}
    • ', force_text(w))) + output.append('
    ') + + 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('') + output.append(", ".join([value.value for value in gridrow])) + output.append('') + output.append('') + for val2 in prop2v: + output.append(format_html('', val2.value)) + output.append('') + for val1 in prop1v: + output.append(format_html('', 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('', flatatt(final_attrs))) + output.append('') + output.append('
    {0}
    {0}
    ') + output.append('
    ') + 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) diff --git a/src/tixlcontrol/views/item.py b/src/tixlcontrol/views/item.py index 5d90d65f3..9afb31422 100644 --- a/src/tixlcontrol/views/item.py +++ b/src/tixlcontrol/views/item.py @@ -1,4 +1,5 @@ from itertools import product +from django.db import transaction from django.views.generic import ListView 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.core.urlresolvers import resolve, reverse from django.http import HttpResponseRedirect, HttpResponseForbidden -from django import forms from django.shortcuts import redirect 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.views.forms import TolerantFormsetModelForm +from tixlcontrol.signals import restriction_formset class ItemList(ListView): @@ -21,12 +25,12 @@ class ItemList(ListView): template_name = 'tixlcontrol/items/index.html' def get_queryset(self): - return Item.objects.filter( + return Item.objects.current.filter( event=self.request.event ).prefetch_related("category") -class CategoryForm(forms.ModelForm): +class CategoryForm(VersionedModelForm): class Meta: model = ItemCategory @@ -45,13 +49,15 @@ class CategoryDelete(EventPermissionRequiredMixin, DeleteView): def get_object(self, queryset=None): url = resolve(self.request.path_info) - return self.request.event.categories.get( - id=url.kwargs['category'] + return self.request.event.categories.current.get( + identity=url.kwargs['category'] ) def delete(self, request, *args, **kwargs): 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() self.object.delete() return HttpResponseRedirect(success_url) @@ -72,8 +78,8 @@ class CategoryUpdate(EventPermissionRequiredMixin, UpdateView): def get_object(self, queryset=None): url = resolve(self.request.path_info) - return self.request.event.categories.get( - id=url.kwargs['category'] + return self.request.event.categories.current.get( + identity=url.kwargs['category'] ) def get_success_url(self): @@ -107,14 +113,14 @@ class CategoryList(ListView): template_name = 'tixlcontrol/items/categories.html' 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): - category = request.event.categories.get( - id=category + category = request.event.categories.current.get( + identity=category ) - categories = list(request.event.categories.order_by("position")) + categories = list(request.event.categories.current.order_by("position")) index = categories.index(category) if index != 0 and up: @@ -152,12 +158,12 @@ class PropertyList(ListView): template_name = 'tixlcontrol/items/properties.html' def get_queryset(self): - return Property.objects.filter( + return Property.objects.current.filter( event=self.request.event ) -class PropertyForm(forms.ModelForm): +class PropertyForm(VersionedModelForm): class Meta: model = Property localized_fields = '__all__' @@ -184,8 +190,8 @@ class PropertyUpdate(EventPermissionRequiredMixin, UpdateView): def get_object(self, queryset=None): url = resolve(self.request.path_info) - return self.request.event.properties.get( - id=url.kwargs['property'] + return self.request.event.properties.current.get( + identity=url.kwargs['property'] ) def get_success_url(self): @@ -203,7 +209,9 @@ class PropertyUpdate(EventPermissionRequiredMixin, UpdateView): can_order=True, 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 def get_context_data(self, *args, **kwargs): @@ -212,9 +220,15 @@ class PropertyUpdate(EventPermissionRequiredMixin, UpdateView): return context 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): + if f.instance.pk is not None: + f.instance = f.instance.clone() f.instance.position = i - formset.save() + f.instance.save() return super().form_valid(form) def post(self, request, *args, **kwargs): @@ -285,18 +299,18 @@ class PropertyDelete(EventPermissionRequiredMixin, DeleteView): def get_context_data(self, *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() return context 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): if not hasattr(self, 'object') or not self.object: url = resolve(self.request.path_info) - self.object = self.request.event.properties.get( - id=url.kwargs['property'] + self.object = self.request.event.properties.current.get( + identity=url.kwargs['property'] ) return self.object @@ -321,10 +335,10 @@ class QuestionList(ListView): template_name = 'tixlcontrol/items/questions.html' 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: model = Question @@ -344,18 +358,17 @@ class QuestionDelete(EventPermissionRequiredMixin, DeleteView): def get_object(self, queryset=None): url = resolve(self.request.path_info) - return self.request.event.questions.get( - id=url.kwargs['question'] + return self.request.event.questions.current.get( + identity=url.kwargs['question'] ) def get_context_data(self, *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 def delete(self, request, *args, **kwargs): self.object = self.get_object() - self.object.items.update(category=None) success_url = self.get_success_url() self.object.delete() return HttpResponseRedirect(success_url) @@ -376,8 +389,8 @@ class QuestionUpdate(EventPermissionRequiredMixin, UpdateView): def get_object(self, queryset=None): url = resolve(self.request.path_info) - return self.request.event.questions.get( - id=url.kwargs['question'] + return self.request.event.questions.current.get( + identity=url.kwargs['question'] ) def get_success_url(self): @@ -405,13 +418,117 @@ class QuestionCreate(EventPermissionRequiredMixin, CreateView): 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): super().__init__(*args, **kwargs) - self.fields['category'].queryset = self.instance.event.categories.all() - self.fields['properties'].queryset = self.instance.event.properties.all() - self.fields['questions'].queryset = self.instance.event.questions.all() + self.fields['category'].queryset = self.instance.event.categories.current.all() + self.fields['properties'].queryset = self.instance.event.properties.current.all() + self.fields['questions'].queryset = self.instance.event.questions.current.all() class Meta: model = Item @@ -429,28 +546,20 @@ class ItemUpdateFormGeneral(forms.ModelForm): ] -class ItemUpdateGeneral(EventPermissionRequiredMixin, UpdateView): - model = Item +class ItemUpdateGeneral(ItemDetailMixin, EventPermissionRequiredMixin, UpdateView): form_class = ItemUpdateFormGeneral template_name = 'tixlcontrol/item/index.html' 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): return reverse('control:event.item', kwargs={ 'organizer': self.request.event.organizer.slug, 'event': self.request.event.slug, - 'item': self.get_object().pk, + 'item': self.get_object().identity, }) + '?success=true' -class ItemVariationForm(forms.ModelForm): +class ItemVariationForm(VersionedModelForm): class Meta: 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' def __init__(self, *args, **kwargs): @@ -482,13 +589,16 @@ class ItemVariations(EventPermissionRequiredMixin, TemplateView, SingleObjectMix form = ItemVariationForm( data, instance=variation['variation'], - prefix=",".join([str(i.pk) for i in values]), + prefix=",".join([str(i.identity) for i in values]), ) else: + inst = ItemVariation(item=self.object) + inst.item_id = self.object.identity + inst.creation = True form = ItemVariationForm( data, - instance=ItemVariation(item=self.object), - prefix=",".join([str(i.pk) for i in values]), + instance=inst, + prefix=",".join([str(i.identity) for i in values]), ) form.values = values return form @@ -518,34 +628,8 @@ class ItemVariations(EventPermissionRequiredMixin, TemplateView, SingleObjectMix forms.append(form) forms_flat = forms - elif self.dimension == 2: - # For two-dimensional structures we have a grid of forms - # 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 + elif self.dimension >= 2: + # For 2 or more dimensional structures we display a list of grids # of forms # 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. # We'll see later why we need this. selector = lambda values: [ - v.pk for v in sorted(values, key=lambda v: v.prop.pk) - if v.prop.pk != prop2.pk + 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.pk].pk + 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.all() for prop in self.properties[2:]]): + for gridrow in product(*[prop.values.current.all() for prop in self.properties[2:]]): grids = [] - for val1 in prop1.values.all(): + for val1 in prop1.values.current.all(): formrow = [] # 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 @@ -597,7 +681,7 @@ class ItemVariations(EventPermissionRequiredMixin, TemplateView, SingleObjectMix def main(self, request, *args, **kwargs): 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.forms, self.forms_flat = self.get_forms() @@ -609,29 +693,24 @@ class ItemVariations(EventPermissionRequiredMixin, TemplateView, SingleObjectMix def post(self, request, *args, **kwargs): self.main(request, *args, **kwargs) context = self.get_context_data(object=self.object) - for form in self.forms_flat: - if form.is_valid(): - if form.instance.pk is None: - form.save() - form.instance.values.add(*form.values) - else: + with transaction.atomic(): + for form in self.forms_flat: + if form.is_valid() and form.has_changed(): 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) - 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): - if self.dimension == 1: + if self.dimension == 0: + return ['tixlcontrol/item/variations_0d.html'] + elif self.dimension == 1: return ['tixlcontrol/item/variations_1d.html'] - elif self.dimension == 2: - return ['tixlcontrol/item/variations_2d.html'] - elif self.dimension > 2: + elif self.dimension >= 2: return ['tixlcontrol/item/variations_nd.html'] def get_context_data(self, **kwargs): @@ -639,3 +718,64 @@ class ItemVariations(EventPermissionRequiredMixin, TemplateView, SingleObjectMix context['forms'] = self.forms context['properties'] = self.properties 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' diff --git a/src/tixlcontrol/views/main.py b/src/tixlcontrol/views/main.py index c080e584f..a53cecf5d 100644 --- a/src/tixlcontrol/views/main.py +++ b/src/tixlcontrol/views/main.py @@ -10,7 +10,7 @@ class EventList(ListView): template_name = 'tixlcontrol/events/index.html' def get_queryset(self): - return Event.objects.filter( + return Event.objects.current.filter( permitted__id__exact=self.request.user.pk ).prefetch_related( "organizer", diff --git a/src/tixlplugins/timerestriction/__init__.py b/src/tixlplugins/timerestriction/__init__.py index f98bb6248..c8be10cee 100644 --- a/src/tixlplugins/timerestriction/__init__.py +++ b/src/tixlplugins/timerestriction/__init__.py @@ -9,7 +9,7 @@ class TimeRestrictionApp(AppConfig): class TixlPluginMeta: type = PluginType.RESTRICTION - name = _("Restriciton by time") + name = _("Restricition by time") author = _("the tixl team") version = '1.0.0' description = _("This plugin adds the possibility to restrict the sale " + diff --git a/src/tixlplugins/timerestriction/migrations/0001_initial.py b/src/tixlplugins/timerestriction/migrations/0001_initial.py index 817f266ca..e412150bb 100644 --- a/src/tixlplugins/timerestriction/migrations/0001_initial.py +++ b/src/tixlplugins/timerestriction/migrations/0001_initial.py @@ -2,30 +2,36 @@ from __future__ import unicode_literals from django.db import models, migrations +import tixlbase.models +import versions.models class Migration(migrations.Migration): dependencies = [ - ('tixlbase', '0015_auto_20141006_2205'), + ('tixlbase', '0001_initial'), ] operations = [ migrations.CreateModel( name='TimeRestriction', 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_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)), - ('event', models.ForeignKey(related_name='restrictions_timerestriction_timerestriction', to='tixlbase.Event', verbose_name='Event')), - ('items', models.ManyToManyField(to='tixlbase.Item', related_name='restrictions_timerestriction_timerestriction')), - ('variations', models.ManyToManyField(to='tixlbase.ItemVariation', related_name='restrictions_timerestriction_timerestriction')), + ('price', models.DecimalField(null=True, blank=True, verbose_name='Price in time frame', max_digits=7, decimal_places=2)), + ('event', versions.models.VersionedForeignKey(to='tixlbase.Event', related_name='restrictions_timerestriction_timerestriction', verbose_name='Event')), + ('item', versions.models.VersionedForeignKey(to='tixlbase.Item', blank=True, null=True, related_name='restrictions_timerestriction_timerestriction', verbose_name='Item')), + ('variations', tixlbase.models.VariationsField(to='tixlbase.ItemVariation', blank=True, verbose_name='Variations', related_name='restrictions_timerestriction_timerestriction')), ], options={ - 'abstract': False, - 'verbose_name_plural': 'Restrictions', 'verbose_name': 'Restriction', + 'verbose_name_plural': 'Restrictions', + 'abstract': False, }, bases=(models.Model,), ), diff --git a/src/tixlplugins/timerestriction/signals.py b/src/tixlplugins/timerestriction/signals.py index 2a77f087b..4fbcd4030 100644 --- a/src/tixlplugins/timerestriction/signals.py +++ b/src/tixlplugins/timerestriction/signals.py @@ -1,7 +1,12 @@ from django.dispatch import receiver 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.models import Item +from tixlcontrol.views.forms import VariationsField, RestrictionInlineFormset, RestrictionForm +from tixlcontrol.signals import restriction_formset from .models import TimeRestriction @@ -15,8 +20,8 @@ def availability_handler(sender, **kwargs): context = kwargs['context'] # NOQA # Fetch all restriction objects applied to this item - restrictions = list(TimeRestriction.objects.filter( - items__in=(item,), + restrictions = list(TimeRestriction.objects.current.filter( + item=item, ).prefetch_related('variations')) # If we do not know anything about this item, we are done here. @@ -57,8 +62,8 @@ def availability_handler(sender, **kwargs): price = None # Make up some unique key for this variation - cachekey = 'timerestriction:%d:%s' % ( - item.pk, + cachekey = 'timerestriction:%s:%s' % ( + item.identity, v.identify(), ) @@ -74,7 +79,7 @@ def availability_handler(sender, **kwargs): # Walk through all restriction objects applied to this item 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 # 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: continue - if (restriction.timeframe_from <= now() - and restriction.timeframe_to >= now()): + if restriction.timeframe_from <= now() <= restriction.timeframe_to: # Selling this item is currently possible available = True # If multiple time frames are currently active, make sure to @@ -105,3 +109,35 @@ def availability_handler(sender, **kwargs): ) 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', + } diff --git a/src/tixlplugins/timerestriction/tests.py b/src/tixlplugins/timerestriction/tests.py index 98b5e9074..6bb4f76cd 100644 --- a/src/tixlplugins/timerestriction/tests.py +++ b/src/tixlplugins/timerestriction/tests.py @@ -46,7 +46,8 @@ class TimeRestrictionTest(TestCase): event=self.event, price=12 ) - r.items.add(self.item) + r.item = self.item + r.save() result = signals.availability_handler( self.event, item=self.item, variations=self.item.get_all_variations(), @@ -64,7 +65,8 @@ class TimeRestrictionTest(TestCase): event=self.event, price=12 ) - r.items.add(self.item) + r.item = self.item + r.save() result = signals.availability_handler( self.event, item=self.item, variations=self.item.get_all_variations(), @@ -91,7 +93,8 @@ class TimeRestrictionTest(TestCase): event=self.event, price=12 ) - r.items.add(self.item) + r.item = self.item + r.save() result = signals.availability_handler( self.event, item=self.item, variations=self.item.get_all_variations(), @@ -108,14 +111,16 @@ class TimeRestrictionTest(TestCase): event=self.event, price=12 ) - r1.items.add(self.item) + r1.item = self.item + r1.save() r2 = TimeRestriction.objects.create( timeframe_from=now() - timedelta(days=3), timeframe_to=now() + timedelta(days=5), event=self.event, price=8 ) - r2.items.add(self.item) + r2.item = self.item + r2.save() result = signals.availability_handler( self.event, item=self.item, variations=self.item.get_all_variations(), @@ -133,14 +138,16 @@ class TimeRestrictionTest(TestCase): event=self.event, price=12 ) - r1.items.add(self.item) + r1.item = self.item + r1.save() r2 = TimeRestriction.objects.create( timeframe_from=now() + timedelta(days=1), timeframe_to=now() + timedelta(days=7), event=self.event, price=8 ) - r2.items.add(self.item) + r2.item = self.item + r2.save() result = signals.availability_handler( self.event, item=self.item, variations=self.item.get_all_variations(), @@ -158,14 +165,16 @@ class TimeRestrictionTest(TestCase): event=self.event, price=12 ) - r1.items.add(self.item) + r1.item = self.item + r1.save() r2 = TimeRestriction.objects.create( timeframe_from=now() + timedelta(days=4), timeframe_to=now() + timedelta(days=7), event=self.event, price=8 ) - r2.items.add(self.item) + r2.item = self.item + r2.save() result = signals.availability_handler( self.event, item=self.item, variations=self.item.get_all_variations(), @@ -183,14 +192,16 @@ class TimeRestrictionTest(TestCase): event=self.event, price=12 ) - r1.items.add(self.item) + r1.item = self.item + r1.save() r2 = TimeRestriction.objects.create( timeframe_from=now() + timedelta(days=4), timeframe_to=now() + timedelta(days=7), event=self.event, price=8 ) - r2.items.add(self.item) + r2.item = self.item + r2.save() result = signals.availability_handler( self.event, item=self.item, variations=self.item.get_all_variations(), @@ -213,7 +224,8 @@ class TimeRestrictionTest(TestCase): event=self.event, price=12 ) - r1.items.add(self.item) + r1.item = self.item + r1.save() r1.variations.add(v1) result = signals.availability_handler( self.event, item=self.item, @@ -241,14 +253,16 @@ class TimeRestrictionTest(TestCase): event=self.event, price=12 ) - r1.items.add(self.item) + r1.item = self.item + r1.save() r2 = TimeRestriction.objects.create( timeframe_from=now() - timedelta(days=5), timeframe_to=now() + timedelta(days=1), event=self.event, price=8 ) - r2.items.add(self.item) + r2.item = self.item + r2.save() r2.variations.add(v1) r3 = TimeRestriction.objects.create( timeframe_from=now() - timedelta(days=5), @@ -256,7 +270,8 @@ class TimeRestrictionTest(TestCase): event=self.event, price=10 ) - r3.items.add(self.item) + r3.item = self.item + r3.save() r3.variations.add(v2) result = signals.availability_handler( self.event, item=self.item, @@ -285,7 +300,8 @@ class TimeRestrictionTest(TestCase): event=self.event, price=12 ) - r1.items.add(self.item) + r1.item = self.item + r1.save() r1.variations.add(v1) r2 = TimeRestriction.objects.create( timeframe_from=now() - timedelta(days=5), @@ -293,7 +309,8 @@ class TimeRestrictionTest(TestCase): event=self.event, price=8 ) - r2.items.add(self.item) + r2.item = self.item + r2.save() r2.variations.add(v1) r3 = TimeRestriction.objects.create( timeframe_from=now() - timedelta(days=5), @@ -301,7 +318,8 @@ class TimeRestrictionTest(TestCase): event=self.event, price=8 ) - r3.items.add(self.item) + r3.item = self.item + r3.save() r3.variations.add(v2) result = signals.availability_handler( self.event, item=self.item, diff --git a/src/tixlpresale/migrations/__init__.py b/src/tixlpresale/migrations/__init__.py deleted file mode 100644 index e69de29bb..000000000