Compare commits

...

4 Commits

Author SHA1 Message Date
Christian Merten aae75ce291
perf(finance/admin): remove `is_valid` from list view (#9)
The new unified statement view is very slow in production. This is
caused by the computation of `is_valid` for every entry of the list,
which entails at least one database query per row. Instead of removing
the `is_valid` field from the list view, we could prefetch the related
fields, but I don't think the `is_valid` provides any noticeable benefit
anyway.

We also add pagination to the statement view.
1 month ago
Christian Merten efe20bc721
chore(finance/models): add documentation and test for `validity` (#11)
Currently, the `INVALID_TOTAL` case of the `Statement.validity` method
is tested indirectly via the admin tests and the behavior is hard to
trace back. We therefore add documentation and a targeted test.
1 month ago
Christian Merten 5d4f29be89
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.
1 month ago
Christian Merten 3b8964fbb0
chore(deploy): update urls to github repository (#8) 1 month ago

@ -13,7 +13,7 @@ services:
master: master:
<<: *kompass <<: *kompass
build: build:
context: git@git.jdav-hd.merten.dev:digitales/kompass#main context: https://github.com/chrisflav/kompass.git#main
dockerfile: docker/production/Dockerfile dockerfile: docker/production/Dockerfile
entrypoint: /app/docker/production/entrypoint-master.sh entrypoint: /app/docker/production/entrypoint-master.sh
volumes: volumes:
@ -28,7 +28,7 @@ services:
- "host:10.26.42.1" - "host:10.26.42.1"
nginx: nginx:
build: git@git.jdav-hd.merten.dev:digitales/kompass#main:docker/production/nginx build: https://github.com/chrisflav/kompass.git#main:docker/production/nginx
restart: always restart: always
volumes: volumes:
- uwsgi_data:/tmp/uwsgi/ - uwsgi_data:/tmp/uwsgi/

@ -62,17 +62,45 @@ def decorate_statement_view(model, perm=None):
return decorator return decorator
@admin.register(StatementUnSubmitted) @admin.register(Statement)
class StatementUnSubmittedAdmin(CommonAdminMixin, admin.ModelAdmin): class StatementAdmin(CommonAdminMixin, admin.ModelAdmin):
fields = ['short_description', 'explanation', 'excursion', 'status'] fields = ['short_description', 'explanation', 'excursion', 'status']
list_display = ['__str__', 'excursion', 'created_by'] list_display = ['__str__', 'total_pretty', 'created_by', 'submitted_date', 'status_badge']
list_filter = ['status']
search_fields = ('excursion__name', 'short_description')
ordering = ['-submitted_date']
inlines = [BillOnStatementInline] inlines = [BillOnStatementInline]
list_per_page = 25
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): def save_model(self, request, obj, form, change):
if not change and hasattr(request.user, 'member'): if not change and hasattr(request.user, 'member'):
obj.created_by = request.user.member obj.created_by = request.user.member
super().save_model(request, obj, form, change) 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): def get_readonly_fields(self, request, obj=None):
readonly_fields = ['status', 'excursion'] readonly_fields = ['status', 'excursion']
if obj is not None and obj.submitted: if obj is not None and obj.submitted:
@ -80,6 +108,12 @@ class StatementUnSubmittedAdmin(CommonAdminMixin, admin.ModelAdmin):
else: else:
return readonly_fields 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): def get_urls(self):
urls = super().get_urls() urls = super().get_urls()
@ -96,10 +130,30 @@ class StatementUnSubmittedAdmin(CommonAdminMixin, admin.ModelAdmin):
wrap(self.submit_view), wrap(self.submit_view),
name="%s_%s_submit" % (self.opts.app_label, self.opts.model_name), 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 return custom_urls + urls
@decorate_statement_view(Statement) @decorate_statement_view(StatementUnSubmitted)
def submit_view(self, request, statement): def submit_view(self, request, statement):
if statement.submitted: # pragma: no cover if statement.submitted: # pragma: no cover
logger.error(f"submit_view reached with submitted statement {statement}. This should not happen.") logger.error(f"submit_view reached with submitted statement {statement}. This should not happen.")
@ -131,91 +185,6 @@ class StatementUnSubmittedAdmin(CommonAdminMixin, admin.ModelAdmin):
statement=statement) statement=statement)
return render(request, 'admin/submit_statement.html', context=context) 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) @decorate_statement_view(StatementSubmitted)
def overview_view(self, request, statement): def overview_view(self, request, statement):
if not statement.submitted: # pragma: no cover if not statement.submitted: # pragma: no cover
@ -238,7 +207,8 @@ class StatementSubmittedAdmin(admin.ModelAdmin):
messages.success(request, messages.success(request,
_("Successfully confirmed %(name)s. I hope you executed the associated transactions, I wont remind you again.") _("Successfully confirmed %(name)s. I hope you executed the associated transactions, I wont remind you again.")
% {'name': str(statement)}) % {'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, messages.success(request,
mark_safe(_("You can download a <a href='%(link)s', target='_blank'>receipt</a>.") % {'link': download_link})) 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))) return HttpResponseRedirect(reverse('admin:%s_%s_changelist' % (self.opts.app_label, self.opts.model_name)))
@ -310,52 +280,6 @@ class StatementSubmittedAdmin(admin.ModelAdmin):
messages.success(request, messages.success(request,
_("Successfully reduced transactions for %(name)s.") % {'name': str(statement)}) _("Successfully reduced transactions for %(name)s.") % {'name': str(statement)})
return HttpResponseRedirect(request.GET['redirectTo']) 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') @decorate_statement_view(StatementConfirmed, perm='finance.may_manage_confirmed_statements')
def unconfirm_view(self, request, statement): def unconfirm_view(self, request, statement):
@ -399,6 +323,39 @@ class StatementConfirmedAdmin(admin.ModelAdmin):
statement_summary_view.short_description = _('Download summary') 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) @admin.register(Transaction)
class TransactionAdmin(admin.ModelAdmin): class TransactionAdmin(admin.ModelAdmin):
"""The transaction admin site. This is only used to display transactions. All editing """The transaction admin site. This is only used to display transactions. All editing

