diff --git a/.gitignore b/.gitignore index 2e3fb03..1d70fe4 100644 --- a/.gitignore +++ b/.gitignore @@ -133,3 +133,6 @@ jdav_web/static/docs # mac files .DS_Store + +# Claude configuration file +CLAUDE.md diff --git a/NOTICE.txt b/NOTICE.txt new file mode 100644 index 0000000..f68b0f1 --- /dev/null +++ b/NOTICE.txt @@ -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. diff --git a/README.md b/README.md index e37f645..b62aa3a 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/docker/test/entrypoint-master.sh b/docker/test/entrypoint-master.sh index e36cc94..6dc1db9 100755 --- a/docker/test/entrypoint-master.sh +++ b/docker/test/entrypoint-master.sh @@ -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 diff --git a/docs/source/_static/favicon.png b/docs/source/_static/favicon.png deleted file mode 100644 index bac2794..0000000 Binary files a/docs/source/_static/favicon.png and /dev/null differ diff --git a/docs/source/_static/favicon.svg b/docs/source/_static/favicon.svg new file mode 100644 index 0000000..af52b6d --- /dev/null +++ b/docs/source/_static/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/docs/source/conf.py b/docs/source/conf.py index f261b16..2bc295e 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -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"], diff --git a/jdav_web/contrib/admin.py b/jdav_web/contrib/admin.py index 4346029..96a3838 100644 --- a/jdav_web/contrib/admin.py +++ b/jdav_web/contrib/admin.py @@ -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 = {} diff --git a/jdav_web/finance/admin.py b/jdav_web/finance/admin.py index 360cb02..c19e9ad 100644 --- a/jdav_web/finance/admin.py +++ b/jdav_web/finance/admin.py @@ -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,))) diff --git a/jdav_web/finance/tests/admin.py b/jdav_web/finance/tests/admin.py index 944903b..320a14f 100644 --- a/jdav_web/finance/tests/admin.py +++ b/jdav_web/finance/tests/admin.py @@ -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('/') diff --git a/jdav_web/finance/tests/models.py b/jdav_web/finance/tests/models.py index 0cfc61a..0ff7553 100644 --- a/jdav_web/finance/tests/models.py +++ b/jdav_web/finance/tests/models.py @@ -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): diff --git a/jdav_web/jdav_web/settings/__init__.py b/jdav_web/jdav_web/settings/__init__.py index e474d0e..060c16e 100644 --- a/jdav_web/jdav_web/settings/__init__.py +++ b/jdav_web/jdav_web/settings/__init__.py @@ -58,6 +58,7 @@ base_settings = [ 'components/emails.py', 'components/texts.py', 'components/locale.py', + 'components/logging.py', 'components/oauth.py', ] diff --git a/jdav_web/jdav_web/settings/components/logging.py b/jdav_web/jdav_web/settings/components/logging.py new file mode 100644 index 0000000..464eb47 --- /dev/null +++ b/jdav_web/jdav_web/settings/components/logging.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, + }, + }, +} diff --git a/jdav_web/jdav_web/tests.py b/jdav_web/jdav_web/tests.py new file mode 100644 index 0000000..6714873 --- /dev/null +++ b/jdav_web/jdav_web/tests.py @@ -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) diff --git a/jdav_web/mailer/mailutils.py b/jdav_web/mailer/mailutils.py index b78cb1d..d3de5af 100644 --- a/jdav_web/mailer/mailutils.py +++ b/jdav_web/mailer/mailutils.py @@ -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 diff --git a/jdav_web/mailer/models.py b/jdav_web/mailer/models.py index e9a5199..dc0ee73 100644 --- a/jdav_web/mailer/models.py +++ b/jdav_web/mailer/models.py @@ -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() diff --git a/jdav_web/mailer/tests/__init__.py b/jdav_web/mailer/tests/__init__.py index 0d2e866..7d5234a 100644 --- a/jdav_web/mailer/tests/__init__.py +++ b/jdav_web/mailer/tests/__init__.py @@ -2,3 +2,4 @@ from .models import * from .admin import * from .views import * from .rules import * +from .mailutils import * diff --git a/jdav_web/mailer/tests/mailutils.py b/jdav_web/mailer/tests/mailutils.py new file mode 100644 index 0000000..00eed05 --- /dev/null +++ b/jdav_web/mailer/tests/mailutils.py @@ -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="") + 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) \ No newline at end of file diff --git a/jdav_web/members/admin.py b/jdav_web/members/admin.py index f7575d7..0b8bf04 100644 --- a/jdav_web/members/admin.py +++ b/jdav_web/members/admin.py @@ -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( + "/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 = { diff --git a/jdav_web/members/locale/de/LC_MESSAGES/django.po b/jdav_web/members/locale/de/LC_MESSAGES/django.po index 787b965..675f954 100644 --- a/jdav_web/members/locale/de/LC_MESSAGES/django.po +++ b/jdav_web/members/locale/de/LC_MESSAGES/django.po @@ -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 \n" "Language-Team: LANGUAGE \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." diff --git a/jdav_web/members/migrations/0010_create_default_permission_groups.py b/jdav_web/members/migrations/0010_create_default_permission_groups.py index f5cd8ee..086b8f7 100644 --- a/jdav_web/members/migrations/0010_create_default_permission_groups.py +++ b/jdav_web/members/migrations/0010_create_default_permission_groups.py @@ -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) diff --git a/jdav_web/members/migrations/0043_waitinglist_permissions.py b/jdav_web/members/migrations/0043_waitinglist_permissions.py new file mode 100644 index 0000000..d090357 --- /dev/null +++ b/jdav_web/members/migrations/0043_waitinglist_permissions.py @@ -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), + ] diff --git a/jdav_web/members/models.py b/jdav_web/members/models.py index a254b54..3e624b9 100644 --- a/jdav_web/members/models.py +++ b/jdav_web/members/models.py @@ -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 diff --git a/jdav_web/members/pdf.py b/jdav_web/members/pdf.py index 57e414a..d55060e 100644 --- a/jdav_web/members/pdf.py +++ b/jdav_web/members/pdf.py @@ -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): diff --git a/jdav_web/members/rules.py b/jdav_web/members/rules.py index 4193f68..6c8762d 100644 --- a/jdav_web/members/rules.py +++ b/jdav_web/members/rules.py @@ -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() diff --git a/jdav_web/members/tasks.py b/jdav_web/members/tasks.py index f114396..fa773ae 100644 --- a/jdav_web/members/tasks.py +++ b/jdav_web/members/tasks.py @@ -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 diff --git a/jdav_web/members/templates/admin/request_registration_form.html b/jdav_web/members/templates/admin/request_registration_form.html new file mode 100644 index 0000000..be44696 --- /dev/null +++ b/jdav_web/members/templates/admin/request_registration_form.html @@ -0,0 +1,40 @@ +{% extends "admin/base_site.html" %} +{% load i18n admin_urls static %} + +{% block extrahead %} + {{ block.super }} + {{ media }} + + + +{% endblock %} + +{% block bodyclass %}{{ block.super }} app-{{ opts.app_label }} model-{{ opts.model_name }} invite-waiter +{% endblock %} + +{% block breadcrumbs %} + +{% endblock %} + +{% block content %} +

