Merge pull request 'Merge with master' (#39) from master into production
All checks were successful
buildbot/tox Build done.

Reviewed-on: #39
This commit was merged in pull request #39.
This commit is contained in:
2020-12-22 19:45:11 +01:00
70 changed files with 2529 additions and 270 deletions

View File

@@ -62,7 +62,7 @@ class SetPasswordForm(forms.Form):
return self.user
class ResetPasswordForm(forms.Form):
class CreateAndSendPasswordForm(forms.Form):
username = auth_forms.UsernameField(
max_length=254,
label=_(u'E-Mail-Adresse'),

View File

@@ -15,7 +15,8 @@
<form action="" method="post">
{% csrf_token %}
{% bootstrap_form form %}
<div class="pull-right"><a href="{% url 'dav_auth:reset_password' %}">{% trans 'Passwort vergessen?' %}</a></div>
<div class="pull-right"><a
href="{% url 'dav_auth:recreate_password' %}">{% trans 'Passwort vergessen?' %}</a></div>
{% buttons %}
<button type="submit" class="btn btn-success">
{% bootstrap_icon 'log-in' %}&thinsp;

View File

@@ -32,4 +32,38 @@
&nbsp;
</div>
</div>
<div class="row">
<div class="col-sm-2">
&nbsp;
</div>
<div class="col-sm-8">
<h3 class="top-most">{% trans 'Passwortrichtlinien' %}</h3>
<div class="well">
<p>
Damit die persönlichen Daten unserer Teilnehmer jederzeit geschützt sind, ist es unabdingbar,
dass eure Passwörter für das Touren- & Kurse-Portal nicht einfach zu erraten sind.
</p>
<p>
Je länger das Passwort ist und je mehr <i>ungewöhnliche</i> Zeichen darin enthalten sind,
umso unwahrscheinlicher ist es, dass das Passwort erraten werden kann.
</p>
<p>
Um sicher zu stellen, dass eure Passwörter ausreichend lang sind und nicht nur
aus einfachen Wörtern bestehen, wird die Güte eures Passwortes mit Punkten bewertet.<br />
Ausserdem dürfen bestimmte Wörter nicht enthalten sein (z.B. Karlsruhe).
</p>
<p>
Mehr Zeichen geben mehr Punkte. Und Großbuchstaben, Ziffern und Sonderzeichen geben Extrapunkte.
</p>
<p>
Im Regelfall sollte euer Passwort aus mindestens 12 Zeichen bestehen,
darunter mindestens je zwei Großbuchstaben, Ziffern und Sonderzeichen.<br />
Nehmt da ruhig alles, was eure Tastatur so hergibt.
</p>
</div>
</div>
<div class="col-sm-3">
&nbsp;
</div>
</div>
{% endblock page-container %}

View File

@@ -9,7 +9,7 @@ from ..emails import PasswordSetEmail
TEST_USERNAME = 'user'
TEST_PASSWORD = u'me||ön 2'
TEST_PASSWORD = u'me||ön 21ABll'
TEST_EMAIL = 'root@localhost'
PASSWORD_EMAIL_TEMPLATE = u"""Hallo {fullname},

View File

@@ -6,10 +6,10 @@ from django.utils.translation import ugettext
from dav_base.tests.generic import FormDataSet, FormsTestCase
from ..forms import LoginForm, SetPasswordForm, ResetPasswordForm
from ..forms import LoginForm, SetPasswordForm, CreateAndSendPasswordForm
TEST_USERNAME = 'root@localhost'
TEST_PASSWORD = u'me||ön 2'
TEST_PASSWORD = u'me||ön 21ABll'
TEST_EMAIL = TEST_USERNAME
USERNAME_MAX_LENGTH = 254
@@ -108,7 +108,7 @@ class SetPasswordFormTestCase(FormsTestCase):
def test_mismatch(self):
data_sets = [
FormDataSet({'new_password': 'mellon12', 'new_password_repeat': 'mellon13'},
FormDataSet({'new_password': 'mellonAB12+-', 'new_password_repeat': 'mellonAB13+-'},
[('new_password_repeat', 'password_mismatch')]),
]
super(SetPasswordFormTestCase, self).test_invalid_data(data_sets=data_sets, form_kwargs={'user': self.user})
@@ -150,9 +150,10 @@ class SetPasswordFormTestCase(FormsTestCase):
def test_valid(self):
data_sets = [
FormDataSet({'new_password': 'mellon12', 'new_password_repeat': 'mellon12'}),
FormDataSet({'new_password': 'mellon12', 'new_password_repeat': 'mellon12', 'send_password_mail': True}),
FormDataSet({'new_password': u'"ä§ Mellon12', 'new_password_repeat': u'"ä§ Mellon12'}),
FormDataSet({'new_password': 'mellonAB12+-', 'new_password_repeat': 'mellonAB12+-'}),
FormDataSet({'new_password': 'mellonAB12+-', 'new_password_repeat': 'mellonAB12+-',
'send_password_mail': True}),
FormDataSet({'new_password': u'"ä§ MellonAB12+-', 'new_password_repeat': u'"ä§ MellonAB12+-'}),
FormDataSet({'new_password': 'mellon12' * 128, 'new_password_repeat': 'mellon12' * 128}),
]
super(SetPasswordFormTestCase, self).test_valid_data(data_sets=data_sets, form_kwargs={'user': self.user})
@@ -193,8 +194,8 @@ class SetPasswordFormTestCase(FormsTestCase):
self.assertTrue(self.client.login(username=self.test_username, password=new_password))
class ResetPasswordFormTestCase(FormsTestCase):
form_class = ResetPasswordForm
class CreateAndSendPasswordFormTestCase(FormsTestCase):
form_class = CreateAndSendPasswordForm
valid_data_sets = (
FormDataSet({'username': 'unittest@example.com'}),

View File

@@ -9,7 +9,7 @@ from selenium.webdriver.common.keys import Keys
from dav_base.tests.generic import ScreenshotTestCase
TEST_USERNAME = 'root@localhost'
TEST_PASSWORD = u'me||ön 2'
TEST_PASSWORD = u'me||ön 21ABll'
TEST_EMAIL = TEST_USERNAME
@@ -177,10 +177,10 @@ class TestCase(ScreenshotTestCase):
self.wait_until_stale(c, password2_field)
self.save_screenshot('set_password_succeed', sequence=sequence_name)
# Get password reset page -> since we are logged in, it should
# Get password recreate page -> since we are logged in, it should
# redirect to set password page again -> save
html = c.find_element_by_tag_name('html')
c.get(self.complete_url(reverse('dav_auth:reset_password')))
c.get(self.complete_url(reverse('dav_auth:recreate_password')))
self.wait_until_stale(c, html)
self.wait_on_presence(c, (By.ID, 'id_new_password'))
self.save_screenshot('empty_set_password_form', sequence=sequence_name)
@@ -194,24 +194,24 @@ class TestCase(ScreenshotTestCase):
self.wait_until_stale(c, user_menu)
self.save_screenshot('logout_succeed', sequence=sequence_name)
# Click on 'login' to access password reset link
# Click on 'login' to access password recreate link
link = c.find_element_by_css_selector('#login-widget a')
link.click()
self.wait_on_presence(c, (By.ID, 'id_username'))
# Locate password reset link, click it -> save password reset form
# Locate password recreate link, click it -> save password recreate form
link = c.find_element_by_partial_link_text(ugettext(u'Passwort vergessen'))
link.click()
username_field = self.wait_on_presence(c, (By.ID, 'id_username'))
self.save_screenshot('empty_reset_password_form', sequence=sequence_name)
self.save_screenshot('empty_recreate_password_form', sequence=sequence_name)
# Enter invalid username -> save result (login form, no message)
username_field.send_keys(self.test_username[::-1])
username_field.send_keys(Keys.RETURN)
self.wait_until_stale(c, username_field)
self.save_screenshot('reset_password_invalid_user', sequence=sequence_name)
self.save_screenshot('recreate_password_invalid_user', sequence=sequence_name)
# Locate password reset link, click it
# Locate password recreate link, click it
link = c.find_element_by_partial_link_text(ugettext(u'Passwort vergessen'))
link.click()
username_field = self.wait_on_presence(c, (By.ID, 'id_username'))
@@ -220,4 +220,4 @@ class TestCase(ScreenshotTestCase):
username_field.send_keys(self.test_username)
username_field.send_keys(Keys.RETURN)
self.wait_until_stale(c, username_field)
self.save_screenshot('reset_password_valid_user', sequence=sequence_name)
self.save_screenshot('recreate_password_valid_user', sequence=sequence_name)

View File

@@ -12,16 +12,16 @@ from dav_base.tests.generic import SeleniumTestCase
from .generic import SeleniumAuthMixin
TEST_USERNAME = 'root@localhost'
TEST_PASSWORD = 'me||ön 2'
TEST_PASSWORD = 'me||ön 21ABll'
TEST_EMAIL = TEST_USERNAME
class TemplatesTestCase(SimpleTestCase):
def test_reset_link_in_login_form(self):
def test_recreate_link_in_login_form(self):
login_url = reverse('dav_auth:login')
reset_url = reverse('dav_auth:reset_password')
recreate_url = reverse('dav_auth:recreate_password')
text = ugettext('Passwort vergessen?')
html = '<a href="{url}">{text}</a>'.format(url=reset_url, text=text)
html = '<a href="{url}">{text}</a>'.format(url=recreate_url, text=text)
response = self.client.get(login_url)
self.assertInHTML(html, response.content.decode('utf-8'))

View File

@@ -9,5 +9,5 @@ class TestCase(UrlsTestCase):
Url('/auth/logout', 'dav_auth:logout', views.LogoutView.as_view(), status_code=302),
Url('/auth/password', 'dav_auth:set_password', views.SetPasswordView.as_view(),
redirect='/auth/login?next=/auth/password'),
Url('/auth/password/reset', 'dav_auth:reset_password', views.ResetPasswordView.as_view()),
Url('/auth/password/recreate', 'dav_auth:recreate_password', views.CreateAndSendPasswordView.as_view()),
)

View File

@@ -0,0 +1,227 @@
# -*- coding: utf-8 -*-
from django.core.exceptions import ValidationError
from django.test import SimpleTestCase
from ..validators import PasswordScoreValidator, CustomWordlistPasswordValidator, CharacterClassPasswordValidator
class PasswordScoreValidatorTestCase(SimpleTestCase):
def test_too_little_score(self):
passwords = [
u'', # score = 0
u'abcdefghijklmnopq', # score = 17
u'Abcdefghijklmnop', # score = 16 + 1
u'ABcdefghijklmno', # score = 15 + 2
u'AB1defghijklmn', # score = 14 + 2 + 1
u'AB12efghijklm', # score = 13 + 2 + 2
u'AB12+fghijkl', # score = 12 + 2 + 2 + 1
u'AB12+-ghijk', # score = 11 + 2 + 2 + 2
u'AB12+-*hij', # score = 10 + 2 + 2 + 3
u'AB12+-*hi/', # score = 10 + 2 + 2 + 3
u'AB12+-*hi3', # score = 10 + 2 + 2 + 3
u'AB12+-*hiC', # score = 10 + 2 + 2 + 3
u'abcbbbgbbjklmnopqr', # score = 17
]
validator = PasswordScoreValidator(min_classes=0)
for password in passwords:
try:
validator.validate(password)
except ValidationError as e:
self.assertEqual('too_little_score', e.code)
else:
self.fail(u'%s: no validation error was raised' % password)
def test_too_few_classes(self):
passwords = [
u'', # classes = 0
u'abcdefgh', # classes = 1
u'abcdef+-', # classes = 2
u'abcd12gh', # classes = 2
u'abcd12-+', # classes = 3
u'abCDefgh', # classes = 2
u'abCDef+-', # classes = 3
u'abCD12gh', # classes = 3
u'ABCD12-+', # classes = 3
]
validator = PasswordScoreValidator(min_score=0, min_classes=4)
for password in passwords:
try:
validator.validate(password)
except ValidationError as e:
self.assertEqual('too_few_classes', e.code)
else:
self.fail(u'%s: no validation error was raised' % password)
def test_valid(self):
passwords = [
u'Abcdefghijklmnopq',
u'ABcdefghijklmnop',
u'AB1defghijklmno',
u'AB12efghijklmn',
u'AB12+fghijklm',
u'AB12+-ghijkl',
u'AB12+-*hijk',
u'ab1defghijklmnopq',
u'abcd+fghijklmnopq',
u'AB1CDEFGHIJKLMNOPQ',
]
validator = PasswordScoreValidator()
for password in passwords:
try:
validator.validate(password)
except ValidationError as e:
self.fail(e)
class CustomWordlistPasswordValidatorTestCase(SimpleTestCase):
def test_invalid(self):
invalid_passwords = [
(u'passwort', [
u'Das Passwort darf nicht die Zeichenfolge \'passwort\' enthalten.',
]),
(u'abcdDaVefgh', [
u'Das Passwort darf nicht die Zeichenfolge \'dav\' enthalten.',
]),
(u'abcdsektIonefgh', [
u'Das Passwort darf nicht die Zeichenfolge \'sektion\' enthalten.',
]),
(u'alpen12verein34KArlsruhE berge', [
u'Das Passwort darf nicht die Zeichenfolge \'karlsruhe\' enthalten.',
u'Das Passwort darf nicht die Zeichenfolge \'berge\' enthalten.',
]),
(u'heinzel@alpenverein-karlsruhe.de', [
u'Das Passwort darf nicht die Zeichenfolge \'heinzel\' enthalten.',
u'Das Passwort darf nicht die Zeichenfolge \'alpenverein\' enthalten.',
u'Das Passwort darf nicht die Zeichenfolge \'karlsruhe\' enthalten.',
]),
]
validator = CustomWordlistPasswordValidator()
for password, expected_errors in invalid_passwords:
try:
validator.validate(password)
except ValidationError as e:
errors = e.messages
for expected_error in expected_errors:
self.assertIn(expected_error, errors)
for error in errors:
self.assertIn(error, expected_errors)
else:
self.fail(u'%s: no validation error was raised' % password)
def test_valid(self):
passwords = [
u'',
u'password',
u'münchen',
]
validator = CustomWordlistPasswordValidator()
for password in passwords:
try:
validator.validate(password)
except ValidationError as e:
self.fail(e)
class CharacterClassPasswordValidatorTestCase(SimpleTestCase):
def setUp(self):
super(CharacterClassPasswordValidatorTestCase, self).setUp()
self.validator = CharacterClassPasswordValidator()
def test_invalid(self):
invalid_passwords = [
(u'', [
u'Das Passwort muss mindestens 2 Kleinbuchstaben enthalten.',
u'Das Passwort muss mindestens 2 Großbuchstaben enthalten.',
u'Das Passwort muss mindestens 2 Ziffern enthalten.',
u'Das Passwort muss mindestens 2 Sonderzeichen enthalten.',
]),
(u'A+-', [
u'Das Passwort muss mindestens 2 Kleinbuchstaben enthalten.',
u'Das Passwort muss mindestens 2 Großbuchstaben enthalten.',
u'Das Passwort muss mindestens 2 Ziffern enthalten.',
]),
(u'1234567890*', [
u'Das Passwort muss mindestens 2 Kleinbuchstaben enthalten.',
u'Das Passwort muss mindestens 2 Großbuchstaben enthalten.',
u'Das Passwort muss mindestens 2 Sonderzeichen enthalten.',
]),
(u'34*/()', [
u'Das Passwort muss mindestens 2 Kleinbuchstaben enthalten.',
u'Das Passwort muss mindestens 2 Großbuchstaben enthalten.',
]),
(u'AA', [
u'Das Passwort muss mindestens 2 Kleinbuchstaben enthalten.',
u'Das Passwort muss mindestens 2 Ziffern enthalten.',
u'Das Passwort muss mindestens 2 Sonderzeichen enthalten.',
]),
(u'CD0.,', [
u'Das Passwort muss mindestens 2 Kleinbuchstaben enthalten.',
u'Das Passwort muss mindestens 2 Ziffern enthalten.',
]),
(u'EF56', [
u'Das Passwort muss mindestens 2 Kleinbuchstaben enthalten.',
u'Das Passwort muss mindestens 2 Sonderzeichen enthalten.',
]),
(u'8GH?!8', [
u'Das Passwort muss mindestens 2 Kleinbuchstaben enthalten.',
]),
(u'bbX', [
u'Das Passwort muss mindestens 2 Großbuchstaben enthalten.',
u'Das Passwort muss mindestens 2 Ziffern enthalten.',
u'Das Passwort muss mindestens 2 Sonderzeichen enthalten.',
]),
(u'$cd%', [
u'Das Passwort muss mindestens 2 Großbuchstaben enthalten.',
u'Das Passwort muss mindestens 2 Ziffern enthalten.',
]),
(u'ef90', [
u'Das Passwort muss mindestens 2 Großbuchstaben enthalten.',
u'Das Passwort muss mindestens 2 Sonderzeichen enthalten.',
]),
(u'1g=h3~', [
u'Das Passwort muss mindestens 2 Großbuchstaben enthalten.',
]),
(u'Gi&jH', [
u'Das Passwort muss mindestens 2 Ziffern enthalten.',
u'Das Passwort muss mindestens 2 Sonderzeichen enthalten.',
]),
(u'IkK:i;', [
u'Das Passwort muss mindestens 2 Ziffern enthalten.',
]),
(u'mKn4L8', [
u'Das Passwort muss mindestens 2 Sonderzeichen enthalten.',
]),
]
validator = self.validator
for password, expected_errors in invalid_passwords:
try:
validator.validate(password)
except ValidationError as e:
errors = e.messages
for expected_error in expected_errors:
self.assertIn(expected_error, errors)
for error in errors:
self.assertIn(error, expected_errors)
else:
self.fail(u'%s: no validation error was raised' % password)
def test_valid(self):
valid_passwords = [u'abCD12+-']
validator = self.validator
for password in valid_passwords:
try:
validator.validate(password)
except ValidationError as e:
self.fail(e)

View File

@@ -7,10 +7,10 @@ from django.test import TestCase
from django.utils.translation import ugettext
from django.urls import reverse
from ..forms import LoginForm, SetPasswordForm, ResetPasswordForm
from ..forms import LoginForm, SetPasswordForm, CreateAndSendPasswordForm
TEST_USERNAME = 'root@localhost'
TEST_PASSWORD = u'me||ön 2'
TEST_PASSWORD = u'me||ön 21ABll'
TEST_EMAIL = TEST_USERNAME
@@ -27,7 +27,7 @@ class ViewsTestCase(TestCase):
cls.logout_url = reverse('dav_auth:logout')
cls.logout_redirect_url = resolve_url(cls.app_settings.logout_redirect_url)
cls.set_password_url = reverse('dav_auth:set_password')
cls.reset_password_url = reverse('dav_auth:reset_password')
cls.recreate_password_url = reverse('dav_auth:recreate_password')
# Some messages
cls.wrong_credentials_message = ugettext(u'Benutzername oder Passwort falsch.')
@@ -179,23 +179,23 @@ class ViewsTestCase(TestCase):
self.assertFalse(self.client.login(username=username, password=password), 'Old password still valid')
self.assertTrue(self.client.login(username=username, password=new_password), 'New password not valid')
def test_reset_password_integrated_unauth_get(self):
response = self.client.get(self.reset_password_url)
def test_recreate_password_integrated_unauth_get(self):
response = self.client.get(self.recreate_password_url)
self.assertEqual(response.status_code, 200)
self.assertTemplateUsed(response, 'dav_auth/forms/reset_password.html')
self.assertTemplateUsed(response, 'dav_auth/forms/recreate_password.html')
self.assertIn('form', response.context)
self.assertIsInstance(response.context['form'], ResetPasswordForm)
self.assertIsInstance(response.context['form'], CreateAndSendPasswordForm)
field = response.context['form'].fields['username']
self.assertTrue(field.required)
def test_reset_password_integrated_auth_get(self):
def test_recreate_password_integrated_auth_get(self):
self.client.login(username=self.test_username, password=self.test_password)
response = self.client.get(self.reset_password_url)
response = self.client.get(self.recreate_password_url)
self.assertRedirects(response, self.set_password_url)
def test_reset_password_integrated_post(self):
location = self.reset_password_url
def test_recreate_password_integrated_post(self):
location = self.recreate_password_url
response = self.client.post(location, {'username': self.user.username})
self.assertRedirects(response, self.login_url)

View File

@@ -6,5 +6,5 @@ urlpatterns = [
url(r'^login$', views.LoginView.as_view(), name='login'),
url(r'^logout$', views.LogoutView.as_view(), name='logout'),
url(r'^password$', views.SetPasswordView.as_view(), name='set_password'),
url(r'^password/reset$', views.ResetPasswordView.as_view(), name='reset_password'),
url(r'^password/recreate$', views.CreateAndSendPasswordView.as_view(), name='recreate_password'),
]

200
dav_auth/validators.py Normal file
View File

@@ -0,0 +1,200 @@
# -*- coding: utf-8 -*-
import re
from django.core.exceptions import ValidationError
from django.utils.translation import gettext as _
class PasswordScoreValidator(object):
def _get_score(self, password, user=None):
score = 0
char_counters = {}
used_classes = []
credits = {
'lower': self.lcredit,
'upper': self.ucredit,
'digit': self.dcredit,
'other': self.ocredit,
}
for c in password:
if c not in char_counters:
char_counters[c] = 1
else:
char_counters[c] += 1
if (self.max_repeat > 0) and (char_counters[c] > self.max_repeat):
continue
score += 1
if c.isalpha() and c.islower():
char_class = 'lower'
elif c.isupper():
char_class = 'upper'
elif c.isdigit():
char_class = 'digit'
else:
char_class = 'other'
if char_class not in used_classes:
used_classes.append(char_class)
if credits[char_class] > 0:
score += 1
credits[char_class] -= 1
return score, len(used_classes)
def __init__(self, min_score=18, max_repeat=5, lcredit=0, ucredit=2, dcredit=2, ocredit=3, min_classes=2):
self.min_score = min_score
self.max_repeat = max_repeat
self.lcredit = lcredit
self.ucredit = ucredit
self.dcredit = dcredit
self.ocredit = ocredit
self.min_classes = min_classes
def validate(self, password, user=None):
score, used_classes = self._get_score(password, user=user)
if used_classes < self.min_classes:
raise ValidationError(_(u'Das Passwort muss Zeichen aus mindestens %(min_classes)d'
u' verschiedenen Arten von Zeichen bestehen'
u' (d.h. Kleinbuchstaben, Großbuchstaben, Ziffern und Sonderzeichen).'),
code='too_few_classes',
params={'min_classes': self.min_classes})
if score < self.min_score:
raise ValidationError(_(u'Dieses Passwort ist zu einfach. Benutze mehr Zeichen'
u' und gegebenenfalls auch Großbuchstaben, Ziffern und Sonderzeichen.'),
code='too_little_score')
return score
def get_help_text(self):
text = u'%s\n%s' % (
_(u'The password must get a minimum score of %d points.') % self.min_score,
_(u'For each character the score is increased by 1 point.'),
)
if self.lcredit > 0:
text += '\n'
text += _(u'The first %d lower characters increase the score by 1 point each.') % self.lcredit
if self.ucredit > 0:
text += '\n'
text += _(u'The first %d upper characters increase the score by 1 point each.') % self.ucredit
if self.dcredit > 0:
text += '\n'
text += _(u'The first %d digits increase the score by 1 point each.') % self.dcredit
if self.ocredit > 0:
text += '\n'
text += _(u'The first %d non alpha numeric characters'
u' increase the score by 1 point each.') % self.ocredit
if self.max_repeat > 0:
text += '\n'
text += _(u'If a particular character is used more than %d times,'
u' it will not increase the score anymore.') % self.max_repeat
if self.min_classes > 0:
text += '\n'
text += _(u'Also the password must contain characters from %d different character classes'
u' (i.e. lower, upper, digits, others).') % self.min_classes
return text
class CustomWordlistPasswordValidator(object):
context = 'the Sektion Karlsruhe'
words = (
u'dav',
u'berge',
u'sektion',
u'karlsruhe',
u'alpenverein',
u'heinzel',
u'passwort',
)
def validate(self, password, user=None):
errors = []
lower_pw = password.lower()
for word in self.words:
if word in lower_pw:
error = ValidationError(_(u'Das Passwort darf nicht die Zeichenfolge \'%(word)s\' enthalten.'),
code='forbidden_word',
params={'word': word})
errors.append(error)
if errors:
raise ValidationError(errors)
def get_help_text(self):
text = _(u'The password must not contain some specific words,'
u' that are common in context with %(context)s.') % {'context': self.context}
text += u'\n'
text += _(u'All words are matched case insensitive.')
return text
class CharacterClassPasswordValidator(object):
def _is_enough_lower(self, password):
lower = re.sub(r'[^a-z]', '', password)
if len(lower) < self.minimum_lower:
return False
return True
def _is_enough_upper(self, password):
upper = re.sub(r'[^A-Z]', '', password)
if len(upper) < self.minimum_upper:
return False
return True
def _is_enough_digits(self, password):
digits = re.sub(r'[^0-9]', '', password)
if len(digits) < self.minimum_digits:
return False
return True
def _is_enough_others(self, password):
others = re.sub(r'[a-zA-Z0-9]', '', password)
if len(others) < self.minimum_others:
return False
return True
def __init__(self, minimum_lower=2, minimum_upper=2, minimum_digits=2, minimum_others=2):
self.minimum_lower = minimum_lower
self.minimum_upper = minimum_upper
self.minimum_digits = minimum_digits
self.minimum_others = minimum_others
def validate(self, password, user=None):
errors = []
if not self._is_enough_lower(password):
error = ValidationError(_(u'Das Passwort muss mindestens %(min_lower)d Kleinbuchstaben enthalten.'),
code='too_few_lower_characters',
params={'min_lower': self.minimum_lower})
errors.append(error)
if not self._is_enough_upper(password):
error = ValidationError(_(u'Das Passwort muss mindestens %(min_upper)d Großbuchstaben enthalten.'),
code='too_few_upper_characters',
params={'min_upper': self.minimum_upper})
errors.append(error)
if not self._is_enough_digits(password):
error = ValidationError(_(u'Das Passwort muss mindestens %(min_digits)d Ziffern enthalten.'),
code='too_few_digits',
params={'min_digits': self.minimum_digits})
errors.append(error)
if not self._is_enough_others(password):
error = ValidationError(_(u'Das Passwort muss mindestens %(min_others)d'
u' Sonderzeichen enthalten.'),
code='too_few_other_characters',
params={'min_others': self.minimum_others})
errors.append(error)
if errors:
raise ValidationError(errors)
def get_help_text(self):
text = u'%s %s %s %s' % (
_('The password must contain at least %d characters from a-z.') % self.minimum_lower,
_('The password must contain at least %d characters from A-Z.') % self.minimum_upper,
_('The password must contain at least %d digits from 0-9.') % self.minimum_digits,
_('The password must contain at least %d non alpha numeric characters.') % self.minimum_others,
)
return text

View File

@@ -1,10 +1,14 @@
# -*- coding: utf-8 -*-
import logging
from django.apps import apps
from django.core.exceptions import ValidationError
from django.contrib import messages
from django.contrib.auth import views as auth_views, get_user_model
from django.contrib.auth.password_validation import validate_password
from django.http import HttpResponseRedirect
from django.shortcuts import resolve_url
from django.urls import reverse_lazy, reverse
from django.utils.safestring import mark_safe
from django.utils.translation import ugettext as _
from django.views import generic
@@ -28,6 +32,18 @@ class LoginView(auth_views.LoginView):
def form_valid(self, form):
r = super(LoginView, self).form_valid(form)
messages.success(self.request, _(u'Benutzer angemeldet: %(username)s') % {'username': form.get_user()})
try:
validate_password(form.cleaned_data['password'])
except ValidationError as e:
logger.warning(u'Weak password (%d): %s', self.request.user.pk, e)
message = u'<br />\n<p>\n'
message += u'Dein Passwort entspricht nicht mehr den aktuellen Passwortrichtlinien.<br />\n'
message += u'Bitte hilf uns die Daten deiner Teilnehmer zu schützen und ändere dein Passwort.<br />\n'
message += u'</p>\n'
message += u'<p>\n'
message += u'<a href="%(href)s">Passwort ändern</a>\n' % {'href': reverse('dav_auth:set_password')}
message += u'</p>\n<br />\n'
messages.warning(self.request, mark_safe(message))
return r
@@ -60,9 +76,9 @@ class SetPasswordView(auth_views.PasswordChangeView):
return r
class ResetPasswordView(generic.FormView):
form_class = forms.ResetPasswordForm
template_name = 'dav_auth/forms/reset_password.html'
class CreateAndSendPasswordView(generic.FormView):
form_class = forms.CreateAndSendPasswordForm
template_name = 'dav_auth/forms/recreate_password.html'
success_url = reverse_lazy('dav_auth:login')
def form_valid(self, form):
@@ -70,19 +86,19 @@ class ResetPasswordView(generic.FormView):
user_model = get_user_model()
try:
user = user_model.objects.get(username=username)
random_password = user_model.objects.make_random_password(length=12)
random_password = user_model.objects.make_random_password(length=32)
user.set_password(random_password)
user.save()
email = emails.PasswordSetEmail(user, random_password)
email.send()
messages.success(self.request, _(u'Neues Passwort versendet.'))
logger.info('Password reset for user \'%s\'', username)
logger.info('Password recreated for user \'%s\'', username)
except user_model.DoesNotExist:
logger.warning('Password reset for unknown user \'%s\'', username)
logger.warning('Password recreated for unknown user \'%s\'', username)
return super(ResetPasswordView, self).form_valid(form)
return super(CreateAndSendPasswordView, self).form_valid(form)
def get(self, request, *args, **kwargs):
if request.user.is_authenticated:
return HttpResponseRedirect(reverse('dav_auth:set_password'))
return super(ResetPasswordView, self).get(request, *args, **kwargs)
return super(CreateAndSendPasswordView, self).get(request, *args, **kwargs)

View File

@@ -52,6 +52,30 @@ DATABASES['default'] = {
'NAME': os.path.join(BASE_VAR_DIR, 'db', 'devel.sqlite3'),
}
AUTH_PASSWORD_VALIDATORS = [
{
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
'OPTIONS': {
'min_length': 12,
},
},
{
'NAME': 'dav_auth.validators.PasswordScoreValidator',
},
{
'NAME': 'dav_auth.validators.CustomWordlistPasswordValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
},
]
STATIC_ROOT = os.path.join(BASE_VAR_DIR, 'www', 'static')
LANGUAGE_CODE = 'de'

View File

@@ -114,7 +114,9 @@
</div>
</div>
</div>
<hr />
<h4>{% trans 'Teilnehmer' %}</h4>
<div class="panel-group" id="form-accordion-participants" role="tablist" aria-multiselectable="true">
{% if registrations_support %}
@@ -131,7 +133,7 @@
<div id="collapseRegistrations" class="panel-collapse collapse"
role="tabpanel" aria-labelledby="headingRegistrations">
<div class="panel-body">
{% for registration in registrations %}
{% for registration in registrations_pending %}
<form action="" method="post" class="form-inline">
{% csrf_token %}
<input type="hidden" name="registration" value="{{ registration.id }}">
@@ -147,9 +149,6 @@
</button>
&nbsp;
{% endif %}
{% if registration.answered %}
<span class="text-muted">
{% endif %}
{{ registration.get_full_name }}
(<a href="mailto:{{ registration.email_address }}">{{ registration.email_address }}</a>,
{{ registration.phone_number }})
@@ -162,12 +161,15 @@
<span title="{{ registration.get_info }}">
{% bootstrap_icon 'info-sign' %}
</span>
{% if registration.answered %}
{% if registration.apply_reduced_fee %}
&nbsp;
<span class="text-info">
<strong>%</strong>{% bootstrap_icon 'piggy-bank' %} (reduzierte Gebühr)
</span>
{% endif %}
</form>
{% empty %}
{% trans 'Keine unbearbeiteten Anmeldungen vorhanden' %}
{% trans 'Keine Anmeldungen vorhanden' %}
{% endfor %}
</div>
</div>
@@ -179,21 +181,46 @@
{% with position=participant.position %}
<div class="panel {% if event.max_participants and position > event.max_participants %}panel-warning{% else %}panel-default{% endif %}">
<div id="headingParticipant_{{ participant.id }}" class="panel-heading" role="tab">
<h5 class="panel-title">
<a role="button" href="#collapseParticipant_{{ participant.id }}"
data-toggle="collapse"
aria-expanded="true" aria-controls="collapseParticipant_{{ participant.id }}">
<span class="caret"></span>&nbsp;&nbsp;
{{ position }}. {{ participant.get_full_name }}
</a>
<div>
<strong><span class="panel-title">
<a role="button" href="#collapseParticipant_{{ participant.id }}"
data-toggle="collapse"
aria-expanded="true" aria-controls="collapseParticipant_{{ participant.id }}">
<span class="caret"></span>&nbsp;&nbsp;
{{ position }}. {{ participant.get_full_name }}
</a>
</span></strong>
&nbsp;
<small>
(<a href="mailto:{{ participant.email_address }}">{{ participant.email_address }}</a>, {{ participant.phone_number }})
</small>
&nbsp; {{ participant.dav_number }}
&nbsp;
{% if participant.dav_member %}
{{ participant.dav_number|default:'Fehler! heinzel Bescheid geben!' }}
{% else %}
{% trans 'Nicht Mitglied' %}
{% endif %}
&nbsp;
<span class="text-info"
title="{{ participant.get_info }}
{% trans 'Zeitpunkt der automatischen Löschung' %}: {{ participant.purge_at|date:'d. F Y' }}">
{% bootstrap_icon 'info-sign' %}
</span>
<div class="pull-right">
<form action="" method="post" class="form-inline">
{% csrf_token %}
<input type="hidden" name="id" value="{{ participant.id }}">
{% if event.charge and has_permission_payment %}
<button name="action" value="toggle_reduced_fee"
title="{% trans 'Hier klicken, um zwischen voller und reduzierter Teilnahmegebühr umzuschalten' %}"
class="btn btn-link no-padding">
<span class="text-primary">
{% if participant.apply_reduced_fee %}{% bootstrap_icon 'check' %}{% else %}{% bootstrap_icon 'unchecked' %}{% endif %}
Reduzierte Teilnamegebühr
</span>
</button>
&nbsp;
{% endif %}
{% if has_permission_update_participants %}
<button name="action" value="moveup_participant"
title="{% trans 'Nach oben verschieben' %}"
@@ -205,42 +232,47 @@
class="btn btn-link no-padding {% if forloop.last %}invisible{% endif %}">
<span class="text-info">{% bootstrap_icon 'triangle-bottom' %}</span>
</button>
<button name="action" value="remove_participant"
title="{% trans 'Teilnehmer jetzt löschen' %}
({% trans 'erfolgt automatisch am' %} {{ participant.purge_at|date:'d. F Y' }})"
<button name="action" value="trash_participant"
title="{% trans 'Eintrag in Papierkorb verschieben' %}"
class="btn btn-link no-padding">
<span class="text-danger">{% bootstrap_icon 'remove-circle' %}</span>
<span class="text-danger">{% bootstrap_icon 'trash' %}</span>
</button>
&nbsp;
{% endif %}
{% if event.charge and participant.paid and has_permission_payment %}
<button name="action" value="revoke_payment"
title="{% trans 'Geldeingang wurde bestätigt' %} - {% trans 'Bestätigung des Geldeingangs zurückziehen' %}"
class="btn btn-link no-padding">
<span class="text-success">{% bootstrap_icon 'piggy-bank' %}</span>
</button>
&nbsp;
<span class="text-success {% if not participant.apply_reduced_fee %}invisible{% endif %}"
title="{% trans 'Reduzierte Teilnahmegebühr' %}"><strong>%</strong></span><button
name="action" value="revoke_payment"
title="{% trans 'Geldeingang wurde bestätigt' %} - {% trans 'Bestätigung des Geldeingangs zurückziehen' %}"
class="btn btn-link no-padding"><span class="text-success">{% bootstrap_icon 'piggy-bank' %}</span></button>
{% elif event.charge and participant.paid %}
<span class="text-success" title="{% trans 'Geldeingang bestätigt' %}">
{% bootstrap_icon 'piggy-bank' %}
</span>
&nbsp;
<span class="text-success {% if not participant.apply_reduced_fee %}invisible{% endif %}"
title="{% trans 'Reduzierte Teilnahmegebühr' %}"><strong>%</strong></span><span
class="text-success"
title="{% trans 'Geldeingang bestätigt' %}">{% bootstrap_icon 'piggy-bank' %}</span>
{% elif event.charge and has_permission_payment %}
<button name="action" value="confirm_payment"
title="{% trans 'Geldeingang bestätigen' %}"
class="btn btn-link no-padding">
<span class="text-danger">{% bootstrap_icon 'piggy-bank' %}</span>
</button>
&nbsp;
<span class="text-danger {% if not participant.apply_reduced_fee and participant.created_at|date:'U' > '1608662327' %}invisible{% endif %}"
title="{% trans 'Reduzierte Teilnahmegebühr' %}"><strong>{% if participant.apply_reduced_fee %}%{% else %}? {% endif %}</strong></span><button
name="action" value="confirm_payment"
title="{% trans 'Geldeingang bestätigen' %}"
class="btn btn-link no-padding"><span class="text-danger">{% bootstrap_icon 'piggy-bank' %}</span></button>
{% elif event.charge %}
<span class="text-danger" title="{% trans 'Geldeingang unbestätigt' %}">
{% bootstrap_icon 'piggy-bank' %}
</span>
&nbsp;
<span class="text-danger {% if not participant.apply_reduced_fee and participant.created_at|date:'U' > '1608662327' %}invisible{% endif %}"
title="{% trans 'Reduzierte Teilnahmegebühr' %}"><strong>{% if participant.apply_reduced_fee %}%{% else %}? {% endif %}</strong></span><span
class="text-danger"
title="{% trans 'Geldeingang unbestätigt' %}">{% bootstrap_icon 'piggy-bank' %}</span>
{% else %}
<span class="hidden" title="{% trans 'Keine Teilnehmergebühr gefordert' %}">
{% bootstrap_icon 'piggy-bank' %}
</span>
<span class="hidden {% if not participant.apply_reduced_fee %}invisible{% endif %}"
title="{% trans 'Reduzierte Teilnahmegebühr' %}"><strong>%</strong></span><span
class="hidden"
title="{% trans 'Keine Teilnehmergebühr gefordert' %}">{% bootstrap_icon 'piggy-bank' %}</span>
{% endif %}
</form>
</div>
</h5>
</div>
</div>
<div id="collapseParticipant_{{ participant.id }}"
class="panel-collapse collapse {% if form.errors %}in{% endif %}"
@@ -271,6 +303,11 @@
</div>
</div>
{% endif %}
<div class="panel panel-default">
<div class="panel-body">
<strong title="Summe der bestätigten Geldeingänge (theoretisch!, da wir hier nur die Gebühren, aber nicht die tatsächlich überwiesenen Beträge kennen)">{% trans 'Gebuchte Teilnahmegebühren' %}:</strong> {{ earnings|floatformat:2 }} €
</div>
</div>
{% if participant_emails %}
<div class="panel panel-default">
<div class="panel-body">
@@ -280,6 +317,112 @@
</div>
{% endif %}
</div>
<hr />
<h4>{% trans 'Papierkorb' %}</h4>
<div class="panel-group" id="form-accordion-trash" role="tablist" aria-multiselectable="true">
{% if registrations_support and registrations_answered %}
<div class="panel panel-info">
<div id="headingAnsweredRegistrations" class="panel-heading" role="tab">
<h5 class="panel-title">
<a role="button" href="#collapseAnsweredRegistrations"
data-toggle="collapse"
aria-expanded="true" aria-controls="collapseAnsweredRegistrations">
<span class="caret"></span>&nbsp;&nbsp;{% trans 'Bearbeitete Anmeldungen' %}
</a>
</h5>
</div>
<div id="collapseAnsweredRegistrations" class="panel-collapse collapse"
role="tabpanel" aria-labelledby="headingAnsweredRegistrations">
<div class="panel-body">
{% for registration in registrations_answered %}
<div>
<button disabled="disabled"
class="btn btn-link no-padding" title="Anmeldung wurde bereits bearbeitet">
<span class="{% if registration.status.accepted == True %}text-success{% else %}text-muted{% endif %}">{% bootstrap_icon 'plus-sign' %}</span>
</button>
&nbsp;
<button disabled="disabled"
class="btn btn-link no-padding" title="Anmeldung wurde bereits bearbeitet">
<span class="{% if registration.status.accepted == False %}text-danger{% else %}text-muted{% endif %}">{% bootstrap_icon 'minus-sign' %}</span>
</button>
&nbsp;
<span class="text-muted">
{{ registration.get_full_name }}
(<a href="mailto:{{ registration.email_address }}">{{ registration.email_address }}</a>,
{{ registration.phone_number }})
&nbsp;
<span title="Anmeldezeitpunkt">
{% bootstrap_icon 'time' %}
{{ registration.created_at|date:'d. F Y, G:i' }}
</span>
&nbsp;
<span title="{{ registration.get_info }}">
{% bootstrap_icon 'info-sign' %}
</span>
</span>
</div>
{% endfor %}
</div>
</div>
</div>
{% endif %}
{% if participants_trash %}
<div class="panel panel-info">
<div id="headingTrashedParticipants" class="panel-heading" role="tab">
<h5 class="panel-title">
<a role="button" href="#collapseTrashedParticipants"
data-toggle="collapse"
aria-expanded="true" aria-controls="collapseTrashedParticipants">
<span class="caret"></span>&nbsp;&nbsp;{% trans 'Gelöschte Teilnehmer' %}
</a>
</h5>
</div>
<div id="collapseTrashedParticipants" class="panel-collapse collapse"
role="tabpanel" aria-labelledby="headingTrashedParticipants">
<div class="panel-body">
{% for participant in participants_trash %}
<div>
<span class="text-muted">
{{ participant.get_full_name }}
(<a href="mailto:{{ participant.email_address }}">{{ participant.email_address }}</a>,
{{ participant.phone_number }})
&nbsp;
{% if participant.dav_member %}
{{ participant.dav_number|default:'Fehler! heinzel Bescheid geben!' }}
{% else %}
{% trans 'Nicht Mitglied' %}
{% endif %}
&nbsp;
<span title="{{ participant.get_info }}
{% trans 'Zeitpunkt der automatischen Löschung' %}: {{ participant.purge_at|date:'d. F Y' }}">
{% bootstrap_icon 'info-sign' %}
</span>
{% if event.charge and participant.paid %}
&nbsp;
<span class="text-success {% if not participant.apply_reduced_fee %}invisible{% endif %}"
title="{% trans 'Reduzierte Teilnahmegebühr' %}"><strong>%</strong></span><span
class="text-success"
title="{% trans 'Geldeingang bestätigt' %}">{% bootstrap_icon 'piggy-bank' %}</span>
{% elif event.charge %}
&nbsp;
<span class="text-danger {% if not participant.apply_reduced_fee %}invisible{% endif %}"
title="{% trans 'Reduzierte Teilnahmegebühr' %}"><strong>%</strong></span><span
class="text-danger"
title="{% trans 'Geldeingang unbestätigt' %}">{% bootstrap_icon 'piggy-bank' %}</span>
{% endif %}
</span>
</div>
{% endfor %}
</div>
</div>
</div>
{% endif %}
{% if not registrations_answered and not participants_trash %}
<span class="text-muted small">{% trans 'Der Papierkorb ist leer.' %}</span>
{% endif %}
</div>
</div>
</div>
{% endblock page-container-fluid %}

View File

@@ -29,9 +29,20 @@
</div>
</div>
<div class="row">
<div class="col-sm-6">
<div class="col-sm-3">
{% bootstrap_field form.year_of_birth %}
</div>
<div class="col-sm-9">
{% bootstrap_field form.apply_reduced_fee %}
</div>
</div>
<div class="row">
<div class="col-sm-3">
{% bootstrap_field form.dav_number %}
</div>
<div class="col-sm-3">
{% bootstrap_field form.dav_member %}
</div>
<div class="col-sm-6">
{% bootstrap_field form.emergency_contact %}
</div>

View File

@@ -24,7 +24,7 @@
<th>{% trans 'Nachname' %}</th>
<th>{% trans 'Vorname' %}</th>
<th>{% trans 'Mitgliedsnummer' %}</th>
<th>{% trans 'Teilnehmergebühr' %}</th>
<th>{% trans 'Teilnahmegebühr' %}</th>
</tr>
<tr>
<th><input type="text" placeholder="{% trans 'Filter' %}" /></th>
@@ -32,7 +32,7 @@
<th><input type="text" placeholder="{% trans 'Filter' %}" /></th>
<th><input type="text" placeholder="{% trans 'Filter' %}" /></th>
<th><input type="text" placeholder="{% trans 'Filter' %}" /></th>
<th><input type="text" placeholder="{% trans 'Filter' %} ({% trans 'Grün' %}/{% trans 'Rot' %})" /></th>
<th><input type="text" placeholder="{% trans 'Filter' %} ({% trans 'Grün' %}/{% trans 'Rot' %}/{% trans 'Grau' %}/%)" /></th>
</tr>
</thead>
<tbody>
@@ -55,10 +55,14 @@
{{ participant.personal_names }}
</td>
<td>
{{ participant.dav_number }}
{% if participant.dav_member %}
{{ participant.dav_number|default:'Fehler! heinzel Bescheid geben!' }}
{% else %}
{% trans 'Nicht Mitglied' %}
{% endif %}
</td>
<td data-order="{{ participant.paid }} {{ event.charge|floatformat:'-2' }}"
data-search="{% if participant.paid %}{% trans 'Grün' %}{% else %}{% trans 'Rot' %}{% endif %} {{ event.charge|floatformat:'-2' }}">
data-search="{% if not event.charge %}{% trans 'Grau' %}{% elif participant.paid %}{% trans 'Grün' %}{% else %}{% trans 'Rot' %}{% endif %} {% if participant.apply_reduced_fee %}%{% endif %} {{ event.charge|floatformat:'-2' }}">
<div class="pull-right">
<a title="{{ participant.email_address}}" href="mailto:{{ participant.email_address}}">{% bootstrap_icon 'envelope' %}</a>
<span class="text-info" title="{{ participant.get_info }}
@@ -67,26 +71,37 @@
<form action="" method="post" class="form-inline">
{% csrf_token %}
<input type="hidden" name="id" value="{{ participant.id }}">
{% if event.charge %}
<button name="action" value="toggle_reduced_fee"
title="{% trans 'Hier klicken, um zwischen voller und reduzierter Teilnahmegebühr umzuschalten' %}"
class="btn btn-link no-padding">
<span class="text-primary">
{% if participant.apply_reduced_fee %}{% bootstrap_icon 'check' %}{% else %}{% bootstrap_icon 'unchecked' %}{% endif %}
</span>
</button>
&nbsp;
{% endif %}
{% if event.charge and participant.paid %}
<button name="action" value="revoke_payment"
title="{% trans 'Geldeingang wurde bestätigt' %} - {% trans 'Bestätigung des Geldeingangs zurückziehen' %}"
class="btn btn-link no-padding">
<span class="text-success">{% bootstrap_icon 'piggy-bank' %}</span>
</button>
<span class="text-success {% if not participant.apply_reduced_fee %}invisible{% endif %}"
title="{% trans 'Reduzierte Teilnahmegebühr' %}"><strong>%</strong></span><button
name="action" value="revoke_payment"
title="{% trans 'Geldeingang wurde bestätigt' %} - {% trans 'Bestätigung des Geldeingangs zurückziehen' %}"
class="btn btn-link no-padding"><span class="text-success">{% bootstrap_icon 'piggy-bank' %}</span></button>
&nbsp;
({{ event.charge|floatformat:'-2' }} €)
({{ event.charge|floatformat:'-2' }}{% if participant.apply_reduced_fee %} / 2{% endif %} €)
{% elif event.charge %}
<button name="action" value="confirm_payment"
title="{% trans 'Geldeingang bestätigen' %}"
class="btn btn-link no-padding">
<span class="text-danger">{% bootstrap_icon 'piggy-bank' %}</span>
</button>
<span class="text-danger {% if not participant.apply_reduced_fee and participant.created_at|date:'U' > '1608662327' %}invisible{% endif %}"
title="{% trans 'Reduzierte Teilnahmegebühr' %}"><strong>{% if participant.apply_reduced_fee %}%{% else %}? {% endif %}</strong></span><button
name="action" value="confirm_payment"
title="{% trans 'Geldeingang bestätigen' %}"
class="btn btn-link no-padding"><span class="text-danger">{% bootstrap_icon 'piggy-bank' %}</span></button>
&nbsp;
({{ event.charge|floatformat:'-2' }} €)
({{ event.charge|floatformat:'-2' }}{% if participant.apply_reduced_fee %} / 2{% endif %} €)
{% else %}
<span class="text-muted" title="{% trans 'Keine Teilnehmergebühr gefordert' %}">
{% bootstrap_icon 'piggy-bank' %}
</span>
<span class="hidden {% if not participant.apply_reduced_fee %}invisible{% endif %}"
title="{% trans 'Reduzierte Teilnahmegebühr' %}"><strong>%</strong></span><span
class="hidden"
title="{% trans 'Keine Teilnehmergebühr gefordert' %}">{% bootstrap_icon 'piggy-bank' %}</span>
{% endif %}
</form>
</td>

View File

@@ -53,6 +53,11 @@ class ParticipantListView(generic.ListView):
participant = get_object_or_404(Participant, pk=participant_id)
participant.paid = False
participant.save()
elif action == 'toggle_reduced_fee':
participant_id = request.POST.get('id')
participant = get_object_or_404(Participant, pk=participant_id)
participant.apply_reduced_fee = not participant.apply_reduced_fee
participant.save()
else:
messages.error(request, 'unsupported action: {}'.format(action))
return HttpResponseRedirect(reverse('dav_event_office:participant-list'))

View File

@@ -1,6 +1,6 @@
from django.contrib import admin
from .models import EventStatus, EventFlag, Event, OneClickAction, Participant
from .models import EventStatus, EventFlag, Event, OneClickAction, Participant, TrashedParticipant
@admin.register(EventStatus)
@@ -31,3 +31,8 @@ class OneClickActionAdmin(admin.ModelAdmin):
@admin.register(Participant)
class ParticipantAdmin(admin.ModelAdmin):
pass
@admin.register(TrashedParticipant)
class TrashedParticipantAdmin(admin.ModelAdmin):
pass

View File

@@ -1,3 +1,4 @@
from . import generic
from . import events
from . import participant
from . import registration

View File

@@ -7,7 +7,9 @@ from ..models import Participant
class ParticipantForm(forms.ModelForm):
class Meta:
model = Participant
exclude = ['event', 'created_at', 'position', 'purge_at']
exclude = ['event', 'created_at', 'position',
'privacy_policy', 'privacy_policy_accepted',
'paid', 'purge_at']
widgets = {
'emergency_contact': forms.Textarea(attrs={'rows': 4}),
'experience': forms.Textarea(attrs={'rows': 5}),

View File

@@ -0,0 +1,8 @@
# -*- coding: utf-8 -*-
from django import forms
from django.utils.translation import ugettext_lazy as _
class RegistrationResponseForm(forms.Form):
apply_reduced_fee = forms.BooleanField(required=False,
label=_(u'Reduzierte Teilnahmegebühr'))

View File

@@ -0,0 +1,20 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.29 on 2020-09-25 13:43
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dav_events', '0032_auto_20190605_1400'),
]
operations = [
migrations.AlterField(
model_name='eventstatus',
name='bootstrap_context',
field=models.CharField(blank=True, choices=[('default', 'default'), ('primary', 'primary'), ('success', 'success'), ('info', 'info'), ('warning', 'warning'), ('danger', 'danger'), ('black', 'black')], max_length=20),
),
]

View File

@@ -0,0 +1,26 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.29 on 2020-10-15 15:38
from __future__ import unicode_literals
import django.core.validators
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dav_events', '0033_auto_20200925_1543'),
]
operations = [
migrations.AddField(
model_name='participant',
name='dav_member',
field=models.BooleanField(default=True, help_text='In Ausnahmefällen nehmen wir auch Nichtmitglieder mit.', verbose_name='DAV Mitglied'),
),
migrations.AlterField(
model_name='participant',
name='dav_number',
field=models.CharField(blank=True, max_length=62, validators=[django.core.validators.RegexValidator('^([0-9]{1,10}/[0-9]{2,10}/)?[0-9]{1,10}(\\*[0-9]{1,10})?(\\*[0-9]{4}\\*[0-9]{4})?([* ][0-9]{8})?$', 'Ungültiges Format.')], verbose_name='DAV Mitgliedsnummer'),
),
]

View File

@@ -0,0 +1,35 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.29 on 2020-09-29 20:15
from __future__ import unicode_literals
import dav_events.models.eventchange
import dav_events.roles
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import django.utils.timezone
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('dav_events', '0033_auto_20200925_1543'),
]
operations = [
migrations.CreateModel(
name='EventChange',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('timestamp', models.DateTimeField(default=django.utils.timezone.now)),
('operation', models.CharField(choices=[('update', 'Update'), ('set_flag', 'Raise Flag'), ('unset_flag', 'Lower Flag')], max_length=20)),
('content', models.TextField()),
('event', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='changes', to='dav_events.Event')),
('user', models.ForeignKey(default=dav_events.models.eventchange.get_system_user_id, on_delete=models.SET(dav_events.roles.get_ghost_user), related_name='+', to=settings.AUTH_USER_MODEL)),
],
options={
'ordering': ['event', 'timestamp'],
},
),
]

View File

@@ -0,0 +1,16 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.29 on 2020-11-03 10:12
from __future__ import unicode_literals
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('dav_events', '0034_auto_20201015_1738'),
('dav_events', '0034_eventchange'),
]
operations = [
]

View File

@@ -0,0 +1,46 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.29 on 2020-12-03 10:44
from __future__ import unicode_literals
import django.core.validators
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('dav_events', '0035_merge_20201103_1112'),
]
operations = [
migrations.CreateModel(
name='TrashedParticipant',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('personal_names', models.CharField(max_length=1024, verbose_name='Vorname(n)')),
('family_names', models.CharField(max_length=1024, verbose_name='Familienname')),
('address', models.CharField(help_text='Straße, Hausnummer', max_length=1024, verbose_name='Anschrift')),
('postal_code', models.CharField(max_length=254, verbose_name='Postleitzahl')),
('city', models.CharField(max_length=1024, verbose_name='Ort')),
('email_address', models.EmailField(max_length=254, verbose_name='E-Mail-Adresse')),
('phone_number', models.CharField(max_length=254, verbose_name='Telefonnummer')),
('dav_member', models.BooleanField(default=True, help_text='In Ausnahmefällen nehmen wir auch Nichtmitglieder mit.', verbose_name='DAV Mitglied')),
('dav_number', models.CharField(blank=True, max_length=62, validators=[django.core.validators.RegexValidator('^([0-9]{1,10}/[0-9]{2,10}/)?[0-9]{1,10}(\\*[0-9]{1,10})?(\\*[0-9]{4}\\*[0-9]{4})?([* ][0-9]{8})?$', 'Ungültiges Format.')], verbose_name='DAV Mitgliedsnummer')),
('emergency_contact', models.TextField(blank=True, help_text='Kann frei gelassen werden.', verbose_name='Notfall-Kontakt')),
('experience', models.TextField(blank=True, help_text='Kann frei gelassen werden.', verbose_name='Erfahrung')),
('note', models.TextField(blank=True, help_text='Kann frei gelassen werden.', verbose_name='Anmerkung')),
('paid', models.BooleanField(default=False, verbose_name='Teilnehmerbeitrag bezahlt')),
('purge_at', models.DateTimeField()),
('created_at', models.DateTimeField()),
('trashed_at', models.DateTimeField(auto_now_add=True)),
('position', models.IntegerField(verbose_name='Listennummer')),
('event', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='trashed_participants', to='dav_events.Event')),
],
options={
'verbose_name': 'Gelöschter Teilnehmer (Papierkorb)',
'verbose_name_plural': 'Gelöschte Teilnehmer (Papierkorb)',
'ordering': ['event', 'trashed_at'],
},
),
]

View File

@@ -0,0 +1,27 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.29 on 2020-12-09 12:27
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dav_events', '0036_trashedparticipant'),
]
operations = [
migrations.AddField(
model_name='participant',
name='year_of_birth',
field=models.IntegerField(default=1870, verbose_name='Geburtsjahr'),
preserve_default=False,
),
migrations.AddField(
model_name='trashedparticipant',
name='year_of_birth',
field=models.IntegerField(default=1870, verbose_name='Geburtsjahr'),
preserve_default=False,
),
]

View File

@@ -0,0 +1,25 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.29 on 2020-12-09 14:42
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dav_events', '0037_auto_20201209_1327'),
]
operations = [
migrations.AddField(
model_name='participant',
name='apply_reduced_fee',
field=models.BooleanField(default=False, verbose_name='Antrag auf reduzierte Teilnahmegebühr'),
),
migrations.AddField(
model_name='trashedparticipant',
name='apply_reduced_fee',
field=models.BooleanField(default=False, verbose_name='Antrag auf reduzierte Teilnahmegebühr'),
),
]

View File

@@ -0,0 +1,35 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.29 on 2020-12-15 10:55
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dav_events', '0038_auto_20201209_1542'),
]
operations = [
migrations.AlterField(
model_name='participant',
name='apply_reduced_fee',
field=models.BooleanField(default=False, help_text='Für Jugendliche und Junioren (bis zum vollendeten 25. Lebensjahr), sowie Mitglieder mit geringen finanziellen Mitteln (Nachweis durch "Karlsruher Pass"), wird die Teilnahmegebühr auf 50% ermäßigt.', verbose_name='Antrag auf reduzierte Teilnahmegebühr'),
),
migrations.AlterField(
model_name='participant',
name='year_of_birth',
field=models.IntegerField(help_text='Vierstellige Jahreszahl', verbose_name='Geburtsjahr'),
),
migrations.AlterField(
model_name='trashedparticipant',
name='apply_reduced_fee',
field=models.BooleanField(default=False, help_text='Für Jugendliche und Junioren (bis zum vollendeten 25. Lebensjahr), sowie Mitglieder mit geringen finanziellen Mitteln (Nachweis durch "Karlsruher Pass"), wird die Teilnahmegebühr auf 50% ermäßigt.', verbose_name='Antrag auf reduzierte Teilnahmegebühr'),
),
migrations.AlterField(
model_name='trashedparticipant',
name='year_of_birth',
field=models.IntegerField(help_text='Vierstellige Jahreszahl', verbose_name='Geburtsjahr'),
),
]

View File

@@ -0,0 +1,35 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.29 on 2020-12-16 16:12
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dav_events', '0039_auto_20201215_1155'),
]
operations = [
migrations.AddField(
model_name='participant',
name='privacy_policy',
field=models.TextField(blank=True, verbose_name='Erklärung zur Datenspeicherung'),
),
migrations.AddField(
model_name='participant',
name='privacy_policy_accepted',
field=models.BooleanField(default=False, verbose_name='Einwilligung zur Datenspeicherung'),
),
migrations.AddField(
model_name='trashedparticipant',
name='privacy_policy',
field=models.TextField(blank=True, verbose_name='Erklärung zur Datenspeicherung'),
),
migrations.AddField(
model_name='trashedparticipant',
name='privacy_policy_accepted',
field=models.BooleanField(default=False, verbose_name='Einwilligung zur Datenspeicherung'),
),
]

View File

@@ -1,6 +1,8 @@
from ..roles import get_system_user, get_ghost_user
from .event import Event
from .eventchange import EventChange
from .eventflag import EventFlag
from .eventstatus import EventStatus
from .oneclickaction import OneClickAction
from .participant import Participant
from .trash import TrashedParticipant

View File

@@ -2,6 +2,7 @@
from __future__ import unicode_literals
import datetime
import difflib
import json
import logging
import os
import re
@@ -14,14 +15,14 @@ from django.db import models
from django.template.loader import get_template
from django.utils.encoding import python_2_unicode_compatible
from django.utils.translation import get_language, ugettext_lazy as _
from django_countries.fields import CountryField
from django_countries.fields import Country, CountryField
from . import get_ghost_user
from .. import choices
from .. import config
from .. import signals
from ..workflow import DefaultWorkflow
from . import get_ghost_user
from .eventchange import EventChange
logger = logging.getLogger(__name__)
@@ -292,9 +293,8 @@ class Event(models.Model):
logger.warning('Event is not created by its owner (Current user: %s, Owner: %s)!', self.editor, owner)
self.owner = owner
creating = True
elif not implicit_update:
else:
original = Event.objects.get(id=self.id)
original_text = original.render_as_text(show_internal_fields=True)
if not self.editor or not self.editor.is_authenticated:
self.editor = self.owner
@@ -305,13 +305,50 @@ class Event(models.Model):
logger.info('Event created: %s', self)
signals.event_created.send(sender=self.__class__, event=self)
self.workflow.update_status('draft', self.editor)
elif not implicit_update:
modified_text = self.render_as_text(show_internal_fields=True)
o_lines = original_text.split('\n')
m_lines = modified_text.split('\n')
diff_lines = list(difflib.unified_diff(o_lines, m_lines, n=len(m_lines), lineterm=''))
logger.info('Event updated: %s', self)
signals.event_updated.send(sender=self.__class__, event=self, diff=diff_lines, user=self.editor)
else:
change = EventChange(event=self, user=self.editor, operation=EventChange.UPDATE,
content=self.diff(original))
change.save()
if not implicit_update:
logger.info('Event updated: %s', self)
signals.event_updated.send(sender=self.__class__, event=self, user=self.editor,
diff=self.diff(original, fmt='human_readable'))
def diff(self, event, fmt='json'):
if fmt == 'human_readable':
from_text = event.render_as_text(show_internal_fields=True)
to_text = self.render_as_text(show_internal_fields=True)
from_lines = from_text.split('\n')
to_lines = to_text.split('\n')
diff_lines = list(difflib.unified_diff(from_lines, to_lines, n=len(from_lines), lineterm=''))
diff_text = '\n'.join(diff_lines[3:])
elif fmt == 'json':
fields = self._meta.get_fields()
changes = []
for field in fields:
field_name = field.name
from_value = getattr(event, field_name)
try:
json.dumps(from_value)
except TypeError:
from_value = str(from_value)
to_value = getattr(self, field_name)
try:
json.dumps(to_value)
except TypeError:
to_value = str(to_value)
if from_value != to_value:
change = {
'field': field_name,
'refer': from_value,
'current': to_value,
}
changes.append(change)
diff_text = json.dumps(changes)
else:
raise ValueError("Event.diff(): Unsupported format: {}".format(fmt))
return diff_text
def is_deadline_expired(self):
today = datetime.date.today()
@@ -468,6 +505,7 @@ class Event(models.Model):
'course_goal_6': self.course_goal_6,
'planned_publication_date': self.planned_publication_date,
'internal_note': self.internal_note,
'registration_closed': self.registration_closed,
}
if context is not None:
r.update(context)

View File

@@ -0,0 +1,42 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.conf import settings
from django.db import models
from django.utils import timezone
from django.utils.encoding import python_2_unicode_compatible
from . import get_ghost_user, get_system_user
def get_system_user_id():
return get_system_user().id
@python_2_unicode_compatible
class EventChange(models.Model):
UPDATE = 'update'
RAISE_FLAG = 'set_flag'
LOWER_FLAG = 'unset_flag'
OPERATION_CHOICES = (
(UPDATE, 'Update'),
(RAISE_FLAG, 'Raise Flag'),
(LOWER_FLAG, 'Lower Flag'),
)
event = models.ForeignKey('dav_events.Event', related_name='changes')
timestamp = models.DateTimeField(default=timezone.now)
user = models.ForeignKey(settings.AUTH_USER_MODEL,
default=get_system_user_id,
on_delete=models.SET(get_ghost_user),
related_name='+')
operation = models.CharField(max_length=20, choices=OPERATION_CHOICES)
content = models.TextField()
class Meta:
ordering = ['event', 'timestamp']
def __str__(self):
s = '{timestamp} - {user} - {operation}'
return s.format(operation=self.operation, timestamp=self.timestamp.strftime('%d.%m.%Y %H:%M:%S %Z'),
user=self.user)

View File

@@ -1,6 +1,7 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
import datetime
from django.core.exceptions import ValidationError
from django.db import models
from django.utils import timezone
from django.utils.encoding import python_2_unicode_compatible
@@ -12,12 +13,7 @@ midnight = datetime.time(00, 00, 00)
@python_2_unicode_compatible
class Participant(models.Model):
event = models.ForeignKey('Event', related_name='participants')
created_at = models.DateTimeField(auto_now_add=True)
position = models.IntegerField(verbose_name='Listennummer')
class AbstractParticipant(models.Model):
personal_names = models.CharField(max_length=1024,
verbose_name=_('Vorname(n)'))
family_names = models.CharField(max_length=1024,
@@ -32,8 +28,20 @@ class Participant(models.Model):
email_address = models.EmailField(verbose_name=_('E-Mail-Adresse'))
phone_number = models.CharField(max_length=254,
verbose_name=_('Telefonnummer'))
year_of_birth = models.IntegerField(verbose_name=_('Geburtsjahr'),
help_text=_('Vierstellige Jahreszahl'))
apply_reduced_fee = models.BooleanField(default=False,
verbose_name=_('Antrag auf reduzierte Teilnahmegebühr'),
help_text=_('Für Jugendliche und Junioren'
' (bis zum vollendeten 25. Lebensjahr),'
' sowie Mitglieder mit geringen finanziellen Mitteln'
' (Nachweis durch "Karlsruher Pass"),'
' wird die Teilnahmegebühr auf 50% ermäßigt.'))
dav_member = models.BooleanField(default=True,
verbose_name=_('DAV Mitglied'),
help_text=_('In Ausnahmefällen nehmen wir auch Nichtmitglieder mit.'))
dav_number = models.CharField(max_length=62,
validators=[DAVNumberValidator],
blank=True, validators=[DAVNumberValidator],
verbose_name=_('DAV Mitgliedsnummer'))
emergency_contact = models.TextField(blank=True,
verbose_name=_('Notfall-Kontakt'),
@@ -45,22 +53,25 @@ class Participant(models.Model):
verbose_name=_('Anmerkung'),
help_text=_('Kann frei gelassen werden.'))
privacy_policy = models.TextField(blank=True,
verbose_name=_('Erklärung zur Datenspeicherung'))
privacy_policy_accepted = models.BooleanField(default=False,
verbose_name=_('Einwilligung zur Datenspeicherung'))
paid = models.BooleanField('Teilnehmerbeitrag bezahlt', default=False)
purge_at = models.DateTimeField()
def approx_age(self):
now = datetime.datetime.now()
year_now = now.year
return year_now - self.year_of_birth
class Meta:
unique_together = (('event', 'position'), )
verbose_name = _('Teilnehmer')
verbose_name_plural = _('Teilnehmer')
ordering = ['event', 'position']
abstract = True
def __str__(self):
return '{eventnumber} - {position}. {name}'.format(
eventnumber=self.event.get_number(),
position=self.position,
name=self.get_full_name(),
)
return self.get_full_name()
def get_full_name(self):
return '{} {}'.format(self.personal_names, self.family_names)
@@ -69,26 +80,56 @@ class Participant(models.Model):
text = """{fullname}
{address}, {postal_code} {city}
DAV Mitglied: {dav_info}
Jahrgang: {year_of_birth} (ungefähres Alter: {approx_age})
Antrag auf reduzierte Teilnehmergebühr: {apply_reduced_fee_yesno}
Notfallkontakt:
{emergency_contact}
Anmerkung:
{note}
"""
if not self.dav_member:
dav_info = _('Nein')
else:
dav_info = self.dav_number
if self.apply_reduced_fee:
apply_reduced_fee_yesno = _('Ja')
else:
apply_reduced_fee_yesno = _('Nein')
return text.format(
fullname=self.get_full_name(),
address=self.address,
postal_code=self.postal_code,
city=self.city,
dav_info=dav_info,
year_of_birth=self.year_of_birth,
approx_age=self.approx_age(),
apply_reduced_fee_yesno=apply_reduced_fee_yesno,
emergency_contact=self.emergency_contact,
note=self.note,
)
def get_data_dict(self):
data = {}
for field in self._meta.fields:
if not field.primary_key:
data[field.name] = getattr(self, field.name)
return data
def clean(self):
if self.dav_member and not self.dav_number:
raise ValidationError({'dav_number': _('Bei DAV Mitgliedern brauchen wir die Mitgliedsnummer.')})
def save(self, **kwargs):
if not self.purge_at and self.event:
self.purge_at = self.__class__.calc_purge_at(self.event)
super(Participant, self).save(**kwargs)
self.full_clean()
super(AbstractParticipant, self).save(**kwargs)
@staticmethod
def calc_purge_at(event):
@@ -115,3 +156,23 @@ class Participant(models.Model):
purge_date = july_nextyear
return timezone.make_aware(datetime.datetime.combine(purge_date, midnight))
@python_2_unicode_compatible
class Participant(AbstractParticipant):
event = models.ForeignKey('Event', related_name='participants')
created_at = models.DateTimeField(auto_now_add=True)
position = models.IntegerField(verbose_name='Listennummer')
class Meta:
verbose_name = _('Teilnehmer')
verbose_name_plural = _('Teilnehmer')
unique_together = (('event', 'position'), )
ordering = ['event', 'position']
def __str__(self):
return '{eventnumber} - {position}. {name}'.format(
eventnumber=self.event.get_number(),
position=self.position,
name=self.get_full_name(),
)

View File

@@ -0,0 +1 @@
from .trashed_participant import TrashedParticipant

View File

@@ -0,0 +1,26 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import models
from django.utils.encoding import python_2_unicode_compatible
from django.utils.translation import ugettext_lazy as _
from ..participant import AbstractParticipant
@python_2_unicode_compatible
class TrashedParticipant(AbstractParticipant):
event = models.ForeignKey('Event', related_name='trashed_participants')
created_at = models.DateTimeField()
trashed_at = models.DateTimeField(auto_now_add=True)
position = models.IntegerField(verbose_name='Listennummer')
class Meta:
verbose_name = _('Gelöschter Teilnehmer (Papierkorb)')
verbose_name_plural = _('Gelöschte Teilnehmer (Papierkorb)')
ordering = ['event', 'trashed_at']
def __str__(self):
return '{eventnumber} - {name}'.format(
eventnumber=self.event.get_number(),
name=self.get_full_name(),
)

View File

@@ -54,6 +54,7 @@
{% trans 'Schwierigkeitsnivau' %}: {{ event.get_level_display }}
{% if event.sport == 'S' %}{% trans 'Skiliftbenutzung' %}: {% if event.ski_list %}{% trans 'Ja' %}{% else %}{% trans 'Nein' %}{% endif %}
{% endif %}{% trans 'Gelände' %}: {{ event.get_terrain_display }}
{% trans 'Anmeldung' %}: {% if registration_required %}{% if registration_closed %}{% trans 'Geschlossen' %}{% else %}{% trans 'Erforderlich' %}{% endif %}{% else %}{% trans 'Nicht erforderlich' %}{% endif %}
{% trans 'Anreise des Kurs-/Tourenleiters am Vortag' %}: {% if event.arrival_previous_day %}{% trans 'Ja' %}{% else %}{% trans 'Nein' %}{% endif %}
{% trans 'Veröffentlichung' %}: {% if planned_publication_date %}{{ planned_publication_date|date:'l, d. F Y' }}{% else %}{% trans 'sofort' %}{% endif %}
{% if internal_note %}

View File

@@ -40,11 +40,9 @@
{% bootstrap_icon 'remove' %}&thinsp;
{% trans 'Abbrechen' %}
</a>
<!--
<button id="btn-form-save" type="submit" name="save" class="btn btn-info">
{% bootstrap_icon 'hdd' %}&thinsp;
{% trans 'Als Entwurf speichern' %}
</button>
-->
{% endbuttons %}
{% endblock form-buttons %}

View File

@@ -1,6 +1,7 @@
{% extends 'dav_events/base.html' %}
{% load bootstrap3 %}
{% load i18n %}
{% load dav_events %}
{% block head-title %}{{ event }} - {{ block.super }}{% endblock head-title %}
@@ -188,35 +189,77 @@
<div class="row">
<div class="col-sm-12">
{{ event.render_as_html }}
<div class="panel panel-default">
<div class="panel-body">
<div class="row">
<div class="col-sm-7">
<h5>Status-Log</h5>
{% for flag in event.flags.all %}
<div class="row">
<div class="col-sm-5">
<span class="text-{{ flag.status.bootstrap_context|default:'default' }}">{% bootstrap_icon 'check' %}</span>
<strong>{{ flag.status.label }}:</strong>
</div>
<div class="col-sm-7">
{{ flag.timestamp|date:'l, d. F Y, H:i' }} {% trans 'Uhr' %}<br />
{% trans 'von' %} {{ flag.user.get_full_name|default:flag.user }}
<div class="row">
<div class="col-sm-7">
<div class="panel-group" id="log-accordion" role="tablist" aria-multiselectable="true">
<div class="panel panel-default">
<div id="headingStatusLog" class="panel-heading" role="tab">
<h5 class="panel-title">
<a role="button" href="#collapseStatusLog" data-toggle="collapse"
data-parent="#log-accordion" aria-expanded="true"
aria-controls="collapseStatusLog">
Status-Flags
</a>
</h5>
</div>
<div id="collapseStatusLog" class="panel-collapse collapse in"
role="tabpanel" aria-labelledby="headingStatusLog">
<div class="panel-body">
{% for flag in event.flags.all %}
<div class="row">
<div class="col-sm-4">
<span class="text-{{ flag.status.bootstrap_context|default:'default' }}">{% bootstrap_icon 'check' %}</span>
<strong>{{ flag.status.label }}:</strong>
</div>
<div class="col-sm-8">
{{ flag.timestamp|date:'l, d. F Y, H:i' }} {% trans 'Uhr' %}<br />
{% trans 'von' %} {{ flag.user.get_full_name|default:flag.user }}
</div>
</div>
{% endfor %}
</div>
</div>
{% endfor %}
</div>
<div class="col-sm-5">
<h5>{% trans 'Veröffentlichung' %}</h5>
<div class="panel panel-default">
<div id="headingChangeLog" class="panel-heading" role="tab">
<h5 class="panel-title">
<a role="button" href="#collapseChangeLog" data-toggle="collapse"
data-parent="#log-accordion" aria-expanded="true"
aria-controls="collapseChangeLog">
Change-Log
</a>
</h5>
</div>
<div id="collapseChangeLog" class="panel-collapse collapse"
role="tabpanel" aria-labelledby="headingChangeLog">
<div class="panel-body">
{% render_event_changelog event %}
</div>
</div>
</div>
</div>
</div>
<div class="col-sm-5">
{% if event.internal_note %}
<div class="panel panel-danger">
<div class="panel-heading">
<h5 class="panel-title">{% trans 'Bearbeitungshinweis' %}</h5>
</div>
<div class="panel-body">
<div><small>{{ event.internal_note|linebreaksbr }}</small></div>
</div>
</div>
{% endif %}
<div class="panel panel-default">
<div class="panel-heading">
<h5 class="panel-title">{% trans 'Veröffentlichung' %}</h5>
</div>
<div class="panel-body">
{% if event.planned_publication_date %}
{{ event.planned_publication_date|date:'l, d. F Y' }}
{% else %}
{% trans 'Unverzüglich' %}
{% endif %}
{% if event.internal_note %}
<h5 style="margin-top: 1em;">{% trans 'Bearbeitungshinweis' %}</h5>
<div class="well well-sm"><small>{{ event.internal_note|linebreaksbr }}</small></div>
{% endif %}
</div>
</div>
</div>

View File

@@ -222,12 +222,40 @@
</div>
</div>
</div>
<hr />
<div class="pull-right text-info" style="margin-right: 1em;"
title="Sobald du im Kasten 'Anmeldungen' bei einzelnen Personen auf das Plus-Symbol geklickt hast, oder jemanden mit dem Formular unter 'weiteren Teilnehmer hinzufügen' eingetragen hast, erscheinen diese Personen weiter unten als Teilnehmer (graue Kästen).
Diese Teilnehmerliste muss zum Zeitpunkt der Tour wirklich stimmen, da wir sie im Falle eines Unfalls brauchen!
Das rote oder grüne Sparschweinchensymbol am rechten Rand eines Teilnehmerkastens zeigt dir, ob die Geschäftstelle bereits den Teilnehmerbeitrag erhalten bzw. zugeordnet hat.
Wenn mehr Teilnehmer eingetragen sind, als in der Teilnehmerzahl der Ausschreibung angegeben, werden die entsprechenden Teilnehmer in gelben Kästen dargestellt.
Wichtig: das System verschickt keine Zu- oder Absagen an die Teilnehmer!
Das musst du selbst per E-Mail oder telefonisch machen.
">
{% bootstrap_icon 'question-sign' %}
</div>
<h4>{% trans 'Teilnehmer' %}</h4>
<div class="panel-group" id="form-accordion-participants" role="tablist" aria-multiselectable="true">
{% if registrations_support %}
<div class="panel panel-info">
<div id="headingRegistrations" class="panel-heading" role="tab">
<div class="pull-right text-info" title="Unter Anmeldungen siehst du, wer gerne mit auf deine Tour möchte.
- Wenn du jemanden davon mitnehmen möchtest, schicke ihm per E-Mail eine Zusage und klicke dann auf das Plus-Symbol.
Die Person wird dann automatisch auf die Teilnehmerliste übernommen.
- Wenn du jemanden nicht mitnehmen möchtest, schicke ihm per E-Mail eine Absage und klicke dann auf das Minus-Symbol.
Nach einem Klick auf Plus oder Minus werden die entsprechenden Zeilen in den Papierkorb verschoben.
Wichtig: das System verschickt keine Zu- oder Absagen an die Teilnehmer!
Das musst du selbst (per E-Mail oder telefonisch) machen.
">
{% bootstrap_icon 'question-sign' %}
</div>
<h5 class="panel-title">
<a role="button" href="#collapseRegistrations"
data-toggle="collapse"
@@ -236,18 +264,25 @@
</a>
</h5>
</div>
<div id="collapseRegistrations" class="panel-collapse collapse {% if registrations %}in{% endif %}"
<div id="collapseRegistrations" class="panel-collapse collapse {% if registrations_pending %}in{% endif %}"
role="tabpanel" aria-labelledby="headingRegistrations">
<div class="panel-body">
{% for registration in registrations %}
{% for registration in registrations_pending %}
<form action="" method="post" class="form-inline">
{% csrf_token %}
<input type="hidden" name="registration" value="{{ registration.id }}">
{% if has_permission_update_participants %}
{% if registration.apply_reduced_fee %}
<a href="{% url 'dav_events:respond_registration' registration.pk %}"
class="btn btn-link no-padding" title="zur Teilnehmerliste hinzufügen">
<span class="text-success">{% bootstrap_icon 'plus-sign' %}</span>
</a>
{% else %}
<button type="submit" name="action" value="accept_registration"
class="btn btn-link no-padding" title="zur Teilnehmerliste hinzufügen">
<span class="text-success">{% bootstrap_icon 'plus-sign' %}</span>
</button>
{% endif %}
&nbsp;
<button type="submit" name="action" value="reject_registration"
class="btn btn-link no-padding" title="Anmeldung löschen">
@@ -255,9 +290,6 @@
</button>
&nbsp;
{% endif %}
{% if registration.answered %}
<span class="text-muted">
{% endif %}
{{ registration.get_full_name }}
(<a href="mailto:{{ registration.email_address }}">{{ registration.email_address }}</a>,
{{ registration.phone_number }})
@@ -270,7 +302,10 @@
<span title="{{ registration.get_info }}">
{% bootstrap_icon 'info-sign' %}
</span>
{% if registration.answered %}
{% if registration.apply_reduced_fee %}
&nbsp;
<span class="text-info">
<strong title="{% trans 'reduzierte Teilnahmegebühr' %}">%</strong>{% bootstrap_icon 'piggy-bank' %} (reduzierte Gebühr)
</span>
{% endif %}
</form>
@@ -284,6 +319,14 @@
{% if has_permission_update_participants %}
<div class="panel panel-info">
<div id="headingAddParticipant" class="panel-heading" role="tab">
<div class="pull-right text-info" title="Wenn du jemanden in die Teilnehmerliste aufnehmen möchtest,
der nicht unter 'Anmeldungen' steht, klicke auf 'weiteren Teilnehmer hinzufügen' um das Teilnehmerformular aufzuklappen.
Über das Formular kannst du dann die Teilnehmerdaten eintragen und speichern.
Wichtig: das System verschickt keine Bestätigung an dich oder den neuen Teilnehmer.
">
{% bootstrap_icon 'question-sign' %}
</div>
<h5 class="panel-title">
<a role="button" href="#collapseAddParticipant"
data-toggle="collapse"
@@ -314,16 +357,25 @@
{% with position=participant.position %}
<div class="panel {% if event.max_participants and position > event.max_participants %}panel-warning{% else %}panel-default{% endif %}">
<div id="headingParticipant_{{ participant.id }}" class="panel-heading" role="tab">
<h5 class="panel-title">
<a role="button" href="#collapseParticipant_{{ participant.id }}"
data-toggle="collapse"
aria-expanded="true" aria-controls="collapseParticipant_{{ participant.id }}">
<span class="caret"></span>&nbsp;&nbsp;
{{ position }}. {{ participant.get_full_name }}
</a>
<div>
<strong><span class="panel-title">
<a role="button" href="#collapseParticipant_{{ participant.id }}"
data-toggle="collapse"
aria-expanded="true" aria-controls="collapseParticipant_{{ participant.id }}">
<span class="caret"></span>&nbsp;&nbsp;
{{ position }}. {{ participant.get_full_name }}
</a>
</span></strong>
&nbsp;
<small>
(<a href="mailto:{{ participant.email_address }}">{{ participant.email_address }}</a>, {{ participant.phone_number }})
</small>
&nbsp;
<span class="text-info"
title="{{ participant.get_info }}
{% trans 'Zeitpunkt der automatischen Löschung' %}: {{ participant.purge_at|date:'d. F Y' }}">
{% bootstrap_icon 'info-sign' %}
</span>
<div class="pull-right">
<form action="" method="post" class="form-inline">
{% csrf_token %}
@@ -339,42 +391,47 @@
class="btn btn-link no-padding {% if forloop.last %}invisible{% endif %}">
<span class="text-info">{% bootstrap_icon 'triangle-bottom' %}</span>
</button>
<button name="action" value="remove_participant"
title="{% trans 'Teilnehmer jetzt löschen' %}
({% trans 'erfolgt automatisch am' %} {{ participant.purge_at|date:'d. F Y'}})"
<button name="action" value="trash_participant"
title="{% trans 'Eintrag in Papierkorb verschieben' %}"
class="btn btn-link no-padding">
<span class="text-danger">{% bootstrap_icon 'remove-circle' %}</span>
<span class="text-danger">{% bootstrap_icon 'trash' %}</span>
</button>
&nbsp;
{% endif %}
{% if event.charge and participant.paid and has_permission_payment %}
<button name="action" value="revoke_payment"
title="{% trans 'Geldeingang wurde bestätigt' %} - {% trans 'Bestätigung des Geldeingangs zurückziehen' %}"
class="btn btn-link no-padding">
<span class="text-success">{% bootstrap_icon 'piggy-bank' %}</span>
</button>
&nbsp;
<span class="text-success {% if not participant.apply_reduced_fee %}invisible{% endif %}"
title="{% trans 'Reduzierte Teilnahmegebühr' %}"><strong>%</strong></span><button
name="action" value="revoke_payment"
title="{% trans 'Geldeingang wurde bestätigt' %} - {% trans 'Bestätigung des Geldeingangs zurückziehen' %}"
class="btn btn-link no-padding"><span class="text-success">{% bootstrap_icon 'piggy-bank' %}</span></button>
{% elif event.charge and participant.paid %}
<span class="text-success" title="{% trans 'Geldeingang bestätigt' %}">
{% bootstrap_icon 'piggy-bank' %}
</span>
&nbsp;
<span class="text-success {% if not participant.apply_reduced_fee %}invisible{% endif %}"
title="{% trans 'Reduzierte Teilnahmegebühr' %}"><strong>%</strong></span><span
class="text-success"
title="{% trans 'Geldeingang bestätigt' %}">{% bootstrap_icon 'piggy-bank' %}</span>
{% elif event.charge and has_permission_payment %}
<button name="action" value="confirm_payment"
title="{% trans 'Geldeingang bestätigen' %}"
class="btn btn-link no-padding">
<span class="text-danger">{% bootstrap_icon 'piggy-bank' %}</span>
</button>
&nbsp;
<span class="text-danger {% if not participant.apply_reduced_fee %}invisible{% endif %}"
title="{% trans 'Reduzierte Teilnahmegebühr' %}"><strong>%</strong></span><button
name="action" value="confirm_payment"
title="{% trans 'Geldeingang bestätigen' %}"
class="btn btn-link no-padding"><span class="text-danger">{% bootstrap_icon 'piggy-bank' %}</span></button>
{% elif event.charge %}
<span class="text-danger" title="{% trans 'Geldeingang unbestätigt' %}">
{% bootstrap_icon 'piggy-bank' %}
</span>
&nbsp;
<span class="text-danger {% if not participant.apply_reduced_fee %}invisible{% endif %}"
title="{% trans 'Reduzierte Teilnahmegebühr' %}"><strong>%</strong></span><span
class="text-danger"
title="{% trans 'Geldeingang unbestätigt' %}">{% bootstrap_icon 'piggy-bank' %}</span>
{% else %}
<span class="hidden" title="{% trans 'Keine Teilnehmergebühr gefordert' %}">
{% bootstrap_icon 'piggy-bank' %}
</span>
<span class="hidden {% if not participant.apply_reduced_fee %}invisible{% endif %}"
title="{% trans 'Reduzierte Teilnahmegebühr' %}"><strong>%</strong></span><span
class="hidden"
title="{% trans 'Keine Teilnehmergebühr gefordert' %}">{% bootstrap_icon 'piggy-bank' %}</span>
{% endif %}
</form>
</div>
</h5>
</div>
</div>
<div id="collapseParticipant_{{ participant.id }}"
class="panel-collapse collapse {% if form.errors %}in{% endif %}"
@@ -401,6 +458,10 @@
{% else %}
<div class="panel panel-info">
<div class="panel-body">
<div class="pull-right text-info" title="Sobald du im Kasten 'Anmeldungen' bei einzelnen Personen auf das Plus-Symbol geklickt hast, oder jemanden mit dem Formular unter 'weiteren Teilnehmer hinzufügen' eingetragen hast, erscheinen diese Personen hier als Teilnehmer.
">
{% bootstrap_icon 'question-sign' %}
</div>
<span class="text-info">{% trans 'Es wurden noch keine Teilnehmer hinzugefügt.' %}</span>
</div>
</div>
@@ -414,6 +475,159 @@
</div>
{% endif %}
</div>
<hr />
<div class="pull-right text-info" style="margin-right: 1em;"
title="Wenn du Anmeldungen mit einem Klick auf das Plus- oder Minus-Symbol 'bearbeitest', oder Teilnehmer aus der Teilnehmerliste entfernst, dann sind diese Einträge danach noch im Papierkorb zu finden.
">
{% bootstrap_icon 'question-sign' %}
</div>
<h4>{% trans 'Papierkorb' %}</h4>
<div class="panel-group" id="form-accordion-trash" role="tablist" aria-multiselectable="true">
{% if registrations_support and registrations_answered %}
<div class="panel panel-info">
<div id="headingAnsweredRegistrations" class="panel-heading" role="tab">
<div class="pull-right text-info" title="Wenn du unter 'Anmeldungen' auf das Plus- oder Minus-Symbol geklickt hast, dann sind diese Einträge hier zu sehen.
">
{% bootstrap_icon 'question-sign' %}
</div>
<h5 class="panel-title">
<a role="button" href="#collapseAnsweredRegistrations"
data-toggle="collapse"
aria-expanded="true" aria-controls="collapseAnsweredRegistrations">
<span class="caret"></span>&nbsp;&nbsp;{% trans 'Bearbeitete Anmeldungen' %}
</a>
</h5>
</div>
<div id="collapseAnsweredRegistrations" class="panel-collapse collapse"
role="tabpanel" aria-labelledby="headingAnsweredRegistrations">
<div class="panel-body">
{% for registration in registrations_answered %}
<div>
<button disabled="disabled"
class="btn btn-link no-padding" title="Bei dieser Anmeldung hast du bereits
am {{ registration.status.updated_at|date:'d. F Y, G:i' }}
auf {% if registration.status.accepted == True %}Plus{% elif registration.status.accepted == False %}Minus{% else %}Plus oder Minus{% endif %} geklickt.
">
<span class="{% if registration.status.accepted == True %}text-success{% else %}text-muted{% endif %}">{% bootstrap_icon 'plus-sign' %}</span>
</button>
&nbsp;
<button disabled="disabled"
class="btn btn-link no-padding" title="Bei dieser Anmeldung hast du bereits
am {{ registration.status.updated_at|date:'d. F Y, G:i' }}
auf {% if registration.status.accepted == True %}Plus{% elif registration.status.accepted == False %}Minus{% else %}Plus oder Minus{% endif %} geklickt.
">
<span class="{% if registration.status.accepted == False %}text-danger{% else %}text-muted{% endif %}">{% bootstrap_icon 'minus-sign' %}</span>
</button>
&nbsp;
<span class="text-muted">
{{ registration.get_full_name }}
(<a href="mailto:{{ registration.email_address }}">{{ registration.email_address }}</a>,
{{ registration.phone_number }})
&nbsp;
<span title="Anmeldezeitpunkt">
{% bootstrap_icon 'time' %}
{{ registration.created_at|date:'d. F Y, G:i' }}
</span>
&nbsp;
<span title="{{ registration.get_info }}">
{% bootstrap_icon 'info-sign' %}
</span>
</span>
{% if has_permission_update_registration %}
<div class="pull-right">
<form action="" method="post" class="form-inline">
{% csrf_token %}
<input type="hidden" name="registration" value="{{ registration.id }}">
<button type="submit" name="action" value="untrash_registration"
class="btn btn-link no-padding"
title="{% trans 'Eintrag in Anmeldungen zurückholen' %}">
<span class="text-success">&#9851;</span>
</button>
</form>
</div>
{% endif %}
</div>
{% endfor %}
</div>
</div>
</div>
{% endif %}
{% if participants_trash %}
<div class="panel panel-info">
<div id="headingTrashedParticipants" class="panel-heading" role="tab">
<div class="pull-right text-info" title="Wenn du Teilnehmer deiner Teilnehmerliste hinzugefügt hast und sie später wieder entfernt hast, dann tauchen diese Einträge hier auf.">
{% bootstrap_icon 'question-sign' %}
</div>
<h5 class="panel-title">
<a role="button" href="#collapseTrashedParticipants"
data-toggle="collapse"
aria-expanded="true" aria-controls="collapseTrashedParticipants">
<span class="caret"></span>&nbsp;&nbsp;{% trans 'Gelöschte Teilnehmer' %}
</a>
</h5>
</div>
<div id="collapseTrashedParticipants" class="panel-collapse collapse"
role="tabpanel" aria-labelledby="headingTrashedParticipants">
<div class="panel-body">
{% for participant in participants_trash %}
<div>
<button disabled="disabled"
class="btn btn-link no-padding" title="Diesen Teilnehmer hast du
am {{ participant.trashed_at|date:'d. F Y, G:i' }}
von Position {{ participant.position }} der Teilnehmerliste entfernt.
">
<span class="text-danger">{% bootstrap_icon 'trash' %}</span>
</button>
&nbsp;
<span class="text-muted">
{{ participant.get_full_name }}
(<a href="mailto:{{ participant.email_address }}">{{ participant.email_address }}</a>,
{{ participant.phone_number }})
&nbsp;
<span title="{{ participant.get_info }}
{% trans 'Zeitpunkt der automatischen Löschung' %}: {{ participant.purge_at|date:'d. F Y' }}">
{% bootstrap_icon 'info-sign' %}
</span>
{% if event.charge and participant.paid %}
&nbsp;
<span class="text-success {% if not participant.apply_reduced_fee %}invisible{% endif %}"
title="{% trans 'Reduzierte Teilnahmegebühr' %}"><strong>%</strong></span><span
class="text-success"
title="{% trans 'Geldeingang bestätigt' %}">{% bootstrap_icon 'piggy-bank' %}</span>
{% elif event.charge %}
&nbsp;
<span class="text-danger {% if not participant.apply_reduced_fee %}invisible{% endif %}"
title="{% trans 'Reduzierte Teilnahmegebühr' %}"><strong>%</strong></span><span
class="text-danger"
title="{% trans 'Geldeingang unbestätigt' %}">{% bootstrap_icon 'piggy-bank' %}</span>
{% endif %}
</span>
{% if has_permission_update_participants %}
<div class="pull-right">
<form action="" method="post" class="form-inline">
{% csrf_token %}
<input type="hidden" name="id" value="{{ participant.id }}">
<button name="action" value="untrash_participant"
title="{% trans 'Eintrag in Teilnehmerliste zurückholen' %}"
class="btn btn-link no-padding">
<span class="text-success">&#9851;</span>
</button>
</form>
</div>
{% endif %}
</div>
{% endfor %}
</div>
</div>
</div>
{% endif %}
{% if not registrations_answered and not participants_trash %}
<span class="text-muted small">{% trans 'Der Papierkorb ist leer.' %}</span>
{% endif %}
</div>
</div>
</div>
{% endblock page-container-fluid %}

View File

@@ -29,9 +29,20 @@
</div>
</div>
<div class="row">
<div class="col-sm-6">
<div class="col-sm-3">
{% bootstrap_field form.year_of_birth %}
</div>
<div class="col-sm-9">
{% bootstrap_field form.apply_reduced_fee %}
</div>
</div>
<div class="row">
<div class="col-sm-3">
{% bootstrap_field form.dav_number %}
</div>
<div class="col-sm-3">
{% bootstrap_field form.dav_member %}
</div>
<div class="col-sm-6">
{% bootstrap_field form.emergency_contact %}
</div>

View File

@@ -0,0 +1,45 @@
{% extends 'dav_events/base.html' %}
{% load bootstrap3 %}
{% load i18n %}
{% block head-title %}
{% trans 'Anmeldung' %} {{ registration.get_full_name }} - {{ registration.event.number }} - {{ block.super }}
{% endblock head-title %}
{% block page-container %}
<div class="well">
<p>
Hallo {{ registration.event.trainer_firstname }},
</p>
<p>
du hast sicherlich schon gesehen, dass {{ registration.get_full_name }} angekreuzt hat,
die reduzierte Teilnahmegebühr zahlen zu wollen.
</p>
<p>
Für Jugendliche und Junioren sowie Mitglieder mit geringen finanziellen Mitteln (Nachweis durch Karlsruher Paß)
wird die Teilnahmegebühr auf 50% ermäßigt.
</p>
<p>
Wenn ihr bereits darüber gesprochen habt und ihr übereingekommen seid,
dass {{ registration.personal_names }} doch den vollen Betrag zahlen soll,
dann kannst du unten den Haken entfernen.
</p>
</div>
<div class="well">
<h6>
{{ registration.event.number }} - {{ registration.event.title }}<br />
{% trans 'Anmeldung' %} {{ registration.get_full_name }}
</h6>
<form action="" method="post">
{% csrf_token %}
{% bootstrap_form form %}
<button type="submit" name="action" value="accept_registration"
class="btn btn-success">
{% bootstrap_icon 'plus-sign' %} zur Teilnehmerliste hinzufügen
</button>
<a href="{% url 'dav_events:registrations' registration.event.pk %}" class="btn btn-danger">
{% bootstrap_icon 'remove' %} Zurück
</a>
</form>
</div>
{% endblock page-container %}

View File

@@ -1,7 +1,12 @@
# -*- coding: utf-8 -*-
import json
from django import template
from django.utils.html import format_html
from django.utils.safestring import mark_safe
from django.utils import timezone
from django.utils.translation import ugettext as _
from ..models.eventchange import EventChange
from ..models.eventstatus import EventStatus, get_or_create_event_status
register = template.Library()
@@ -30,3 +35,86 @@ def render_event_status(event, show_void=True):
context=context)
return mark_safe(html)
@register.simple_tag
def render_event_changelog(event):
change_templ = u'<li class="list-group-item">\n' \
u'\t<p class="list-group-item-heading">' \
u'<span class="glyphicon glyphicon-{icon}"></span>' \
u' {timestamp}' \
u' - ' \
u' {user}</p>\n' \
u'\t{content}\n' \
u'</li>\n'
update_sub_templ = u'<li class="list-group-item">\n' \
u'\t{field}:{separator1}\n' \
u'\t<span style="background-color: #ffe0e0;">{refer}</span>\n' \
u'\t{separator2}\n' \
u'\t<span style="background-color: #e0ffe0;">{current}</span>\n' \
u'</li>\n'
raise_flag_templ = u'<span class="text-success glyphicon glyphicon-plus"></span>' \
u' <span class="label label-{bcontext}">{label}</span>' \
u' <span class="text-success glyphicon glyphicon-plus"></span>'
lower_flag_templ = u'<span class="text-danger glyphicon glyphicon-minus"></span>' \
u' <del><span class="label label-{bcontext}">{label}</span></del>' \
u' <span class="text-danger glyphicon glyphicon-minus"></span>'
if event.changes.exists():
html = u'<ul class="list-group">\n'
for change in event.changes.all():
username = change.user.get_full_name()
if not username:
username = change.user
if change.operation == EventChange.UPDATE:
icon = u'pencil'
content_html = u'<ul class="list-group">'
subchanges = json.loads(change.content)
for subchange in subchanges:
field_label = event._meta.get_field(subchange['field']).verbose_name
try:
is_long_strings = (len(subchange['refer']) + len(subchange['current'])) > 20
except TypeError:
is_long_strings = False
if is_long_strings:
separator1 = u'<br />'
separator2 = u'<br />'
else:
separator1 = u' '
separator2 = u' -&gt; '
content_html += format_html(update_sub_templ,
field=field_label,
separator1=mark_safe(separator1),
refer=subchange['refer'],
separator2=mark_safe(separator2),
current=subchange['current'])
content_html += u'</ul>'
elif change.operation == EventChange.RAISE_FLAG:
icon = u'flag'
status = get_or_create_event_status(change.content)
content_html = format_html(raise_flag_templ,
bcontext=status.bootstrap_context or u'default',
label=status.label)
elif change.operation == EventChange.LOWER_FLAG:
icon = u'flag'
status = get_or_create_event_status(change.content)
content_html = format_html(lower_flag_templ,
bcontext=status.bootstrap_context or u'default',
label=status.label)
else:
icon = u'question-sign'
content_html = format_html(u'{content}', content=change.content)
html += format_html(change_templ,
icon=icon,
timestamp=timezone.localtime(change.timestamp).strftime('%Y-%m-%d %H:%M:%S %Z'),
user=username,
content=mark_safe(content_html))
html += u'</ul>\n'
else:
html = _(u'Keine Einträge')
return mark_safe(html)

View File

@@ -49,6 +49,7 @@ Link zur Veranstaltung:
Veranstaltungsart: gemeinschaftliche Tour
Schwierigkeitsnivau: Anfänger
Gelände: Kletterhalle
Anmeldung: Nicht erforderlich
Anreise des Kurs-/Tourenleiters am Vortag: Nein
Veröffentlichung: sofort
"""

View File

@@ -0,0 +1,67 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
import datetime
import json
from django.test import TestCase
from ..models.eventchange import EventChange
from .generic import EventMixin
TEST_EVENT_DATA = {
'title': 'Täst',
'description': 'Teßt',
'mode': 'joint',
'sport': 'W',
'level': 'beginner',
'first_day': datetime.date(2019, 3, 1),
'country': 'DE',
'trainer_firstname': 'Übungsleiter',
'trainer_familyname': 'Weißalles',
'trainer_email': 'trainer@localhost',
}
class EventsTestCase(EventMixin, TestCase):
def test_changelog(self):
data = TEST_EVENT_DATA
event = self.create_event_by_model(data)
event.alt_first_day = event.first_day + datetime.timedelta(1)
event.sport = 'M'
event.ski_lift = True
event.save()
event.country = 'FR'
event.save()
event.trainer_familyname += '-Ömlaut'
event.max_participants = 8
event.save()
changes = event.changes
self.assertEqual(changes.count(), 4)
change = changes.get(pk=1)
self.assertEqual(change.operation, EventChange.RAISE_FLAG)
self.assertEqual(change.content, 'draft')
change = changes.get(pk=2)
self.assertEqual(change.operation, EventChange.UPDATE)
subchanges = json.loads(change.content)
self.assertEqual(len(subchanges), 3)
self.assertIn({'field': 'alt_first_day', 'refer': None, 'current': '2019-03-02'}, subchanges)
self.assertIn({'field': 'sport', 'refer': 'W', 'current': 'M'}, subchanges)
self.assertIn({'field': 'ski_lift', 'refer': False, 'current': True}, subchanges)
change = changes.get(pk=3)
self.assertEqual(change.operation, EventChange.UPDATE)
subchanges = json.loads(change.content)
self.assertEqual(len(subchanges), 1)
self.assertIn({'field': 'country', 'refer': 'DE', 'current': 'FR'}, subchanges)
change = changes.get(pk=4)
self.assertEqual(change.operation, EventChange.UPDATE)
subchanges = json.loads(change.content)
self.assertEqual(len(subchanges), 2)
self.assertIn({'field': 'trainer_familyname', 'refer': 'Weißalles', 'current': 'Weißalles-Ömlaut'}, subchanges)
self.assertIn({'field': 'max_participants', 'refer': 0, 'current': 8}, subchanges)

View File

@@ -121,7 +121,11 @@ class ActionTestCase(EmailTestMixin, RoleMixin, EventMixin, TestCase):
'user': user.get_full_name(),
})
html = message.replace('\'', '&#39;')
self.assertInHTML(html, content)
# Sometimes this test fail, and we cannot see the tested content, so we create our own Exception
try:
self.assertInHTML(html, content)
except AssertionError:
raise AssertionError('Not in HTML:\n{}\n-----\n{}\n'.format(html, content))
self.assertRegex(content, r'alert-success')
def setUp(self):

