Merge remote-tracking branch 'origin/main' into MK/training_tab

pull/174/head
mariusrklein 2 months ago
commit d4e2efc03a

3
.gitignore vendored

@ -133,3 +133,6 @@ jdav_web/static/docs
# mac files
.DS_Store
# Claude configuration file
CLAUDE.md

@ -0,0 +1,2 @@
This repository contains third-party assets. Attributions are either placed in the file itself or
in a file `NOTICE.txt` in the respective folder.

@ -16,9 +16,9 @@ want to contribute code, please open a [pull request](https://git.jdav-hd.merten
The following is a short description of where to find the documentation with more information.
# Documentation
# Documentation
Documentation is handled by [sphinx](https://www.sphinx-doc.org/) and located in `docs/`.
Documentation is handled by [sphinx](https://www.sphinx-doc.org/) and located in `docs/`.
The sphinx documentation contains information about:
- Development Setup
@ -40,7 +40,14 @@ cd docs/
make html
# MacOS (with firefox)
open -a firefox $(pwd)/docs/build/html/index.html
open -a firefox $(pwd)/docs/build/html/index.html
# Linux (I guess?!?)
firefox ${pwd}/docs/build/html/index.html
firefox ${pwd}/docs/build/html/index.html
```
# License
This code is licensed under the [GNU Affero General Public License](https://www.gnu.org/licenses/agpl-3.0.en.html).
For the full license text, see `LICENCSE`.
See the `NOTICE.txt` file for attributions.

@ -39,8 +39,8 @@ fi
cd jdav_web
if [[ "$DJANGO_TEST_KEEPDB" == 1 ]]; then
coverage run manage.py test startpage finance members contrib logindata mailer material ludwigsburgalpin -v 2 --noinput --keepdb
coverage run manage.py test startpage finance members contrib logindata mailer material ludwigsburgalpin jdav_web -v 2 --noinput --keepdb
else
coverage run manage.py test startpage finance members contrib logindata mailer material ludwigsburgalpin -v 2 --noinput
coverage run manage.py test startpage finance members contrib logindata mailer material ludwigsburgalpin jdav_web -v 2 --noinput
fi
coverage html

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

@ -0,0 +1 @@
<?xml version="1.0" encoding="UTF-8"?><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48"><defs><style>.c{fill:#9cc;}.d{fill:#2d5955;}.e{stroke:#2d5955;stroke-width:3.2px;}.e,.f{fill:none;stroke-miterlimit:10;}.g{fill:#666;mix-blend-mode:multiply;}.f{stroke:#666;stroke-width:3.3px;}.h{opacity:.36;}.i{isolation:isolate;}.j{fill:#fff;opacity:.24;}</style></defs><g class="i"><g id="a"><circle class="f" cx="24.31" cy="24.31" r="21.69"/></g><g id="b"><circle class="e" cx="23.69" cy="23.69" r="21.69"/><polygon class="g" points="21.2 22.96 15.21 43.89 27.68 25.98 33.66 5.05 21.2 22.96"/><polygon class="c" points="14.34 43.15 26.8 25.24 20.32 22.23 14.34 43.15"/><polygon class="d" points="32.79 4.32 20.32 22.23 26.8 25.24 32.79 4.32"/><polyline class="h" points="14.34 43.15 26.8 25.24 32.79 4.32"/><circle class="j" cx="23.61" cy="23.64" r="1.55"/></g></g></svg>

After

Width:  |  Height:  |  Size: 873 B

@ -35,8 +35,8 @@ html_static_path = ['_static']
# -- Sphinxawsome-theme options ------------------------------------------------
# https://sphinxawesome.xyz/how-to/configure/
html_logo = "_static/favicon.png"
html_favicon = "_static/favicon.png"
html_logo = "_static/favicon.svg"
html_favicon = "_static/favicon.svg"
html_sidebars = {
"about": ["sidebar_main_nav_links.html"],

@ -12,6 +12,26 @@ import rules.contrib.admin
from rules.permissions import perm_exists
def decorate_admin_view(model, perm=None):
"""
Decorator for wrapping admin views.
"""
def decorator(fun):
def aux(self, request, object_id):
try:
obj = model.objects.get(pk=object_id)
except model.DoesNotExist:
messages.error(request, _('%(modelname)s not found.') % {'modelname': self.opts.verbose_name})
return HttpResponseRedirect(reverse('admin:%s_%s_changelist' % (self.opts.app_label, self.opts.model_name)))
permitted = self.has_change_permission(request, obj) if not perm else request.user.has_perm(perm)
if not permitted:
messages.error(request, _('Insufficient permissions.'))
return HttpResponseRedirect(reverse('admin:%s_%s_changelist' % (self.opts.app_label, self.opts.model_name)))
return fun(self, request, obj)
return aux
return decorator
class FieldPermissionsAdminMixin:
field_change_permissions = {}
field_view_permissions = {}

@ -1,3 +1,4 @@
import logging
from django.contrib import admin, messages
from django.utils.safestring import mark_safe
from django import forms
@ -19,6 +20,8 @@ from members.pdf import render_tex_with_attachments
from .models import Ledger, Statement, Receipt, Transaction, Bill, StatementSubmitted, StatementConfirmed,\
StatementUnSubmitted, BillOnStatementProxy
logger = logging.getLogger(__name__)
@admin.register(Ledger)
class LedgerAdmin(admin.ModelAdmin):
@ -98,7 +101,8 @@ class StatementUnSubmittedAdmin(CommonAdminMixin, admin.ModelAdmin):
@decorate_statement_view(Statement)
def submit_view(self, request, statement):
if statement.submitted:
if statement.submitted: # pragma: no cover
logger.error(f"submit_view reached with submitted statement {statement}. This should not happen.")
messages.error(request,
_("%(name)s is already submitted.") % {'name': str(statement)})
return HttpResponseRedirect(reverse('admin:%s_%s_changelist' % (self.opts.app_label, self.opts.model_name)))
@ -214,14 +218,16 @@ class StatementSubmittedAdmin(admin.ModelAdmin):
@decorate_statement_view(StatementSubmitted)
def overview_view(self, request, statement):
if not statement.submitted:
if not statement.submitted: # pragma: no cover
logger.error(f"overview_view reached with unsubmitted statement {statement}. This should not happen.")
messages.error(request,
_("%(name)s is not yet submitted.") % {'name': str(statement)})
return HttpResponseRedirect(reverse('admin:%s_%s_change' % (self.opts.app_label, self.opts.model_name), args=(statement.pk,)))
if "transaction_execution_confirm" in request.POST or "transaction_execution_confirm_and_send" in request.POST:
res = statement.confirm(confirmer=get_member(request))
if not res:
if not res: # pragma: no cover
# this should NOT happen!
logger.error(f"Error occured while confirming {statement}, this should not be possible.")
messages.error(request,
_("An error occured while trying to confirm %(name)s. Please try again.") % {'name': str(statement)})
return HttpResponseRedirect(reverse('admin:%s_%s_overview' % (self.opts.app_label, self.opts.model_name)))
@ -258,11 +264,14 @@ class StatementSubmittedAdmin(admin.ModelAdmin):
messages.error(request,
_("The configured recipients for the allowance don't match the regulations. Please correct this on the excursion."))
return HttpResponseRedirect(reverse('admin:%s_%s_overview' % (self.opts.app_label, self.opts.model_name), args=(statement.pk,)))
elif res == Statement.INVALID_TOTAL:
elif res == Statement.INVALID_TOTAL: # pragma: no cover
logger.error(f"INVALID_TOTAL reached on {statement}.")
messages.error(request,
_("The calculated total amount does not match the sum of all transactions. This is most likely a bug."))
return HttpResponseRedirect(reverse('admin:%s_%s_overview' % (self.opts.app_label, self.opts.model_name), args=(statement.pk,)))
return HttpResponseRedirect(reverse('admin:%s_%s_changelist' % (self.opts.app_label, self.opts.model_name)))
else: # pragma: no cover
logger.error(f"Statement.validity returned invalid value for {statement}.")
return HttpResponseRedirect(reverse('admin:%s_%s_changelist' % (self.opts.app_label, self.opts.model_name)))
if "reject" in request.POST:
statement.submitted = False
@ -350,7 +359,8 @@ class StatementConfirmedAdmin(admin.ModelAdmin):
@decorate_statement_view(StatementConfirmed, perm='finance.may_manage_confirmed_statements')
def unconfirm_view(self, request, statement):
if not statement.confirmed:
if not statement.confirmed: # pragma: no cover
logger.error(f"unconfirm_view reached with unconfirmed statement {statement}.")
messages.error(request,
_("%(name)s is not yet confirmed.") % {'name': str(statement)})
return HttpResponseRedirect(reverse('admin:%s_%s_change' % (self.opts.app_label, self.opts.model_name), args=(statement.pk,)))
@ -374,7 +384,8 @@ class StatementConfirmedAdmin(admin.ModelAdmin):
@decorate_statement_view(StatementConfirmed, perm='finance.may_manage_confirmed_statements')
def statement_summary_view(self, request, statement):
if not statement.confirmed:
if not statement.confirmed: # pragma: no cover
logger.error(f"statement_summary_view reached with unconfirmed statement {statement}.")
messages.error(request,
_("%(name)s is not yet confirmed.") % {'name': str(statement)})
return HttpResponseRedirect(reverse('admin:%s_%s_change' % (self.opts.app_label, self.opts.model_name), args=(statement.pk,)))

@ -110,13 +110,13 @@ class StatementUnSubmittedAdminTestCase(AdminTestCase):
readonly_fields = self.admin.get_readonly_fields(None, self.statement)
self.assertEqual(readonly_fields, ['submitted', 'excursion'])
@unittest.skip('Request returns 200, but should give insufficient permissions.')
def test_submit_view_insufficient_permission(self):
url = reverse('admin:finance_statementunsubmitted_submit',
args=(self.statement.pk,))
c = self._login('standard')
response = c.get(url, follow=True)
self.assertEqual(response.status_code, HTTPStatus.FORBIDDEN)
self.assertEqual(response.status_code, HTTPStatus.OK)
self.assertContains(response, _('Insufficient permissions.'))
def test_submit_view_get(self):
url = reverse('admin:finance_statementunsubmitted_submit',
@ -247,16 +247,6 @@ class StatementSubmittedAdminTestCase(AdminTestCase):
paid_by=self.member
)
def _add_session_to_request(self, request):
"""Add session to request"""
middleware = SessionMiddleware(lambda req: None)
middleware.process_request(request)
request.session.save()
middleware = MessageMiddleware(lambda req: None)
middleware.process_request(request)
request._messages = FallbackStorage(request)
def test_has_add_permission(self):
"""Test that add permission is disabled"""
request = self.factory.get('/')

@ -2,6 +2,7 @@ from unittest import skip
from django.test import TestCase
from django.utils import timezone
from django.conf import settings
from django.utils.translation import gettext_lazy as _
from decimal import Decimal
from finance.models import Statement, StatementUnSubmitted, StatementSubmitted, Bill, Ledger, Transaction,\
StatementUnSubmittedManager, StatementSubmittedManager, StatementConfirmedManager,\
@ -59,13 +60,35 @@ class StatementTestCase(TestCase):
if i < self.allowance_to_count:
self.st3.allowance_to.add(m)
# Create a small excursion with < 5 theoretic LJP participants for LJP contribution test
small_ex = Freizeit.objects.create(name='Small trip', kilometers_traveled=100,
tour_type=GEMEINSCHAFTS_TOUR,
tour_approach=MUSKELKRAFT_ANREISE,
difficulty=1)
# Add only 3 participants (< 5 for theoretic_ljp_participant_count)
for i in range(3):
# Create young participants (< 6 years old) so they don't count toward LJP
birth_date = timezone.now().date() - relativedelta(years=4)
m = Member.objects.create(prename='Small {}'.format(i), lastname='Participant',
birth_date=birth_date,
email=settings.TEST_MAIL, gender=MALE)
NewMemberOnList.objects.create(member=m, memberlist=small_ex)
# Create LJP proposal for the small excursion
ljp_proposal = LJPProposal.objects.create(title='Small LJP', category=LJPProposal.LJP_STAFF_TRAINING)
small_ex.ljpproposal = ljp_proposal
small_ex.save()
self.st_small = Statement.objects.create(night_cost=10, excursion=small_ex)
ex = Freizeit.objects.create(name='Wild trip 2', kilometers_traveled=self.kilometers_traveled,
tour_type=GEMEINSCHAFTS_TOUR,
tour_approach=MUSKELKRAFT_ANREISE,
difficulty=2)
self.st4 = Statement.objects.create(night_cost=self.night_cost, excursion=ex, subsidy_to=self.fritz)
for i in range(2):
m = Member.objects.create(prename='Peter {}'.format(i), lastname='Walter', birth_date=timezone.now().date(),
m = Member.objects.create(prename='Peter {}'.format(i), lastname='Walter',
birth_date=timezone.now().date() - relativedelta(years=30),
email=settings.TEST_MAIL, gender=DIVERSE)
mol = NewMemberOnList.objects.create(member=m, memberlist=ex)
ex.membersonlist.add(mol)
@ -408,6 +431,63 @@ class StatementTestCase(TestCase):
self.assertIn('euro_per_km', context)
self.assertIsInstance(context['euro_per_km'], (int, float, Decimal))
def test_title_with_excursion(self):
title = self.st3.title
self.assertIn('Wild trip', title)
def test_transaction_issues_with_org_fee(self):
issues = self.st4.transaction_issues
self.assertIsInstance(issues, list)
def test_transaction_issues_with_ljp(self):
self.st3.ljp_to = self.fritz
self.st3.save()
issues = self.st3.transaction_issues
self.assertIsInstance(issues, list)
def test_generate_transactions_org_fee(self):
# Ensure conditions for org fee are met: need subsidy_to or allowances
# and participants >= 27 years old
self.st4.subsidy_to = self.fritz
self.st4.save()
# Verify org fee is calculated
self.assertGreater(self.st4.total_org_fee, 0, "Org fee should be > 0 with subsidies and old participants")
initial_count = Transaction.objects.count()
self.st4.generate_transactions()
final_count = Transaction.objects.count()
self.assertGreater(final_count, initial_count)
org_fee_transaction = Transaction.objects.filter(statement=self.st4,
reference__icontains=_('reduced by org fee')).first()
self.assertIsNotNone(org_fee_transaction)
def test_generate_transactions_ljp(self):
self.st3.ljp_to = self.fritz
self.st3.save()
initial_count = Transaction.objects.count()
self.st3.generate_transactions()
final_count = Transaction.objects.count()
self.assertGreater(final_count, initial_count)
ljp_transaction = Transaction.objects.filter(statement=self.st3, member=self.fritz, reference__icontains='LJP').first()
self.assertIsNotNone(ljp_transaction)
def test_subsidies_paid_property(self):
subsidies_paid = self.st3.subsidies_paid
expected = self.st3.total_subsidies - self.st3.total_org_fee
self.assertEqual(subsidies_paid, expected)
def test_ljp_contributions_low_participant_count(self):
self.st_small.ljp_to = self.fritz
self.st_small.save()
# Verify that the small excursion has < 5 theoretic LJP participants
self.assertLess(self.st_small.excursion.theoretic_ljp_participant_count, 5,
"Should have < 5 theoretic LJP participants")
ljp_contrib = self.st_small.paid_ljp_contributions
self.assertEqual(ljp_contrib, 0)
class LedgerTestCase(TestCase):
def setUp(self):

@ -58,6 +58,7 @@ base_settings = [
'components/emails.py',
'components/texts.py',
'components/locale.py',
'components/logging.py',
'components/oauth.py',
]

@ -0,0 +1,59 @@
import os
DJANGO_LOG_LEVEL = get_var('logging', 'django_level', default='INFO')
ROOT_LOG_LEVEL = get_var('logging', 'level', default='INFO')
LOG_ERROR_TO_EMAIL = get_var('logging', 'email_admins', default=False)
LOG_EMAIL_BACKEND = EMAIL_BACKEND if LOG_ERROR_TO_EMAIL else "django.core.mail.backends.console.EmailBackend"
LOG_ERROR_INCLUDE_HTML = get_var('logging', 'error_report_include_html', default=False)
LOGGING = {
"version": 1,
"disable_existing_loggers": False,
"filters": {
"require_debug_false": {
"()": "django.utils.log.RequireDebugFalse",
},
"require_debug_true": {
"()": "django.utils.log.RequireDebugTrue",
},
},
"formatters": {
"simple": {
"format": "[{asctime}: {levelname}/{name}] {message}",
"style": "{",
},
"verbose": {
"format": "[{asctime}: {levelname}/{name}] {pathname}:{lineno} {message}",
"style": "{",
},
},
"handlers": {
"console": {
"class": "logging.StreamHandler",
"formatter": "simple",
},
"console_verbose": {
"class": "logging.StreamHandler",
"formatter": "verbose",
"level": "ERROR",
},
"mail_admins": {
"level": "ERROR",
"class": "django.utils.log.AdminEmailHandler",
"email_backend": LOG_EMAIL_BACKEND,
"include_html": LOG_ERROR_INCLUDE_HTML,
"filters": ["require_debug_false"],
},
},
"root": {
"handlers": ["console"],
"level": ROOT_LOG_LEVEL,
},
"loggers": {
"django": {
"handlers": ["console", "mail_admins"],
"level": DJANGO_LOG_LEVEL,
"propagate": False,
},
},
}

@ -0,0 +1,30 @@
from django.test import TestCase, RequestFactory, override_settings
from django.contrib.auth.models import User
from django.contrib import admin
from unittest.mock import Mock, patch
from jdav_web.views import media_unprotected, custom_admin_view
from startpage.models import Link
class ViewsTestCase(TestCase):
def setUp(self):
self.factory = RequestFactory()
self.user = User.objects.create_user('testuser', 'test@example.com', 'password')
Link.objects.create(title='Test Link', url='https://example.com')
@override_settings(DEBUG=True)
def test_media_unprotected_debug_true(self):
request = self.factory.get('/media/test.jpg')
with patch('jdav_web.views.serve') as mock_serve:
mock_serve.return_value = Mock()
result = media_unprotected(request, 'test.jpg')
mock_serve.assert_called_once()
def test_custom_admin_view(self):
request = self.factory.get('/admin/')
request.user = self.user
with patch.object(admin.site, 'get_app_list') as mock_get_app_list:
mock_get_app_list.return_value = []
response = custom_admin_view(request)
self.assertEqual(response.status_code, 200)
mock_get_app_list.assert_called_once_with(request)

@ -1,9 +1,13 @@
from django.core import mail
from django.core.mail import EmailMessage
from django.conf import settings
import logging
import os
logger = logging.getLogger(__name__)
NOT_SENT, SENT, PARTLY_SENT = 0, 1, 2
def send(subject, content, sender, recipients, message_id=None, reply_to=None,
@ -41,7 +45,7 @@ def send(subject, content, sender, recipients, message_id=None, reply_to=None,
# send all mails with one connection
connection.send_messages(mails)
except Exception as e:
print("Error when sending mail:", e)
logger.error(f"Caught exception while sending email: {e}")
failed = True
else:
succeeded = True

@ -1,3 +1,4 @@
import logging
from django.db import models
from django.core.exceptions import ValidationError
from django import forms
@ -17,6 +18,9 @@ from .rules import is_creator
import os
logger = logging.getLogger(__name__)
alphanumeric = RegexValidator(r'^[0-9a-zA-Z._-]*$',
_('Only alphanumeric characters, ., - and _ are allowed'))
@ -145,7 +149,7 @@ class Message(CommonModel):
members.update([mol.member for mol in
self.to_notelist.membersonlist.all()])
filtered = [m for m in members if m.gets_newsletter]
print("sending mail to", filtered)
logger.info(f"sending mail to {filtered}")
attach = [a.f.path for a in Attachment.objects.filter(msg__id=self.pk)
if a.f.name]
@ -189,7 +193,7 @@ class Message(CommonModel):
a.delete()
success = SENT
except Exception as e:
print("Exception caught", e)
logger.error(f"Caught exception while sending email: {e}")
success = NOT_SENT
finally:
self.save()

@ -2,3 +2,4 @@ from .models import *
from .admin import *
from .views import *
from .rules import *
from .mailutils import *

@ -0,0 +1,34 @@
from django.test import TestCase, override_settings
from unittest.mock import patch, Mock
from mailer.mailutils import send, SENT, NOT_SENT
class MailUtilsTest(TestCase):
def setUp(self):
self.subject = "Test Subject"
self.content = "Test Content"
self.sender = "sender@example.com"
self.recipient = "recipient@example.com"
def test_send_with_reply_to(self):
with patch('mailer.mailutils.mail.get_connection') as mock_connection:
mock_conn = Mock()
mock_connection.return_value = mock_conn
result = send(self.subject, self.content, self.sender, self.recipient, reply_to=["reply@example.com"])
self.assertEqual(result, SENT)
def test_send_with_message_id(self):
with patch('mailer.mailutils.mail.get_connection') as mock_connection:
mock_conn = Mock()
mock_connection.return_value = mock_conn
result = send(self.subject, self.content, self.sender, self.recipient, message_id="<test@example.com>")
self.assertEqual(result, SENT)
def test_send_exception_handling(self):
with patch('mailer.mailutils.mail.get_connection') as mock_connection:
mock_conn = Mock()
mock_conn.send_messages.side_effect = Exception("Test exception")
mock_connection.return_value = mock_conn
with patch('builtins.print'):
result = send(self.subject, self.content, self.sender, self.recipient)
self.assertEqual(result, NOT_SENT)

@ -32,7 +32,7 @@ from .pdf import render_tex, fill_pdf_form, merge_pdfs, serve_pdf, render_docx
from .excel import generate_group_overview, generate_ljp_vbk
from .models import WEEKDAYS
from contrib.admin import CommonAdminInlineMixin, CommonAdminMixin
from contrib.admin import CommonAdminInlineMixin, CommonAdminMixin, decorate_admin_view
import nested_admin
@ -136,44 +136,6 @@ class TrainingCategoryAdmin(admin.ModelAdmin):
ordering = ('name', )
class RegistrationFilter(admin.SimpleListFilter):
title = _('Registration complete')
parameter_name = 'registration_complete'
default_value = ('All', None)
def lookups(self, request, model_admin):
return (
('True', _('True')),
('False', _('False')),
('All', _('All'))
)
def queryset(self, request, queryset):
if self.value() == 'True':
return queryset.filter(registration_complete=True)
elif self.value() == 'False':
return queryset.filter(registration_complete=False)
elif self.value() is None:
if self.default_value[1] is None:
return queryset
else:
return queryset.filter(registration_complete=self.default_value[1])
elif self.value() == 'All':
return queryset
def choices(self, cl):
for lookup, title in self.lookup_choices:
yield {
'selected':
self.value() == lookup or
(self.value() is None and lookup == self.default_value[0]),
'query_string': cl.get_query_string({
self.parameter_name:
lookup,
}, []),
'display': title
}
class MemberAdminForm(forms.ModelForm):
class Meta:
@ -471,7 +433,8 @@ class MemberUnconfirmedAdmin(CommonAdminMixin, admin.ModelAdmin):
}
),
]
list_display = ('name', 'birth_date', 'age', 'get_group', 'confirmed_mail', 'confirmed_alternative_mail')
list_display = ('name', 'birth_date', 'age', 'get_group', 'confirmed_mail', 'confirmed_alternative_mail',
'registration_form_uploaded')
search_fields = ('prename', 'lastname', 'email')
list_filter = ('group', 'confirmed_mail', 'confirmed_alternative_mail')
readonly_fields = ['confirmed_mail', 'confirmed_alternative_mail',
@ -525,13 +488,15 @@ class MemberUnconfirmedAdmin(CommonAdminMixin, admin.ModelAdmin):
notify_individual = len(queryset.all()) < 10
success = True
for member in queryset:
if member.confirm() and notify_individual:
messages.success(request, _("Successfully confirmed %(name)s.") % {'name': member.name})
else:
if notify_individual:
confirmed = member.confirm()
if not confirmed:
success = False
if notify_individual:
if confirmed:
messages.success(request, _("Successfully confirmed %(name)s.") % {'name': member.name})
else:
messages.error(request,
_("Can't confirm. %(name)s has unconfirmed email addresses.") % {'name': member.name})
success = False
if notify_individual:
return
if success:
@ -556,6 +521,11 @@ class MemberUnconfirmedAdmin(CommonAdminMixin, admin.ModelAdmin):
wrap(self.demote_to_waiter_view),
name="%s_%s_demote" % (self.opts.app_label, self.opts.model_name),
),
path(
"<path:object_id>/request_registration_form/",
wrap(self.request_registration_form_view),
name="%s_%s_request_registration_form" % (self.opts.app_label, self.opts.model_name),
),
]
return custom_urls + urls
@ -588,6 +558,18 @@ class MemberUnconfirmedAdmin(CommonAdminMixin, admin.ModelAdmin):
member.demote_to_waiter()
messages.success(request, _("Successfully demoted %(name)s to waiter.") % {'name': member.name})
@decorate_admin_view(MemberUnconfirmedProxy)
def request_registration_form_view(self, request, member):
if "apply" in request.POST:
member.request_registration_form()
messages.success(request, _("Requested registration form for %(name)s.") % {'name': member.name})
return HttpResponseRedirect(reverse('admin:members_memberunconfirmedproxy_change', args=(member.pk,)))
context = dict(self.admin_site.each_context(request),
title=_('Request upload registration form'),
opts=self.opts,
member=member)
return render(request, 'admin/request_registration_form.html', context=context)
def response_change(self, request, member):
if "_confirm" in request.POST:
if member.confirm():
@ -610,7 +592,7 @@ class WaiterInviteTextForm(forms.Form):
widget=forms.Textarea(attrs={'rows': 30, 'cols': 100}))
class InvitationToGroupAdmin(admin.TabularInline):
class InvitationToGroupAdmin(CommonAdminInlineMixin, admin.TabularInline):
model = InvitationToGroup
fields = ['group', 'date', 'status']
readonly_fields = ['group', 'date', 'status']
@ -665,6 +647,9 @@ class MemberWaitingListAdmin(CommonAdminMixin, admin.ModelAdmin):
def has_add_permission(self, request, obj=None):
return False
def has_action_permission(self, request):
return request.user.has_perm('members.change_global_memberwaitinglist')
def age(self, obj):
return obj.birth_date_delta
age.short_description=_('age')
@ -677,6 +662,7 @@ class MemberWaitingListAdmin(CommonAdminMixin, admin.ModelAdmin):
messages.success(request,
_("Successfully asked %(name)s to confirm their waiting status.") % {'name': waiter.name})
ask_for_wait_confirmation.short_description = _('Ask selected waiters to confirm their waiting status')
ask_for_wait_confirmation.allowed_permissions = ('action',)
def response_change(self, request, waiter):
ret = super(MemberWaitingListAdmin, self).response_change(request, waiter)
@ -691,12 +677,14 @@ class MemberWaitingListAdmin(CommonAdminMixin, admin.ModelAdmin):
member.request_mail_confirmation()
messages.success(request, _("Successfully requested mail confirmation from selected waiters."))
request_mail_confirmation.short_description = _('Request mail confirmation from selected waiters.')
request_mail_confirmation.allowed_permissions = ('action',)
def request_required_mail_confirmation(self, request, queryset):
for member in queryset:
member.request_mail_confirmation(rerequest=False)
messages.success(request, _("Successfully re-requested missing mail confirmations from selected waiters."))
request_required_mail_confirmation.short_description = _('Re-request missing mail confirmations from selected waiters.')
request_required_mail_confirmation.allowed_permissions = ('action',)
def get_urls(self):
urls = super().get_urls()
@ -736,6 +724,7 @@ class MemberWaitingListAdmin(CommonAdminMixin, admin.ModelAdmin):
def ask_for_registration_action(self, request, queryset):
return self.invite_view(request, queryset)
ask_for_registration_action.short_description = _('Offer waiter a place in a group.')
ask_for_registration_action.allowed_permissions = ('action',)
def invite_view(self, request, object_id):
if type(object_id) == str:
@ -1385,13 +1374,13 @@ class KlettertreffAdmin(admin.ModelAdmin):
inlines = [KlettertreffAttendeeInline]
list_display = ['__str__', 'date', 'get_jugendleiter']
search_fields = ('date', 'location', 'topic')
list_filter = [('date', DateFieldListFilter), 'group__name']
list_filter = [('date', DateFieldListFilter), 'group']
actions = ['overview']
def overview(self, request, queryset):
group = request.GET.get('group__name')
group = request.GET.get('group__id__exact')
if group != None:
members = Member.objects.filter(group__name__contains=group)
members = Member.objects.filter(group=group)
else:
members = Member.objects.all()
context = {

@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-07-25 18:44+0200\n"
"POT-Creation-Date: 2025-09-20 02:43+0200\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@ -174,7 +174,7 @@ msgstr "Aufforderung zur Bestätigung der Email Adresse versendet."
msgid "Request mail confirmation from selected registrations"
msgstr "Aufforderung zur Bestätigung der Email Adresse versenden"
#: members/admin.py
#: members/admin.py members/tests/basic.py
msgid ""
"Successfully re-requested missing mail confirmations from selected "
"registrations."
@ -226,6 +226,15 @@ msgstr "Ausgewählte Registrierung zurück auf die Warteliste setzen."
msgid "Successfully demoted %(name)s to waiter."
msgstr "%(name)s zurück auf die Warteliste gesetzt."
#: members/admin.py
#, python-format
msgid "Requested registration form for %(name)s."
msgstr "Anmeldeformular für %(name)s angefragt."
#: members/admin.py
msgid "Request upload registration form"
msgstr "Anmeldeformular anfragen"
#: members/admin.py members/models.py
msgid "Group"
msgstr "Gruppe"
@ -280,7 +289,7 @@ msgstr ""
msgid "Offer waiter a place in a group."
msgstr "Personen auf der Warteliste einen Gruppenplatz anbieten."
#: members/admin.py
#: members/admin.py members/tests/basic.py
msgid "A waiter with this ID does not exist."
msgstr "Es existiert keine wartende Person mit dieser ID."
@ -468,7 +477,7 @@ msgstr ""
"Keine Abrechnung angelegt. Bitte lege eine Abrechnung and und versuche es "
"erneut."
#: members/admin.py
#: members/admin.py members/tests/basic.py
msgid ""
"The configured recipients of the allowance don't match the regulations. "
"Please correct this and try again."
@ -476,7 +485,7 @@ msgstr ""
"Die ausgewählten Empfänger*innen der Aufwandsentschädigung stimmen nicht mit "
"den Richtlinien überein. Bitte korrigiere das und versuche es erneut. "
#: members/admin.py
#: members/admin.py members/tests/basic.py
msgid ""
"The excursion is configured to claim LJP contributions. In that case, for "
"all bills, a proof must be uploaded. Please correct this and try again."
@ -795,6 +804,10 @@ msgstr ""
msgid "Good conduct certificate valid"
msgstr "Führungszeugnis gültig"
#: members/models.py
msgid "Registration complete"
msgstr "Anmeldung vollständig"
#: members/models.py
msgid "member"
msgstr "Teilnehmer*in"
@ -803,6 +816,10 @@ msgstr "Teilnehmer*in"
msgid "members"
msgstr "Teilnehmer*innen"
#: members/models.py
msgid "Registration form"
msgstr "Anmeldeformular"
#: members/models.py
msgid "Upload registration form"
msgstr "Anmeldeformular hochladen"
@ -1281,19 +1298,25 @@ msgstr "Fortbildungen"
#: members/templates/admin/invite_for_group_text.html
#: members/templates/admin/invite_selected_as_user.html
#: members/templates/admin/invite_selected_for_group.html
#: members/templates/admin/request_registration_form.html
#: members/templates/admin/request_registratoin_form.html
msgid "Home"
msgstr "Start"
#: members/templates/admin/demote_to_waiter.html
#: members/templates/admin/request_registration_form.html
#: members/templates/admin/request_registratoin_form.html
msgid "Demote to waiter"
msgstr "Zurück auf die Warteliste setzen"
#: members/templates/admin/demote_to_waiter.html
#: members/templates/admin/request_registratoin_form.html
msgid ""
"Do you want to demote the following unconfirmed registrations to waiters?"
msgstr "Möchtest du die folgenden Personen zurück auf die Warteliste setzen?"
#: members/templates/admin/demote_to_waiter.html
#: members/templates/admin/request_registratoin_form.html
msgid "Demote"
msgstr "Zurück auf die Warteliste setzen"
@ -1304,6 +1327,8 @@ msgstr "Zurück auf die Warteliste setzen"
#: members/templates/admin/invite_for_group.html
#: members/templates/admin/invite_selected_as_user.html
#: members/templates/admin/invite_selected_for_group.html
#: members/templates/admin/request_registration_form.html
#: members/templates/admin/request_registratoin_form.html
msgid "Cancel"
msgstr "Abbrechen"
@ -1811,6 +1836,21 @@ msgstr "Anzahl Personen:"
msgid "thereof leaders:"
msgstr "davon Leitung:"
#: members/templates/admin/request_registration_form.html
#: members/tests/basic.py
msgid "Request registration form"
msgstr "Anmeldeformular anfragen"
#: members/templates/admin/request_registration_form.html
#, python-format
msgid "Do you want to ask %(member)s to upload their registration form?"
msgstr "Möchtest du %(member)s auffordern das Anmeldeformular hochzuladen?"
#: members/templates/admin/request_registration_form.html
#, python-format
msgid "Warning: %(member)s has already uploaded a registration form."
msgstr "Warnung: %(member)s hat bereits ein Anmeldeformular hochgeladen."
#: members/templates/members/change_member.html
msgid "Participations:"
msgstr "Ausfahrtteilnahmen:"
@ -1833,10 +1873,11 @@ msgstr "Teilnahme bestätigen"
#: members/templates/members/confirm_invalid.html
#: members/templates/members/reject_invalid.html members/tests/basic.py
#: members/tests/views.py
msgid "This invitation is invalid or expired."
msgstr "Diese Einladung ist ungültig oder abgelaufen."
#: members/templates/members/confirm_invitation.html
#: members/templates/members/confirm_invitation.html members/tests/views.py
msgid "Confirm trial group meeting invitation"
msgstr "Teilnahme bestätigen"
@ -1858,7 +1899,7 @@ msgstr ""
msgid "Confirm trial group meeting"
msgstr "Teilnahme bestätigen"
#: members/templates/members/confirm_success.html
#: members/templates/members/confirm_success.html members/tests/views.py
msgid "Invitation confirmed"
msgstr "Teilnahme bestätigt"
@ -1953,7 +1994,7 @@ msgstr ""
"Jugendleiter nach einem korrekten Passwort."
#: members/templates/members/invited_registration_failed.html
#: members/templates/members/register_failed.html
#: members/templates/members/register_failed.html members/tests/basic.py
msgid "Registration failed"
msgstr "Registrierung fehlgeschlagen"
@ -2359,6 +2400,15 @@ msgstr "Optionale zusätzliche E-Mailadresse"
msgid "Invalid emergency contacts"
msgstr "Ungültige Notfallkontakte"
#~ msgid "True"
#~ msgstr "Ja"
#~ msgid "False"
#~ msgstr "Nein"
#~ msgid "All"
#~ msgstr "Alle"
#~ msgid "Inform youth leaders about sending of crisis intervention list."
#~ msgstr ""
#~ "Informiere Jugendleiter:innen über Versand der Kriseninterventionsliste."

@ -83,7 +83,7 @@ def create_group_with_perms(apps, schema_editor, name, perm_names):
Group = apps.get_model("auth", "Group")
Permission = apps.get_model("auth", "Permission")
if Group.objects.filter(name=name).exists():
raise ValueError("A group with name %s already exists." % name)
raise ValueError("A group with name %s already exists." % name) # pragma: no cover
perms = [ Permission.objects.get(codename=codename, content_type__app_label=app_label) for app_label, codename in perm_names ]
g = Group.objects.using(db_alias).create(name=name)
g.permissions.set(perms)

@ -0,0 +1,121 @@
from django.utils.translation import gettext_lazy as _
from django.db import migrations
from django.contrib.auth.management import create_permissions
STANDARD_PERMS = [
('members', 'view_member'),
('members', 'view_freizeit'),
('members', 'add_global_freizeit'),
('members', 'view_memberwaitinglist'),
('members', 'view_memberunconfirmedproxy'),
('mailer', 'view_message'),
('mailer', 'add_global_message'),
('finance', 'view_statementunsubmitted'),
('finance', 'add_global_statementunsubmitted'),
]
FINANCE_PERMS = [
('finance', 'view_bill'),
('finance', 'view_ledger'),
('finance', 'add_ledger'),
('finance', 'change_ledger'),
('finance', 'delete_ledger'),
('finance', 'view_statementsubmitted'),
('finance', 'view_global_statementsubmitted'),
('finance', 'change_global_statementsubmitted'),
('finance', 'view_transaction'),
('finance', 'change_transaction'),
('finance', 'add_transaction'),
('finance', 'delete_transaction'),
('finance', 'process_statementsubmitted'),
('members', 'list_global_freizeit'),
('members', 'view_global_freizeit'),
]
WAITINGLIST_PERMS = [
('members', 'view_global_memberwaitinglist'),
('members', 'list_global_memberwaitinglist'),
('members', 'change_global_memberwaitinglist'),
('members', 'delete_global_memberwaitinglist'),
]
TRAINING_PERMS = [
('members', 'change_global_member'),
('members', 'list_global_member'),
('members', 'view_global_member'),
('members', 'add_global_membertraining'),
('members', 'change_global_membertraining'),
('members', 'list_global_membertraining'),
('members', 'view_global_membertraining'),
('members', 'view_trainingcategory'),
('members', 'add_trainingcategory'),
('members', 'change_trainingcategory'),
('members', 'delete_trainingcategory'),
]
REGISTRATION_PERMS = [
('members', 'may_manage_all_registrations'),
('members', 'change_memberunconfirmedproxy'),
('members', 'delete_memberunconfirmedproxy'),
]
MATERIAL_PERMS = [
('members', 'list_global_member'),
('material', 'view_materialpart'),
('material', 'change_materialpart'),
('material', 'add_materialpart'),
('material', 'delete_materialpart'),
('material', 'view_materialcategory'),
('material', 'change_materialcategory'),
('material', 'add_materialcategory'),
('material', 'delete_materialcategory'),
('material', 'view_ownership'),
('material', 'change_ownership'),
('material', 'add_ownership'),
('material', 'delete_ownership'),
]
def ensure_group_perms(apps, schema_editor, name, perm_names):
"""
Ensure the group `name` has the permissions `perm_names`. If the group does not
exist, create it with the given permissions, otherwise add the missing ones.
This only adds permissions, already existing ones that are not listed here are not
removed.
"""
db_alias = schema_editor.connection.alias
Group = apps.get_model("auth", "Group")
Permission = apps.get_model("auth", "Permission")
perms = [ Permission.objects.get(codename=codename, content_type__app_label=app_label) for app_label, codename in perm_names ]
try:
g = Group.objects.using(db_alias).get(name=name)
for perm in perms:
g.permissions.add(perm)
g.save()
# This case is only executed if users have manually removed one of the standard groups.
except Group.DoesNotExist: # pragma: no cover
g = Group.objects.using(db_alias).create(name=name)
g.permissions.set(perms)
g.save()
def update_default_permission_groups(apps, schema_editor):
ensure_group_perms(apps, schema_editor, "Standard", STANDARD_PERMS)
ensure_group_perms(apps, schema_editor, "Finance", FINANCE_PERMS)
ensure_group_perms(apps, schema_editor, "Waitinglist", WAITINGLIST_PERMS)
ensure_group_perms(apps, schema_editor, "Trainings", TRAINING_PERMS)
ensure_group_perms(apps, schema_editor, "Registrations", REGISTRATION_PERMS)
ensure_group_perms(apps, schema_editor, "Material", MATERIAL_PERMS)
class Migration(migrations.Migration):
dependencies = [
('members', '0010_create_default_permission_groups'),
('members', '0042_member_ticket_no'),
]
operations = [
migrations.RunPython(update_default_permission_groups, migrations.RunPython.noop),
]

@ -24,7 +24,8 @@ from django.contrib.auth.models import User
from django.conf import settings
from django.core.validators import MinValueValidator
from .rules import may_view, may_change, may_delete, is_own_training, is_oneself, is_leader, is_leader_of_excursion
from .rules import may_view, may_change, may_delete, is_own_training, is_oneself, is_leader, is_leader_of_excursion,\
is_leader_of_relevant_invitation
from .pdf import render_tex
import rules
from contrib.models import CommonModel
@ -565,6 +566,12 @@ class Member(Person):
waiter.delete()
return self.request_mail_confirmation(rerequest=False)
def registration_form_uploaded(self):
print(self.registration_form.name)
return not self.registration_form.name is None and self.registration_form.name != ""
registration_form_uploaded.boolean = True
registration_form_uploaded.short_description = _('Registration form')
def registration_ready(self):
"""Returns if the member is currently unconfirmed and all email addresses
are confirmed."""
@ -590,12 +597,16 @@ class Member(Person):
def send_upload_registration_form_link(self):
if not self.upload_registration_form_key:
return
print(self.name, self.upload_registration_form_key)
link = self.get_upload_registration_form_link()
self.send_mail(_('Upload registration form'),
settings.UPLOAD_REGISTRATION_FORM_TEXT.format(name=self.prename,
link=link))
def request_registration_form(self):
"""Ask the member to upload a registration form via email."""
self.generate_upload_registration_form_key()
self.send_upload_registration_form_link()
def notify_jugendleiters_about_confirmed_mail(self):
group = ", ".join([g.name for g in self.group.all()])
# notify jugendleiters of group of registration
@ -632,6 +643,8 @@ class Member(Person):
return self.filter_statements_by_permissions(queryset, annotate)
elif name == "Freizeit":
return self.filter_excursions_by_permissions(queryset, annotate)
elif name == "MemberWaitingList":
return self.filter_waiters_by_permissions(queryset, annotate)
elif name == "LJPProposal":
return queryset
elif name == "MemberTraining":
@ -654,6 +667,8 @@ class Member(Person):
return queryset
elif name == "MemberUnconfirmedProxy":
return queryset
elif name == "InvitationToGroup":
return queryset
else:
raise ValueError(name)
@ -732,6 +747,12 @@ class Member(Person):
queryset = queryset.filter(Q(groups__in=groups) | Q(jugendleiter=self)).distinct()
return queryset
def filter_waiters_by_permissions(self, queryset, annotate=False):
# ignores annotate
# return waiters that have a pending, expired or rejected group invitation for a group
# led by the member
return queryset.filter(invitationtogroup__group__leiters=self)
def may_list(self, other):
if self.pk == other.pk:
return True
@ -920,7 +941,7 @@ def gen_key():
return uuid.uuid4().hex
class InvitationToGroup(models.Model):
class InvitationToGroup(CommonModel):
"""An invitation of a waiter to a group."""
waiter = models.ForeignKey('MemberWaitingList', verbose_name=_('Waiter'), on_delete=models.CASCADE)
group = models.ForeignKey(Group, verbose_name=_('Group'), on_delete=models.CASCADE)
@ -933,9 +954,15 @@ class InvitationToGroup(models.Model):
on_delete=models.SET_NULL,
related_name='created_group_invitations')
class Meta:
class Meta(CommonModel.Meta):
verbose_name = _('Invitation to group')
verbose_name_plural = _('Invitations to groups')
rules_permissions = {
'add_obj': has_global_perm('members.add_global_memberwaitinglist'),
'view_obj': is_leader_of_relevant_invitation | has_global_perm('members.view_global_memberwaitinglist'),
'change_obj': has_global_perm('members.change_global_memberwaitinglist'),
'delete_obj': has_global_perm('members.delete_global_memberwaitinglist'),
}
def is_expired(self):
return self.date < (timezone.now() - timezone.timedelta(days=30)).date()
@ -1042,7 +1069,7 @@ class MemberWaitingList(Person):
permissions = (('may_manage_waiting_list', 'Can view and manage the waiting list.'),)
rules_permissions = {
'add_obj': has_global_perm('members.add_global_memberwaitinglist'),
'view_obj': has_global_perm('members.view_global_memberwaitinglist'),
'view_obj': is_leader_of_relevant_invitation | has_global_perm('members.view_global_memberwaitinglist'),
'change_obj': has_global_perm('members.change_global_memberwaitinglist'),
'delete_obj': has_global_perm('members.delete_global_memberwaitinglist'),
}
@ -1278,6 +1305,23 @@ class Freizeit(CommonModel):
def code(self):
return f"B{self.date:%y}-{self.pk}"
@staticmethod
def filter_queryset_date_next_n_hours(hours, queryset=None):
if queryset is None:
queryset = Freizeit.objects.all()
return queryset.filter(date__lte=timezone.now() + timezone.timedelta(hours=hours),
date__gte=timezone.now())
@staticmethod
def to_notify_crisis_intervention_list():
qs = Freizeit.objects.filter(notification_crisis_intervention_list_sent=False)
return Freizeit.filter_queryset_date_next_n_hours(48, queryset=qs)
@staticmethod
def to_send_crisis_intervention_list():
qs = Freizeit.objects.filter(crisis_intervention_list_sent=False)
return Freizeit.filter_queryset_date_next_n_hours(24, queryset=qs)
def get_tour_type(self):
if self.tour_type == FUEHRUNGS_TOUR:
return "Führungstour"
@ -1305,18 +1349,28 @@ class Freizeit(CommonModel):
@property
def duration(self):
# number of nights is number of full days + 1
full_days = self.night_count - 1
full_days = max(self.night_count - 1, 0)
extra_days = 0
if self.date.hour <= 12:
extra_days += 1.0
if self.date.date() == self.end.date():
# excursion starts and ends on the same day
hours = max(self.end.hour - self.date.hour, 0)
# at least 6 hours counts as full day
if hours >= 6:
extra_days = 1.0
# otherwise half day
else:
extra_days = 0.5
else:
extra_days += 0.5
if self.date.hour <= 12:
extra_days += 1.0
else:
extra_days += 0.5
if self.end.hour >= 12:
extra_days += 1.0
else:
extra_days += 0.5
if self.end.hour >= 12:
extra_days += 1.0
else:
extra_days += 0.5
return full_days + extra_days
@ -1569,7 +1623,6 @@ class Freizeit(CommonModel):
'Betreuer/in': str(numbers['staff']),
'Auswahl Veranstaltung': 'Auswahl2',
'Ort, Datum': '{p}, {d}'.format(p=settings.SEKTION, d=datetime.now().strftime('%d.%m.%Y'))}
print(members)
for i, m in enumerate(members):
suffix = str(' {}'.format(i + 1))
# indexing starts at zero, but the listing in the pdf starts at 1

@ -4,6 +4,7 @@ import os
import subprocess
import time
import glob
import logging
from io import BytesIO
from pypdf import PdfReader, PdfWriter, PageObject
from django import template
@ -15,6 +16,8 @@ from contrib.media import media_path, media_dir, serve_media, ensure_media_dir,
from utils import normalize_filename
from PIL import Image
logger = logging.getLogger(__name__)
def serve_pdf(filename_pdf):
return serve_media(filename_pdf, 'application/pdf')
@ -107,8 +110,7 @@ def pdf_add_attachments(pdf_writer, attachments):
pdf_writer.append(img_pdf_scaled)
except Exception as e:
print("Could not add image", fp)
print(e)
logger.warning(f"Could not add image under filepath {fp}: {e}.")
def scale_pdf_page_to_a4(page):

@ -1,4 +1,5 @@
from contrib.rules import memberize_user
from django.utils import timezone
from rules import predicate
@ -73,3 +74,10 @@ def statement_not_submitted(self, excursion):
if excursion.statement is None:
return False
return not excursion.statement.submitted
@predicate
@memberize_user
def is_leader_of_relevant_invitation(member, waiter):
assert waiter is not None
return waiter.invitationtogroup_set.filter(group__leiters=member).exists()

@ -27,8 +27,7 @@ def send_crisis_intervention_list():
that have not been sent yet.
"""
no = 0
for excursion in Freizeit.objects.filter(date__date=timezone.now().date(),
crisis_intervention_list_sent=False):
for excursion in Freizeit.to_send_crisis_intervention_list():
excursion.send_crisis_intervention_list()
no += 1
return no
@ -41,8 +40,7 @@ def send_notification_crisis_intervention_list():
day and that have not been sent yet.
"""
no = 0
for excursion in Freizeit.objects.filter(date__date=timezone.now().date() + timezone.timedelta(days=1),
notification_crisis_intervention_list_sent=False):
for excursion in Freizeit.to_notify_crisis_intervention_list():
excursion.notify_leaders_crisis_intervention_list()
no += 1
return no

@ -0,0 +1,40 @@
{% extends "admin/base_site.html" %}
{% load i18n admin_urls static %}
{% block extrahead %}
{{ block.super }}
{{ media }}
<script src="{% static 'admin/js/cancel.js' %}" async></script>
<script type="text/javascript" src="{% static "admin/js/vendor/jquery/jquery.js" %}"></script>
<script type="text/javascript" src="{% static "admin/js/jquery.init.js" %}"></script>
{% endblock %}
{% block bodyclass %}{{ block.super }} app-{{ opts.app_label }} model-{{ opts.model_name }} invite-waiter
{% endblock %}
{% block breadcrumbs %}
<div class="breadcrumbs">
<a href="{% url 'admin:index' %}">{% translate 'Home' %}</a>
&rsaquo; <a href="{% url 'admin:app_list' app_label=opts.app_label %}">{{ opts.app_config.verbose_name }}</a>
&rsaquo; <a href="{% url opts|admin_urlname:'changelist' %}">{{ opts.verbose_name_plural|capfirst }}</a>
&rsaquo; {% translate 'Demote to waiter' %}
</div>
{% endblock %}
{% block content %}
<h2>{% translate "Request registration form" %}</h2>
<p>
{% blocktrans %}Do you want to ask {{ member }} to upload their registration form?{% endblocktrans %}
</p>
<p>
{% if member.registration_form %}
{% blocktrans %}Warning: {{ member }} has already uploaded a registration form.{% endblocktrans %}
{% endif %}
</p>
<form action="" method="post">
{% csrf_token %}
<input class="default" style="color: $default-link-color" type="submit" name="apply" value="{% translate 'Request registration form' %}">
<a href="#" class="button cancel-link">{% translate "Cancel" %}</a>
</form>
{% endblock %}

@ -0,0 +1,48 @@
{% extends "admin/base_site.html" %}
{% load i18n admin_urls static %}
{% block extrahead %}
{{ block.super }}
{{ media }}
<script src="{% static 'admin/js/cancel.js' %}" async></script>
<script type="text/javascript" src="{% static "admin/js/vendor/jquery/jquery.js" %}"></script>
<script type="text/javascript" src="{% static "admin/js/jquery.init.js" %}"></script>
{% endblock %}
{% block bodyclass %}{{ block.super }} app-{{ opts.app_label }} model-{{ opts.model_name }} invite-waiter
{% endblock %}
{% block breadcrumbs %}
<div class="breadcrumbs">
<a href="{% url 'admin:index' %}">{% translate 'Home' %}</a>
&rsaquo; <a href="{% url 'admin:app_list' app_label=opts.app_label %}">{{ opts.app_config.verbose_name }}</a>
&rsaquo; <a href="{% url opts|admin_urlname:'changelist' %}">{{ opts.verbose_name_plural|capfirst }}</a>
&rsaquo; {% translate 'Demote to waiter' %}
</div>
{% endblock %}
{% block content %}
<h2>{% translate "Demote to waiter" %}</h2>
<p>
{% trans "Do you want to demote the following unconfirmed registrations to waiters?" %}
</p>
<p>
<ul>
{% for member in queryset %}
<li>
<a href="{% url 'admin:members_memberunconfirmedproxy_change' 3 %}">{{ member }}</a>
</li>
{% endfor %}
</ul>
</p>
<form action="" method="post">
{% csrf_token %}
{% if form %}
{{form}}
{% endif %}
<input type="hidden" name="action" value="demote_to_waiter_action">
<input class="default" style="color: $default-link-color" type="submit" name="apply" value="{% translate 'Demote' %}">
<a href="#" class="button cancel-link">{% translate "Cancel" %}</a>
</form>
{% endblock %}

@ -12,6 +12,7 @@ from django.test import TestCase, Client, RequestFactory
from django.utils import timezone, translation
from django.conf import settings
from django.urls import reverse
from django.http import HttpResponse, HttpResponseRedirect
from django import template
from unittest import skip, mock
import os
@ -27,11 +28,12 @@ from members.models import Member, Group, PermissionMember, PermissionGroup, Fre
Klettertreff, KlettertreffAttendee, LJPProposal, ActivityCategory, WEEKDAYS,\
TrainingCategory, Person
from members.admin import MemberWaitingListAdmin, MemberAdmin, FreizeitAdmin, MemberNoteListAdmin,\
MemberUnconfirmedAdmin, RegistrationFilter, FilteredMemberFieldMixin,\
MemberUnconfirmedAdmin, FilteredMemberFieldMixin,\
MemberAdminForm, StatementOnListForm, KlettertreffAdmin, GroupAdmin,\
InvitationToGroupAdmin, AgeFilter, InvitedToGroupFilter
from members.pdf import fill_pdf_form, render_tex, media_path, serve_pdf, find_template, merge_pdfs, render_docx, pdf_add_attachments, scale_pdf_page_to_a4, scale_pdf_to_a4
from members.excel import generate_ljp_vbk
from members.views import render_register_success, render_register_failed
from mailer.models import EmailAddress, Message
from finance.models import Statement, Bill
@ -178,6 +180,14 @@ class MemberTestCase(BasicMemberTestCase):
self.assertQuerysetEqual(self.fritz.filter_statements_by_permissions(qs),
[st1, st2], ordered=False)
def test_filter_waiters_by_permissions(self):
waiter = MemberWaitingList.objects.create(**WAITER_DATA)
MemberWaitingList.objects.create(**WAITER_DATA)
InvitationToGroup.objects.create(group=self.alp, waiter=waiter)
qs = MemberWaitingList.objects.all()
self.assertQuerysetEqual(self.lise.filter_waiters_by_permissions(qs),
[waiter], ordered=False)
def test_annotate_view_permissions(self):
qs = Member.objects.all()
# if the model is not Member, the queryset should not change
@ -487,6 +497,7 @@ class AdminTestCase(TestCase):
trainer = create_custom_user('trainer', ['Standard', 'Trainings'], 'Lise', 'Lotte')
treasurer = create_custom_user('treasurer', ['Standard', 'Finance'], 'Lara', 'Litte')
materialwarden = create_custom_user('materialwarden', ['Standard', 'Material'], 'Loro', 'Lutte')
waitinglistmanager = create_custom_user('waitinglistmanager', ['Standard', 'Waitinglist'], 'Liri', 'Litti')
paul = standard.member
@ -535,6 +546,8 @@ class PermissionTestCase(AdminTestCase):
def test_standard_permissions(self):
u = User.objects.get(username='standard')
self.assertTrue(u.has_perm('members.view_member'))
self.assertTrue(u.has_perm('members.view_memberwaitinglist'))
self.assertFalse(u.has_perm('members.view_memberwaitinglist_global'))
def test_queryset_standard(self):
u = User.objects.get(username='standard')
@ -814,6 +827,12 @@ class FreizeitTestCase(BasicMemberTestCase):
self.ex2.jugendleiter.add(self.fritz)
self.st = Statement.objects.create(excursion=self.ex2, night_cost=42, subsidy_to=None)
self.ex2.save()
# this excursion is used in the other tests
self.ex3 = Freizeit.objects.create(name='Wild trip 3', kilometers_traveled=120,
tour_type=GEMEINSCHAFTS_TOUR,
tour_approach=MUSKELKRAFT_ANREISE,
difficulty=1,
date=timezone.localtime())
def _setup_test_sjr_application_numbers(self, n_yl, n_b27_local, n_b27_non_local):
add_memberonlist_by_local(self.ex, n_yl, n_b27_local, n_b27_non_local)
@ -934,14 +953,31 @@ class FreizeitTestCase(BasicMemberTestCase):
def test_duration(self):
self.assertGreaterEqual(self.ex.duration, 0)
# less than 6 hours
self.ex.date = timezone.datetime(2000, 1, 1, 8, 0, 0)
self.ex.end = timezone.datetime(2000, 1, 1, 10, 0, 0)
self.assertEqual(self.ex.duration, 0.5)
# TODO: fix this in the model, the duration of this excursion should be 0
# at least 6 hours
self.ex.date = timezone.datetime(2000, 1, 1, 8, 0, 0)
self.ex.end = timezone.datetime(2000, 1, 1, 14, 0, 0)
self.assertEqual(self.ex.duration, 1)
# one full day and two extra days on beginning and end
self.ex.date = timezone.datetime(2000, 1, 1, 8, 0, 0)
self.ex.end = timezone.datetime(2000, 1, 3, 14, 0, 0)
self.assertEqual(self.ex.duration, 3)
# one full day and two half days on beginning and end
self.ex.date = timezone.datetime(2000, 1, 1, 16, 0, 0)
self.ex.end = timezone.datetime(2000, 1, 3, 8, 0, 0)
self.assertEqual(self.ex.duration, 2)
def test_duration_midday_midday(self):
self.ex.date = timezone.datetime(2000, 1, 1, 12, 0, 0)
self.ex.end = timezone.datetime(2000, 1, 1, 12, 0, 0)
self.assertEqual(self.ex.duration, 1)
self.assertEqual(self.ex.duration, 0.5)
def test_generate_ljp_vbk_no_proposal_raises_error(self):
"""Test generate_ljp_vbk raises ValueError when excursion has no LJP proposal"""
@ -949,6 +985,34 @@ class FreizeitTestCase(BasicMemberTestCase):
generate_ljp_vbk(self.ex)
self.assertIn("Excursion has no LJP proposal", str(cm.exception))
def test_filter_queryset_date_next_n_hours(self):
self.ex.date = timezone.now() + timezone.timedelta(hours=12)
self.ex.save()
self.ex2.date = timezone.now() + timezone.timedelta(hours=36)
self.ex2.save()
self.ex3.date = timezone.now() - timezone.timedelta(hours=1)
self.ex3.save()
qs = Freizeit.filter_queryset_date_next_n_hours(24)
self.assertIn(self.ex, qs)
self.assertNotIn(self.ex2, qs)
self.assertNotIn(self.ex3, qs)
def test_querysets_crisis_intervention_list(self):
self.ex.date = timezone.now() + timezone.timedelta(hours=12)
self.ex.crisis_intervention_list_sent = False
self.ex.save()
self.ex2.date = timezone.now() + timezone.timedelta(hours=36)
self.ex2.notification_crisis_intervention_list_sent = False
self.ex2.save()
self.ex3.notification_crisis_intervention_list_sent = True
self.ex3.save()
to_send = Freizeit.to_send_crisis_intervention_list()
to_notify = Freizeit.to_notify_crisis_intervention_list()
self.assertIn(self.ex, to_send)
self.assertNotIn(self.ex2, to_send)
self.assertNotIn(self.ex3, to_send)
self.assertIn(self.ex2, to_notify)
class PDFActionMixin:
def _test_pdf(self, name, pk, model='freizeit', invalid=False, username='superuser', post_data=None):
@ -1039,7 +1103,7 @@ class FreizeitAdminTestCase(AdminTestCase, PDFActionMixin):
self.assertEqual(response.status_code, 200, 'Response code is not 200.')
@skip("The filtering is currently (intentionally) disabled.")
def test_add_queryset_filter(self):
def test_add_queryset_filter(self): # pragma: no cover
"""Test if queryset on `jugendleiter` field is properly filtered by permissions."""
u = User.objects.get(username='standard')
c = self._login('standard')
@ -1215,6 +1279,18 @@ class FreizeitAdminTestCase(AdminTestCase, PDFActionMixin):
response = c.post(url, data={'finance_overview': '', 'apply': ''})
self.assertEqual(response.status_code, HTTPStatus.FOUND)
def test_save_model_with_statement(self):
user_with_member = User.objects.get(username='standard')
self.ex.statement = self.st
request = self.factory.post('/')
request.user = user_with_member
form = mock.MagicMock()
with mock.patch('members.admin.super') as mock_super:
mock_super.return_value.save_model.return_value = None
self.admin.save_model(request, self.ex, form, change=False)
self.st.refresh_from_db()
self.assertEqual(self.st.created_by, user_with_member.member)
class MemberNoteListAdminTestCase(AdminTestCase, PDFActionMixin):
def setUp(self):
@ -1265,6 +1341,22 @@ class MemberWaitingListAdminTestCase(AdminTestCase):
request.user = u
return request
def test_has_view_permission(self):
request = self.factory.get('/')
request.user = User.objects.get(username='standard')
self.assertTrue(self.admin.has_view_permission(request))
self.assertFalse(self.admin.has_view_permission(request, self.waiter))
def test_changelist(self):
c = self._login('standard')
url = reverse('admin:members_memberwaitinglist_changelist')
response = c.get(url)
self.assertEqual(response.status_code, HTTPStatus.OK)
c = self._login('waitinglistmanager')
response = c.get(url)
self.assertEqual(response.status_code, HTTPStatus.OK)
def test_age_eq_birth_date_delta(self):
queryset = self.admin.get_queryset(self._request())
today = timezone.now().date()
@ -1353,6 +1445,23 @@ class MemberWaitingListAdminTestCase(AdminTestCase):
'_selected_action': [q.pk for q in qs]}, follow=True)
self.assertEqual(response.status_code, HTTPStatus.OK)
def test_response_change_invite(self):
request = self.factory.post('/', {'_invite': True})
request.user = User.objects.get(username='superuser')
with mock.patch('members.admin.super') as mock_super:
mock_super.return_value.response_change.return_value = HttpResponse()
response = self.admin.response_change(request, self.waiter)
self.assertIsInstance(response, HttpResponseRedirect)
def test_response_change_no_invite(self):
request = self.factory.post('/', {})
request.user = User.objects.get(username='superuser')
expected_response = HttpResponse()
with mock.patch('members.admin.super') as mock_super:
mock_super.return_value.response_change.return_value = expected_response
response = self.admin.response_change(request, self.waiter)
self.assertEqual(response, expected_response)
class MemberUnconfirmedAdminTestCase(AdminTestCase):
def setUp(self):
@ -1375,6 +1484,29 @@ class MemberUnconfirmedAdminTestCase(AdminTestCase):
qs = self.admin.get_queryset(request)
self.assertQuerysetEqual(qs, MemberUnconfirmedProxy.objects.none(), ordered=False)
def test_request_registration_form_invalid(self):
c = self._login('standard')
url = reverse('admin:members_memberunconfirmedproxy_request_registration_form', args=(124,))
response = c.get(url)
self.assertEqual(response.status_code, HTTPStatus.FOUND)
def test_request_registration_form_insufficient_permission(self):
c = self._login('standard')
url = reverse('admin:members_memberunconfirmedproxy_request_registration_form', args=(self.reg.pk,))
response = c.get(url, follow=True)
self.assertEqual(response.status_code, HTTPStatus.OK)
self.assertContains(response, _('Insufficient permissions.'))
def test_request_registration_form(self):
c = self._login('superuser')
url = reverse('admin:members_memberunconfirmedproxy_request_registration_form', args=(self.reg.pk,))
response = c.get(url)
self.assertEqual(response.status_code, HTTPStatus.OK)
self.assertContains(response, _('Request registration form'))
response = c.post(url, data={'apply': ''})
self.assertEqual(response.status_code, HTTPStatus.FOUND)
def test_demote_to_waiter(self):
c = self._login('superuser')
url = reverse('admin:members_memberunconfirmedproxy_demote', args=(self.reg.pk,))
@ -1407,7 +1539,6 @@ class MemberUnconfirmedAdminTestCase(AdminTestCase):
response = c.post(url, data={'action': 'confirm', '_selected_action': [self.reg.pk]}, follow=True)
self.assertEqual(response.status_code, HTTPStatus.OK)
@skip('Even when every `member.confirm()` succeeds, it still shows the error message.')
def test_confirm_multiple(self):
c = self._login('superuser')
url = reverse('admin:members_memberunconfirmedproxy_changelist')
@ -1446,12 +1577,26 @@ class MemberUnconfirmedAdminTestCase(AdminTestCase):
c = self._login('standard')
url = reverse('admin:members_memberunconfirmedproxy_changelist')
response = c.get(url)
self.assertEqual(response.status_code, HTTPStatus.FORBIDDEN)
c = self._login('superuser')
response = c.get(url)
# By default, standard users may access the member unconfirmed listing (but only view
# the relevant registrations)
self.assertEqual(response.status_code, HTTPStatus.OK)
def test_response_change_confirm(self):
request = self.factory.post('/', {'_confirm': True})
request.user = User.objects.get(username='superuser')
request._messages = mock.MagicMock()
# Test successful confirm
self.reg.confirmed_mail = True
self.reg.confirmed_alternative_mail = True
self.reg.save()
with mock.patch.object(self.reg, 'confirm', return_value=True):
response = self.admin.response_change(request, self.reg)
# Test failed confirm
with mock.patch.object(self.reg, 'confirm', return_value=False):
response = self.admin.response_change(request, self.reg)
class MailConfirmationTestCase(BasicMemberTestCase):
def setUp(self):
@ -1498,7 +1643,7 @@ class MailConfirmationTestCase(BasicMemberTestCase):
self.assertTrue(self.father.confirmed_mail, msg='After confirming by key, the mail should be confirmed.')
@skip("Currently, emergency contact email addresses are not required to be confirmed.")
def test_emergency_contact_confirmation(self):
def test_emergency_contact_confirmation(self): # pragma: no cover
# request mail confirmation of fritz, should also ask for confirmation of father
requested_confirmation = self.fritz.request_mail_confirmation()
self.assertTrue(requested_confirmation,
@ -1550,6 +1695,7 @@ class RegisterViewTestCase(BasicMemberTestCase):
def setUp(self):
super().setUp()
self.factory = RequestFactory()
RegistrationPassword.objects.create(group=self.alp,
password=RegisterViewTestCase.REGISTRATION_PASSWORD)
@ -1602,6 +1748,27 @@ class RegisterViewTestCase(BasicMemberTestCase):
self.assertEqual(response.status_code, HTTPStatus.OK)
self.assertContains(response, _("The entered password is wrong."))
def test_register_no_group(self):
# Test when group is None, render_register_failed is called with reason
url = reverse('members:register')
response = self.client.post(url, data={
'password': '',
'waiter_key': '',
'save': '',
})
self.assertEqual(response.status_code, HTTPStatus.OK)
self.assertContains(response, _('Registration failed'))
def test_render_register_success(self):
# Test render_register_success return statement
response = render_register_success(self.factory.get('/'), "Test Group", "Test Member", False)
self.assertEqual(response.status_code, HTTPStatus.OK)
def test_render_register_failed_with_reason(self):
# Test render_register_failed with reason to cover context assignment
response = render_register_failed(self.factory.get('/'), "Test reason")
self.assertEqual(response.status_code, HTTPStatus.OK)
class UploadRegistrationFormViewTestCase(BasicMemberTestCase):
def setUp(self):
@ -1656,6 +1823,21 @@ class UploadRegistrationFormViewTestCase(BasicMemberTestCase):
self.assertContains(response,
_("Our team will process your registration shortly."))
def test_upload_registration_form_validation_error(self):
# Test ValueError exception handling during form validation
url = reverse('members:upload_registration_form')
file = SimpleUploadedFile("form.pdf", b"file_content", content_type="application/pdf")
with mock.patch.object(Member, 'validate_registration_form') as mock_validate:
mock_validate.side_effect = ValueError("Test validation error")
response = self.client.post(url, data={
'key': self.reg.upload_registration_form_key,
'registration_form': file,
})
self.assertEqual(response.status_code, HTTPStatus.OK)
# Should stay on upload form page due to error
self.assertContains(response,
_('If you are not an adult yet, please let someone responsible for you sign the agreement.'))
class DownloadRegistrationFormViewTestCase(BasicMemberTestCase):
def setUp(self):
super().setUp()
@ -1919,6 +2101,15 @@ class ConfirmWaitingViewTestCase(BasicMemberTestCase):
response = self.client.post(url, data={'key': self.waiter.leave_key})
self.assertEqual(response.status_code, HTTPStatus.NOT_FOUND)
def test_confirm_waiting_invalid_status(self):
# Test invalid status handling in confirm_waiting
url = reverse('members:confirm_waiting')
with mock.patch.object(MemberWaitingList, 'confirm_waiting') as mock_confirm:
mock_confirm.return_value = 999 # Invalid status
response = self.client.get(url, data={'key': self.key})
self.assertEqual(response.status_code, HTTPStatus.OK)
self.assertContains(response, _('The supplied link is invalid.'))
class MailConfirmationViewTestCase(BasicMemberTestCase):
def setUp(self):
@ -2017,51 +2208,21 @@ class EchoViewTestCase(BasicMemberTestCase):
self.assertEqual(response.status_code, HTTPStatus.OK)
self.assertContains(response, _('Your data was successfully updated.'))
class TestRegistrationFilterTestCase(AdminTestCase):
def setUp(self):
super().setUp(model=Member, admin=MemberAdmin)
def test_lookups(self):
fil = RegistrationFilter(None, {}, Member, self.admin)
self.assertTrue(('All', _('All')) in fil.lookups(None, None))
def test_queryset_no_filter(self):
qs = Member.objects.all()
# filtering with All returns passed queryset
fil = RegistrationFilter(None, {'registration_complete': 'All'}, Member, self.admin)
self.assertQuerysetEqual(fil.queryset(None, qs), qs, ordered=False)
# or with None
fil = RegistrationFilter(None, {}, Member, self.admin)
self.assertQuerysetEqual(fil.queryset(None, qs), qs, ordered=False)
def test_choices(self):
fil = RegistrationFilter(None, {'registration_complete': 'True'}, Member, self.admin)
request = RequestFactory().get("/", {})
request.user = User.objects.get(username='superuser')
changelist = self.admin.get_changelist_instance(request)
choices = list(fil.choices(changelist))
self.assertEqual(choices[0]['display'], _('Yes'))
@skip("Currently errors, because 'registration_complete' is not a field.")
def test_queryset_filter(self):
qs = Member.objects.all()
fil = RegistrationFilter(None, {'registration_complete': 'True'}, Member, self.admin)
self.assertQuerysetEqual(fil.queryset(None, qs),
Member.objects.filter(registration_complete=True),
ordered=False)
fil = RegistrationFilter(None, {'registration_complete': 'False'}, Member, self.admin)
self.assertQuerysetEqual(fil.queryset(None, qs),
Member.objects.filter(registration_complete=True),
ordered=False)
fil = RegistrationFilter(None, {}, Member, self.admin)
fil.default_value = ('True', True)
self.assertQuerysetEqual(fil.queryset(None, qs),
Member.objects.filter(registration_complete=True),
ordered=False)
def test_post_save_without_registration_form(self):
# Clear registration form to test member without registration_form case
self.fritz.registration_form = None
self.fritz.save()
url = reverse('members:echo')
response = self.client.post(url, data=dict(
REGISTRATION_DATA,
**EMERGENCY_CONTACT_DATA,
key=self.key,
password=self.fritz.echo_password,
save='',
))
# Should redirect to upload registration form
self.assertEqual(response.status_code, HTTPStatus.FOUND)
self.assertIn('upload', response.url)
class MemberAdminFormTestCase(TestCase):
@ -2127,18 +2288,17 @@ class KlettertreffAdminTestCase(AdminTestCase):
'_selected_action': [kl.pk for kl in qs]}, follow=True)
self.assertEqual(response.status_code, HTTPStatus.OK)
@skip('Members are not filtered by group, because group attribute is retrieved from GET data.')
def test_overview_filtered(self):
qs = Klettertreff.objects.all()
url = reverse('admin:members_klettertreff_changelist')
cool_kids = Group.objects.get(name='cool kids')
url = reverse('admin:members_klettertreff_changelist') + f"?group__id__exact={cool_kids.pk}"
# expect: success and filtered by group
c = self._login('superuser')
response = c.post(url, data={'action': 'overview',
'group__name': 'cool kids',
'_selected_action': [kl.pk for kl in qs]}, follow=True)
self.assertEqual(response.status_code, HTTPStatus.OK)
self.assertNotContains(response, 'Lulla')
self.assertNotContains(response, 'Lise')
class GroupAdminTestCase(AdminTestCase):
@ -2162,6 +2322,16 @@ class GroupAdminTestCase(AdminTestCase):
response = c.post(url, data={'group_overview': ''}, follow=True)
self.assertEqual(response.status_code, HTTPStatus.OK)
def test_group_checklist(self):
url = reverse('admin:members_group_action')
c = self._login('standard')
response = c.post(url, data={'group_checklist': ''}, follow=True)
self.assertEqual(response.status_code, HTTPStatus.FORBIDDEN)
c = self._login('superuser')
response = c.post(url, data={'group_checklist': ''}, follow=True)
self.assertEqual(response.status_code, HTTPStatus.OK)
class FilteredMemberFieldMixinTestCase(AdminTestCase):
def setUp(self):

@ -22,7 +22,7 @@ from members.models import Member, Group, PermissionMember, PermissionGroup, Fre
Klettertreff, KlettertreffAttendee, LJPProposal, ActivityCategory, WEEKDAYS,\
TrainingCategory, Person
from members.admin import MemberWaitingListAdmin, MemberAdmin, FreizeitAdmin, MemberNoteListAdmin,\
MemberUnconfirmedAdmin, RegistrationFilter, FilteredMemberFieldMixin,\
MemberUnconfirmedAdmin, FilteredMemberFieldMixin,\
MemberAdminForm, StatementOnListForm, KlettertreffAdmin, GroupAdmin
from members.pdf import fill_pdf_form, render_tex, media_path, serve_pdf, find_template, merge_pdfs
from mailer.models import EmailAddress

@ -77,7 +77,7 @@ class MemberRegistrationWaitingListForm(ModelForm):
'prename': _('Prename of the member.'),
'lastname': _('Lastname of the member.'),
}
required = []
required = ['birth_date']
class EmergencyContactForm(ModelForm):
@ -183,8 +183,7 @@ def echo(request):
member.save()
if not member.registration_form:
# If the member does not have a registration form, forward them to the upload page.
member.generate_upload_registration_form_key()
member.send_upload_registration_form_link()
member.request_registration_form()
return HttpResponseRedirect(reverse('members:upload_registration_form') + "?key=" + member.upload_registration_form_key)
else:
return render_echo_success(request, member.prename)
@ -291,7 +290,6 @@ def register(request):
new_member.send_upload_registration_form_link()
return HttpResponseRedirect(reverse('members:upload_registration_form') + "?key=" + new_member.upload_registration_form_key)
except ValueError as e:
print("value error", e)
# when input is invalid
if pwd:
return render_register(request, group, form, emergency_contacts_formset, pwd=pwd.password,
@ -319,7 +317,6 @@ def download_registration_form(request):
return render_download_registration_form(request, member)
except Member.DoesNotExist:
return render_upload_registration_form_invalid(request)
return render_upload_registration_form_invalid(request)
def render_upload_registration_form_invalid(request):
@ -364,7 +361,6 @@ def upload_registration_form(request):
return render_upload_registration_form_success(request, member)
except ValueError as e:
return render_upload_registration_form(request, member, form, key)
return render_upload_registration_form_invalid(request)
def confirm_mail(request):

@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-03-08 16:16+0100\n"
"POT-Creation-Date: 2025-09-07 02:13+0200\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@ -48,7 +48,7 @@ msgstr "Abschnitt"
msgid "Sections"
msgstr "Abschnitte"
#: startpage/models.py
#: startpage/models.py startpage/tests.py
msgid "deactivated"
msgstr "deaktiviert"
@ -80,7 +80,7 @@ msgstr "Einträge"
msgid "file"
msgstr "Datei"
#: startpage/models.py
#: startpage/models.py startpage/tests.py
msgid "Empty"
msgstr "Leer"
@ -138,5 +138,29 @@ msgstr ""
"Dies ist nur ein Platzhalter. Bitte überschreibe diesen Platzhalter wie in "
"der Dokumentation beschrieben mit einem benutzerdefinierten Text."
#: startpage/templates/startpage/impressum.html
msgid "Attributions"
msgstr "Copyright Informationen"
#: startpage/templates/startpage/impressum.html
msgid "The source code of this website is licensed under"
msgstr "Der Quelltext dieser Webseite ist lizenziert unter"
#: startpage/templates/startpage/impressum.html
msgid "Copyright © 2025 JDAV Sektion "
msgstr "Copyright © 2025 JDAV Sektion "
#: startpage/templates/startpage/impressum.html
msgid "for the content of this website."
msgstr "für den Inhalt dieser Webseite."
#: startpage/templates/startpage/impressum.html
msgid "External assets used on this website:"
msgstr "Lizenzhinweise für Ressourcen Dritter:"
#: startpage/templates/startpage/impressum.html
msgid "Background image"
msgstr "Hintergrundbild"
#~ msgid "Awesome JDAV website being able to do a lot!"
#~ msgstr "Tolle JDAV Webseite die ganz viel kann!"

@ -7,7 +7,7 @@
<link rel="stylesheet" href="{% static 'startpage/css/base.css' %}">
<link rel="stylesheet" href="{% static 'startpage/css/fonts.css' %}">
<link rel="stylesheet" href="{% static 'startpage/css/people_grid.css' %}">
<link rel="shortcut icon" type="image/png" href="{% static 'startpage/img/favicon.png' %}"/>
<link rel="shortcut icon" type="image/svg+xml" href="{% static 'startpage/img/favicon.svg' %}"/>
</head>
<body>

@ -1,8 +1,31 @@
{% extends "startpage/base_subsite.html" %}
{% load static %}
{% load static common i18n %}
{% block content %}
{% include "startpage/impressum_content.html" %}
{% block attribution %}
<h1>{% trans "Attributions" %}</h1>
<p>
{% trans "The source code of this website is licensed under" %}
<a href="https://www.gnu.org/licenses/agpl-3.0.en.html">AGPLv3</a>.
{% trans "Copyright © 2025 JDAV Sektion " %} {% settings_value 'SEKTION' %}
{% trans "for the content of this website." %}
</p>
<p>
{% trans "External assets used on this website:" %}
</p>
<ul>
<li>
{% trans "Background image" %}:
<a href="https://commons.wikimedia.org/wiki/File:Alps_Panorama_(4954145205).jpg">Reza</a>, <a href="https://creativecommons.org/licenses/by/2.0">CC BY 2.0</a>, via Wikimedia Commons
</li>
</ul>
{% endblock %}
{% endblock %}

@ -44,7 +44,7 @@ window.onclick = function(event) {
{% if not redirect_url %}
<div>
<button onclick="myFunction()"class="dropbtn">Menü</button>
<a class="nav" href="/kompass">Login</a>
<a class="dropbtn" href="/kompass">Login</a>
</div>
{% endif %}
</div>

@ -8,7 +8,7 @@
{% if member.image %}
<img src="{{member.image.url}}">
{% else %}
<img src="{% static 'startpage/img/placeholder.jpg' %}">
<img src="{% static 'startpage/img/placeholder.svg' %}">
{% endif %}
<div class="namebox">
<div class="name">{{ member.name }}</div>

@ -13,7 +13,7 @@
{% if member.image %}
<img src="{{ member.image.url }}">
{% else %}
<img src="{% static 'startpage/img/placeholder.jpg' %}">
<img src="{% static 'startpage/img/placeholder.svg' %}">
{% endif %}
<div class="namebox">
<div class="name">{{ member.name }}</div>

@ -9,7 +9,7 @@
{% if member.image %}
<img src="{{ member.image.url }}">
{% else %}
<img src="{% static 'startpage/img/placeholder.jpg' %}">
<img src="{% static 'startpage/img/placeholder.svg' %}">
{% endif %}
<div class="namebox">
<div class="name">{{ member.name }}</div>

@ -6,12 +6,15 @@ from django.templatetags.static import static
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from django.core.files.uploadedfile import SimpleUploadedFile
from django.template import Template, Context, TemplateSyntaxError, VariableDoesNotExist
from unittest import mock
from unittest.mock import Mock
from importlib import reload
from members.models import Member, Group, DIVERSE
from startpage import urls
from startpage.views import redirect, handler500
from startpage.templatetags.markdown_extras import RenderAsTemplateNode, render_as_template
from .models import Post, Section, Image, Link, MemberOnPost
@ -209,3 +212,32 @@ class ViewTestCase(BasicTestCase):
request = RequestFactory().get('/')
response = handler500(request)
self.assertEqual(response.status_code, 500)
class MarkdownExtrasTestCase(TestCase):
def test_render_as_template_node_variable_does_not_exist(self):
node = RenderAsTemplateNode('nonexistent_var', 'result')
context = Context({})
result = node.render(context)
self.assertEqual(result, '')
def test_render_as_template_no_arguments(self):
token = Mock()
token.contents = 'render_as_template'
parser = Mock()
with self.assertRaises(TemplateSyntaxError):
render_as_template(parser, token)
def test_render_as_template_invalid_syntax(self):
token = Mock()
token.contents = 'render_as_template "content"'
parser = Mock()
with self.assertRaises(TemplateSyntaxError):
render_as_template(parser, token)
def test_render_as_template_unquoted_argument(self):
token = Mock()
token.contents = 'render_as_template content as result'
parser = Mock()
with self.assertRaises(TemplateSyntaxError):
render_as_template(parser, token)

@ -0,0 +1,3 @@
- `climber.png`:
Paul Sherman (https://commons.wikimedia.org/wiki/File:Rock_climbing_vector.svg),
Public Domain, via Wikimedia Commons

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

@ -0,0 +1 @@
<?xml version="1.0" encoding="UTF-8"?><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48"><defs><style>.c{fill:#9cc;}.d{fill:#2d5955;}.e{stroke:#2d5955;stroke-width:3.2px;}.e,.f{fill:none;stroke-miterlimit:10;}.g{fill:#666;mix-blend-mode:multiply;}.f{stroke:#666;stroke-width:3.3px;}.h{opacity:.36;}.i{isolation:isolate;}.j{fill:#fff;opacity:.24;}</style></defs><g class="i"><g id="a"><circle class="f" cx="24.31" cy="24.31" r="21.69"/></g><g id="b"><circle class="e" cx="23.69" cy="23.69" r="21.69"/><polygon class="g" points="21.2 22.96 15.21 43.89 27.68 25.98 33.66 5.05 21.2 22.96"/><polygon class="c" points="14.34 43.15 26.8 25.24 20.32 22.23 14.34 43.15"/><polygon class="d" points="32.79 4.32 20.32 22.23 26.8 25.24 32.79 4.32"/><polyline class="h" points="14.34 43.15 26.8 25.24 32.79 4.32"/><circle class="j" cx="23.61" cy="23.64" r="1.55"/></g></g></svg>

After

Width:  |  Height:  |  Size: 873 B

@ -0,0 +1,39 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 27.3.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 48 48" style="enable-background:new 0 0 48 48;" xml:space="preserve">
<style type="text/css">
.st0{opacity:0.27;fill:url(#SVGID_1_);}
.st1{fill:none;stroke:#999999;stroke-width:3.3;stroke-miterlimit:10;}
.st2{fill:none;stroke:#508480;stroke-width:3.2;stroke-miterlimit:10;}
.st3{fill:none;stroke:url(#SVGID_00000121983970493329986990000017723393330248815746_);stroke-width:3.2;stroke-miterlimit:10;}
.st4{fill:#999999;}
.st5{fill:#BADDD9;}
.st6{fill:#508480;}
.st7{opacity:0.36;}
.st8{opacity:0.24;fill:#FFFFFF;}
</style>
<g id="Logo_x5F_Schatten">
<radialGradient id="SVGID_1_" cx="20.8612" cy="20.647" r="22.9444" gradientUnits="userSpaceOnUse">
<stop offset="0" style="stop-color:#FFFFFF"/>
<stop offset="1" style="stop-color:#000000"/>
</radialGradient>
<circle class="st0" cx="23.6" cy="23.6" r="22.9"/>
<circle class="st1" cx="24.3" cy="24.3" r="21.7"/>
</g>
<g id="LogooVordergrund">
<circle class="st2" cx="23.7" cy="23.7" r="21.7"/>
<linearGradient id="SVGID_00000165954078947660943750000008230134672540192957_" gradientUnits="userSpaceOnUse" x1="3.5175" y1="12.0447" x2="43.8685" y2="35.3413">
<stop offset="0" style="stop-color:#BADDD9;stop-opacity:0.1"/>
<stop offset="1" style="stop-color:#508480"/>
</linearGradient>
<circle style="fill:none;stroke:url(#SVGID_00000165954078947660943750000008230134672540192957_);stroke-width:3.2;stroke-miterlimit:10;" cx="23.7" cy="23.7" r="21.7"/>
<polygon class="st4" points="21.2,23 15.2,43.9 27.7,26 33.7,5 "/>
<polygon class="st5" points="14.3,43.2 26.8,25.2 20.3,22.2 "/>
<polygon class="st6" points="32.8,4.3 20.3,22.2 26.8,25.2 "/>
<polyline class="st7" points="14.3,43.2 26.8,25.2 32.8,4.3 "/>
<circle class="st8" cx="23.6" cy="23.6" r="1.5"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 251 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

@ -2,7 +2,7 @@
<head>
<meta http-equiv="Content-Type" content="text/html; charset=ISO-8859-1"/>
<link rel="stylesheet" href="/downtime/base.css">
<link rel="shortcut icon" type="image/png" href="/downtime/favicon.png"/>
<link rel="shortcut icon" type="image/svg+xml" href="/downtime/favicon.svg"/>
<title>Wartungsarbeiten</title>
</head>
<body>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

@ -0,0 +1,100 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="56.216759mm"
height="56.216759mm"
viewBox="0 0 56.216757 56.216759"
version="1.1"
id="svg5"
inkscape:version="1.2.2 (b0a8486541, 2022-12-01)"
sodipodi:docname="favicon.svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview7"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:showpageshadow="2"
inkscape:pageopacity="0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="mm"
showgrid="false"
inkscape:zoom="2.149246"
inkscape:cx="-9.305589"
inkscape:cy="38.618194"
inkscape:window-width="1920"
inkscape:window-height="1131"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="layer1" />
<defs
id="defs2">
<rect
x="182.14978"
y="169.88068"
width="232.517"
height="79.842117"
id="rect1516" />
</defs>
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(-29.946331,-23.342808)">
<circle
style="fill:#57ab27;fill-opacity:1;stroke-width:1.48706;paint-order:markers fill stroke"
id="path1730-6-1"
cx="58.05471"
cy="51.451187"
r="28.108379" />
<path
id="path3966-9-1"
style="fill:#ffffff;stroke-width:1.25619;paint-order:markers fill stroke"
inkscape:transform-center-x="-3.782124"
inkscape:transform-center-y="-4.3700306"
d="m 80.920792,53.466318 c -0.470466,3.311904 -13.350103,2.12902 -13.587434,2.635683 -0.679604,1.45084 3.938362,4.608269 6.042483,9.523902 1.290814,3.015588 -7.047323,-4.101451 -9.96792,-2.601752 -2.920596,1.499698 1.923956,15.743775 -1.35023,16.024675 -3.274187,0.2809 -5.545456,-15.647445 -6.494518,-16.033885 0,0 -14.24792,4.094643 -12.199826,1.483245 2.677707,-3.414185 9.25763,-4.828116 7.202174,-7.479738 -1.999671,-2.579656 -17.963879,8.442198 -19.103134,5.432682 -1.139256,-3.009516 14.035148,-8.666125 13.827521,-10.163424 -0.228782,-1.649843 -14.98569,-4.161215 -14.129021,-7.377038 0.856668,-3.215822 16.386111,0.75886 17.148239,-0.297092 0.816496,-1.131285 -6.937786,-9.696012 -4.409117,-11.776169 2.528672,-2.080158 9.745361,8.500431 10.087158,5.182534 0.123275,-1.196669 -2.960867,-14.105709 0.371765,-14.334482 3.332631,-0.228774 7.580901,13.354936 8.041922,13.749376 0.699867,0.598794 5.430215,-12.068543 8.236543,-10.279927 2.806326,1.788615 -4.56826,14.76871 -4.56889,14.956184 -8.48e-4,0.253286 17.004343,-6.237848 18.142636,-3.187729 1.13829,3.050119 -13.372314,8.616356 -13.110355,9.467028 0.306445,1.091863 10.29047,1.764021 9.820004,5.075927 z"
sodipodi:nodetypes="ssssssssssssssssssscs" />
<path
sodipodi:type="star"
style="fill:#57ab27;fill-opacity:1;stroke-width:0.551392;paint-order:markers fill stroke"
id="path6994"
inkscape:flatsided="false"
sodipodi:sides="8"
sodipodi:cx="55.687187"
sodipodi:cy="56.318855"
sodipodi:r1="10.57478"
sodipodi:r2="5.2873902"
sodipodi:arg1="0.47788294"
sodipodi:arg2="0.87058202"
inkscape:rounded="0.5"
inkscape:randomized="0.129"
d="m 65.047164,60.016711 c -1.043329,2.898852 -4.58494,-0.903394 -7.278213,0.616665 -2.693273,1.520059 3.29224,6.067166 0.367555,6.407593 -2.924685,0.340427 0.630911,-6.090507 -2.288698,-6.186604 -2.919608,-0.0961 -2.860116,5.86842 -5.572988,4.998628 -2.712872,-0.869792 3.356668,-2.945529 1.635993,-5.647407 -1.720675,-2.701878 -5.056026,0.874989 -6.446919,-1.853343 -1.390894,-2.728333 5.701768,1.667645 5.601562,-1.509319 -0.100206,-3.176963 -5.011127,-1.483341 -3.626569,-4.163713 1.384558,-2.680371 2.490677,3.259555 4.40284,0.852349 1.912162,-2.407207 -3.184342,-6.105625 -0.367555,-6.407593 2.816787,-0.301968 1.987545,4.704949 5.164071,4.71759 3.176526,0.01264 0.335826,-7.989334 2.697614,-6.257907 2.361789,1.731427 -0.301995,4.45675 1.239381,6.906686 1.541377,2.449936 5.616663,-1.066225 6.44692,1.853343 0.830256,2.919568 -5.402008,-1.40305 -5.601563,1.509319 -0.199555,2.912368 4.669898,1.264861 3.626569,4.163713 z"
inkscape:transform-center-x="0.14670795"
inkscape:transform-center-y="-0.080514039"
transform="matrix(1.0087409,0,0,1.0087409,2.0271192,-6.9908862)" />
<path
sodipodi:type="star"
style="fill:#ffffff;fill-opacity:1;stroke-width:0.551392;paint-order:markers fill stroke"
id="path6994-80"
inkscape:flatsided="false"
sodipodi:sides="8"
sodipodi:cx="55.687187"
sodipodi:cy="56.318855"
sodipodi:r1="10.57478"
sodipodi:r2="5.2873902"
sodipodi:arg1="0.47788294"
sodipodi:arg2="0.87058202"
inkscape:rounded="0.5"
inkscape:randomized="0.129"
d="m 65.047164,60.016711 c -1.043329,2.898852 -4.58494,-0.903394 -7.278213,0.616665 -2.693273,1.520059 3.29224,6.067166 0.367555,6.407593 -2.924685,0.340427 0.630911,-6.090507 -2.288698,-6.186604 -2.919608,-0.0961 -2.860116,5.86842 -5.572988,4.998628 -2.712872,-0.869792 3.356668,-2.945529 1.635993,-5.647407 -1.720675,-2.701878 -5.056026,0.874989 -6.446919,-1.853343 -1.390894,-2.728333 5.701768,1.667645 5.601562,-1.509319 -0.100206,-3.176963 -5.011127,-1.483341 -3.626569,-4.163713 1.384558,-2.680371 2.490677,3.259555 4.40284,0.852349 1.912162,-2.407207 -3.184342,-6.105625 -0.367555,-6.407593 2.816787,-0.301968 1.987545,4.704949 5.164071,4.71759 3.176526,0.01264 0.335826,-7.989334 2.697614,-6.257907 2.361789,1.731427 -0.301995,4.45675 1.239381,6.906686 1.541377,2.449936 5.616663,-1.066225 6.44692,1.853343 0.830256,2.919568 -5.402008,-1.40305 -5.601563,1.509319 -0.199555,2.912368 4.669898,1.264861 3.626569,4.163713 z"
inkscape:transform-center-x="-0.94540916"
inkscape:transform-center-y="0.54557406"
transform="matrix(-0.2826781,-0.253817,0.253817,-0.2826781,60.377101,79.593343)" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 894 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.6 KiB

After

Width:  |  Height:  |  Size: 284 B

@ -0,0 +1,4 @@
The files in this folder are adapted from the Django JET project
(formerly at https://github.com/geex-arts/django-jet, now at https://github.com/assem-ch/django-jet-reboot).
Django JET is released under AGPLv3 (https://www.gnu.org/licenses/agpl-3.0.en.html).

@ -154,38 +154,30 @@ h6 {
box-shadow: 0px 8px 16px 0px rgba(0,0,0,0.2);
}
/* Links inside the navbar */
.navbar a.nav {
/* The dropdown container */
.dropdown {
float: left;
overflow: hidden;
}
/* Dropdown button */
.dropbtn {
float: right;
font-size: 16px;
color: black;
text-align: center;
padding: 11px 17px;
text-decoration: none;
background-color: rgba(255, 255, 255, 0.9);
border: 1px solid black;
border-radius: 25px;
vertical-align: middle;
margin-left: auto;
cursor: pointer;
}
/* The dropdown container */
.dropdown {
float: left;
overflow: hidden;
}
/* Dropdown button */
.dropbtn {
font-size: 19px;
border: none;
outline: none;
color: black;
padding: 14px 16px;
background-color: rgba(255, 255, 255, 0);
font: inherit; /* Important for vertical align on mobile phones */
margin: 0; /* Important for vertical align on mobile phones */
float: right;
cursor: pointer;
.dropbtn:hover {
background-color: rgba(240, 240, 240, 1.0);
}
/* Add a red background color to navbar links on hover */
@ -345,6 +337,10 @@ a, a:visited {
text-decoration: none;
}
a.dropbtn {
color: black;
}
.helptext {
font-size: 9pt;
}

@ -0,0 +1,4 @@
The fonts in this directory are licensed under the Open Font License (https://openfontlicense.org/).
- Roboto font: https://fonts.google.com/specimen/Roboto
- Oswald font: https://fonts.google.com/specimen/Oswald

@ -0,0 +1,3 @@
- `background.jpeg`:
Reza (https://commons.wikimedia.org/wiki/File:Alps_Panorama_(4954145205).jpg),
CC BY 2.0 <https://creativecommons.org/licenses/by/2.0>, via Wikimedia Commons

Binary file not shown.

Before

Width:  |  Height:  |  Size: 139 KiB

After

Width:  |  Height:  |  Size: 348 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

@ -0,0 +1,100 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="56.216759mm"
height="56.216759mm"
viewBox="0 0 56.216757 56.216759"
version="1.1"
id="svg5"
inkscape:version="1.2.2 (b0a8486541, 2022-12-01)"
sodipodi:docname="favicon.svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview7"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:showpageshadow="2"
inkscape:pageopacity="0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="mm"
showgrid="false"
inkscape:zoom="2.149246"
inkscape:cx="-9.305589"
inkscape:cy="38.618194"
inkscape:window-width="1920"
inkscape:window-height="1131"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="layer1" />
<defs
id="defs2">
<rect
x="182.14978"
y="169.88068"
width="232.517"
height="79.842117"
id="rect1516" />
</defs>
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(-29.946331,-23.342808)">
<circle
style="fill:#57ab27;fill-opacity:1;stroke-width:1.48706;paint-order:markers fill stroke"
id="path1730-6-1"
cx="58.05471"
cy="51.451187"
r="28.108379" />
<path
id="path3966-9-1"
style="fill:#ffffff;stroke-width:1.25619;paint-order:markers fill stroke"
inkscape:transform-center-x="-3.782124"
inkscape:transform-center-y="-4.3700306"
d="m 80.920792,53.466318 c -0.470466,3.311904 -13.350103,2.12902 -13.587434,2.635683 -0.679604,1.45084 3.938362,4.608269 6.042483,9.523902 1.290814,3.015588 -7.047323,-4.101451 -9.96792,-2.601752 -2.920596,1.499698 1.923956,15.743775 -1.35023,16.024675 -3.274187,0.2809 -5.545456,-15.647445 -6.494518,-16.033885 0,0 -14.24792,4.094643 -12.199826,1.483245 2.677707,-3.414185 9.25763,-4.828116 7.202174,-7.479738 -1.999671,-2.579656 -17.963879,8.442198 -19.103134,5.432682 -1.139256,-3.009516 14.035148,-8.666125 13.827521,-10.163424 -0.228782,-1.649843 -14.98569,-4.161215 -14.129021,-7.377038 0.856668,-3.215822 16.386111,0.75886 17.148239,-0.297092 0.816496,-1.131285 -6.937786,-9.696012 -4.409117,-11.776169 2.528672,-2.080158 9.745361,8.500431 10.087158,5.182534 0.123275,-1.196669 -2.960867,-14.105709 0.371765,-14.334482 3.332631,-0.228774 7.580901,13.354936 8.041922,13.749376 0.699867,0.598794 5.430215,-12.068543 8.236543,-10.279927 2.806326,1.788615 -4.56826,14.76871 -4.56889,14.956184 -8.48e-4,0.253286 17.004343,-6.237848 18.142636,-3.187729 1.13829,3.050119 -13.372314,8.616356 -13.110355,9.467028 0.306445,1.091863 10.29047,1.764021 9.820004,5.075927 z"
sodipodi:nodetypes="ssssssssssssssssssscs" />
<path
sodipodi:type="star"
style="fill:#57ab27;fill-opacity:1;stroke-width:0.551392;paint-order:markers fill stroke"
id="path6994"
inkscape:flatsided="false"
sodipodi:sides="8"
sodipodi:cx="55.687187"
sodipodi:cy="56.318855"
sodipodi:r1="10.57478"
sodipodi:r2="5.2873902"
sodipodi:arg1="0.47788294"
sodipodi:arg2="0.87058202"
inkscape:rounded="0.5"
inkscape:randomized="0.129"
d="m 65.047164,60.016711 c -1.043329,2.898852 -4.58494,-0.903394 -7.278213,0.616665 -2.693273,1.520059 3.29224,6.067166 0.367555,6.407593 -2.924685,0.340427 0.630911,-6.090507 -2.288698,-6.186604 -2.919608,-0.0961 -2.860116,5.86842 -5.572988,4.998628 -2.712872,-0.869792 3.356668,-2.945529 1.635993,-5.647407 -1.720675,-2.701878 -5.056026,0.874989 -6.446919,-1.853343 -1.390894,-2.728333 5.701768,1.667645 5.601562,-1.509319 -0.100206,-3.176963 -5.011127,-1.483341 -3.626569,-4.163713 1.384558,-2.680371 2.490677,3.259555 4.40284,0.852349 1.912162,-2.407207 -3.184342,-6.105625 -0.367555,-6.407593 2.816787,-0.301968 1.987545,4.704949 5.164071,4.71759 3.176526,0.01264 0.335826,-7.989334 2.697614,-6.257907 2.361789,1.731427 -0.301995,4.45675 1.239381,6.906686 1.541377,2.449936 5.616663,-1.066225 6.44692,1.853343 0.830256,2.919568 -5.402008,-1.40305 -5.601563,1.509319 -0.199555,2.912368 4.669898,1.264861 3.626569,4.163713 z"
inkscape:transform-center-x="0.14670795"
inkscape:transform-center-y="-0.080514039"
transform="matrix(1.0087409,0,0,1.0087409,2.0271192,-6.9908862)" />
<path
sodipodi:type="star"
style="fill:#ffffff;fill-opacity:1;stroke-width:0.551392;paint-order:markers fill stroke"
id="path6994-80"
inkscape:flatsided="false"
sodipodi:sides="8"
sodipodi:cx="55.687187"
sodipodi:cy="56.318855"
sodipodi:r1="10.57478"
sodipodi:r2="5.2873902"
sodipodi:arg1="0.47788294"
sodipodi:arg2="0.87058202"
inkscape:rounded="0.5"
inkscape:randomized="0.129"
d="m 65.047164,60.016711 c -1.043329,2.898852 -4.58494,-0.903394 -7.278213,0.616665 -2.693273,1.520059 3.29224,6.067166 0.367555,6.407593 -2.924685,0.340427 0.630911,-6.090507 -2.288698,-6.186604 -2.919608,-0.0961 -2.860116,5.86842 -5.572988,4.998628 -2.712872,-0.869792 3.356668,-2.945529 1.635993,-5.647407 -1.720675,-2.701878 -5.056026,0.874989 -6.446919,-1.853343 -1.390894,-2.728333 5.701768,1.667645 5.601562,-1.509319 -0.100206,-3.176963 -5.011127,-1.483341 -3.626569,-4.163713 1.384558,-2.680371 2.490677,3.259555 4.40284,0.852349 1.912162,-2.407207 -3.184342,-6.105625 -0.367555,-6.407593 2.816787,-0.301968 1.987545,4.704949 5.164071,4.71759 3.176526,0.01264 0.335826,-7.989334 2.697614,-6.257907 2.361789,1.731427 -0.301995,4.45675 1.239381,6.906686 1.541377,2.449936 5.616663,-1.066225 6.44692,1.853343 0.830256,2.919568 -5.402008,-1.40305 -5.601563,1.509319 -0.199555,2.912368 4.669898,1.264861 3.626569,4.163713 z"
inkscape:transform-center-x="-0.94540916"
inkscape:transform-center-y="0.54557406"
transform="matrix(-0.2826781,-0.253817,0.253817,-0.2826781,60.377101,79.593343)" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.3 KiB

@ -0,0 +1,102 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="33.394791mm"
height="38.402649mm"
viewBox="0 0 33.39479 38.402649"
version="1.1"
id="svg5"
inkscape:version="1.2.2 (b0a8486541, 2022-12-01)"
sodipodi:docname="placeholder.svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:cc="http://creativecommons.org/ns#">
<sodipodi:namedview
id="namedview7"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="mm"
showgrid="false"
showguides="true"
inkscape:zoom="4.8176525"
inkscape:cx="53.449268"
inkscape:cy="87.179389"
inkscape:window-width="1920"
inkscape:window-height="1131"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="layer1">
<sodipodi:guide
position="61.505519,-44.730047"
orientation="0,-1"
id="guide6240"
inkscape:locked="false" />
<sodipodi:guide
position="121.55171,-27.740338"
orientation="1,0"
id="guide6242"
inkscape:locked="false" />
<sodipodi:guide
position="-16.760398,-6.5643082"
orientation="0,-1"
id="guide6244"
inkscape:locked="false" />
<sodipodi:guide
position="50.270998,-1.4630882"
orientation="1,0"
id="guide6246"
inkscape:locked="false" />
</sodipodi:namedview>
<defs
id="defs2" />
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(-16.876211,-15.478853)">
<path
id="rect5358"
style="fill:#cdcdcd;fill-opacity:1;fill-rule:nonzero;stroke-width:0.262325;paint-order:markers fill stroke"
d="m 28.798227,38.417392 10.670704,-0.05716 c 5.784556,-0.03099 10.802072,5.45565 10.802071,8.875741 l -1e-6,2.278154 v 4.367374 l -28.549274,2e-6 -4.845516,-2e-6 v -6.31172 c 0,-5.567546 7.738888,-9.129984 11.922018,-9.152391 z"
sodipodi:nodetypes="sssccccsss" />
<circle
style="fill:#cdcdcd;fill-opacity:1;fill-rule:nonzero;stroke-width:0.718272;paint-order:markers fill stroke"
id="path11605"
cx="33.573605"
cy="26.010752"
r="8.5013752" />
</g>
<metadata
id="metadata12706">
<rdf:RDF>
<cc:Work
rdf:about="">
<cc:license
rdf:resource="http://creativecommons.org/licenses/by/4.0/" />
</cc:Work>
<cc:License
rdf:about="http://creativecommons.org/licenses/by/4.0/">
<cc:permits
rdf:resource="http://creativecommons.org/ns#Reproduction" />
<cc:permits
rdf:resource="http://creativecommons.org/ns#Distribution" />
<cc:requires
rdf:resource="http://creativecommons.org/ns#Notice" />
<cc:requires
rdf:resource="http://creativecommons.org/ns#Attribution" />
<cc:permits
rdf:resource="http://creativecommons.org/ns#DerivativeWorks" />
</cc:License>
</rdf:RDF>
</metadata>
</svg>

After

Width:  |  Height:  |  Size: 3.4 KiB

@ -2,7 +2,7 @@
{% load static i18n %}
{# Setup favicon #}
{% block extrahead %}<link rel="shortcut icon" type="image/png" href="{% static 'admin/img/favicon.png' %}"/>{% endblock %}
{% block extrahead %}<link rel="shortcut icon" type="image/png" href="{% static 'admin/img/favicon.svg' %}"/>{% endblock %}
{# Setup browser tab label #}
{% block title %}{{ title }} | KOMPASS {% endblock %}
@ -12,9 +12,9 @@
<h1 id="site-name">
<a href="{% url 'admin:index' %}">
{# Your logo here #}
<img src="{% static 'admin/img/jdav_logo_transparent.png' %}" alt="Your Company Name" height="50%" width="50%">
<img src="{% static 'admin/img/favicon_bright.svg' %}" alt="KOMPASS" height="50px" width="50px">
<br><br>
</span> KOMPASS
</span>
</a>
</h1>
{% endblock %}

@ -123,7 +123,7 @@ Hier kannst du E-Mails an deine Gruppe oder an andere Menschen in der JDAV {% se
{% if link.icon %}
<img src="{{ link.icon.url }}" alt="">
{% else %}
<img src="{% static 'admin/img/favicon.png' %}" style="transform: scale(0.8);" alt="">
<img src="{% static 'admin/img/favicon.svg' %}" style="transform: scale(0.8);" alt="">
{% endif %}
<div class="icon-text">
<span class="icon-title">{{ link.title }}</span>
@ -134,7 +134,7 @@ Hier kannst du E-Mails an deine Gruppe oder an andere Menschen in der JDAV {% se
{% endfor %}
<a href="{% settings_value 'DOCS_LINK' %}" class="icon-item" target="_blank">
<img src="{% static 'admin/img/favicon.png' %}" style="transform: scale(0.8);" alt="">
<img src="{% static 'admin/img/favicon.svg' %}" style="transform: scale(0.8);" alt="">
<div class="icon-text">
<span class="icon-title">Kompass-Dokumentation</span>
<span class="icon-subtext">Anleitung zur Benutzung des Kompasses</span>

@ -50,7 +50,7 @@ Hier siehst du alle von dir geleiteten Ausfahrten und für dich sichtbare Teilne
<div class="app-members module current-app">
<h2>Neue Mitglieder</h2>
<p>
{% if perms.member.may_manage_waiting_list %}
{% if perms.members.view_global_memberwaitinglist %}
Um ein neues Mitglied anzulegen, muss sich die Person
<a href="{% url 'members:register_waiting_list' %}">anmelden</a>. Daraufhin landet
sie auf der <a href="{% url 'admin:members_memberwaitinglist_changelist' %}">Warteliste</a>. Eine
@ -59,6 +59,8 @@ Diese Einladung enthält einen Registrierungslink zu einem Formular in dem die P
Stammdaten eingbit. Diese Daten landen dann unter
<a href="{% url 'admin:members_memberunconfirmedproxy_changelist' %}">Unbestätigte Registrierungen</a>.
{% else %}
Neue Teilnehmer:innen für deine Gruppen werden auf Anfrage von der Warteliste eingeladen. Die ausstehenden
Einladungen für deine Gruppe siehst du unter Warteliste.<br>
Ob über die Warteliste oder über ein Registrierungspasswort,
liegt eine neue Registrierung für eine von dir geleitete Jugendgruppe vor, kannst du die hier einsehen
und die Daten prüfen. Falls die Daten vollständig sind, bestätige die Registrierung um die Person in deine
@ -66,7 +68,7 @@ Jugendgruppe aufzunehmen.
{% endif %}
</p>
<table>
{% if perms.member.may_manage_waiting_list %}
{% if perms.members.view_memberwaitinglist %}
<tr>
<th scope="row">
<a href="{% url 'admin:members_memberwaitinglist_changelist' %}">Warteliste</a>

@ -3,6 +3,11 @@
{% block object-tools-items %}
<li>
{% url opts|admin_urlname:'request_registration_form' original.pk|admin_urlquote as request_url %}
<a class="historylink" href="{% add_preserved_filters request_url %}">{% trans 'Request registration form' %}</a>
</li>
<li>
{% url opts|admin_urlname:'demote' original.pk|admin_urlquote as demote_url %}
<a class="historylink" href="{% add_preserved_filters demote_url %}">{% trans 'Demote to waiter' %}</a>

@ -3,10 +3,12 @@
{% block object-tools-items %}
{% if perms.members.change_global_memberwaitinglist %}
<li>
{% url opts|admin_urlname:'invite' original.pk|admin_urlquote as invite_url %}
<a class="historylink" href="{% add_preserved_filters invite_url %}">{% trans 'Invite to group' %}</a>
</li>
{% endif %}
{{block.super}}

@ -4,10 +4,12 @@
{% block submit-row %}
{{block.super}}
{% if perms.members.change_global_memberwaitinglist %}
<p class="deletelink-box">
{% url opts|admin_urlname:'invite' original.pk|admin_urlquote as invite_url %}
<a class="button" style="" href="{% add_preserved_filters invite_url %}">{% trans 'Invite to group' %}</a>
</p>
{% endif %}
{% endblock %}

@ -5,6 +5,8 @@ from django.core.exceptions import ValidationError
from django.utils.translation import gettext_lazy as _
from decimal import Decimal, ROUND_HALF_DOWN
import unicodedata
import logging
logger = logging.getLogger(__name__)
def file_size_validator(max_upload_size):
@ -50,7 +52,7 @@ class RestrictedFileField(models.FileField):
'{}').format(self.max_upload_size,
f._size))
except AttributeError as e:
print(e)
logger.warning(e)
return data

Loading…
Cancel
Save