{% translate "Request registration form" %}

+

+{% blocktrans %}Do you want to ask {{ member }} to upload their registration form?{% endblocktrans %} +

+

+{% if member.registration_form %} +{% blocktrans %}Warning: {{ member }} has already uploaded a registration form.{% endblocktrans %} +{% endif %} +

+ +
+ {% csrf_token %} + + {% translate "Cancel" %} +
+{% endblock %} diff --git a/jdav_web/members/templates/admin/request_registratoin_form.html b/jdav_web/members/templates/admin/request_registratoin_form.html new file mode 100644 index 0000000..661ddef --- /dev/null +++ b/jdav_web/members/templates/admin/request_registratoin_form.html @@ -0,0 +1,48 @@ +{% extends "admin/base_site.html" %} +{% load i18n admin_urls static %} + +{% block extrahead %} + {{ block.super }} + {{ media }} + + + +{% endblock %} + +{% block bodyclass %}{{ block.super }} app-{{ opts.app_label }} model-{{ opts.model_name }} invite-waiter +{% endblock %} + +{% block breadcrumbs %} + +{% endblock %} + +{% block content %} +

{% translate "Demote to waiter" %}

+

+{% trans "Do you want to demote the following unconfirmed registrations to waiters?" %} +

+

+

    + {% for member in queryset %} +
  • + {{ member }} +
  • + {% endfor %} +