View File

@@ -14,7 +14,7 @@ from dav_auth.tests.generic import SeleniumAuthMixin
from .generic import RoleMixin
TEST_TRAINER_EMAIL = 'trainer@localhost'
TEST_PASSWORD = u'me||ön 2'
TEST_PASSWORD = u'me||ön 21ABll'
TEST_EVENT_DATA_S = {
'mode': 'training',
'sport': 'S',

View File

@@ -12,6 +12,7 @@ urlpatterns = [
views.events.EventUpdateStatusView.as_view(), name='updatestatus'),
url(r'^(?P<pk>\d+)/edit', views.events.EventUpdateView.as_view(), name='update'),
url(r'^(?P<pk>\d+)/', views.events.EventDetailView.as_view(), name='detail'),
url(r'^registration/(?P<pk>\d+)/', views.events.RespondRegistrationView.as_view(), name='respond_registration'),
url(r'^action/(?P<pk>[a-fA-F0-9]{8}-([a-fA-F0-9]{4}-){3}[a-fA-F0-9]{12})/',
views.actions.OneClickActionRunView.as_view(), name='action_run'),
]

View File

@@ -1,7 +1,7 @@
import logging
from django.utils import timezone
from .models import Participant
from .models import Participant, TrashedParticipant
logger = logging.getLogger(__name__)
@@ -11,3 +11,6 @@ def purge_participants():
for p in Participant.objects.filter(purge_at__lte=now):
logger.info('Purge participant \'%s\'', p)
p.delete()
for p in TrashedParticipant.objects.filter(purge_at__lte=now):
logger.info('Purge participant from trash \'%s\'', p)
p.delete()

