diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000000..1b35dd8036 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "src/tixlbase/static/bootstrap"] + path = src/tixlbase/static/bootstrap + url = https://github.com/twbs/bootstrap.git diff --git a/.travis.yml b/.travis.yml index 5049c20313..71736cd150 100644 --- a/.travis.yml +++ b/.travis.yml @@ -6,7 +6,7 @@ install: - pip install -q -r src/requirements.txt before_script: - cd src - - "pep8 --ignore=E501,E128 --exclude=migrations ." - - pyflakes . + - flake8 --ignore=E128,F403,F401 . + - python manage.py validate script: - python manage.py test diff --git a/doc/development/setup.rst b/doc/development/setup.rst index 2d4af3494c..079defba59 100644 --- a/doc/development/setup.rst +++ b/doc/development/setup.rst @@ -3,11 +3,18 @@ The development setup Obtain a copy of the source code -------------------------------- -Just clone our git repository:: +Just clone our git repository including its submodules:: - git clone https://github.com/tixl/tixl.git + git clone --recursive https://github.com/tixl/tixl.git cd tixl/ +Dependencies +------------ +* Python 3.4 or newer +* ``pip`` for Python 3 +* ``git`` +* ``lessc`` (Debian package: ``node-less``) + Your local python environment ----------------------------- @@ -29,13 +36,29 @@ Then, create the local database:: python manage.py syncdb +Create the translation files +^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +To generate updated translation files, run a:: + + make localegen + +To compile the language files for use, run:: + + make localecompile + +Run the development server +^^^^^^^^^^^^^^^^^^^^^^^^^^ +Execute:: + + python manage.py runserver + +to start a local development webserver on port 8000. + Static code checks ^^^^^^^^^^^^^^^^^^ - Before you check in your code into git, always run:: - pyflakes . - pep8 --ignore=E501,E128 . + flake8 . to check for syntax, style and other errors. diff --git a/src/.gitignore b/src/.gitignore index 61d9f80265..0d27eb1905 100644 --- a/src/.gitignore +++ b/src/.gitignore @@ -5,6 +5,8 @@ *.aux *.log *.toc +*.mo *~ .ropeproject __pycache__/ +_static/ diff --git a/src/Makefile b/src/Makefile new file mode 100644 index 0000000000..75067417f0 --- /dev/null +++ b/src/Makefile @@ -0,0 +1,7 @@ +all: localecompile + +localecompile: + django-admin compilemessages + +localegen: + django-admin makemessages --all diff --git a/src/locale/de/LC_MESSAGES/django.po b/src/locale/de/LC_MESSAGES/django.po new file mode 100644 index 0000000000..7f3cf9626f --- /dev/null +++ b/src/locale/de/LC_MESSAGES/django.po @@ -0,0 +1,84 @@ +# tixl translation file German +# Copyright (C) 2014 the tixl authors +# This file is distributed under the same license as the tixl package. +# Raphael Michel , 2014. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: 1\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2014-09-11 11:16+0200\n" +"PO-Revision-Date: 2014-09-11 11:05+200\n" +"Last-Translator: Raphael Michel \n" +"Language-Team: Raphael Michel \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#: tixl/settings.py:92 +msgid "German" +msgstr "Deutsch" + +#: tixl/settings.py:93 +msgid "English" +msgstr "Englisch" + +#: tixlbase/admin.py:16 +msgid "The two password fields didn't match." +msgstr "Die beiden eingegebenen Passwörter stimmen nicht überein." + +#: tixlbase/admin.py:18 tixlcontrol/views/auth.py:14 +msgid "Password" +msgstr "Passwort" + +#: tixlbase/admin.py:20 +msgid "Password confirmation" +msgstr "Passwort bestätigen" + +#: tixlbase/admin.py:22 +msgid "Enter the same password as above, for verification." +msgstr "Geben Sie zur Bestätigung das selbe Passwort wie oben ein" + +#: tixlbase/admin.py:50 +msgid "Personal info" +msgstr "Persönliche Daten" + +#: tixlbase/admin.py:51 +msgid "Permissions" +msgstr "Berechtigungen" + +#: tixlbase/models.py:65 +msgid "Letters, digits and @/./+/-/_ only." +msgstr "Nur Buchstaben, Zahlen und @/./+/-/_" + +#: tixlbase/models.py:71 +msgid "E-mail" +msgstr "E-Mail" + +#: tixlbase/models.py:74 +msgid "Given name" +msgstr "Vorname" + +#: tixlbase/models.py:77 +msgid "Family name" +msgstr "Nachname" + +#: tixlcontrol/templates/tixlcontrol/auth/login.html:12 +msgid "Log in" +msgstr "Anmelden" + +#: tixlcontrol/views/auth.py:13 +msgid "E-mail address" +msgstr "E-Mail-Adresse" + +#: tixlcontrol/views/auth.py:18 +msgid "Please enter a correct e-mail address and password." +msgstr "" +"Bitte geben Sie eine gültige Kombination aus E-Mail-Adresse und Passwort ein." + +#: tixlcontrol/views/auth.py:19 +msgid "This account is inactive." +msgstr "Dieses Konto ist deaktiviert." diff --git a/src/requirements.txt b/src/requirements.txt index 9d221a8efc..4af31dbad9 100644 --- a/src/requirements.txt +++ b/src/requirements.txt @@ -1,3 +1,11 @@ Django>=1.7 +django-bootstrap3 +django-compressor +BeautifulSoup4 +html5lib +slimit +lxml pyflakes pep8 +pep8-naming +flake8 diff --git a/src/setup.cfg b/src/setup.cfg new file mode 100644 index 0000000000..ff9ae58768 --- /dev/null +++ b/src/setup.cfg @@ -0,0 +1,5 @@ +[flake8] +ignore = E128 +max-line-length = 160 +exclude = tests,migrations,.ropeproject,static +max-complexity = 12 diff --git a/src/tixl/settings.py b/src/tixl/settings.py index 4be0c19e54..2301caf0d9 100644 --- a/src/tixl/settings.py +++ b/src/tixl/settings.py @@ -39,16 +39,20 @@ INSTALLED_APPS = ( 'tixlbase', 'tixlcontrol', 'tixlpresale', + 'compressor', + 'bootstrap3', ) MIDDLEWARE_CLASSES = ( 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.middleware.locale.LocaleMiddleware', 'django.middleware.common.CommonMiddleware', 'django.middleware.csrf.CsrfViewMiddleware', 'django.contrib.auth.middleware.AuthenticationMiddleware', 'django.contrib.auth.middleware.SessionAuthenticationMiddleware', 'django.contrib.messages.middleware.MessageMiddleware', 'django.middleware.clickjacking.XFrameOptionsMiddleware', + 'tixlcontrol.middleware.LoginRequiredMiddleware', ) ROOT_URLCONF = 'tixl.urls' @@ -79,17 +83,46 @@ USE_L10N = True USE_TZ = True +LOCALE_PATHS = ( + 'locale', +) + +from django.utils.translation import ugettext_lazy as _ +LANGUAGES = ( + ('de', _('German')), + ('en', _('English')), +) + # Authentication AUTH_USER_MODEL = 'tixlbase.User' - +LOGIN_URL = '/login' +LOGIN_URL_CONTROL = '/control/login' # Static files (CSS, JavaScript, Images) # https://docs.djangoproject.com/en/dev/howto/static-files/ STATIC_URL = '/static/' +STATIC_ROOT = '_static' + +STATICFILES_FINDERS = ( + 'django.contrib.staticfiles.finders.FileSystemFinder', + 'django.contrib.staticfiles.finders.AppDirectoriesFinder', + 'compressor.finders.CompressorFinder', +) + +COMPRESS_PRECOMPILERS = ( + ('text/less', 'lessc {infile} {outfile}'), +) + +COMPRESS_CSS_FILTERS = ( + 'compressor.filters.css_default.CssAbsoluteFilter', + 'compressor.filters.cssmin.CSSMinFilter', +) + + try: from local_settings import * except ImportError: diff --git a/src/tixl/urls.py b/src/tixl/urls.py index 26a762cf38..82512bd265 100644 --- a/src/tixl/urls.py +++ b/src/tixl/urls.py @@ -1,10 +1,16 @@ from django.conf.urls import patterns, include, url from django.contrib import admin +from django.conf import settings + +import tixlcontrol.urls + urlpatterns = patterns('', - # Examples: - # url(r'^$', 'tixl.views.home', name='home'), - # url(r'^blog/', include('blog.urls')), - + url(r'^control/', include(tixlcontrol.urls, namespace='control')), url(r'^admin/', include(admin.site.urls)), ) + +if settings.DEBUG: + urlpatterns += patterns('django.contrib.staticfiles.views', + url(r'^static/(?P.*)$', 'serve'), + ) diff --git a/src/tixlbase/admin.py b/src/tixlbase/admin.py index 8c38f3f3da..2dc9d3772d 100644 --- a/src/tixlbase/admin.py +++ b/src/tixlbase/admin.py @@ -1,3 +1,60 @@ from django.contrib import admin +from django.contrib.auth.admin import UserAdmin +from django.utils.translation import ugettext as _ +from django import forms -# Register your models here. +from tixlbase.models import User + + +class TixlUserCreationForm(forms.ModelForm): + + """ + A form that creates a user, with no privileges, from the given username and + password. + """ + error_messages = { + 'password_mismatch': _("The two password fields didn't match."), + } + password1 = forms.CharField(label=_("Password"), + widget=forms.PasswordInput) + password2 = forms.CharField(label=_("Password confirmation"), + widget=forms.PasswordInput, + help_text=_("Enter the same password as above, for verification.")) + + class Meta: + model = User + fields = ("email", "username", "event") + + def clean_password2(self): + password1 = self.cleaned_data.get("password1") + password2 = self.cleaned_data.get("password2") + if password1 and password2 and password1 != password2: + raise forms.ValidationError( + self.error_messages['password_mismatch'], + code='password_mismatch', + ) + return password2 + + def save(self, commit=True): + user = super(TixlUserCreationForm, self).save(commit=False) + user.set_password(self.cleaned_data["password1"]) + if commit: + user.save() + return user + + +class TixlUserAdmin(UserAdmin): + + fieldsets = ( + (None, {'fields': ('identifier', 'event', 'username', 'password')}), + (_('Personal info'), {'fields': ('familyname', 'givenname', 'email')}), + (_('Permissions'), {'fields': ('is_active', 'is_staff', + 'groups', 'user_permissions')}), + ) + list_display = ('identifier', 'event', 'username', 'email', 'givenname', 'familyname', 'is_staff') + search_fields = ('identifier', 'username', 'givenname', 'familyname', 'email') + ordering = ('identifier',) + list_filter = ('is_staff', 'is_active', 'groups') + add_form = TixlUserCreationForm + +admin.site.register(User, TixlUserAdmin) diff --git a/src/tixlbase/migrations/0002_auto_20140910_1628.py b/src/tixlbase/migrations/0002_auto_20140910_1628.py new file mode 100644 index 0000000000..d08eaa8e26 --- /dev/null +++ b/src/tixlbase/migrations/0002_auto_20140910_1628.py @@ -0,0 +1,26 @@ +# -*- 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 new file mode 100644 index 0000000000..826d1a1c9e --- /dev/null +++ b/src/tixlbase/migrations/0003_auto_20140910_1649.py @@ -0,0 +1,19 @@ +# -*- 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/models.py b/src/tixlbase/models.py index 45674511d2..065b68844c 100644 --- a/src/tixlbase/models.py +++ b/src/tixlbase/models.py @@ -1,5 +1,6 @@ from django.db import models from django.contrib.auth.models import AbstractBaseUser, BaseUserManager, PermissionsMixin +from django.utils.translation import ugettext as _ class UserManager(BaseUserManager): @@ -8,19 +9,19 @@ class UserManager(BaseUserManager): model documentation to see what's so special about our user model. """ - def create_user(self, email, password=None): - user = self.model(email=email) - user.set_password(user) + def create_user(self, identifier, username, password=None): + user = self.model(identifier=identifier) + user.set_password(password) user.save() return user - def create_superuser(self, email, password=None): + def create_superuser(self, identifier, username, password=None): if password is None: raise Exception("You must provide a password") - user = self.model(email=email) + user = self.model(identifier=identifier, username=username) user.is_staff = True user.is_superuser = True - user.set_password(user) + user.set_password(password) user.save() return user @@ -59,11 +60,21 @@ class User(AbstractBaseUser, PermissionsMixin): """ identifier = models.CharField(max_length=255, unique=True) - username = models.CharField(max_length=120) + username = models.CharField(max_length=120, blank=True, + null=True, + help_text=_('Letters, digits and @/./+/-/_ only.')) event = models.ForeignKey('Event', related_name="users", - null=True, blank=True) + null=True, blank=True, + on_delete=models.PROTECT) email = models.EmailField(unique=False, db_index=True, - null=True, blank=True) + null=True, blank=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) is_staff = models.BooleanField(default=False) date_joined = models.DateTimeField(auto_now_add=True) @@ -73,12 +84,35 @@ class User(AbstractBaseUser, PermissionsMixin): def __str__(self): return self.identifier + def get_short_name(self): + if self.givenname: + return self.givenname + elif self.familyname: + return self.familyname + else: + return self.username + + def get_full_name(self): + if self.givenname and not self.familyname: + return self.givenname + elif not self.givenname and self.familyname: + return self.familyname + elif self.familyname and self.givenname: + return '%(family)s, %(given)s' % { + 'family': self.familyname, + 'given': self.givenname + } + else: + return self.username + def save(self, *args, **kwargs): if self.identifier is None: if self.event is None: - self.identifier = self.email + self.identifier = self.email.lower() else: - self.identifier = "%s@%d.event.tixl" % (self.username, self.event.id) + self.identifier = "%s@%d.event.tixl" % (self.username.lower(), self.event.id) + if not self.pk: + self.identifier = self.identifier.lower() super().save(*args, **kwargs) USERNAME_FIELD = 'identifier' @@ -101,7 +135,8 @@ class Organizer(models.Model): slug = models.CharField(max_length=50, unique=True, db_index=True) - owner = models.ForeignKey(User, null=True, blank=True) + owner = models.ForeignKey(User, null=True, blank=True, + on_delete=models.PROTECT) class Meta: ordering = ("name",) @@ -133,7 +168,8 @@ class Event(models.Model): matter when they were ordered (and thus, ignoring payment_term_days). """ - organizer = models.ForeignKey(Organizer, related_name="events") + organizer = models.ForeignKey(Organizer, related_name="events", + on_delete=models.PROTECT) name = models.CharField(max_length=200) slug = models.CharField(max_length=50, db_index=True) diff --git a/src/tixlbase/static/bootstrap b/src/tixlbase/static/bootstrap new file mode 160000 index 0000000000..97027a2f6f --- /dev/null +++ b/src/tixlbase/static/bootstrap @@ -0,0 +1 @@ +Subproject commit 97027a2f6fad00c4d74fbef5aef6cccb179f8229 diff --git a/src/tixlcontrol/middleware.py b/src/tixlcontrol/middleware.py new file mode 100644 index 0000000000..63a97f89ee --- /dev/null +++ b/src/tixlcontrol/middleware.py @@ -0,0 +1,39 @@ +from django.conf import settings +from django.core.urlresolvers import resolve +from django.utils.encoding import force_str +from django.utils.six.moves.urllib.parse import urlparse +from django.shortcuts import resolve_url +from django.contrib.auth import REDIRECT_FIELD_NAME + + +class LoginRequiredMiddleware: + + """ + This middleware enforces all requests to the control app + to require login. + """ + + EXCEPTIONS = ( + "auth.login" + ) + + def process_request(self, request): + if not request.user.is_authenticated(): + url_namespace = resolve(request.path_info).namespace + url_name = resolve(request.path_info).url_name + if url_namespace == 'control' and url_name not in self.EXCEPTIONS: + # Taken from django/contrib/auth/decorators.py + path = request.build_absolute_uri() + # urlparse chokes on lazy objects in Python 3, force to str + resolved_login_url = force_str( + resolve_url(settings.LOGIN_URL_CONTROL)) + # If the login url is the same scheme and net location then just + # use the path as the "next" url. + login_scheme, login_netloc = urlparse(resolved_login_url)[:2] + current_scheme, current_netloc = urlparse(path)[:2] + if ((not login_scheme or login_scheme == current_scheme) and + (not login_netloc or login_netloc == current_netloc)): + path = request.get_full_path() + from django.contrib.auth.views import redirect_to_login + return redirect_to_login( + path, resolved_login_url, REDIRECT_FIELD_NAME) diff --git a/src/tixlcontrol/static/tixlcontrol/less/auth.less b/src/tixlcontrol/static/tixlcontrol/less/auth.less new file mode 100644 index 0000000000..be6422a7fb --- /dev/null +++ b/src/tixlcontrol/static/tixlcontrol/less/auth.less @@ -0,0 +1,22 @@ +@import "../../../../tixlbase/static/bootstrap/less/bootstrap.less"; + +body { + background: #eee; +} + +.form-signin { + .well; + + max-width: 330px; + margin: auto; + margin-top: 10%; + padding-bottom: 0; + + .control-label { + .sr-only; + } + + .buttons { + text-align: right; + } +} diff --git a/src/tixlcontrol/templates/tixlcontrol/auth/base.html b/src/tixlcontrol/templates/tixlcontrol/auth/base.html new file mode 100644 index 0000000000..a4fe3a1097 --- /dev/null +++ b/src/tixlcontrol/templates/tixlcontrol/auth/base.html @@ -0,0 +1,16 @@ +{% load compress %} +{% load staticfiles %} + + + + + {% compress css %} + + {% endcompress %} + + +
+ {% block content %} + {% endblock %} + + diff --git a/src/tixlcontrol/templates/tixlcontrol/auth/login.html b/src/tixlcontrol/templates/tixlcontrol/auth/login.html new file mode 100644 index 0000000000..0d5f1ff0f8 --- /dev/null +++ b/src/tixlcontrol/templates/tixlcontrol/auth/login.html @@ -0,0 +1,16 @@ +{% extends "tixlcontrol/auth/base.html" %} +{% load bootstrap3 %} +{% load i18n %} +{% block content %} + +{% endblock %} diff --git a/src/tixlcontrol/urls.py b/src/tixlcontrol/urls.py new file mode 100644 index 0000000000..3f6fc73632 --- /dev/null +++ b/src/tixlcontrol/urls.py @@ -0,0 +1,6 @@ +from django.conf.urls import patterns, url + +urlpatterns = patterns('', + url(r'^$', 'tixlcontrol.views.main.index', name='index'), + url(r'^login$', 'tixlcontrol.views.auth.login', name='auth.login'), +) diff --git a/src/tixlcontrol/views.py b/src/tixlcontrol/views.py deleted file mode 100644 index 91ea44a218..0000000000 --- a/src/tixlcontrol/views.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.shortcuts import render - -# Create your views here. diff --git a/src/tixlcontrol/views/__init__.py b/src/tixlcontrol/views/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/tixlcontrol/views/auth.py b/src/tixlcontrol/views/auth.py new file mode 100644 index 0000000000..51505b5c6b --- /dev/null +++ b/src/tixlcontrol/views/auth.py @@ -0,0 +1,61 @@ +from django.shortcuts import render, redirect +from django.contrib.auth.forms import AuthenticationForm as BaseAuthenticationForm +from django import forms +from django.utils.translation import ugettext as _ +from django.contrib.auth import authenticate +from django.contrib.auth import login as auth_login + + +class AuthenticationForm(BaseAuthenticationForm): + """ + The login form. + """ + email = forms.EmailField(label=_("E-mail address"), max_length=254) + password = forms.CharField(label=_("Password"), widget=forms.PasswordInput) + username = None + + error_messages = { + 'invalid_login': _("Please enter a correct e-mail address and password."), + 'inactive': _("This account is inactive."), + } + + def __init__(self, request=None, *args, **kwargs): + self.request = request + self.user_cache = None + super(forms.Form, self).__init__(*args, **kwargs) + + def clean(self): + email = self.cleaned_data.get('email') + password = self.cleaned_data.get('password') + + if email and password: + self.user_cache = authenticate(identifier=email.lower(), + password=password) + if self.user_cache is None: + raise forms.ValidationError( + self.error_messages['invalid_login'], + code='invalid_login', + ) + else: + self.confirm_login_allowed(self.user_cache) + + return self.cleaned_data + + +def login(request): + ctx = {} + if request.user.is_authenticated(): + if "next" in request.GET: + return redirect(request.GET.get("next", 'control:index')) + return redirect('control:index') + if request.method == 'POST': + form = AuthenticationForm(data=request.POST) + if form.is_valid() and form.user_cache: + auth_login(request, form.user_cache) + if "next" in request.GET: + return redirect(request.GET.get("next", 'control:index')) + return redirect('control:index') + else: + form = AuthenticationForm() + ctx['form'] = form + return render(request, 'tixlcontrol/auth/login.html', ctx) diff --git a/src/tixlcontrol/views/main.py b/src/tixlcontrol/views/main.py new file mode 100644 index 0000000000..8090b1d99f --- /dev/null +++ b/src/tixlcontrol/views/main.py @@ -0,0 +1,5 @@ +from django.http import HttpResponse + + +def index(request): + return HttpResponse('Coming soon.')