+

+ +
+ {% csrf_token %} + {% if form %} + {{form}} + {% endif %} + + + {% translate "Cancel" %} +
+{% endblock %} diff --git a/jdav_web/members/tests/basic.py b/jdav_web/members/tests/basic.py index 2758c84..6bb29df 100644 --- a/jdav_web/members/tests/basic.py +++ b/jdav_web/members/tests/basic.py @@ -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): diff --git a/jdav_web/members/tests/utils.py b/jdav_web/members/tests/utils.py index 9660aea..46dc986 100644 --- a/jdav_web/members/tests/utils.py +++ b/jdav_web/members/tests/utils.py @@ -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 diff --git a/jdav_web/members/views.py b/jdav_web/members/views.py index 29ade70..9938b81 100644 --- a/jdav_web/members/views.py +++ b/jdav_web/members/views.py @@ -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): diff --git a/jdav_web/startpage/locale/de/LC_MESSAGES/django.po b/jdav_web/startpage/locale/de/LC_MESSAGES/django.po index 1ba8c5b..9b815e8 100644 --- a/jdav_web/startpage/locale/de/LC_MESSAGES/django.po +++ b/jdav_web/startpage/locale/de/LC_MESSAGES/django.po @@ -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 \n" "Language-Team: LANGUAGE \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!" diff --git a/jdav_web/startpage/templates/startpage/base.html b/jdav_web/startpage/templates/startpage/base.html index ae69f54..edec920 100644 --- a/jdav_web/startpage/templates/startpage/base.html +++ b/jdav_web/startpage/templates/startpage/base.html @@ -7,7 +7,7 @@ - + diff --git a/jdav_web/startpage/templates/startpage/impressum.html b/jdav_web/startpage/templates/startpage/impressum.html index 97e8914..71a1a21 100644 --- a/jdav_web/startpage/templates/startpage/impressum.html +++ b/jdav_web/startpage/templates/startpage/impressum.html @@ -1,8 +1,31 @@ {% extends "startpage/base_subsite.html" %} -{% load static %} +{% load static common i18n %} {% block content %} {% include "startpage/impressum_content.html" %} +{% block attribution %} +

{% trans "Attributions" %}

+ +

+{% trans "The source code of this website is licensed under" %} +AGPLv3. +{% trans "Copyright © 2025 JDAV Sektion " %} {% settings_value 'SEKTION' %} +{% trans "for the content of this website." %} +

+ +

+{% trans "External assets used on this website:" %} +

+ +
    +
  • +{% trans "Background image" %}: +Reza, CC BY 2.0, via Wikimedia Commons +
  • +