View File

@@ -2,6 +2,7 @@
import datetime
import logging
import os
from django.apps import apps
from django.contrib import messages
from django.contrib.auth import login
from django.contrib.auth.decorators import login_required
@@ -215,6 +216,18 @@ class EventRegistrationsView(EventPermissionMixin, generic.DetailView):
participants = event.participants.all()
context['participants'] = participants
participants_trash = event.trashed_participants.all()
context['participants_trash'] = participants_trash
earnings = 0
if event.charge:
for participant in participants:
if participant.paid:
if participant.apply_reduced_fee:
earnings += event.charge / 2
else:
earnings += event.charge
context['earnings'] = earnings
if participants.count() > 1:
email_list = [u'"{}" <{}>'.format(p.get_full_name(), p.email_address) for p in participants]
@@ -230,9 +243,13 @@ class EventRegistrationsView(EventPermissionMixin, generic.DetailView):
registrations_support = hasattr(event, 'registrations')
context['registrations_support'] = registrations_support
if registrations_support:
registrations = event.registrations.filter(answered=False)
# registrations = event.registrations.all()
context['registrations'] = registrations
registrations_all = event.registrations.all()
registrations_pending = registrations_all.filter(~Q(status__answered=True))
registrations_answered = registrations_all.filter(status__answered=True)
context['registrations_all'] = registrations_all
context['registrations_pending'] = registrations_pending
context['registrations_answered'] = registrations_answered
context['registrations'] = registrations_all
return context
@@ -265,34 +282,21 @@ class EventRegistrationsView(EventPermissionMixin, generic.DetailView):
messages.success(request, _(u'Der Anmeldeschluss wurde gelöscht'))
def _accept_registration(self, request, registration):
event = registration.event
data = registration.get_data_dict()
del data['created_at']
del data['answered_obsolete']
data['position'] = registration.event.participants.count() + 1
position = event.participants.count() + 1
data = {
'event': event,
'position': position,
'personal_names': registration.personal_names,
'family_names': registration.family_names,
'address': registration.address,
'postal_code': registration.postal_code,
'city': registration.city,
'email_address': registration.email_address,
'phone_number': registration.phone_number,
'dav_number': registration.dav_number,
'emergency_contact': registration.emergency_contact,
'experience': registration.experience,
'note': registration.note,
'purge_at': registration.purge_at,
}
participant = models.Participant.objects.create(**data)
registration.answered = True
registration.save()
registration.status.set_accepted()
messages.success(request, _(u'Teilnehmer hinzugefügt: {}'.format(participant.get_full_name())))
def _reject_registration(self, registration):
registration.answered = True
registration.save()
registration.status.set_rejected()
def _reset_registration(self, registration):
registration.status.reset()
def _swap_participants_position(self, participant1, participant2):
event = participant1.event
@@ -342,6 +346,20 @@ class EventRegistrationsView(EventPermissionMixin, generic.DetailView):
self._reject_registration(registration)
else:
raise FieldDoesNotExist('Event has no registrations')
elif action == 'untrash_registration':
self.enforce_permission(event, permission='update-registration')
if hasattr(event, 'registrations'):
registration_id = request.POST.get('registration')
registration = event.registrations.get(id=registration_id)
self._reset_registration(registration)
else:
raise FieldDoesNotExist('Event has no registrations')
elif action == 'toggle_reduced_fee':
self.enforce_permission(event, permission='payment')
participant_id = request.POST.get('id')
participant = event.participants.get(id=participant_id)
participant.apply_reduced_fee = not participant.apply_reduced_fee
participant.save()
elif action == 'confirm_payment':
self.enforce_permission(event, permission='payment')
participant_id = request.POST.get('id')
@@ -354,18 +372,33 @@ class EventRegistrationsView(EventPermissionMixin, generic.DetailView):
participant = event.participants.get(id=participant_id)
participant.paid = False
participant.save()
elif action == 'remove_participant':
elif action == 'trash_participant':
self.enforce_permission(event, permission='update-participants')
participant_id = request.POST.get('id')
participant = event.participants.get(id=participant_id)
full_name = participant.get_full_name()
position = participant.position
participants_below = event.participants.filter(position__gt=participant.position)
data = participant.get_data_dict()
trashed = models.TrashedParticipant.objects.create(**data)
participant.delete()
qs = event.participants.filter(position__gt=position)
for participant in qs:
for participant in participants_below:
participant.position -= 1
participant.save()
messages.success(request, _(u'Teilnehmer gelöscht: {}'.format(full_name)))
messages.success(request, _(u'Teilnehmer in den Papierkorb verschoben: {}'.format(trashed.get_full_name())))
elif action == 'untrash_participant':
self.enforce_permission(event, permission='update-participants')
trashed_id = request.POST.get('id')
trashed = event.trashed_participants.get(id=trashed_id)
trashed.position = event.participants.count() + 1
data = trashed.get_data_dict()
del data['trashed_at']
participant = models.Participant.objects.create(**data)
trashed.delete()
messages.success(request, _(u'Teilnehmer zurückgeholt: {}'.format(participant.get_full_name())))
elif action == 'moveup_participant':
self.enforce_permission(event, permission='update-participants')
participant_id = request.POST.get('id')
@@ -422,6 +455,62 @@ class EventRegistrationsView(EventPermissionMixin, generic.DetailView):
return super(EventRegistrationsView, self).dispatch(request, *args, **kwargs)
class RespondRegistrationView(EventPermissionMixin, generic.DetailView, generic.FormView):
permission = 'update-participants'
context_object_name = 'registration'
template_name = 'dav_events/registration_response.html'
form_class = forms.registration.RegistrationResponseForm
def _accept_registration(self, request, registration):
data = registration.get_data_dict()
del data['created_at']
del data['answered_obsolete']
data['position'] = registration.event.participants.count() + 1
participant = models.Participant.objects.create(**data)
registration.status.set_accepted()
messages.success(request, _(u'Teilnehmer hinzugefügt: {}'.format(participant.get_full_name())))
def has_permission(self, permission, obj):
user = self.request.user
return obj.event.workflow.has_permission(user, permission)
def get_queryset(self):
model = apps.get_model(app_label='dav_registration', model_name='Registration')
return model.objects.all()
def get_success_url(self):
return reverse('dav_events:registrations', args=[self.object.event.pk])
def get_initial(self):
return {
'apply_reduced_fee': self.object.apply_reduced_fee,
}
def form_valid(self, form):
registration = self.object
registration.apply_reduced_fee = form.cleaned_data['apply_reduced_fee']
registration.save()
self._accept_registration(self.request, registration)
return HttpResponseRedirect(self.get_success_url())
def get(self, request, *args, **kwargs):
self.object = self.get_object()
self.enforce_permission(self.object)
context = self.get_context_data(object=self.object)
return self.render_to_response(context)
def post(self, request, *args, **kwargs):
self.object = self.get_object()
self.enforce_permission(self.object)
return super(RespondRegistrationView, self).post(request, *args, **kwargs)
@method_decorator(login_required)
def dispatch(self, request, *args, **kwargs):
return super(RespondRegistrationView, self).dispatch(request, *args, **kwargs)
class EventUpdateStatusView(EventPermissionMixin, generic.DetailView):
model = models.Event
@@ -454,6 +543,18 @@ class EventUpdateStatusView(EventPermissionMixin, generic.DetailView):
messages.error(request, message)
return HttpResponseRedirect(event.get_absolute_url())
if not event.workflow.has_reached_status('publishing*') and not event.workflow.has_reached_status('published*'):
cur_pub_date = event.planned_publication_date
real_pub_date, real_pub_issue = event.workflow.plan_publication(event.first_day, event.deadline)
if cur_pub_date != real_pub_date:
if real_pub_date is None:
real_pub_str = _(u'Unverzüglich')
else:
real_pub_str = u'%s (%s)' % (real_pub_date.strftime('%d.%m.%Y'), real_pub_issue)
event.planned_publication_date = real_pub_date
event.save()
messages.warning(request, _(u'Veröffentlichungsdatum wurde angepasst: %s') % real_pub_str)
event.workflow.update_status(status, request.user)
if status.startswith('submit'):
@@ -513,7 +614,21 @@ class EventUpdateView(EventPermissionMixin, generic.UpdateView):
def form_valid(self, form):
form.instance.editor = self.request.user
self.object = form.save()
event = form.save()
self.object = event
if not event.workflow.has_reached_status('publishing*') and not event.workflow.has_reached_status('published*'):
cur_pub_date = event.planned_publication_date
real_pub_date, real_pub_issue = event.workflow.plan_publication(event.first_day, event.deadline)
if cur_pub_date != real_pub_date:
if real_pub_date is None:
real_pub_str = _(u'Unverzüglich')
else:
real_pub_str = u'%s (%s)' % (real_pub_date.strftime('%d.%m.%Y'), real_pub_issue)
event.planned_publication_date = real_pub_date
event.save()
messages.warning(self.request, _(u'Veröffentlichungsdatum wurde angepasst: %s') % real_pub_str)
return HttpResponseRedirect(self.get_success_url())
@method_decorator(login_required)
@@ -573,6 +688,31 @@ class EventCreateView(EventPermissionMixin, generic.FormView):
return self.render_to_response(self.get_context_data(form=next_form, event=event))
else:
event.editor = self.request.user
# Check for double submission (seems to happens accidentally if smartphone user reload the submit page)
possible_doublets = models.Event.objects.filter(owner=event.owner,
title=event.title,
first_day=event.first_day)
if possible_doublets.exists():
accident_period = datetime.datetime.now() - datetime.timedelta(hours=24)
possible_doublets = possible_doublets.filter(created_at__gt=accident_period)
if possible_doublets.exists():
doublet = possible_doublets.first()
doublet_created_at = doublet.created_at.strftime('%d.%m.%Y %H:%M:%S')
error_msg = _(u'Du hast bereits eine Veranstaltung'
u' mit dem Titel "%(title)s"'
u' für das Datum %(day)s angelegt'
u' (am %(created_at)s).') % {'title': event.title,
'day': event.first_day,
'created_at': doublet_created_at}
warn_msg = _(u'Deine eingegebenen Daten sind noch da,'
u' wenn du jetzt auf "Neue Veranstaltung anlegen" klickst.'
u' Du musst aber entweder den Titel oder das Startdatum ändern,'
u' um die Veranstaltung anlegen zu können.')
messages.error(self.request, error_msg)
messages.warning(self.request, warn_msg)
return HttpResponseRedirect(reverse('dav_events:list'))
event.save()
if 'submit' in form.data:
event.workflow.update_status('submitted', event.owner)