@ -8,7 +8,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: PACKAGE VERSION\n" "Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \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" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n" "Language-Team: LANGUAGE <LL@li.org>\n"
@ -48,10 +48,6 @@ msgstr "Kostenübersicht"
msgid "Submit statement" msgid "Submit statement"
msgstr "Rechnung einreichen" msgstr "Rechnung einreichen"
#: finance/admin.py
msgid "Length"
msgstr "Länge"
#: finance/admin.py #: finance/admin.py
#, python-format #, python-format
msgid "%(name)s is not yet submitted." msgid "%(name)s is not yet submitted."
@ -180,6 +176,10 @@ msgstr "Bestätigung zurücknehmen"
msgid "Download summary" msgid "Download summary"
msgstr "Beleg herunterladen" msgstr "Beleg herunterladen"
#: finance/admin.py
msgid "Length"
msgstr "Länge"
#: finance/apps.py #: finance/apps.py
msgid "Finance" msgid "Finance"
msgstr "Finanzen" msgstr "Finanzen"
@ -206,7 +206,7 @@ msgid "Submitted"
msgstr "Eingereicht" msgstr "Eingereicht"
#: finance/models.py #: finance/models.py
msgid "Confirmed" msgid "Completed"
msgstr "Abgewickelt" msgstr "Abgewickelt"
#: finance/models.py #: finance/models.py
@ -291,11 +291,6 @@ msgstr "Abrechnung"
msgid "Statements" msgid "Statements"
msgstr "Abrechnungen" msgstr "Abrechnungen"
#: finance/models.py
#, python-format
msgid "Statement: %(excursion)s"
msgstr "Abrechnung: %(excursion)s"
#: finance/models.py #: finance/models.py
#, python-format #, python-format
msgid "Excursion %(excursion)s" 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 import models
from django.db.models import Sum from django.db.models import Sum
from django.utils.translation import gettext_lazy as _ 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 members.models import Member, Freizeit, OEFFENTLICHE_ANREISE, MUSKELKRAFT_ANREISE
from django.conf import settings from django.conf import settings
import rules import rules
@ -54,11 +55,14 @@ class Statement(CommonModel):
UNSUBMITTED, SUBMITTED, CONFIRMED = 0, 1, 2 UNSUBMITTED, SUBMITTED, CONFIRMED = 0, 1, 2
STATUS_CHOICES = [(UNSUBMITTED, _('In preparation')), STATUS_CHOICES = [(UNSUBMITTED, _('In preparation')),
(SUBMITTED, _('Submitted')), (SUBMITTED, _('Submitted')),
(CONFIRMED, _('Confirmed'))] (CONFIRMED, _('Completed'))]
STATUS_CSS_CLASS = { SUBMITTED: 'submitted',
CONFIRMED: 'confirmed',
UNSUBMITTED: 'unsubmitted' }
short_description = models.CharField(verbose_name=_('Short description'), short_description = models.CharField(verbose_name=_('Short description'),
max_length=30, max_length=30,
blank=True) blank=False)
explanation = models.TextField(verbose_name=_('Explanation'), blank=True) explanation = models.TextField(verbose_name=_('Explanation'), blank=True)
excursion = models.OneToOneField(Freizeit, verbose_name=_('Associated excursion'), 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') ('may_edit_submitted_statements', 'Is allowed to edit submitted statements')
] ]
rules_permissions = { rules_permissions = {
# this is suboptimal, but Statement is only ever used as an inline on Freizeit # All users may add draft statements.
# so we check for excursion permissions 'add_obj': rules.is_staff,
'add_obj': is_leader, # All users may view their own statements and statements of excursions they are responsible for.
'view_obj': is_leader | has_global_perm('members.view_global_freizeit'), 'view_obj': is_creator | leads_excursion | has_global_perm('finance.view_global_statement'),
'change_obj': is_leader & statement_not_submitted, # All users may change relevant (see above) draft statements.
'delete_obj': is_leader & statement_not_submitted, '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 @property
def title(self): def title(self):
if self.excursion is not None: if self.excursion is not None:
@ -136,6 +136,9 @@ class Statement(CommonModel):
else: else:
return self.short_description return self.short_description
def __str__(self):
return str(self.title)
@property @property
def submitted(self): def submitted(self):
return self.status == Statement.SUBMITTED or self.status == Statement.CONFIRMED return self.status == Statement.SUBMITTED or self.status == Statement.CONFIRMED
@ -144,6 +147,13 @@ class Statement(CommonModel):
def confirmed(self): def confirmed(self):
return self.status == Statement.CONFIRMED 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): def submit(self, submitter=None):
self.status = self.SUBMITTED self.status = self.SUBMITTED
self.submitted_date = timezone.now() self.submitted_date = timezone.now()
@ -152,6 +162,18 @@ class Statement(CommonModel):
@property @property
def transaction_issues(self): def transaction_issues(self):
"""
Returns a list of critical problems with the currently configured transactions. This is done
by calculating a list of required paiments. From this list, we deduce the total amount
every member should receive (this amount can be negative, due to org fees).
Finally, the amounts are compared to the total amounts paid out by currently setup transactions.
The list of required paiments is generated from:
- All covered bills that have a configured payer.
(Note: This means that `transaction_issues` might return an empty list, but the calculated
total still differs from the transaction total.)
- If the statement is associated with an excursion: allowances, subsidies, LJP paiment and org fee.
"""
needed_paiments = [(b.paid_by, b.amount) for b in self.bill_set.all() if b.costs_covered and b.paid_by] needed_paiments = [(b.paid_by, b.amount) for b in self.bill_set.all() if b.costs_covered and b.paid_by]
if self.excursion is not None: if self.excursion is not None:
@ -196,6 +218,7 @@ class Statement(CommonModel):
@property @property
def transactions_match_expenses(self): def transactions_match_expenses(self):
"""Returns true iff there are no transaction issues."""
return len(self.transaction_issues) == 0 return len(self.transaction_issues) == 0
@property @property
@ -213,7 +236,11 @@ class Statement(CommonModel):
@property @property
def total_valid(self): def total_valid(self):
"""Checks if the calculated total agrees with the total amount of all transactions.""" """
Checks if the calculated total agrees with the total amount of all transactions.
Note: This is not the same as `transactions_match_expenses`. For details see the
docstring of `transaction_issues`.
"""
total_transactions = 0 total_transactions = 0
for transaction in self.transaction_set.all(): for transaction in self.transaction_set.all():
total_transactions += transaction.amount total_transactions += transaction.amount
@ -221,6 +248,19 @@ class Statement(CommonModel):
@property @property
def validity(self): def validity(self):
"""
Returns the validity status of the statement. This is one of:
- `Statement.VALID`:
Everything is correct.
- `Statement.NON_MATCHING_TRANSACTIONS`:
There is a transaction issue (in the sense of `transaction_issues`).
- `Statement.MISSING_LEDGER`:
At least one transaction has no ledger configured.
- `Statement.INVALID_ALLOWANCE_TO`:
The members receiving allowance don't match the regulations.
- `Statement.INVALID_TOTAL`:
The total amount of transactions differs from the calculated total payout.
"""
if not self.transactions_match_expenses: if not self.transactions_match_expenses:
return Statement.NON_MATCHING_TRANSACTIONS return Statement.NON_MATCHING_TRANSACTIONS
if not self.ledgers_configured: if not self.ledgers_configured:
@ -509,6 +549,7 @@ class Statement(CommonModel):
def total_pretty(self): def total_pretty(self):
return "{}".format(self.total) return "{}".format(self.total)
total_pretty.short_description = _('Total') total_pretty.short_description = _('Total')
total_pretty.admin_order_field = 'total'
def template_context(self): def template_context(self):
context = { context = {
@ -580,6 +621,20 @@ class Statement(CommonModel):
attachments=[media_path(filename)]) 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): class StatementUnSubmittedManager(models.Manager):
def get_queryset(self): def get_queryset(self):
return super().get_queryset().filter(status=Statement.UNSUBMITTED) return super().get_queryset().filter(status=Statement.UNSUBMITTED)
@ -636,7 +691,7 @@ class StatementConfirmed(Statement):
class Bill(CommonModel): class Bill(CommonModel):
statement = models.ForeignKey(Statement, verbose_name=_('Statement'), on_delete=models.CASCADE) 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) explanation = models.TextField(verbose_name=_('Explanation'), blank=True)
amount = models.DecimalField(verbose_name=_('Amount'), max_digits=6, decimal_places=2, default=0) 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.contrib.admin.sites import AdminSite
from django.test import RequestFactory, Client from django.test import RequestFactory, Client
from django.contrib.auth.models import User, Permission from django.contrib.auth.models import User, Permission
from django.contrib.auth import models as authmodels
from django.utils import timezone from django.utils import timezone
from django.contrib.sessions.middleware import SessionMiddleware from django.contrib.sessions.middleware import SessionMiddleware
from django.contrib.messages.middleware import MessageMiddleware from django.contrib.messages.middleware import MessageMiddleware
@ -24,8 +25,7 @@ from ..models import (
StatementSubmitted StatementSubmitted
) )
from ..admin import ( from ..admin import (
LedgerAdmin, StatementUnSubmittedAdmin, StatementSubmittedAdmin, LedgerAdmin, StatementAdmin, TransactionAdmin, BillAdmin
StatementConfirmedAdmin, TransactionAdmin, BillAdmin
) )
@ -52,10 +52,10 @@ class AdminTestCase(TestCase):
class StatementUnSubmittedAdminTestCase(AdminTestCase): class StatementUnSubmittedAdminTestCase(AdminTestCase):
"""Test cases for StatementUnSubmittedAdmin""" """Test cases for StatementAdmin in the case of unsubmitted statements"""
def setUp(self): def setUp(self):
super().setUp(model=StatementUnSubmitted, admin=StatementUnSubmittedAdmin) super().setUp(model=Statement, admin=StatementAdmin)
self.superuser = User.objects.get(username='superuser') self.superuser = User.objects.get(username='superuser')
self.member = Member.objects.create( self.member = Member.objects.create(
@ -96,6 +96,26 @@ class StatementUnSubmittedAdminTestCase(AdminTestCase):
self.admin.save_model(request, new_statement, None, change=False) self.admin.save_model(request, new_statement, None, change=False)
self.assertEqual(new_statement.created_by, self.member) 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): def test_get_readonly_fields_submitted(self):
"""Test readonly fields when statement is submitted""" """Test readonly fields when statement is submitted"""
# Mark statement as submitted # Mark statement as submitted
@ -111,7 +131,7 @@ class StatementUnSubmittedAdminTestCase(AdminTestCase):
self.assertEqual(readonly_fields, ['status', 'excursion']) self.assertEqual(readonly_fields, ['status', 'excursion'])
def test_submit_view_insufficient_permission(self): def test_submit_view_insufficient_permission(self):
url = reverse('admin:finance_statementunsubmitted_submit', url = reverse('admin:finance_statement_submit',
args=(self.statement.pk,)) args=(self.statement.pk,))
c = self._login('standard') c = self._login('standard')
response = c.get(url, follow=True) response = c.get(url, follow=True)
@ -119,7 +139,7 @@ class StatementUnSubmittedAdminTestCase(AdminTestCase):
self.assertContains(response, _('Insufficient permissions.')) self.assertContains(response, _('Insufficient permissions.'))
def test_submit_view_get(self): def test_submit_view_get(self):
url = reverse('admin:finance_statementunsubmitted_submit', url = reverse('admin:finance_statement_submit',
args=(self.statement.pk,)) args=(self.statement.pk,))
c = self._login('superuser') c = self._login('superuser')
response = c.get(url, follow=True) response = c.get(url, follow=True)
@ -127,7 +147,7 @@ class StatementUnSubmittedAdminTestCase(AdminTestCase):
self.assertContains(response, _('Submit statement')) self.assertContains(response, _('Submit statement'))
def test_submit_view_get_with_excursion(self): 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,)) args=(self.statement_with_excursion.pk,))
c = self._login('superuser') c = self._login('superuser')
response = c.get(url, follow=True) response = c.get(url, follow=True)
@ -135,7 +155,7 @@ class StatementUnSubmittedAdminTestCase(AdminTestCase):
self.assertContains(response, _('Finance overview')) self.assertContains(response, _('Finance overview'))
def test_submit_view_post(self): def test_submit_view_post(self):
url = reverse('admin:finance_statementunsubmitted_submit', url = reverse('admin:finance_statement_submit',
args=(self.statement.pk,)) args=(self.statement.pk,))
c = self._login('superuser') c = self._login('superuser')
response = c.post(url, follow=True, data={'apply': ''}) response = c.post(url, follow=True, data={'apply': ''})
@ -145,10 +165,10 @@ class StatementUnSubmittedAdminTestCase(AdminTestCase):
class StatementSubmittedAdminTestCase(AdminTestCase): class StatementSubmittedAdminTestCase(AdminTestCase):
"""Test cases for StatementSubmittedAdmin""" """Test cases for StatementAdmin in the case of submitted statements"""
def setUp(self): 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.user = User.objects.create_user('testuser', 'test@example.com', 'pass')
self.member = Member.objects.create( self.member = Member.objects.create(
@ -157,8 +177,8 @@ class StatementSubmittedAdminTestCase(AdminTestCase):
) )
self.finance_user = User.objects.create_user('finance', 'finance@example.com', 'pass') self.finance_user = User.objects.create_user('finance', 'finance@example.com', 'pass')
finance_perm = Permission.objects.get(codename='process_statementsubmitted') self.finance_user.groups.add(authmodels.Group.objects.get(name='Finance'),
self.finance_user.user_permissions.add(finance_perm) authmodels.Group.objects.get(name='Standard'))
self.statement = Statement.objects.create( self.statement = Statement.objects.create(
short_description='Submitted Statement', short_description='Submitted Statement',
@ -247,12 +267,6 @@ class StatementSubmittedAdminTestCase(AdminTestCase):
paid_by=self.member 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): def test_has_change_permission_with_permission(self):
"""Test change permission with proper permission""" """Test change permission with proper permission"""
request = self.factory.get('/') request = self.factory.get('/')
@ -276,14 +290,14 @@ class StatementSubmittedAdminTestCase(AdminTestCase):
self.admin.get_readonly_fields(None, self.statement_unsubmitted)) self.admin.get_readonly_fields(None, self.statement_unsubmitted))
def test_change(self): def test_change(self):
url = reverse('admin:finance_statementsubmitted_change', url = reverse('admin:finance_statement_change',
args=(self.statement.pk,)) args=(self.statement.pk,))
c = self._login('superuser') c = self._login('superuser')
response = c.get(url) response = c.get(url)
self.assertEqual(response.status_code, HTTPStatus.OK) self.assertEqual(response.status_code, HTTPStatus.OK)
def test_overview_view(self): def test_overview_view(self):
url = reverse('admin:finance_statementsubmitted_overview', url = reverse('admin:finance_statement_overview',
args=(self.statement.pk,)) args=(self.statement.pk,))
c = self._login('superuser') c = self._login('superuser')
response = c.get(url) response = c.get(url)
@ -297,7 +311,7 @@ class StatementSubmittedAdminTestCase(AdminTestCase):
self.statement.status = Statement.UNSUBMITTED self.statement.status = Statement.UNSUBMITTED
self.statement.save() 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') c = self._login('superuser')
response = c.get(url, follow=True) response = c.get(url, follow=True)
self.assertEqual(response.status_code, HTTPStatus.OK) 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 # Create a bill that matches the transaction amount to make it valid
self._create_matching_bill() 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') c = self._login('superuser')
response = c.post(url, follow=True, data={'transaction_execution_confirm': ''}) response = c.post(url, follow=True, data={'transaction_execution_confirm': ''})
self.assertEqual(response.status_code, HTTPStatus.OK) 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 # Create a bill that matches the transaction amount to make it valid
self._create_matching_bill() 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') c = self._login('superuser')
response = c.post(url, follow=True, data={'transaction_execution_confirm_and_send': ''}) response = c.post(url, follow=True, data={'transaction_execution_confirm_and_send': ''})
self.assertEqual(response.status_code, HTTPStatus.OK) 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 # Create a bill that matches the transaction amount to make total valid
self._create_matching_bill() self._create_matching_bill()
url = reverse('admin:finance_statementsubmitted_overview', url = reverse('admin:finance_statement_overview',
args=(self.statement.pk,)) args=(self.statement.pk,))
c = self._login('superuser') c = self._login('superuser')
response = c.post(url, data={'confirm': ''}) response = c.post(url, data={'confirm': ''})
@ -361,7 +375,7 @@ class StatementSubmittedAdminTestCase(AdminTestCase):
# Create a bill that doesn't match the transaction # Create a bill that doesn't match the transaction
self._create_non_matching_bill() self._create_non_matching_bill()
url = reverse('admin:finance_statementsubmitted_overview', url = reverse('admin:finance_statement_overview',
args=(self.statement.pk,)) args=(self.statement.pk,))
c = self._login('superuser') c = self._login('superuser')
response = c.post(url, follow=True, data={'confirm': ''}) 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 # Create a bill that matches the transaction amount to pass the first check
self._create_matching_bill() self._create_matching_bill()
url = reverse('admin:finance_statementsubmitted_overview', url = reverse('admin:finance_statement_overview',
args=(self.statement.pk,)) args=(self.statement.pk,))
c = self._login('superuser') c = self._login('superuser')
response = c.post(url, follow=True, data={'confirm': ''}) response = c.post(url, follow=True, data={'confirm': ''})
@ -406,7 +420,7 @@ class StatementSubmittedAdminTestCase(AdminTestCase):
# Check validity obstruction is allowances # Check validity obstruction is allowances
self.assertEqual(self.statement_no_trans_success.validity, Statement.INVALID_ALLOWANCE_TO) 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,)) args=(self.statement_no_trans_success.pk,))
c = self._login('superuser') c = self._login('superuser')
response = c.post(url, follow=True, data={'confirm': ''}) response = c.post(url, follow=True, data={'confirm': ''})
@ -418,7 +432,7 @@ class StatementSubmittedAdminTestCase(AdminTestCase):
def test_overview_view_reject(self): def test_overview_view_reject(self):
"""Test overview_view reject statement""" """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') c = self._login('superuser')
response = c.post(url, follow=True, data={'reject': ''}) response = c.post(url, follow=True, data={'reject': ''})
self.assertEqual(response.status_code, HTTPStatus.OK) self.assertEqual(response.status_code, HTTPStatus.OK)
@ -435,7 +449,7 @@ class StatementSubmittedAdminTestCase(AdminTestCase):
# Ensure there's already a transaction # Ensure there's already a transaction
self.assertTrue(self.statement.transaction_set.count() > 0) 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') c = self._login('superuser')
response = c.post(url, follow=True, data={'generate_transactions': ''}) response = c.post(url, follow=True, data={'generate_transactions': ''})
self.assertEqual(response.status_code, HTTPStatus.OK) self.assertEqual(response.status_code, HTTPStatus.OK)
@ -444,7 +458,7 @@ class StatementSubmittedAdminTestCase(AdminTestCase):
def test_overview_view_generate_transactions_success(self): def test_overview_view_generate_transactions_success(self):
"""Test overview_view generate transactions successfully""" """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,)) args=(self.statement_no_trans_success.pk,))
c = self._login('superuser') c = self._login('superuser')
response = c.post(url, follow=True, data={'generate_transactions': ''}) response = c.post(url, follow=True, data={'generate_transactions': ''})
@ -455,7 +469,7 @@ class StatementSubmittedAdminTestCase(AdminTestCase):
def test_overview_view_generate_transactions_error(self): def test_overview_view_generate_transactions_error(self):
"""Test overview_view generate transactions with error""" """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,)) args=(self.statement_no_trans_error.pk,))
c = self._login('superuser') c = self._login('superuser')
response = c.post(url, follow=True, data={'generate_transactions': ''}) 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)) self.assertTrue(any(expected_text in str(msg) for msg in messages))
def test_reduce_transactions_view(self): def test_reduce_transactions_view(self):
url = reverse('admin:finance_statementsubmitted_reduce_transactions', url = reverse('admin:finance_statement_reduce_transactions',
args=(self.statement.pk,)) args=(self.statement.pk,))
c = self._login('superuser') 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) follow=True)
self.assertContains(response, self.assertContains(response,
_("Successfully reduced transactions for %(name)s.") %\ _("Successfully reduced transactions for %(name)s.") %\
@ -477,10 +491,10 @@ class StatementSubmittedAdminTestCase(AdminTestCase):
class StatementConfirmedAdminTestCase(AdminTestCase): class StatementConfirmedAdminTestCase(AdminTestCase):
"""Test cases for StatementConfirmedAdmin""" """Test cases for StatementAdmin in the case of confirmed statements"""
def setUp(self): 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.user = User.objects.create_user('testuser', 'test@example.com', 'pass')
self.member = Member.objects.create( self.member = Member.objects.create(
@ -489,8 +503,8 @@ class StatementConfirmedAdminTestCase(AdminTestCase):
) )
self.finance_user = User.objects.create_user('finance', 'finance@example.com', 'pass') 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.groups.add(authmodels.Group.objects.get(name='Finance'),
self.finance_user.user_permissions.add(unconfirm_perm) authmodels.Group.objects.get(name='Standard'))
# Create a base statement first # Create a base statement first
base_statement = Statement.objects.create( base_statement = Statement.objects.create(
@ -544,23 +558,17 @@ class StatementConfirmedAdminTestCase(AdminTestCase):
middleware.process_request(request) middleware.process_request(request)
request._messages = FallbackStorage(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): def test_has_change_permission(self):
"""Test that change permission is disabled""" """Test that change permission is disabled"""
request = self.factory.get('/') request = self.factory.get('/')
request.user = self.finance_user 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): def test_has_delete_permission(self):
"""Test that delete permission is disabled""" """Test that delete permission is disabled"""
request = self.factory.get('/') request = self.factory.get('/')
request.user = self.finance_user 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): def test_unconfirm_view_not_confirmed_statement(self):
"""Test unconfirm_view with statement that is not confirmed""" """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) self.assertIn(self.statement.short_description.encode(), response.content)
def test_statement_summary_view_insufficient_permission(self): 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,)) args=(self.statement_with_excursion.pk,))
c = self._login('standard') c = self._login('standard')
response = c.get(url, follow=True) 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): def test_statement_summary_view_unconfirmed(self):
url = reverse('admin:finance_statementconfirmed_summary', url = reverse('admin:finance_statement_summary',
args=(self.unconfirmed_statement.pk,)) args=(self.unconfirmed_statement.pk,))
c = self._login('superuser') c = self._login('superuser')
response = c.get(url, follow=True) response = c.get(url, follow=True)
@ -638,7 +647,7 @@ class StatementConfirmedAdminTestCase(AdminTestCase):
def test_statement_summary_view_confirmed_with_excursion(self): def test_statement_summary_view_confirmed_with_excursion(self):
"""Test statement_summary_view when statement is confirmed with excursion""" """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,)) args=(self.statement_with_excursion.pk,))
c = self._login('superuser') c = self._login('superuser')
response = c.get(url, follow=True) response = c.get(url, follow=True)

@ -147,6 +147,9 @@ class StatementTestCase(TestCase):
refunded=False refunded=False
) )
self.st6 = Statement.objects.create(night_cost=self.night_cost)
Bill.objects.create(statement=self.st6, amount='42', costs_covered=True)
def test_org_fee(self): def test_org_fee(self):
# org fee should be collected if participants are older than 26 # org fee should be collected if participants are older than 26
self.assertEqual(self.st5.excursion.old_participant_count, 3, 'Calculation of number of old people in excursion is incorrect.') self.assertEqual(self.st5.excursion.old_participant_count, 3, 'Calculation of number of old people in excursion is incorrect.')
@ -488,6 +491,11 @@ class StatementTestCase(TestCase):
ljp_contrib = self.st_small.paid_ljp_contributions ljp_contrib = self.st_small.paid_ljp_contributions
self.assertEqual(ljp_contrib, 0) self.assertEqual(ljp_contrib, 0)
def test_validity_paid_by_none(self):
# st6 has one covered bill with no payer, so no transaction issues,
# but total transaction amount (= 0) differs from actual total (> 0).
self.assertEqual(self.st6.validity, Statement.INVALID_TOTAL)
class LedgerTestCase(TestCase): class LedgerTestCase(TestCase):
def setUp(self): def setUp(self):

@ -26,9 +26,7 @@ JET_SIDE_MENU_ITEMS = [
{'name': 'emailaddress', 'permissions': ['mailer.view_emailaddress']}, {'name': 'emailaddress', 'permissions': ['mailer.view_emailaddress']},
]}, ]},
{'app_label': 'finance', 'items': [ {'app_label': 'finance', 'items': [
{'name': 'statementunsubmitted', 'permissions': ['finance.view_statementunsubmitted']}, {'name': 'statement', 'permissions': ['finance.view_statement']},
{'name': 'statementsubmitted', 'permissions': ['finance.view_statementsubmitted']},
{'name': 'statementconfirmed', 'permissions': ['finance.view_statementconfirmed']},
{'name': 'ledger', 'permissions': ['finance.view_ledger']}, {'name': 'ledger', 'permissions': ['finance.view_ledger']},
{'name': 'bill', 'permissions': ['finance.view_bill', 'finance.view_bill_admin']}, {'name': 'bill', 'permissions': ['finance.view_bill', 'finance.view_bill_admin']},
{'name': 'transaction', 'permissions': ['finance.view_transaction']}, {'name': 'transaction', 'permissions': ['finance.view_transaction']},

@ -42,7 +42,7 @@ from .models import (Member, Group, Freizeit, MemberNoteList, NewMemberOnList, K
KlettertreffAttendee, ActivityCategory, EmergencyContact, KlettertreffAttendee, ActivityCategory, EmergencyContact,
annotate_activity_score, RegistrationPassword, MemberUnconfirmedProxy, annotate_activity_score, RegistrationPassword, MemberUnconfirmedProxy,
InvitationToGroup) 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 mailer.mailutils import send as send_mail, get_echo_link
from django.conf import settings from django.conf import settings
from utils import get_member, RestrictedFileField, mondays_until_nth 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() self.fields['ljp_to'].queryset = excursion.jugendleiter.all()
class Meta: class Meta:
model = Statement model = StatementOnExcursionProxy
fields = ['night_cost', 'allowance_to', 'subsidy_to', 'ljp_to'] fields = ['night_cost', 'allowance_to', 'subsidy_to', 'ljp_to']
def clean(self): def clean(self):
@ -959,7 +959,7 @@ class StatementOnListForm(forms.ModelForm):
class StatementOnListInline(CommonAdminInlineMixin, nested_admin.NestedStackedInline): class StatementOnListInline(CommonAdminInlineMixin, nested_admin.NestedStackedInline):
model = Statement model = StatementOnExcursionProxy
extra = 1 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.).') 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 = [] 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": elif name == "NewMemberOnList":
return queryset return queryset
elif name == "Statement": 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": elif name == "BillOnExcursionProxy":
return queryset return queryset
elif name == "Intervention": 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:"/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:"/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 "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 %} {% block extrastyle %}{% endblock %}
{% if LANGUAGE_BIDI %}<link rel="stylesheet" type="text/css" href="{% block stylesheet_rtl %}{% static "admin/css/rtl.css" %}{% endblock %}" />{% endif %} {% 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 Der KOMPASS ist dein Kompass in der Jugendarbeit der JDAV {% settings_value 'SEKTION' %}. Hier hast du Zugriff
auf deine Jugendgruppen, deine letzten auf deine Jugendgruppen, deine letzten
<a href="{% url 'admin:members_freizeit_changelist' %}">Ausfahrten</a> und deine <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> </p>
</div> </div>

Loading…
Cancel
Save