diff --git a/jdav_web/finance/admin.py b/jdav_web/finance/admin.py index 830ed0b..308e9e3 100644 --- a/jdav_web/finance/admin.py +++ b/jdav_web/finance/admin.py @@ -62,17 +62,44 @@ def decorate_statement_view(model, perm=None): return decorator -@admin.register(StatementUnSubmitted) -class StatementUnSubmittedAdmin(CommonAdminMixin, admin.ModelAdmin): +@admin.register(Statement) +class StatementAdmin(CommonAdminMixin, admin.ModelAdmin): fields = ['short_description', 'explanation', 'excursion', 'status'] - list_display = ['__str__', 'excursion', 'created_by'] + list_display = ['__str__', 'total_pretty', 'created_by', 'submitted_date', 'is_valid', 'status_badge'] + list_filter = ['status'] + search_fields = ('excursion__name', 'short_description') + ordering = ['-submitted_date'] inlines = [BillOnStatementInline] + def has_change_permission(self, request, obj=None): + if obj is None: + return super().has_change_permission(request) + if obj.confirmed: + # Confirmed statements may not be changed (they should be unconfirmed first) + return False + return super().has_change_permission(request, obj) + + def has_delete_permission(self, request, obj=None): + if obj is None or obj.submitted: + # Submitted statements may not be deleted (they should be rejected first) + return False + return super().has_delete_permission(request, obj) + def save_model(self, request, obj, form, change): if not change and hasattr(request.user, 'member'): obj.created_by = request.user.member super().save_model(request, obj, form, change) + def get_fields(self, request, obj=None): + if obj is not None and obj.excursion: + # if the object exists and an excursion is set, show the excursion (read only) + # instead of the short description + return ['excursion', 'explanation', 'status'] + else: + # if the object is newly created or no excursion is set, require + # a short description + return ['short_description', 'explanation', 'status'] + def get_readonly_fields(self, request, obj=None): readonly_fields = ['status', 'excursion'] if obj is not None and obj.submitted: @@ -80,6 +107,12 @@ class StatementUnSubmittedAdmin(CommonAdminMixin, admin.ModelAdmin): else: return readonly_fields + def get_inlines(self, request, obj=None): + if obj is None or not obj.submitted: + return [BillOnStatementInline] + else: + return [BillOnSubmittedStatementInline, TransactionOnSubmittedStatementInline] + def get_urls(self): urls = super().get_urls() @@ -96,10 +129,30 @@ class StatementUnSubmittedAdmin(CommonAdminMixin, admin.ModelAdmin): wrap(self.submit_view), name="%s_%s_submit" % (self.opts.app_label, self.opts.model_name), ), + path( + "/overview/", + wrap(self.overview_view), + name="%s_%s_overview" % (self.opts.app_label, self.opts.model_name), + ), + path( + "/reduce_transactions/", + wrap(self.reduce_transactions_view), + name="%s_%s_reduce_transactions" % (self.opts.app_label, self.opts.model_name), + ), + path( + "/unconfirm/", + wrap(self.unconfirm_view), + name="%s_%s_unconfirm" % (self.opts.app_label, self.opts.model_name), + ), + path( + "/summary/", + wrap(self.statement_summary_view), + name="%s_%s_summary" % (self.opts.app_label, self.opts.model_name), + ), ] return custom_urls + urls - @decorate_statement_view(Statement) + @decorate_statement_view(StatementUnSubmitted) def submit_view(self, request, statement): if statement.submitted: # pragma: no cover logger.error(f"submit_view reached with submitted statement {statement}. This should not happen.") @@ -131,91 +184,6 @@ class StatementUnSubmittedAdmin(CommonAdminMixin, admin.ModelAdmin): statement=statement) return render(request, 'admin/submit_statement.html', context=context) - -class TransactionOnSubmittedStatementInline(admin.TabularInline): - model = Transaction - fields = ['amount', 'member', 'reference', 'text_length_warning', 'ledger'] - formfield_overrides = { - TextField: {'widget': Textarea(attrs={'rows': 1, 'cols': 40})} - } - readonly_fields = ['text_length_warning'] - extra = 0 - - def text_length_warning(self, obj): - """Display reference length, warn if exceeds 140 characters.""" - len_reference = len(obj.reference) - len_string = f"{len_reference}/140" - if len_reference > 140: - return mark_safe(f'{len_string}') - - return len_string - text_length_warning.short_description = _("Length") - - -class BillOnSubmittedStatementInline(BillOnStatementInline): - model = BillOnStatementProxy - extra = 0 - sortable_options = [] - fields = ['short_description', 'explanation', 'amount', 'paid_by', 'proof', 'costs_covered'] - formfield_overrides = { - TextField: {'widget': Textarea(attrs={'rows': 1, 'cols': 40})} - } - - def get_readonly_fields(self, request, obj=None): - return ['short_description', 'explanation', 'amount', 'paid_by', 'proof'] - - -@admin.register(StatementSubmitted) -class StatementSubmittedAdmin(admin.ModelAdmin): - fields = ['short_description', 'explanation', 'excursion', 'status'] - list_display = ['__str__', 'is_valid', 'submitted_date', 'submitted_by'] - ordering = ('-submitted_date',) - inlines = [BillOnSubmittedStatementInline, TransactionOnSubmittedStatementInline] - - def has_add_permission(self, request, obj=None): - # Submitted statements should not be added directly, but instead be created - # as unsubmitted statements and then submitted. - return False - - def has_change_permission(self, request, obj=None): - return request.user.has_perm('finance.process_statementsubmitted') - - def has_delete_permission(self, request, obj=None): - # Submitted statements should not be deleted. Instead they can be rejected - # and then deleted as unsubmitted statements. - return False - - def get_readonly_fields(self, request, obj=None): - readonly_fields = ['status'] - if obj is not None and obj.submitted: - return readonly_fields + self.fields - else: - return readonly_fields - - def get_urls(self): - urls = super().get_urls() - - def wrap(view): - def wrapper(*args, **kwargs): - return self.admin_site.admin_view(view)(*args, **kwargs) - - wrapper.model_admin = self - return update_wrapper(wrapper, view) - - custom_urls = [ - path( - "/overview/", - wrap(self.overview_view), - name="%s_%s_overview" % (self.opts.app_label, self.opts.model_name), - ), - path( - "/reduce_transactions/", - wrap(self.reduce_transactions_view), - name="%s_%s_reduce_transactions" % (self.opts.app_label, self.opts.model_name), - ), - ] - return custom_urls + urls - @decorate_statement_view(StatementSubmitted) def overview_view(self, request, statement): if not statement.submitted: # pragma: no cover @@ -238,7 +206,8 @@ class StatementSubmittedAdmin(admin.ModelAdmin): messages.success(request, _("Successfully confirmed %(name)s. I hope you executed the associated transactions, I wont remind you again.") % {'name': str(statement)}) - download_link = reverse('admin:finance_statementconfirmed_summary', args=(statement.pk,)) + download_link = reverse('admin:%s_%s_summary' % (self.opts.app_label, self.opts.model_name), + args=(statement.pk,)) messages.success(request, mark_safe(_("You can download a receipt.") % {'link': download_link})) return HttpResponseRedirect(reverse('admin:%s_%s_changelist' % (self.opts.app_label, self.opts.model_name))) @@ -310,52 +279,6 @@ class StatementSubmittedAdmin(admin.ModelAdmin): messages.success(request, _("Successfully reduced transactions for %(name)s.") % {'name': str(statement)}) return HttpResponseRedirect(request.GET['redirectTo']) - #return HttpResponseRedirect(reverse('admin:%s_%s_change' % (self.opts.app_label, self.opts.model_name), args=(statement.pk,))) - - -@admin.register(StatementConfirmed) -class StatementConfirmedAdmin(admin.ModelAdmin): - fields = ['short_description', 'explanation', 'excursion', 'status'] - #readonly_fields = fields - list_display = ['__str__', 'total_pretty', 'confirmed_date', 'confirmed_by'] - ordering = ('-confirmed_date',) - inlines = [BillOnSubmittedStatementInline, TransactionOnSubmittedStatementInline] - - def has_add_permission(self, request, obj=None): - # To preserve integrity, no one is allowed to add confirmed statements - return False - - def has_change_permission(self, request, obj=None): - # To preserve integrity, no one is allowed to change confirmed statements - return False - - def has_delete_permission(self, request, obj=None): - # To preserve integrity, no one is allowed to delete confirmed statements - return False - - def get_urls(self): - urls = super().get_urls() - - def wrap(view): - def wrapper(*args, **kwargs): - return self.admin_site.admin_view(view)(*args, **kwargs) - - wrapper.model_admin = self - return update_wrapper(wrapper, view) - - custom_urls = [ - path( - "/unconfirm/", - wrap(self.unconfirm_view), - name="%s_%s_unconfirm" % (self.opts.app_label, self.opts.model_name), - ), - path( - "/summary/", - wrap(self.statement_summary_view), - name="%s_%s_summary" % (self.opts.app_label, self.opts.model_name), - ), - ] - return custom_urls + urls @decorate_statement_view(StatementConfirmed, perm='finance.may_manage_confirmed_statements') def unconfirm_view(self, request, statement): @@ -399,6 +322,39 @@ class StatementConfirmedAdmin(admin.ModelAdmin): statement_summary_view.short_description = _('Download summary') +class TransactionOnSubmittedStatementInline(admin.TabularInline): + model = Transaction + fields = ['amount', 'member', 'reference', 'text_length_warning', 'ledger'] + formfield_overrides = { + TextField: {'widget': Textarea(attrs={'rows': 1, 'cols': 40})} + } + readonly_fields = ['text_length_warning'] + extra = 0 + + def text_length_warning(self, obj): + """Display reference length, warn if exceeds 140 characters.""" + len_reference = len(obj.reference) + len_string = f"{len_reference}/140" + if len_reference > 140: + return mark_safe(f'{len_string}') + + return len_string + text_length_warning.short_description = _("Length") + + +class BillOnSubmittedStatementInline(BillOnStatementInline): + model = BillOnStatementProxy + extra = 0 + sortable_options = [] + fields = ['short_description', 'explanation', 'amount', 'paid_by', 'proof', 'costs_covered'] + formfield_overrides = { + TextField: {'widget': Textarea(attrs={'rows': 1, 'cols': 40})} + } + + def get_readonly_fields(self, request, obj=None): + return ['short_description', 'explanation', 'amount', 'paid_by', 'proof'] + + @admin.register(Transaction) class TransactionAdmin(admin.ModelAdmin): """The transaction admin site. This is only used to display transactions. All editing diff --git a/jdav_web/finance/locale/de/LC_MESSAGES/django.po b/jdav_web/finance/locale/de/LC_MESSAGES/django.po index 4895706..0437a8e 100644 --- a/jdav_web/finance/locale/de/LC_MESSAGES/django.po +++ b/jdav_web/finance/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-10-12 11:37+0200\n" +"POT-Creation-Date: 2025-10-16 23:09+0200\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -48,10 +48,6 @@ msgstr "Kostenübersicht" msgid "Submit statement" msgstr "Rechnung einreichen" -#: finance/admin.py -msgid "Length" -msgstr "Länge" - #: finance/admin.py #, python-format msgid "%(name)s is not yet submitted." @@ -180,6 +176,10 @@ msgstr "Bestätigung zurücknehmen" msgid "Download summary" msgstr "Beleg herunterladen" +#: finance/admin.py +msgid "Length" +msgstr "Länge" + #: finance/apps.py msgid "Finance" msgstr "Finanzen" @@ -206,7 +206,7 @@ msgid "Submitted" msgstr "Eingereicht" #: finance/models.py -msgid "Confirmed" +msgid "Completed" msgstr "Abgewickelt" #: finance/models.py @@ -291,11 +291,6 @@ msgstr "Abrechnung" msgid "Statements" msgstr "Abrechnungen" -#: finance/models.py -#, python-format -msgid "Statement: %(excursion)s" -msgstr "Abrechnung: %(excursion)s" - #: finance/models.py #, python-format msgid "Excursion %(excursion)s" diff --git a/jdav_web/finance/migrations/0012_statementonexcursionproxy.py b/jdav_web/finance/migrations/0012_statementonexcursionproxy.py new file mode 100644 index 0000000..ab95dca --- /dev/null +++ b/jdav_web/finance/migrations/0012_statementonexcursionproxy.py @@ -0,0 +1,28 @@ +# Generated by Django 4.2.20 on 2025-10-12 19:16 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('finance', '0011_remove_statement_confirmed_and_submitted'), + ] + + operations = [ + migrations.CreateModel( + name='StatementOnExcursionProxy', + fields=[ + ], + options={ + 'verbose_name': 'Statement', + 'verbose_name_plural': 'Statements', + 'abstract': False, + 'proxy': True, + 'default_permissions': ('add_global', 'change_global', 'view_global', 'delete_global', 'list_global', 'view'), + 'indexes': [], + 'constraints': [], + }, + bases=('finance.statement',), + ), + ] diff --git a/jdav_web/finance/models.py b/jdav_web/finance/models.py index 0356220..fd3d69b 100644 --- a/jdav_web/finance/models.py +++ b/jdav_web/finance/models.py @@ -9,6 +9,7 @@ from django.core.exceptions import ValidationError from django.db import models from django.db.models import Sum from django.utils.translation import gettext_lazy as _ +from django.utils.html import format_html from members.models import Member, Freizeit, OEFFENTLICHE_ANREISE, MUSKELKRAFT_ANREISE from django.conf import settings import rules @@ -54,11 +55,14 @@ class Statement(CommonModel): UNSUBMITTED, SUBMITTED, CONFIRMED = 0, 1, 2 STATUS_CHOICES = [(UNSUBMITTED, _('In preparation')), (SUBMITTED, _('Submitted')), - (CONFIRMED, _('Confirmed'))] + (CONFIRMED, _('Completed'))] + STATUS_CSS_CLASS = { SUBMITTED: 'submitted', + CONFIRMED: 'confirmed', + UNSUBMITTED: 'unsubmitted' } short_description = models.CharField(verbose_name=_('Short description'), max_length=30, - blank=True) + blank=False) explanation = models.TextField(verbose_name=_('Explanation'), blank=True) excursion = models.OneToOneField(Freizeit, verbose_name=_('Associated excursion'), @@ -115,20 +119,16 @@ class Statement(CommonModel): ('may_edit_submitted_statements', 'Is allowed to edit submitted statements') ] rules_permissions = { - # this is suboptimal, but Statement is only ever used as an inline on Freizeit - # so we check for excursion permissions - 'add_obj': is_leader, - 'view_obj': is_leader | has_global_perm('members.view_global_freizeit'), - 'change_obj': is_leader & statement_not_submitted, - 'delete_obj': is_leader & statement_not_submitted, + # All users may add draft statements. + 'add_obj': rules.is_staff, + # All users may view their own statements and statements of excursions they are responsible for. + 'view_obj': is_creator | leads_excursion | has_global_perm('finance.view_global_statement'), + # All users may change relevant (see above) draft statements. + 'change_obj': (not_submitted & (is_creator | leads_excursion)) | has_global_perm('finance.change_global_statement'), + # All users may delete relevant (see above) draft statements. + 'delete_obj': not_submitted & (is_creator | leads_excursion | has_global_perm('finance.delete_global_statement')), } - def __str__(self): - if self.excursion is not None: - return _('Statement: %(excursion)s') % {'excursion': str(self.excursion)} - else: - return self.short_description - @property def title(self): if self.excursion is not None: @@ -136,6 +136,9 @@ class Statement(CommonModel): else: return self.short_description + def __str__(self): + return str(self.title) + @property def submitted(self): return self.status == Statement.SUBMITTED or self.status == Statement.CONFIRMED @@ -144,6 +147,13 @@ class Statement(CommonModel): def confirmed(self): return self.status == Statement.CONFIRMED + def status_badge(self): + code = Statement.STATUS_CSS_CLASS[self.status] + return format_html(f'{Statement.STATUS_CHOICES[self.status][1]}') + status_badge.short_description = _('Status') + status_badge.allow_tags = True + status_badge.admin_order_field = 'status' + def submit(self, submitter=None): self.status = self.SUBMITTED self.submitted_date = timezone.now() @@ -509,6 +519,7 @@ class Statement(CommonModel): def total_pretty(self): return "{}€".format(self.total) total_pretty.short_description = _('Total') + total_pretty.admin_order_field = 'total' def template_context(self): context = { @@ -580,6 +591,20 @@ class Statement(CommonModel): attachments=[media_path(filename)]) +class StatementOnExcursionProxy(Statement): + class Meta(CommonModel.Meta): + proxy = True + verbose_name = _('Statement') + verbose_name_plural = _('Statements') + rules_permissions = { + # This is used as an inline on excursions, so we check for excursion permissions. + 'add_obj': is_leader, + 'view_obj': is_leader | has_global_perm('members.view_global_freizeit'), + 'change_obj': is_leader & statement_not_submitted, + 'delete_obj': is_leader & statement_not_submitted, + } + + class StatementUnSubmittedManager(models.Manager): def get_queryset(self): return super().get_queryset().filter(status=Statement.UNSUBMITTED) @@ -636,7 +661,7 @@ class StatementConfirmed(Statement): class Bill(CommonModel): statement = models.ForeignKey(Statement, verbose_name=_('Statement'), on_delete=models.CASCADE) - short_description = models.CharField(verbose_name=_('Short description'), max_length=30) + short_description = models.CharField(verbose_name=_('Short description'), max_length=30, blank=False) explanation = models.TextField(verbose_name=_('Explanation'), blank=True) amount = models.DecimalField(verbose_name=_('Amount'), max_digits=6, decimal_places=2, default=0) diff --git a/jdav_web/finance/tests/admin.py b/jdav_web/finance/tests/admin.py index 1e0f676..996074d 100644 --- a/jdav_web/finance/tests/admin.py +++ b/jdav_web/finance/tests/admin.py @@ -4,6 +4,7 @@ from django.test import TestCase, override_settings from django.contrib.admin.sites import AdminSite from django.test import RequestFactory, Client from django.contrib.auth.models import User, Permission +from django.contrib.auth import models as authmodels from django.utils import timezone from django.contrib.sessions.middleware import SessionMiddleware from django.contrib.messages.middleware import MessageMiddleware @@ -24,8 +25,7 @@ from ..models import ( StatementSubmitted ) from ..admin import ( - LedgerAdmin, StatementUnSubmittedAdmin, StatementSubmittedAdmin, - StatementConfirmedAdmin, TransactionAdmin, BillAdmin + LedgerAdmin, StatementAdmin, TransactionAdmin, BillAdmin ) @@ -52,10 +52,10 @@ class AdminTestCase(TestCase): class StatementUnSubmittedAdminTestCase(AdminTestCase): - """Test cases for StatementUnSubmittedAdmin""" + """Test cases for StatementAdmin in the case of unsubmitted statements""" def setUp(self): - super().setUp(model=StatementUnSubmitted, admin=StatementUnSubmittedAdmin) + super().setUp(model=Statement, admin=StatementAdmin) self.superuser = User.objects.get(username='superuser') self.member = Member.objects.create( @@ -96,6 +96,26 @@ class StatementUnSubmittedAdminTestCase(AdminTestCase): self.admin.save_model(request, new_statement, None, change=False) self.assertEqual(new_statement.created_by, self.member) + def test_has_delete_permission(self): + """Test if unsubmitted statements may be deleted""" + request = self.factory.post('/') + request.user = self.superuser + self.assertTrue(self.admin.has_delete_permission(request, self.statement)) + + def test_get_fields(self): + """Test get_fields when excursion is set or not set.""" + request = self.factory.post('/') + request.user = self.superuser + self.assertIn('excursion', self.admin.get_fields(request, self.statement_with_excursion)) + self.assertNotIn('excursion', self.admin.get_fields(request, self.statement)) + self.assertNotIn('excursion', self.admin.get_fields(request)) + + def test_get_inlines(self): + """Test get_inlines""" + request = self.factory.post('/') + request.user = self.superuser + self.assertEqual(len(self.admin.get_inlines(request, self.statement)), 1) + def test_get_readonly_fields_submitted(self): """Test readonly fields when statement is submitted""" # Mark statement as submitted @@ -111,7 +131,7 @@ class StatementUnSubmittedAdminTestCase(AdminTestCase): self.assertEqual(readonly_fields, ['status', 'excursion']) def test_submit_view_insufficient_permission(self): - url = reverse('admin:finance_statementunsubmitted_submit', + url = reverse('admin:finance_statement_submit', args=(self.statement.pk,)) c = self._login('standard') response = c.get(url, follow=True) @@ -119,7 +139,7 @@ class StatementUnSubmittedAdminTestCase(AdminTestCase): self.assertContains(response, _('Insufficient permissions.')) def test_submit_view_get(self): - url = reverse('admin:finance_statementunsubmitted_submit', + url = reverse('admin:finance_statement_submit', args=(self.statement.pk,)) c = self._login('superuser') response = c.get(url, follow=True) @@ -127,7 +147,7 @@ class StatementUnSubmittedAdminTestCase(AdminTestCase): self.assertContains(response, _('Submit statement')) def test_submit_view_get_with_excursion(self): - url = reverse('admin:finance_statementunsubmitted_submit', + url = reverse('admin:finance_statement_submit', args=(self.statement_with_excursion.pk,)) c = self._login('superuser') response = c.get(url, follow=True) @@ -135,7 +155,7 @@ class StatementUnSubmittedAdminTestCase(AdminTestCase): self.assertContains(response, _('Finance overview')) def test_submit_view_post(self): - url = reverse('admin:finance_statementunsubmitted_submit', + url = reverse('admin:finance_statement_submit', args=(self.statement.pk,)) c = self._login('superuser') response = c.post(url, follow=True, data={'apply': ''}) @@ -145,10 +165,10 @@ class StatementUnSubmittedAdminTestCase(AdminTestCase): class StatementSubmittedAdminTestCase(AdminTestCase): - """Test cases for StatementSubmittedAdmin""" + """Test cases for StatementAdmin in the case of submitted statements""" def setUp(self): - super().setUp(model=StatementSubmitted, admin=StatementSubmittedAdmin) + super().setUp(model=Statement, admin=StatementAdmin) self.user = User.objects.create_user('testuser', 'test@example.com', 'pass') self.member = Member.objects.create( @@ -157,8 +177,8 @@ class StatementSubmittedAdminTestCase(AdminTestCase): ) self.finance_user = User.objects.create_user('finance', 'finance@example.com', 'pass') - finance_perm = Permission.objects.get(codename='process_statementsubmitted') - self.finance_user.user_permissions.add(finance_perm) + self.finance_user.groups.add(authmodels.Group.objects.get(name='Finance'), + authmodels.Group.objects.get(name='Standard')) self.statement = Statement.objects.create( short_description='Submitted Statement', @@ -247,12 +267,6 @@ class StatementSubmittedAdminTestCase(AdminTestCase): paid_by=self.member ) - def test_has_add_permission(self): - """Test that add permission is disabled""" - request = self.factory.get('/') - request.user = self.finance_user - self.assertFalse(self.admin.has_add_permission(request)) - def test_has_change_permission_with_permission(self): """Test change permission with proper permission""" request = self.factory.get('/') @@ -276,14 +290,14 @@ class StatementSubmittedAdminTestCase(AdminTestCase): self.admin.get_readonly_fields(None, self.statement_unsubmitted)) def test_change(self): - url = reverse('admin:finance_statementsubmitted_change', + url = reverse('admin:finance_statement_change', args=(self.statement.pk,)) c = self._login('superuser') response = c.get(url) self.assertEqual(response.status_code, HTTPStatus.OK) def test_overview_view(self): - url = reverse('admin:finance_statementsubmitted_overview', + url = reverse('admin:finance_statement_overview', args=(self.statement.pk,)) c = self._login('superuser') response = c.get(url) @@ -297,7 +311,7 @@ class StatementSubmittedAdminTestCase(AdminTestCase): self.statement.status = Statement.UNSUBMITTED self.statement.save() - url = reverse('admin:finance_statementsubmitted_overview', args=(self.statement.pk,)) + url = reverse('admin:finance_statement_overview', args=(self.statement.pk,)) c = self._login('superuser') response = c.get(url, follow=True) self.assertEqual(response.status_code, HTTPStatus.OK) @@ -314,7 +328,7 @@ class StatementSubmittedAdminTestCase(AdminTestCase): # Create a bill that matches the transaction amount to make it valid self._create_matching_bill() - url = reverse('admin:finance_statementsubmitted_overview', args=(self.statement.pk,)) + url = reverse('admin:finance_statement_overview', args=(self.statement.pk,)) c = self._login('superuser') response = c.post(url, follow=True, data={'transaction_execution_confirm': ''}) self.assertEqual(response.status_code, HTTPStatus.OK) @@ -332,7 +346,7 @@ class StatementSubmittedAdminTestCase(AdminTestCase): # Create a bill that matches the transaction amount to make it valid self._create_matching_bill() - url = reverse('admin:finance_statementsubmitted_overview', args=(self.statement.pk,)) + url = reverse('admin:finance_statement_overview', args=(self.statement.pk,)) c = self._login('superuser') response = c.post(url, follow=True, data={'transaction_execution_confirm_and_send': ''}) self.assertEqual(response.status_code, HTTPStatus.OK) @@ -349,7 +363,7 @@ class StatementSubmittedAdminTestCase(AdminTestCase): # Create a bill that matches the transaction amount to make total valid self._create_matching_bill() - url = reverse('admin:finance_statementsubmitted_overview', + url = reverse('admin:finance_statement_overview', args=(self.statement.pk,)) c = self._login('superuser') response = c.post(url, data={'confirm': ''}) @@ -361,7 +375,7 @@ class StatementSubmittedAdminTestCase(AdminTestCase): # Create a bill that doesn't match the transaction self._create_non_matching_bill() - url = reverse('admin:finance_statementsubmitted_overview', + url = reverse('admin:finance_statement_overview', args=(self.statement.pk,)) c = self._login('superuser') response = c.post(url, follow=True, data={'confirm': ''}) @@ -378,7 +392,7 @@ class StatementSubmittedAdminTestCase(AdminTestCase): # Create a bill that matches the transaction amount to pass the first check self._create_matching_bill() - url = reverse('admin:finance_statementsubmitted_overview', + url = reverse('admin:finance_statement_overview', args=(self.statement.pk,)) c = self._login('superuser') response = c.post(url, follow=True, data={'confirm': ''}) @@ -406,7 +420,7 @@ class StatementSubmittedAdminTestCase(AdminTestCase): # Check validity obstruction is allowances self.assertEqual(self.statement_no_trans_success.validity, Statement.INVALID_ALLOWANCE_TO) - url = reverse('admin:finance_statementsubmitted_overview', + url = reverse('admin:finance_statement_overview', args=(self.statement_no_trans_success.pk,)) c = self._login('superuser') response = c.post(url, follow=True, data={'confirm': ''}) @@ -418,7 +432,7 @@ class StatementSubmittedAdminTestCase(AdminTestCase): def test_overview_view_reject(self): """Test overview_view reject statement""" - url = reverse('admin:finance_statementsubmitted_overview', args=(self.statement.pk,)) + url = reverse('admin:finance_statement_overview', args=(self.statement.pk,)) c = self._login('superuser') response = c.post(url, follow=True, data={'reject': ''}) self.assertEqual(response.status_code, HTTPStatus.OK) @@ -435,7 +449,7 @@ class StatementSubmittedAdminTestCase(AdminTestCase): # Ensure there's already a transaction self.assertTrue(self.statement.transaction_set.count() > 0) - url = reverse('admin:finance_statementsubmitted_overview', args=(self.statement.pk,)) + url = reverse('admin:finance_statement_overview', args=(self.statement.pk,)) c = self._login('superuser') response = c.post(url, follow=True, data={'generate_transactions': ''}) self.assertEqual(response.status_code, HTTPStatus.OK) @@ -444,7 +458,7 @@ class StatementSubmittedAdminTestCase(AdminTestCase): def test_overview_view_generate_transactions_success(self): """Test overview_view generate transactions successfully""" - url = reverse('admin:finance_statementsubmitted_overview', + url = reverse('admin:finance_statement_overview', args=(self.statement_no_trans_success.pk,)) c = self._login('superuser') response = c.post(url, follow=True, data={'generate_transactions': ''}) @@ -455,7 +469,7 @@ class StatementSubmittedAdminTestCase(AdminTestCase): def test_overview_view_generate_transactions_error(self): """Test overview_view generate transactions with error""" - url = reverse('admin:finance_statementsubmitted_overview', + url = reverse('admin:finance_statement_overview', args=(self.statement_no_trans_error.pk,)) c = self._login('superuser') response = c.post(url, follow=True, data={'generate_transactions': ''}) @@ -466,10 +480,10 @@ class StatementSubmittedAdminTestCase(AdminTestCase): self.assertTrue(any(expected_text in str(msg) for msg in messages)) def test_reduce_transactions_view(self): - url = reverse('admin:finance_statementsubmitted_reduce_transactions', + url = reverse('admin:finance_statement_reduce_transactions', args=(self.statement.pk,)) c = self._login('superuser') - response = c.get(url, data={'redirectTo': reverse('admin:finance_statementsubmitted_changelist')}, + response = c.get(url, data={'redirectTo': reverse('admin:finance_statement_changelist')}, follow=True) self.assertContains(response, _("Successfully reduced transactions for %(name)s.") %\ @@ -477,10 +491,10 @@ class StatementSubmittedAdminTestCase(AdminTestCase): class StatementConfirmedAdminTestCase(AdminTestCase): - """Test cases for StatementConfirmedAdmin""" + """Test cases for StatementAdmin in the case of confirmed statements""" def setUp(self): - super().setUp(model=StatementConfirmed, admin=StatementConfirmedAdmin) + super().setUp(model=Statement, admin=StatementAdmin) self.user = User.objects.create_user('testuser', 'test@example.com', 'pass') self.member = Member.objects.create( @@ -489,8 +503,8 @@ class StatementConfirmedAdminTestCase(AdminTestCase): ) self.finance_user = User.objects.create_user('finance', 'finance@example.com', 'pass') - unconfirm_perm = Permission.objects.get(codename='may_manage_confirmed_statements') - self.finance_user.user_permissions.add(unconfirm_perm) + self.finance_user.groups.add(authmodels.Group.objects.get(name='Finance'), + authmodels.Group.objects.get(name='Standard')) # Create a base statement first base_statement = Statement.objects.create( @@ -544,23 +558,17 @@ class StatementConfirmedAdminTestCase(AdminTestCase): middleware.process_request(request) request._messages = FallbackStorage(request) - def test_has_add_permission(self): - """Test that add permission is disabled""" - request = self.factory.get('/') - request.user = self.finance_user - self.assertFalse(self.admin.has_add_permission(request)) - def test_has_change_permission(self): """Test that change permission is disabled""" request = self.factory.get('/') request.user = self.finance_user - self.assertFalse(self.admin.has_change_permission(request)) + self.assertFalse(self.admin.has_change_permission(request, self.statement)) def test_has_delete_permission(self): """Test that delete permission is disabled""" request = self.factory.get('/') request.user = self.finance_user - self.assertFalse(self.admin.has_delete_permission(request)) + self.assertFalse(self.admin.has_delete_permission(request, self.statement)) def test_unconfirm_view_not_confirmed_statement(self): """Test unconfirm_view with statement that is not confirmed""" @@ -622,14 +630,15 @@ class StatementConfirmedAdminTestCase(AdminTestCase): self.assertIn(self.statement.short_description.encode(), response.content) def test_statement_summary_view_insufficient_permission(self): - url = reverse('admin:finance_statementconfirmed_summary', + url = reverse('admin:finance_statement_summary', args=(self.statement_with_excursion.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_statement_summary_view_unconfirmed(self): - url = reverse('admin:finance_statementconfirmed_summary', + url = reverse('admin:finance_statement_summary', args=(self.unconfirmed_statement.pk,)) c = self._login('superuser') response = c.get(url, follow=True) @@ -638,7 +647,7 @@ class StatementConfirmedAdminTestCase(AdminTestCase): def test_statement_summary_view_confirmed_with_excursion(self): """Test statement_summary_view when statement is confirmed with excursion""" - url = reverse('admin:finance_statementconfirmed_summary', + url = reverse('admin:finance_statement_summary', args=(self.statement_with_excursion.pk,)) c = self._login('superuser') response = c.get(url, follow=True) diff --git a/jdav_web/jdav_web/settings/components/jet.py b/jdav_web/jdav_web/settings/components/jet.py index 415d720..2473b73 100644 --- a/jdav_web/jdav_web/settings/components/jet.py +++ b/jdav_web/jdav_web/settings/components/jet.py @@ -26,9 +26,7 @@ JET_SIDE_MENU_ITEMS = [ {'name': 'emailaddress', 'permissions': ['mailer.view_emailaddress']}, ]}, {'app_label': 'finance', 'items': [ - {'name': 'statementunsubmitted', 'permissions': ['finance.view_statementunsubmitted']}, - {'name': 'statementsubmitted', 'permissions': ['finance.view_statementsubmitted']}, - {'name': 'statementconfirmed', 'permissions': ['finance.view_statementconfirmed']}, + {'name': 'statement', 'permissions': ['finance.view_statement']}, {'name': 'ledger', 'permissions': ['finance.view_ledger']}, {'name': 'bill', 'permissions': ['finance.view_bill', 'finance.view_bill_admin']}, {'name': 'transaction', 'permissions': ['finance.view_transaction']}, diff --git a/jdav_web/members/admin.py b/jdav_web/members/admin.py index d9c36e4..639ed9e 100644 --- a/jdav_web/members/admin.py +++ b/jdav_web/members/admin.py @@ -42,7 +42,7 @@ from .models import (Member, Group, Freizeit, MemberNoteList, NewMemberOnList, K KlettertreffAttendee, ActivityCategory, EmergencyContact, annotate_activity_score, RegistrationPassword, MemberUnconfirmedProxy, InvitationToGroup) -from finance.models import Statement, BillOnExcursionProxy +from finance.models import BillOnExcursionProxy, StatementOnExcursionProxy from mailer.mailutils import send as send_mail, get_echo_link from django.conf import settings from utils import get_member, RestrictedFileField, mondays_until_nth @@ -941,7 +941,7 @@ class StatementOnListForm(forms.ModelForm): self.fields['ljp_to'].queryset = excursion.jugendleiter.all() class Meta: - model = Statement + model = StatementOnExcursionProxy fields = ['night_cost', 'allowance_to', 'subsidy_to', 'ljp_to'] def clean(self): @@ -959,7 +959,7 @@ class StatementOnListForm(forms.ModelForm): class StatementOnListInline(CommonAdminInlineMixin, nested_admin.NestedStackedInline): - model = Statement + model = StatementOnExcursionProxy extra = 1 description = _('Please list here all expenses in relation with this excursion and upload relevant bills. These have to be permanently stored for the application of LJP contributions. The short descriptions are used in the seminar report cost overview (possible descriptions are e.g. food, material, etc.).') sortable_options = [] diff --git a/jdav_web/members/migrations/0045_statement_permissions.py b/jdav_web/members/migrations/0045_statement_permissions.py new file mode 100644 index 0000000..05808b9 --- /dev/null +++ b/jdav_web/members/migrations/0045_statement_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_statement'), + ('finance', 'add_global_statement'), +] + +FINANCE_PERMS = [ + ('finance', 'view_bill'), + ('finance', 'view_ledger'), + ('finance', 'add_ledger'), + ('finance', 'change_ledger'), + ('finance', 'delete_ledger'), + ('finance', 'view_global_statement'), + ('finance', 'change_global_statement'), + ('finance', 'process_statementsubmitted'), + ('finance', 'may_manage_confirmed_statements'), + ('finance', 'view_transaction'), + ('finance', 'change_transaction'), + ('finance', 'add_transaction'), + ('finance', 'delete_transaction'), + ('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 = [ + ('finance', '0012_statementonexcursionproxy'), + ('members', '0044_membertraining_activity_and_more'), + ] + + 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 a03ad10..8a5a6f1 100644 --- a/jdav_web/members/models.py +++ b/jdav_web/members/models.py @@ -652,7 +652,9 @@ class Member(Person): elif name == "NewMemberOnList": return queryset elif name == "Statement": - return queryset + return self.filter_statements_by_permissions(queryset, annotate) + elif name == "StatementOnExcursionProxy": + return self.filter_statements_by_permissions(queryset, annotate) elif name == "BillOnExcursionProxy": return queryset elif name == "Intervention": diff --git a/jdav_web/static/admin/css/extra.css b/jdav_web/static/admin/css/extra.css new file mode 100644 index 0000000..11ece55 --- /dev/null +++ b/jdav_web/static/admin/css/extra.css @@ -0,0 +1,25 @@ +span.statement-unsubmitted, span.statement-submitted, span.statement-confirmed { + color: black; + padding: 4px; + padding-left: 6px; + padding-right: 6px; + border-radius: 10px; + width: 20px; + min-width: 20px; + max-width: 20px; +} + +span.statement-submitted { + background-color: #e8e8bd; + color: black; +} + +span.statement-unsubmitted { + background-color: #f0dada; + color: black; +} + +span.statement-confirmed { + background-color: #e0eec5; + color: black; +} diff --git a/jdav_web/templates/admin/base.html b/jdav_web/templates/admin/base.html index f7d55f2..4ab8144 100644 --- a/jdav_web/templates/admin/base.html +++ b/jdav_web/templates/admin/base.html @@ -14,6 +14,7 @@ + {% block extrastyle %}{% endblock %} {% if LANGUAGE_BIDI %}{% endif %} diff --git a/jdav_web/templates/admin/finance/statement/change_form_object_tools.html b/jdav_web/templates/admin/finance/statement/change_form_object_tools.html new file mode 100644 index 0000000..f685001 --- /dev/null +++ b/jdav_web/templates/admin/finance/statement/change_form_object_tools.html @@ -0,0 +1,41 @@ +{% extends "admin/change_form_object_tools.html" %} +{% load i18n admin_urls rules %} + +{% block object-tools-items %} + +{% if original.confirmed and perms.finance.may_manage_confirmed_statements %} +
  • + {% url opts|admin_urlname:'unconfirm' original.pk|admin_urlquote as invite_url %} + {% trans 'Unconfirm' %} +
  • +
  • + {% url opts|admin_urlname:'summary' original.pk|admin_urlquote as invite_url %} + {% trans 'Download summary' %} +
  • +{% elif original.submitted and perms.finance.process_statementsubmitted %} + + +
  • + {% url opts|admin_urlname:'reduce_transactions' original.pk|admin_urlquote as invite_url %} + + {% trans 'Reduce transactions' %} + +
  • +
  • + {% url opts|admin_urlname:'overview' original.pk|admin_urlquote as invite_url %} + {% trans 'Overview' %} +
  • +{% elif not original.submitted %} +
  • + {% url opts|admin_urlname:'submit' original.pk|admin_urlquote as invite_url %} + {% trans 'Submit' %} +
  • +{% endif %} +{{block.super}} + +{% endblock %} diff --git a/jdav_web/templates/admin/index.html b/jdav_web/templates/admin/index.html index f5484b7..977355c 100644 --- a/jdav_web/templates/admin/index.html +++ b/jdav_web/templates/admin/index.html @@ -24,7 +24,7 @@ for (var i = 0; i < els.length; ++i) { Der KOMPASS ist dein Kompass in der Jugendarbeit der JDAV {% settings_value 'SEKTION' %}. Hier hast du Zugriff auf deine Jugendgruppen, deine letzten Ausfahrten und deine -Abrechnungen. +Abrechnungen.