View File

@@ -9,6 +9,7 @@ from django.utils.translation import ugettext_lazy as _
from . import emails
from . import signals
from .models.eventchange import EventChange
from .models.eventflag import EventFlag
from .models.eventstatus import get_or_create_event_status
from .roles import get_users_by_role, has_role
@@ -50,6 +51,8 @@ class BasicWorkflow(object):
kwargs['status'] = status
flag = EventFlag(**kwargs)
flag.save()
change = EventChange(event=event, user=flag.user, operation=EventChange.RAISE_FLAG, content=status.code)
change.save()
logger.info('Flagging status \'%s\' for %s', status.code, event)
return flag
@@ -312,10 +315,9 @@ class BasicWorkflow(object):
if not app_config.settings.enable_email_on_update:
return
if len(diff) < 1:
if not diff:
logger.debug('send_emails_on_update(): No diff data -> Skip sending mails.')
return
diff_text = '\n'.join(diff[3:])
# Who should be informed about the update?
recipients = [event.owner]
@@ -329,7 +331,7 @@ class BasicWorkflow(object):
for recipient in recipients:
if recipient.email and recipient.email != updater.email:
email = emails.EventUpdatedMail(recipient=recipient, event=event, editor=updater, diff=diff_text)
email = emails.EventUpdatedMail(recipient=recipient, event=event, editor=updater, diff=diff)
email.send()
def send_emails_on_status_update(self, flag):
@@ -487,9 +489,8 @@ class BasicWorkflow(object):
#
# Misc logic
#
# TODO: is a class method a good idea?
@classmethod
def plan_publication(cls, first_day, deadline=None):
@staticmethod
def plan_publication(first_day, deadline=None):
app_config = apps.get_containing_app_config(__package__)
if deadline:

View File

@@ -1,8 +1,12 @@
from django.contrib import admin
from .models import Registration
from .models import Registration, RegistrationStatus
class RegistrationStatusInline(admin.StackedInline):
model = RegistrationStatus
@admin.register(Registration)
class RegistrationAdmin(admin.ModelAdmin):
pass
inlines = [RegistrationStatusInline]

View File

@@ -1,7 +1,8 @@
# -*- coding: utf-8 -*-
import datetime
import logging
from django import forms
from django.utils.translation import ugettext
from django.utils.translation import ugettext, ugettext_lazy as _
from .models import Registration
@@ -9,14 +10,50 @@ logger = logging.getLogger(__name__)
class RegistrationForm(forms.ModelForm):
not_dav_member = forms.BooleanField(required=False,
label=_(u'Ich bin noch kein DAV Mitglied.'),
help_text=u'%s<br />\n%s' % (
_(u'Wenn du noch kein DAV Mitglied bist,'
u' oder deine Aufnahme noch in Arbeit ist,'
u' kreuze dieses Feld hier an.'),
_(u'Spätestens zu Veranstaltungsbeginn muss'
u' jedoch eine Mitgliedschaft bestehen.')
))
class Meta:
model = Registration
exclude = ['event', 'created_at', 'privacy_policy', 'purge_at', 'answered']
widgets = {
'dav_member': forms.HiddenInput(),
'emergency_contact': forms.Textarea(attrs={'rows': 4}),
'experience': forms.Textarea(attrs={'rows': 5}),
'note': forms.Textarea(attrs={'rows': 5}),
}
labels = {
'apply_reduced_fee': _(u'Ich bin noch keine 25 Jahre alt oder besitze einen "Karlsruher Pass".'),
}
def clean_year_of_birth(self):
now = datetime.datetime.now()
year_now = now.year
max_age = 100
val = self.cleaned_data.get('year_of_birth')
if val > year_now:
raise forms.ValidationError(
ugettext(u'Dein Geburtsjahr liegt in der Zukunft?'
u' Das finden wir gut,'
u' aber bitte melde dich besser mal per E-Mail bei uns.'),
code='to_young',
)
elif val < (year_now - max_age):
raise forms.ValidationError(
ugettext(u'Du bist schon über %(max_age)d Jahre alt?'
u' Das finden wir gut,'
u' aber bitte melde dich besser mal per E-Mail bei uns.'),
params={'max_age': max_age},
code='to_old',
)
return val
def clean_experience(self):
val = self.cleaned_data.get('experience')
@@ -41,3 +78,13 @@ class RegistrationForm(forms.ModelForm):
code='privacy_policy_not_accepted',
)
return val
def clean(self):
super(RegistrationForm, self).clean()
dav_member = self.cleaned_data.get('dav_member')
dav_number = self.cleaned_data.get('dav_number')
if dav_member and not dav_number:
error_msg = ugettext(u'Wenn du DAV Mitglied bist, brauchen wir deine Mitgliedsnummer.')
self.add_error('not_dav_member', error_msg)
raise forms.ValidationError(error_msg, code='dav_number_missing')
return self.cleaned_data