+ +{% endblock %} + {% endblock %} diff --git a/jdav_web/startpage/templates/startpage/navigation.html b/jdav_web/startpage/templates/startpage/navigation.html index 731e0ef..267afcf 100644 --- a/jdav_web/startpage/templates/startpage/navigation.html +++ b/jdav_web/startpage/templates/startpage/navigation.html @@ -44,7 +44,7 @@ window.onclick = function(event) { {% if not redirect_url %}
- Login + Login
{% endif %} diff --git a/jdav_web/startpage/templates/startpage/people_grid.html b/jdav_web/startpage/templates/startpage/people_grid.html index 9b17f1a..0b853e8 100644 --- a/jdav_web/startpage/templates/startpage/people_grid.html +++ b/jdav_web/startpage/templates/startpage/people_grid.html @@ -8,7 +8,7 @@ {% if member.image %} {% else %} - + {% endif %}
{{ member.name }}
diff --git a/jdav_web/startpage/templates/startpage/post_people_detail.html b/jdav_web/startpage/templates/startpage/post_people_detail.html index 62d7d65..cec3330 100644 --- a/jdav_web/startpage/templates/startpage/post_people_detail.html +++ b/jdav_web/startpage/templates/startpage/post_people_detail.html @@ -13,7 +13,7 @@ {% if member.image %} {% else %} - + {% endif %}
{{ member.name }}
diff --git a/jdav_web/startpage/templates/startpage/post_people_grid.html b/jdav_web/startpage/templates/startpage/post_people_grid.html index fcbaad1..663e85f 100644 --- a/jdav_web/startpage/templates/startpage/post_people_grid.html +++ b/jdav_web/startpage/templates/startpage/post_people_grid.html @@ -9,7 +9,7 @@ {% if member.image %} {% else %} - + {% endif %}
{{ member.name }}
diff --git a/jdav_web/startpage/tests.py b/jdav_web/startpage/tests.py index 0635d84..eaaa0b2 100644 --- a/jdav_web/startpage/tests.py +++ b/jdav_web/startpage/tests.py @@ -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) diff --git a/jdav_web/static/admin/img/NOTICE.txt b/jdav_web/static/admin/img/NOTICE.txt new file mode 100644 index 0000000..dfcb413 --- /dev/null +++ b/jdav_web/static/admin/img/NOTICE.txt @@ -0,0 +1,3 @@ +- `climber.png`: + Paul Sherman (https://commons.wikimedia.org/wiki/File:Rock_climbing_vector.svg), + Public Domain, via Wikimedia Commons diff --git a/jdav_web/static/admin/img/favicon.png b/jdav_web/static/admin/img/favicon.png deleted file mode 100644 index bac2794..0000000 Binary files a/jdav_web/static/admin/img/favicon.png and /dev/null differ diff --git a/jdav_web/static/admin/img/favicon.svg b/jdav_web/static/admin/img/favicon.svg new file mode 100644 index 0000000..af52b6d --- /dev/null +++ b/jdav_web/static/admin/img/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/jdav_web/static/admin/img/favicon_bright.svg b/jdav_web/static/admin/img/favicon_bright.svg new file mode 100644 index 0000000..51fbe8b --- /dev/null +++ b/jdav_web/static/admin/img/favicon_bright.svg @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/jdav_web/static/admin/img/jdav_logo_transparent.png b/jdav_web/static/admin/img/jdav_logo_transparent.png deleted file mode 100644 index d3ef7b7..0000000 Binary files a/jdav_web/static/admin/img/jdav_logo_transparent.png and /dev/null differ diff --git a/jdav_web/static/admin/img/logo_sidebar.png b/jdav_web/static/admin/img/logo_sidebar.png new file mode 100644 index 0000000..fa5ed60 Binary files /dev/null and b/jdav_web/static/admin/img/logo_sidebar.png differ diff --git a/jdav_web/static/downtime/502.html b/jdav_web/static/downtime/502.html index 439f9c4..9328436 100644 --- a/jdav_web/static/downtime/502.html +++ b/jdav_web/static/downtime/502.html @@ -2,7 +2,7 @@ - + Wartungsarbeiten diff --git a/jdav_web/static/downtime/favicon.png b/jdav_web/static/downtime/favicon.png deleted file mode 100644 index f2a0608..0000000 Binary files a/jdav_web/static/downtime/favicon.png and /dev/null differ diff --git a/jdav_web/static/downtime/favicon.svg b/jdav_web/static/downtime/favicon.svg new file mode 100644 index 0000000..e2b399e --- /dev/null +++ b/jdav_web/static/downtime/favicon.svg @@ -0,0 +1,100 @@ + + + + + + + + + + + + + + + diff --git a/jdav_web/static/general/img/dav_logo_sektion.png b/jdav_web/static/general/img/dav_logo_sektion.png index 8ff290f..0f9e8e0 100644 Binary files a/jdav_web/static/general/img/dav_logo_sektion.png and b/jdav_web/static/general/img/dav_logo_sektion.png differ diff --git a/jdav_web/static/general/img/favicon.ico b/jdav_web/static/general/img/favicon.ico deleted file mode 100644 index df58720..0000000 Binary files a/jdav_web/static/general/img/favicon.ico and /dev/null differ diff --git a/jdav_web/static/general/img/jdav_logo_sektion.png b/jdav_web/static/general/img/jdav_logo_sektion.png index 3fa59b7..bc7c248 100644 Binary files a/jdav_web/static/general/img/jdav_logo_sektion.png and b/jdav_web/static/general/img/jdav_logo_sektion.png differ diff --git a/jdav_web/static/jet/NOTICES.txt b/jdav_web/static/jet/NOTICES.txt new file mode 100644 index 0000000..acb4139 --- /dev/null +++ b/jdav_web/static/jet/NOTICES.txt @@ -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). diff --git a/jdav_web/static/startpage/css/base.css b/jdav_web/static/startpage/css/base.css index 2530db7..187cefe 100644 --- a/jdav_web/static/startpage/css/base.css +++ b/jdav_web/static/startpage/css/base.css @@ -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; } diff --git a/jdav_web/static/startpage/fonts/NOTICE.txt b/jdav_web/static/startpage/fonts/NOTICE.txt new file mode 100644 index 0000000..2d538e2 --- /dev/null +++ b/jdav_web/static/startpage/fonts/NOTICE.txt @@ -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 diff --git a/jdav_web/static/startpage/img/NOTICE.txt b/jdav_web/static/startpage/img/NOTICE.txt new file mode 100644 index 0000000..029cb67 --- /dev/null +++ b/jdav_web/static/startpage/img/NOTICE.txt @@ -0,0 +1,3 @@ +- `background.jpeg`: + Reza (https://commons.wikimedia.org/wiki/File:Alps_Panorama_(4954145205).jpg), + CC BY 2.0 , via Wikimedia Commons diff --git a/jdav_web/static/startpage/img/background.jpeg b/jdav_web/static/startpage/img/background.jpeg index 10be4e1..c843289 100644 Binary files a/jdav_web/static/startpage/img/background.jpeg and b/jdav_web/static/startpage/img/background.jpeg differ diff --git a/jdav_web/static/startpage/img/favicon.png b/jdav_web/static/startpage/img/favicon.png deleted file mode 100644 index f2a0608..0000000 Binary files a/jdav_web/static/startpage/img/favicon.png and /dev/null differ diff --git a/jdav_web/static/startpage/img/favicon.svg b/jdav_web/static/startpage/img/favicon.svg new file mode 100644 index 0000000..e2b399e --- /dev/null +++ b/jdav_web/static/startpage/img/favicon.svg @@ -0,0 +1,100 @@ + + + + + + + + + + + + + + + diff --git a/jdav_web/static/startpage/img/placeholder.jpg b/jdav_web/static/startpage/img/placeholder.jpg deleted file mode 100644 index 8b29218..0000000 Binary files a/jdav_web/static/startpage/img/placeholder.jpg and /dev/null differ diff --git a/jdav_web/static/startpage/img/placeholder.svg b/jdav_web/static/startpage/img/placeholder.svg new file mode 100644 index 0000000..8721d95 --- /dev/null +++ b/jdav_web/static/startpage/img/placeholder.svg @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/jdav_web/templates/admin/base_site.html b/jdav_web/templates/admin/base_site.html index 6fc7db6..426b26a 100644 --- a/jdav_web/templates/admin/base_site.html +++ b/jdav_web/templates/admin/base_site.html @@ -2,7 +2,7 @@ {% load static i18n %} {# Setup favicon #} -{% block extrahead %}{% endblock %} +{% block extrahead %}{% endblock %} {# Setup browser tab label #} {% block title %}{{ title }} | KOMPASS {% endblock %} @@ -12,9 +12,9 @@

{# Your logo here #} - Your Company Name + KOMPASS

- KOMPASS +

{% endblock %} diff --git a/jdav_web/templates/admin/index.html b/jdav_web/templates/admin/index.html index 6d60e91..f5484b7 100644 --- a/jdav_web/templates/admin/index.html +++ b/jdav_web/templates/admin/index.html @@ -123,7 +123,7 @@ Hier kannst du E-Mails an deine Gruppe oder an andere Menschen in der JDAV {% se {% if link.icon %} {% else %} - + {% endif %}
{{ link.title }} @@ -134,7 +134,7 @@ Hier kannst du E-Mails an deine Gruppe oder an andere Menschen in der JDAV {% se {% endfor %} - +
Kompass-Dokumentation Anleitung zur Benutzung des Kompasses diff --git a/jdav_web/templates/admin/members/app_index.html b/jdav_web/templates/admin/members/app_index.html index 11d6fc0..447e4e8 100644 --- a/jdav_web/templates/admin/members/app_index.html +++ b/jdav_web/templates/admin/members/app_index.html @@ -50,7 +50,7 @@ Hier siehst du alle von dir geleiteten Ausfahrten und für dich sichtbare Teilne

Neue Mitglieder

-{% if perms.member.may_manage_waiting_list %} +{% if perms.members.view_global_memberwaitinglist %} Um ein neues Mitglied anzulegen, muss sich die Person anmelden. Daraufhin landet sie auf der Warteliste. 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 Unbestätigte Registrierungen. {% 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.
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 %}

- {% if perms.member.may_manage_waiting_list %} + {% if perms.members.view_memberwaitinglist %}
Warteliste diff --git a/jdav_web/templates/admin/members/memberunconfirmedproxy/change_form_object_tools.html b/jdav_web/templates/admin/members/memberunconfirmedproxy/change_form_object_tools.html index c3fbe46..b39d39a 100644 --- a/jdav_web/templates/admin/members/memberunconfirmedproxy/change_form_object_tools.html +++ b/jdav_web/templates/admin/members/memberunconfirmedproxy/change_form_object_tools.html @@ -3,6 +3,11 @@ {% block object-tools-items %} +
  • + {% url opts|admin_urlname:'request_registration_form' original.pk|admin_urlquote as request_url %} + {% trans 'Request registration form' %} +
  • +
  • {% url opts|admin_urlname:'demote' original.pk|admin_urlquote as demote_url %} {% trans 'Demote to waiter' %} diff --git a/jdav_web/templates/admin/members/memberwaitinglist/change_form_object_tools.html b/jdav_web/templates/admin/members/memberwaitinglist/change_form_object_tools.html index 9b1112b..f5bdf54 100644 --- a/jdav_web/templates/admin/members/memberwaitinglist/change_form_object_tools.html +++ b/jdav_web/templates/admin/members/memberwaitinglist/change_form_object_tools.html @@ -3,10 +3,12 @@ {% block object-tools-items %} +{% if perms.members.change_global_memberwaitinglist %}
  • {% url opts|admin_urlname:'invite' original.pk|admin_urlquote as invite_url %} {% trans 'Invite to group' %}
  • +{% endif %} {{block.super}} diff --git a/jdav_web/templates/admin/members/memberwaitinglist/submit_line.html b/jdav_web/templates/admin/members/memberwaitinglist/submit_line.html index 24411e1..9a4944d 100644 --- a/jdav_web/templates/admin/members/memberwaitinglist/submit_line.html +++ b/jdav_web/templates/admin/members/memberwaitinglist/submit_line.html @@ -4,10 +4,12 @@ {% block submit-row %} {{block.super}} +{% if perms.members.change_global_memberwaitinglist %} +{% endif %} {% endblock %} diff --git a/jdav_web/utils.py b/jdav_web/utils.py index 021dd40..669df4e 100644 --- a/jdav_web/utils.py +++ b/jdav_web/utils.py @@ -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