feat(finance/admin): unified statement view (#6)

This PR replaces the separate admin views for unsubmitted, submitted and
confirmed statements by one common view. To distinguish the state, we
now display a colored badge in the changelist.

The default permissions for the `Standard` group are changed so that
normal users can continue to view statements they are related to when
these are submitted or confirmed.
main
Christian Merten 1 month ago committed by GitHub
parent 3b8964fbb0
commit 5d4f29be89
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -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(
"<path:object_id>/overview/",
wrap(self.overview_view),
name="%s_%s_overview" % (self.opts.app_label, self.opts.model_name),
),
path(
"<path:object_id>/reduce_transactions/",
wrap(self.reduce_transactions_view),
name="%s_%s_reduce_transactions" % (self.opts.app_label, self.opts.model_name),
),
path(
"<path:object_id>/unconfirm/",
wrap(self.unconfirm_view),
name="%s_%s_unconfirm" % (self.opts.app_label, self.opts.model_name),
),
path(
"<path:object_id>/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'<span style="color: red;">{len_string}</span>')
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(
"<path:object_id>/overview/",
wrap(self.overview_view),
name="%s_%s_overview" % (self.opts.app_label, self.opts.model_name),
),
path(
"<path:object_id>/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 <a href='%(link)s', target='_blank'>receipt</a>.") % {'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(
"<path:object_id>/unconfirm/",
wrap(self.unconfirm_view),
name="%s_%s_unconfirm" % (self.opts.app_label, self.opts.model_name),
),
path(
"<path:object_id>/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'<span style="color: red;">{len_string}</span>')
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

@ -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 <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\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"

@ -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',),
),
]

@ -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'<span class="statement-{code}">{Statement.STATUS_CHOICES[self.status][1]}</span>')
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)

@ -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)

@ -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']},

@ -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 = []

@ -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),
]

@ -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":

@ -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;
}

@ -14,6 +14,7 @@
<link rel="stylesheet" type="text/css" href="{% static "jet/css/themes/"|add:THEME|add:"/base.css" as url %}{{ url|jet_append_version }}" class="base-stylesheet" />
<link rel="stylesheet" type="text/css" href="{% static "jet/css/themes/"|add:THEME|add:"/select2.theme.css" as url %}{{ url|jet_append_version }}" class="select2-stylesheet" />
<link rel="stylesheet" type="text/css" href="{% static "jet/css/themes/"|add:THEME|add:"/jquery-ui.theme.css" as url %}{{ url|jet_append_version }}" class="jquery-ui-stylesheet" />
<link rel="stylesheet" type="text/css" href="{% static "admin/css/extra.css" %}">
{% block extrastyle %}{% endblock %}
{% if LANGUAGE_BIDI %}<link rel="stylesheet" type="text/css" href="{% block stylesheet_rtl %}{% static "admin/css/rtl.css" %}{% endblock %}" />{% endif %}

@ -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 %}
<li>
{% url opts|admin_urlname:'unconfirm' original.pk|admin_urlquote as invite_url %}
<a class="historylink" href="{% add_preserved_filters invite_url %}">{% trans 'Unconfirm' %}</a>
</li>
<li>
{% url opts|admin_urlname:'summary' original.pk|admin_urlquote as invite_url %}
<a class="historylink" target="_blank" href="{% add_preserved_filters invite_url %}">{% trans 'Download summary' %}</a>
</li>
{% elif original.submitted and perms.finance.process_statementsubmitted %}
<script>
function requestWithCurrentURL(path) {
var xpath = path + "?redirectTo=" + window.location.href;
location.href = xpath;
}
</script>
<li>
{% url opts|admin_urlname:'reduce_transactions' original.pk|admin_urlquote as invite_url %}
<a value="hi" onclick='requestWithCurrentURL("{% add_preserved_filters invite_url %}")'>
{% trans 'Reduce transactions' %}
</a>
</li>
<li>
{% url opts|admin_urlname:'overview' original.pk|admin_urlquote as invite_url %}
<a class="historylink" href="{% add_preserved_filters invite_url %}">{% trans 'Overview' %}</a>
</li>
{% elif not original.submitted %}
<li>
{% url opts|admin_urlname:'submit' original.pk|admin_urlquote as invite_url %}
<a class="historylink" href="{% add_preserved_filters invite_url %}">{% trans 'Submit' %}</a>
</li>
{% endif %}
{{block.super}}
{% endblock %}

@ -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
<a href="{% url 'admin:members_freizeit_changelist' %}">Ausfahrten</a> und deine
<a href="{% url 'admin:finance_statementunsubmitted_changelist' %}">Abrechnungen</a>.
<a href="{% url 'admin:finance_statement_changelist' %}">Abrechnungen</a>.
</p>
</div>

Loading…
Cancel
Save