View File

@@ -0,0 +1,26 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.29 on 2020-10-15 15:38
from __future__ import unicode_literals
import django.core.validators
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dav_registration', '0004_auto_20190605_1400'),
]
operations = [
migrations.AddField(
model_name='registration',
name='dav_member',
field=models.BooleanField(default=True, verbose_name='DAV Mitglied'),
),
migrations.AlterField(
model_name='registration',
name='dav_number',
field=models.CharField(blank=True, help_text='Deine Mitgliedsnummer findest du unter dem Strichcode auf deinem DAV Ausweis.<br /> Beispiel: <tt>131/00/012345</tt> (der Teil bis zum ersten * genügt)', max_length=62, validators=[django.core.validators.RegexValidator('^([0-9]{1,10}/[0-9]{2,10}/)?[0-9]{1,10}(\\*[0-9]{1,10})?(\\*[0-9]{4}\\*[0-9]{4})?([* ][0-9]{8})?$', 'Ungültiges Format.')], verbose_name='DAV Mitgliedsnummer'),
),
]

View File

@@ -0,0 +1,35 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.29 on 2020-12-03 10:44
from __future__ import unicode_literals
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('dav_registration', '0005_auto_20201015_1738'),
]
operations = [
migrations.CreateModel(
name='RegistrationStatus',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('updated_at', models.DateTimeField(auto_now=True)),
('answered', models.BooleanField(default=False, verbose_name='Durch Tourleitung beantwortet')),
('accepted', models.NullBooleanField(verbose_name='Zusage erteilt')),
],
options={
'verbose_name': 'Anmeldungsstatus',
'verbose_name_plural': 'Anmeldungsstati',
'ordering': ['updated_at'],
},
),
migrations.AddField(
model_name='registrationstatus',
name='registration',
field=models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='status', to='dav_registration.Registration'),
),
]

View File

@@ -0,0 +1,26 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.29 on 2020-12-08 17:53
from __future__ import unicode_literals
from django.db import migrations
def migrate_registration_status(apps, schema_editor):
Registration = apps.get_model('dav_registration', 'Registration')
RegistrationStatus = apps.get_model('dav_registration', 'RegistrationStatus')
db_alias = schema_editor.connection.alias
for r in Registration.objects.using(db_alias).all():
s = RegistrationStatus(registration=r)
s.answered = r.answered
s.save()
class Migration(migrations.Migration):
dependencies = [
('dav_registration', '0006_auto_20201203_1144'),
]
operations = [
migrations.RunPython(migrate_registration_status),
]

View File

@@ -0,0 +1,20 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.29 on 2020-12-08 18:06
from __future__ import unicode_literals
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('dav_registration', '0007_auto_20201208_1853'),
]
operations = [
migrations.RenameField(
model_name='registration',
old_name='answered',
new_name='answered_obsolete',
),
]

View File

@@ -0,0 +1,21 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.29 on 2020-12-09 09:20
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dav_registration', '0008_auto_20201208_1906'),
]
operations = [
migrations.AddField(
model_name='registration',
name='year_of_birth',
field=models.IntegerField(default=1870, help_text='Manchmal müssen wir wissen, wie alt unsere Teilnehmer sind. Darum brauchen wir die vierstellige Jahreszahl, des Jahres in dem du geboren bist (zb. 1991).', verbose_name='Geburtsjahr'),
preserve_default=False,
),
]

View File

@@ -0,0 +1,20 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.29 on 2020-12-09 14:16
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dav_registration', '0009_registration_year_of_birth'),
]
operations = [
migrations.AddField(
model_name='registration',
name='apply_reduced_fee',
field=models.BooleanField(default=False, help_text='Für Jugendliche und Junioren (bis zum vollendeten 25. Lebensjahr), sowie Mitglieder mit geringen finanziellen Mitteln (Nachweis durch "Karlsruher Pass"), wird die Teilnahmegebühr auf 50% ermäßigt.', verbose_name='Antrag auf reduzierte Teilnahmegebühr'),
),
]

View File

@@ -2,6 +2,7 @@
from __future__ import unicode_literals
import datetime
import logging
from django.core.exceptions import ValidationError
from django.db import models
from django.urls import reverse
from django.utils import timezone
@@ -39,8 +40,24 @@ class Registration(models.Model):
phone_number = models.CharField(max_length=254,
verbose_name=_('Telefonnummer'),
help_text=_('Idealerweise eine Mobilfunk-Nummer'))
year_of_birth = models.IntegerField(verbose_name=_('Geburtsjahr'),
help_text=_('Manchmal müssen wir wissen, wie alt unsere Teilnehmer sind.'
' Darum brauchen wir die vierstellige Jahreszahl,'
' des Jahres in dem du geboren bist (zb. 1991).'))
apply_reduced_fee = models.BooleanField(default=False,
verbose_name=_('Antrag auf reduzierte Teilnahmegebühr'),
help_text=_('Für Jugendliche und Junioren'
' (bis zum vollendeten 25. Lebensjahr),'
' sowie Mitglieder mit geringen finanziellen Mitteln'
' (Nachweis durch "Karlsruher Pass"),'
' wird die Teilnahmegebühr auf 50% ermäßigt.'))
dav_member = models.BooleanField(default=True,
verbose_name=_('DAV Mitglied'))
dav_number = models.CharField(max_length=62,
validators=[DAVNumberValidator],
blank=True, validators=[DAVNumberValidator],
verbose_name=_('DAV Mitgliedsnummer'),
help_text='%s<br /> %s %s' % (
_('Deine Mitgliedsnummer findest du unter dem Strichcode'
@@ -69,7 +86,12 @@ class Registration(models.Model):
verbose_name=_('Einwilligung zur Datenspeicherung'))
purge_at = models.DateTimeField(_('Zeitpunkt der Datenlöschung'))
answered = models.BooleanField(_('Durch Tourleitung beantwortet'), default=False)
answered_obsolete = models.BooleanField(default=False, verbose_name=_('Durch Tourleitung beantwortet'))
def approx_age(self):
now = datetime.datetime.now()
year_now = now.year
return year_now - self.year_of_birth
@staticmethod
def pk2hexstr(pk):
@@ -109,21 +131,50 @@ class Registration(models.Model):
text = """{fullname}
{address}, {postal_code} {city}
DAV Mitglied: {dav_info}
Jahrgang: {year_of_birth} (ungefähres Alter: {approx_age})
Antrag auf reduzierte Teilnehmergebühr: {apply_reduced_fee_yesno}
Erfahrung:
{experience}
Anmerkung:
{note}
"""
if not self.dav_member:
dav_info = _('Nein')
else:
dav_info = self.dav_number
if self.apply_reduced_fee:
apply_reduced_fee_yesno = _('Ja')
else:
apply_reduced_fee_yesno = _('Nein')
return text.format(
fullname=self.get_full_name(),
address=self.address,
postal_code=self.postal_code,
city=self.city,
dav_info=dav_info,
year_of_birth=self.year_of_birth,
approx_age=self.approx_age(),
apply_reduced_fee_yesno=apply_reduced_fee_yesno,
experience=self.experience,
note=self.note,
)
def get_data_dict(self):
data = {}
for field in self._meta.fields:
if not field.primary_key:
data[field.name] = getattr(self, field.name)
return data
def clean(self):
if self.dav_member and not self.dav_number:
raise ValidationError({'dav_number': _('Wenn du DAV Mitglied bist, brauchen wir deine Mitgliedsnummer.')})
def save(self, **kwargs):
creating = False
if not self.id:
@@ -132,9 +183,12 @@ Anmerkung:
if not self.purge_at and self.event:
self.purge_at = self.__class__.calc_purge_at(self.event)
self.full_clean()
super(Registration, self).save(**kwargs)
if creating:
status = RegistrationStatus(registration=self)
status.save()
logger.info('Registration stored: %s', self)
signals.registration_created.send(sender=self.__class__, registration=self)
@@ -163,3 +217,42 @@ Anmerkung:
purge_date = july_nextyear
return timezone.make_aware(datetime.datetime.combine(purge_date, midnight))
@python_2_unicode_compatible
class RegistrationStatus(models.Model):
registration = models.OneToOneField(Registration, on_delete=models.CASCADE, related_name='status')
updated_at = models.DateTimeField(auto_now=True)
answered = models.BooleanField(_('Durch Tourleitung beantwortet'), default=False)
accepted = models.NullBooleanField(_('Zusage erteilt'))
class Meta:
verbose_name = _('Anmeldungsstatus')
verbose_name_plural = _('Anmeldungsstati')
ordering = ['updated_at']
def __str__(self):
return '{} (Updated: {})'.format(self.registration, self.updated_at.strftime('%d.%m.%Y %H:%M:%S'))
def clean(self):
if self.accepted is not None and self.answered is not True:
raise ValidationError({'answered': 'if accepted is not None, answered must be True'})
def save(self, **kwargs):
self.full_clean()
super(RegistrationStatus, self).save(**kwargs)
def set_accepted(self):
self.accepted = True
self.answered = True
self.save()
def set_rejected(self):
self.accepted = False
self.answered = True
self.save()
def reset(self):
self.accepted = None
self.answered = False
self.save()

View File

@@ -22,7 +22,9 @@ Personendaten
{{ registration.postal_code }} {{ registration.city }}
Telefon: {{ registration.phone_number }}
E-Mail: {{ registration.email_address }}
DAV Mitgliedsnummer: {{ registration.dav_number }}
Jahrgang: {{ registration.year_of_birth }}
Antrag auf reduzierte Teilnahmegebühr: {% if registration.apply_reduced_fee %}Ja{% else %}Nein{% endif %}
{% if registration.dav_member %}DAV Mitgliedsnummer: {{ registration.dav_number }}{% else %}DAV Mitglied: Nein{% endif %}
Notfall-Kontakt
---------------

View File

@@ -11,7 +11,8 @@ Teilnehmer*in:
{{ registration.address }}, {{ registration.postal_code }} {{ registration.city }}
{{ registration.phone_number }}
{{ registration.email_address }}
{{ registration.dav_number }}
{% if registration.dav_member %}{{ registration.dav_number }}{% else %}Nicht DAV Mitglied{% endif %}
Antrag auf reduzierte Teilnahmegebühr: {% if registration.apply_reduced_fee %}Ja{% else %}Nein{% endif %}
Notfall-Kontakt:
{% if registration.emergency_contact %}{{ registration.emergency_contact }}{% else %}-{% endif %}
@@ -19,6 +20,8 @@ Notfall-Kontakt:
Erfahrung:
{% if registration.experience %}{{ registration.experience }}{% else %}-{% endif %}
Jahrgang: {{ registration.year_of_birth }} (ungefähres Alter: {{ registration.approx_age }})
Anmerkung:
{% if registration.note %}{{ registration.note }}{% else %}-{% endif %}

View File

@@ -17,7 +17,7 @@
<tr>
<td>
<div class="pull-right" style="margin-left: 2em;">
<a role="button" id="controlCollapseDetails{{ event.id }}" data-toggle="collapse"
<a role="button" id="controlChevronCollapseDetails{{ event.id }}" data-toggle="collapse"
href="#collapseDetails{{ event.id }}"
aria-expanded="false" aria-controls="collapseDetails{{ event.id }}">
<span title="{% trans 'Details aufklappen' %}" class="glyphicon glyphicon-chevron-down"></span>
@@ -46,9 +46,13 @@
{% endwith %}
</div>
<div>
<strong>
{{ event.get_number }} - {{ event.title }}
</strong>
<strong><span class="panel-title">
<a role="button" id="controlCollapseDetails{{ event.id }}" data-toggle="collapse"
href="#collapseDetails{{ event.id }}"
aria-expanded="false" aria-controls="collapseDetails{{ event.id }}">
{{ event.get_number }} - {{ event.title }}
</a>
</span></strong>
<p>
{{ event.get_formated_date }}
{% if event.get_alt_formated_date %}
@@ -81,12 +85,12 @@
</div>
<script>
$("#collapseDetails{{ event.id }}").on("shown.bs.collapse", function() {
icon = $("#controlCollapseDetails{{ event.id }}").find(".glyphicon")
icon = $("#controlChevronCollapseDetails{{ event.id }}").find(".glyphicon")
icon.removeClass("glyphicon-chevron-down").addClass("glyphicon-chevron-up");
icon.attr("title", "{% trans 'Details verbergen' %}");
});
$("#collapseDetails{{ event.id }}").on("hidden.bs.collapse", function() {
icon = $("#controlCollapseDetails{{ event.id }}").find(".glyphicon")
icon = $("#controlChevronCollapseDetails{{ event.id }}").find(".glyphicon")
icon.removeClass("glyphicon-chevron-up").addClass("glyphicon-chevron-down");
icon.attr("title", "{% trans 'Details aufklappen' %}");
});

View File

@@ -4,6 +4,51 @@
{% block head-title %}{% block form-title %}{% trans 'Anmeldung' %} - {{ event.number }}{% endblock form-title %} - {{ block.super }}{% endblock head-title %}
{% block head-additional %}
<script type="text/javascript">
function init_not_dav_member_handler() {
var e_orig = $("#id_dav_member");
initial_str = e_orig.val();
initial_bool = (initial_str == 'True')
var e_inverted = $("#id_not_dav_member");
if(e_inverted != null) {
e_inverted.prop("checked", !initial_bool);
e_inverted.change(function(){ not_dav_member_handler(); });
}
}
function not_dav_member_handler() {
var e = $("#id_not_dav_member");
if(e != null)
checked = e.prop("checked");
else
checked = true;
$("#id_dav_number").prop("disabled", checked);
$("#id_dav_member").val(!checked);
if(checked) {
$("#id_dav_number").val("");
}
}
function year_of_birth_handler() {
var junior_age = 25
var year_now = new Date().getFullYear()
var year_of_birth = parseInt($("#id_year_of_birth").val());
if(year_of_birth > (year_now - junior_age)) {
$("#id_apply_reduced_fee").prop('checked', true);
}
}
$(document).ready(function(){
init_not_dav_member_handler();
var e = $("#id_year_of_birth");
e.change(function(){ year_of_birth_handler(); });
});
</script>
{% endblock head-additional %}
{% block page-container-fluid %}
<h3 class="top-most">{% trans 'Anmeldung' %}</h3>
<form>
@@ -40,10 +85,10 @@
</div>
</div>
<div class="row">
<div class="col-sm-4">
<div class="col-sm-3">
{% bootstrap_field form.postal_code %}
</div>
<div class="col-sm-8">
<div class="col-sm-9">
{% bootstrap_field form.city %}
</div>
</div>
@@ -56,9 +101,22 @@
</div>
</div>
<div class="row">
<div class="col-sm-6">
<div class="col-sm-3">
{% bootstrap_field form.year_of_birth %}
</div>
<div class="col-sm-9">
<strong>Antrag auf reduzierte Teilnahmegebühr</strong>
{% bootstrap_field form.apply_reduced_fee %}
</div>
</div>
<div class="row">
<div class="col-sm-3">
{% bootstrap_field form.dav_number %}
</div>
<div class="col-sm-3">
<strong>Nichtmitglieder</strong>
{% bootstrap_field form.not_dav_member %}
</div>
<div class="col-sm-6">
{% bootstrap_field form.emergency_contact %}
</div>

View File

@@ -1,5 +1,8 @@
from django.utils import timezone
from ..models import Registration
THIS_YEAR = timezone.now().year
class RegistrationMixin(object):
def create_registration(self, data):

View File

@@ -10,7 +10,7 @@ from django.utils.translation import get_language
from dav_base.tests.generic import EmailTestMixin
from dav_events.tests.generic import EventMixin
from .generic import RegistrationMixin
from .generic import THIS_YEAR, RegistrationMixin
MAIL_SELF_TEMPLATE = """Hallo {participant_full_name},
@@ -32,11 +32,13 @@ Vorgang: {registration_hexstr} (wird nur gebraucht, wenn irgendwas schief geht)
Personendaten
-------------
{participant_full_name}
Telefon:
Here
1 Karlsruhe
Telefon: 12
E-Mail: {participant_email}
DAV Mitgliedsnummer:
Jahrgang: {year_of_birth}
Antrag auf reduzierte Teilnahmegebühr: {apply_reduced_fee_yesno}
DAV Mitgliedsnummer: 0
Notfall-Kontakt
---------------
@@ -66,10 +68,11 @@ Vorgang: {registration_hexstr}
Teilnehmer*in:
{participant_full_name}
,
Here, 1 Karlsruhe
12
{participant_email}
0
Antrag auf reduzierte Teilnahmegebühr: {apply_reduced_fee_yesno}
Notfall-Kontakt:
-
@@ -77,6 +80,8 @@ Notfall-Kontakt:
Erfahrung:
-
Jahrgang: {year_of_birth} (ungefähres Alter: {approx_age})
Anmerkung:
-
@@ -107,7 +112,13 @@ class EmailsTestCase(EmailTestMixin, EventMixin, RegistrationMixin, TestCase):
'event': event,
'personal_names': 'Participant',
'family_names': 'One',
'address': 'Here',
'postal_code': '1',
'city': 'Karlsruhe',
'phone_number': '12',
'email_address': 'participant@localhost',
'year_of_birth': THIS_YEAR - 10,
'dav_number': '0',
}
registration = self.create_registration(registration_data)
@@ -126,6 +137,8 @@ class EmailsTestCase(EmailTestMixin, EventMixin, RegistrationMixin, TestCase):
body = MAIL_SELF_TEMPLATE.format(
participant_full_name=registration.get_full_name(),
participant_email=registration.email_address,
year_of_birth=registration.year_of_birth,
apply_reduced_fee_yesno='Nein',
event_number=event.get_number(),
event_title=event.title,
event_formated_date=event.get_formated_date(),
@@ -150,6 +163,9 @@ class EmailsTestCase(EmailTestMixin, EventMixin, RegistrationMixin, TestCase):
'postal_code': '76131',
'city': 'Karlsruhe',
'phone_number': '+49 721 1234567890 AB (Büro)',
'year_of_birth': 1976,
'apply_reduced_fee': True,
'dav_member': False,
'dav_number': '131/00/007*12345',
'emergency_contact': 'Call 911!',
'experience': 'Yes, we can!',
@@ -167,7 +183,9 @@ class EmailsTestCase(EmailTestMixin, EventMixin, RegistrationMixin, TestCase):
search += '{} {}\n'.format(registration_data['postal_code'], registration_data['city'])
search += 'Telefon: {}\n'.format(registration_data['phone_number'])
search += 'E-Mail: {}\n'.format(registration_data['email_address'])
search += 'DAV Mitgliedsnummer: {}\n'.format(registration_data['dav_number'])
search += 'Jahrgang: {}\n'.format(registration_data['year_of_birth'])
search += 'Antrag auf reduzierte Teilnahmegebühr: Ja\n'
search += 'DAV Mitglied: Nein\n'
self.assertIn(search, mail.body)
search = '\n'
@@ -204,7 +222,13 @@ class EmailsTestCase(EmailTestMixin, EventMixin, RegistrationMixin, TestCase):
'event': event,
'personal_names': 'Participant',
'family_names': 'One',
'address': 'Here',
'postal_code': '1',
'city': 'Karlsruhe',
'phone_number': '12',
'email_address': 'participant@localhost',
'year_of_birth': THIS_YEAR - 86,
'dav_number': '0',
}
registration = self.create_registration(registration_data)
@@ -224,6 +248,9 @@ class EmailsTestCase(EmailTestMixin, EventMixin, RegistrationMixin, TestCase):
body = MAIL_TRAINER_TEMPLATE.format(
participant_full_name=registration.get_full_name(),
participant_email=registration.email_address,
year_of_birth=registration.year_of_birth,
approx_age=registration.approx_age(),
apply_reduced_fee_yesno='Nein',
event_number=event.get_number(),
event_title=event.title,
event_formated_date=event.get_formated_date(),
@@ -247,6 +274,9 @@ class EmailsTestCase(EmailTestMixin, EventMixin, RegistrationMixin, TestCase):
'postal_code': '76131',
'city': 'Karlsruhe',
'phone_number': '+49 721 1234567890 AB (Büro)',
'year_of_birth': THIS_YEAR,
'apply_reduced_fee': True,
'dav_member': False,
'dav_number': '131/00/007*12345',
'emergency_contact': 'Call 911!',
'experience': 'Yes, we can!',
@@ -265,8 +295,8 @@ class EmailsTestCase(EmailTestMixin, EventMixin, RegistrationMixin, TestCase):
search += '\n'
search += registration_data['email_address']
search += '\n'
search += registration_data['dav_number']
search += '\n'
search += 'Nicht DAV Mitglied\n'
search += 'Antrag auf reduzierte Teilnahmegebühr: Ja\n'
self.assertIn(search, mail.body)
search = '\n'
@@ -281,6 +311,12 @@ class EmailsTestCase(EmailTestMixin, EventMixin, RegistrationMixin, TestCase):
search += '\n'
self.assertIn(search, mail.body)
search = '\n'
search += 'Jahrgang: '
search += str(registration_data['year_of_birth'])
search += ' (ungefähres Alter: 0)\n'
self.assertIn(search, mail.body)
search = '\n'
search += 'Anmerkung:\n'
search += registration_data['note']

View File

@@ -0,0 +1,103 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.apps import apps
from django.core.exceptions import ValidationError
from django.test import TestCase
from dav_events.tests.generic import EventMixin
from .generic import THIS_YEAR, RegistrationMixin
class RegistrationTestCase(EventMixin, RegistrationMixin, TestCase):
def setUp(self):
super(RegistrationTestCase, self).setUp()
app_config = apps.get_app_config('dav_events')
app_config.settings.enable_email_on_status_update = False
self.event = self.create_event_by_model()
self.submit_event(self.event)
self.accept_event(self.event)
self.confirm_publication_event(self.event)
def test_create_members(self):
event = self.event
registration_data = {
'event': event,
'personal_names': 'Participant',
'family_names': 'One',
'address': 'Here',
'postal_code': '1',
'city': 'Karlsruhe',
'phone_number': '12',
'email_address': 'participant@localhost',
'year_of_birth': THIS_YEAR,
}
dav_numbers = ['0', '12345', '131/00/12345']
for n in dav_numbers:
d = registration_data
d['dav_number'] = n
self.create_registration(d)
def test_create_members_without_number(self):
event = self.event
registration_data = {
'event': event,
'personal_names': 'Participant',
'family_names': 'One',
'address': 'Here',
'postal_code': '1',
'city': 'Karlsruhe',
'phone_number': '12',
'email_address': 'participant@localhost',
'year_of_birth': THIS_YEAR,
}
with self.assertRaisesMessage(ValidationError,
'Wenn du DAV Mitglied bist, brauchen wir deine Mitgliedsnummer.'):
self.create_registration(registration_data)
registration_data['dav_number'] = ''
with self.assertRaisesMessage(ValidationError,
'Wenn du DAV Mitglied bist, brauchen wir deine Mitgliedsnummer.'):
self.create_registration(registration_data)
def test_create_members_with_invalid_numbers(self):
event = self.event
registration_data = {
'event': event,
'personal_names': 'Participant',
'family_names': 'One',
'address': 'Here',
'postal_code': '1',
'city': 'Karlsruhe',
'phone_number': '12',
'email_address': 'participant@localhost',
'year_of_birth': THIS_YEAR,
}
dav_numbers = ['Nein', '-', '13100123456789']
for n in dav_numbers:
d = registration_data
d['dav_number'] = n
with self.assertRaises(ValidationError) as context:
self.create_registration(d)
self.assertEqual(context.exception.messages, ['Ungültiges Format.'])
def test_create_non_member(self):
event = self.event
registration_data = {
'event': event,
'personal_names': 'Participant',
'family_names': 'One',
'address': 'Here',
'postal_code': '1',
'city': 'Karlsruhe',
'phone_number': '12',
'email_address': 'participant@localhost',
'year_of_birth': THIS_YEAR,
'dav_member': False,
}
self.create_registration(registration_data)

View File

@@ -9,7 +9,7 @@ from dav_events.tests.generic import EventMixin
from ..models import Registration
from ..utils import purge_registrations
from .generic import RegistrationMixin
from .generic import THIS_YEAR, RegistrationMixin
class UtilsTestCase(RegistrationMixin, EventMixin, TestCase):
@@ -29,6 +29,17 @@ class UtilsTestCase(RegistrationMixin, EventMixin, TestCase):
'trainer_familyname': 'One',
'trainer_email': 'trainer@localhost',
}
registration_data = {
'personal_names': 'Participant',
'family_names': 'P.',
'address': 'Am Fächerbad 2',
'postal_code': '76131',
'city': 'Karlsruhe',
'phone_number': '555 5555',
'email_address': 'participant@localhost',
'year_of_birth': THIS_YEAR - 44,
'dav_number': '1',
}
first_day = today - (one_day * 367)
while first_day < today:
@@ -41,7 +52,9 @@ class UtilsTestCase(RegistrationMixin, EventMixin, TestCase):
self.accept_event(event)
for i in range(0, registrations_per_event):
self.create_registration({'event': event})
d = registration_data
d['event'] = event
self.create_registration(d)
purge_registrations()