finance: add overview, transaction generation and reduction, further fields

v1-0-stable
Christian Merten 3 years ago
parent cafc7f4f97
commit b8daed826d
Signed by: christian.merten
GPG Key ID: D953D69721B948B3

@ -7,7 +7,7 @@ from functools import update_wrapper
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django.shortcuts import render from django.shortcuts import render
from .models import Ledger, Statement, Receipt, Transaction, Bill, StatementSubmitted from .models import Ledger, Statement, Receipt, Transaction, Bill, StatementSubmitted, StatementConfirmed
@admin.register(Ledger) @admin.register(Ledger)
class LedgerAdmin(admin.ModelAdmin): class LedgerAdmin(admin.ModelAdmin):
@ -80,10 +80,32 @@ class StatementAdmin(admin.ModelAdmin):
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']
formfield_overrides = {
TextField: {'widget': Textarea(attrs={'rows': 1, 'cols': 40})}
}
extra = 0
class BillOnSubmittedStatementInline(BillOnStatementInline):
model = Bill
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) @admin.register(StatementSubmitted)
class StatementSubmittedAdmin(admin.ModelAdmin): class StatementSubmittedAdmin(admin.ModelAdmin):
fields = ['short_description', 'explanation', 'excursion', 'submitted'] fields = ['short_description', 'explanation', 'excursion', 'submitted']
inlines = [BillOnStatementInline] inlines = [BillOnSubmittedStatementInline, TransactionOnSubmittedStatementInline]
def has_add_permission(self, request, obj=None): def has_add_permission(self, request, obj=None):
return False return False
@ -111,6 +133,11 @@ class StatementSubmittedAdmin(admin.ModelAdmin):
wrap(self.overview_view), wrap(self.overview_view),
name="%s_%s_overview" % (self.opts.app_label, self.opts.model_name), 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 return custom_urls + urls
@ -120,22 +147,56 @@ class StatementSubmittedAdmin(admin.ModelAdmin):
messages.error(request, messages.error(request,
_("%(name)s is not yet submitted.") % {'name': str(statement)}) _("%(name)s is not yet submitted.") % {'name': str(statement)})
return HttpResponseRedirect(reverse('admin:%s_%s_change' % (self.opts.app_label, self.opts.model_name), args=(statement.pk,))) return HttpResponseRedirect(reverse('admin:%s_%s_change' % (self.opts.app_label, self.opts.model_name), args=(statement.pk,)))
if "confirm" in request.POST:
if "apply" in request.POST: statement.confirmed = True
#statement.submit() statement.save()
#messages.success(request, for trans in statement.transaction_set.all():
# _("Successfully submited %(name)s. The finance department will notify the requestors as soon as possible.") % {'name': str(statement)}) trans.confirmed = True
trans.save()
if "generate_transactions" in request.POST:
if statement.transaction_set.count() > 0:
messages.error(request,
_("%(name)s already has transactions. Please delete them first, if you want to generate new ones") % {'name': str(statement)})
else:
statement.generate_transactions()
messages.success(request,
_("Successfully generated transactions for %(name)s") % {'name': str(statement)})
return HttpResponseRedirect(reverse('admin:%s_%s_change' % (self.opts.app_label, self.opts.model_name), args=(statement.pk,))) return HttpResponseRedirect(reverse('admin:%s_%s_change' % (self.opts.app_label, self.opts.model_name), args=(statement.pk,)))
context = dict(self.admin_site.each_context(request), context = dict(self.admin_site.each_context(request),
title=_('View submitted statement'), title=_('View submitted statement'),
opts=self.opts, opts=self.opts,
statement=statement, statement=statement,
total_bills=statement.total_bills(), nights=statement.excursion.night_count,
total_transportation=statement.total_transportation(), price_per_night=statement.real_night_cost,
duration=statement.excursion.duration,
staff_count=statement.real_staff_count,
kilometers_traveled=statement.excursion.kilometers_traveled,
means_of_transport=statement.excursion.get_tour_approach(),
euro_per_km=statement.euro_per_km,
allowance_per_day=statement.ALLOWANCE_PER_DAY,
total_bills=statement.total_bills,
nights_per_yl=statement.nights_per_yl,
allowance_per_yl=statement.allowance_per_yl,
transportation_per_yl=statement.transportation_per_yl,
total_per_yl=statement.total_per_yl,
total_staff=statement.total_staff,
total=statement.total()) total=statement.total())
return render(request, 'admin/overview_submitted_statement.html', context=context) return render(request, 'admin/overview_submitted_statement.html', context=context)
def reduce_transactions_view(self, request, object_id):
statement = Statement.objects.get(pk=object_id)
statement.reduce_transactions()
messages.success(request,
_("Successfully reduced transactions for %(name)s.") % {'name': str(statement)})
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', 'confirmed']
readonly_fields = fields
@admin.register(Receipt) @admin.register(Receipt)
class ReceiptAdmin(admin.ModelAdmin): class ReceiptAdmin(admin.ModelAdmin):

@ -1,6 +1,9 @@
import math
from itertools import groupby
from django.db import models from django.db import models
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from members.models import Member, Freizeit from members.models import Member, Freizeit, OEFFENTLICHE_ANREISE, MUSKELKRAFT_ANREISE
# Create your models here. # Create your models here.
@ -12,6 +15,8 @@ class Ledger(models.Model):
class Statement(models.Model): class Statement(models.Model):
ALLOWANCE_PER_DAY = 10
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=True)
@ -22,7 +27,10 @@ class Statement(models.Model):
null=True, null=True,
on_delete=models.SET_NULL) on_delete=models.SET_NULL)
night_cost = models.DecimalField(verbose_name=_('Price per night'), default=0, decimal_places=2, max_digits=3)
submitted = models.BooleanField(verbose_name=_('Submitted'), default=False) submitted = models.BooleanField(verbose_name=_('Submitted'), default=False)
confirmed = models.BooleanField(verbose_name=_('Confirmed'), default=False)
class Meta: class Meta:
permissions = [('may_edit_submitted_statements', 'Is allowed to edit submitted statements')] permissions = [('may_edit_submitted_statements', 'Is allowed to edit submitted statements')]
@ -37,23 +45,116 @@ class Statement(models.Model):
self.submitted = True self.submitted = True
self.save() self.save()
def generate_transactions(self):
# bills
for bill in self.bill_set.all():
if not bill.costs_covered:
continue
ref = "{}: {}".format(self.excursion.name, bill.short_description)
Transaction(statement=self, member=bill.paid_by, amount=bill.amount, confirmed=False, reference=ref).save()
# excursion specific
if self.excursion is None:
return
for yl in self.excursion.jugendleiter.all():
real_per_yl = self.total_staff / self.excursion.jugendleiter.count()
ref = _("Compensation for %(excu)s.") % {'excu': self.excursion.name}
Transaction(statement=self, member=yl, amount=real_per_yl, confirmed=False, reference=ref).save()
def reduce_transactions(self):
transactions = sorted(self.transaction_set.all(), key=lambda trans: trans.member.pk)
for member, transaction_group in groupby(transactions, lambda trans: trans.member):
grp = list(transaction_group)
if len(grp) == 1:
continue
new_amount = sum((trans.amount for trans in grp))
new_ref = "\n".join((trans.reference for trans in grp))
Transaction(statement=self, member=member, amount=new_amount, confirmed=False, reference=new_ref).save()
for trans in grp:
trans.delete()
@property
def total_bills(self): def total_bills(self):
return sum([bill.amount for bill in self.bill_set.all()]) return sum([bill.amount for bill in self.bill_set.all() if bill.costs_covered])
@property
def euro_per_km(self):
if self.excursion is None:
return 0
if self.excursion.tour_approach == MUSKELKRAFT_ANREISE \
or self.excursion.tour_approach == OEFFENTLICHE_ANREISE:
return 0.15
else:
return 0.1
@property
def transportation_per_yl(self):
if self.excursion is None:
return 0
return self.excursion.kilometers_traveled * self.euro_per_km
@property
def allowance_per_yl(self):
if self.excursion is None:
return 0
return self.excursion.duration * self.ALLOWANCE_PER_DAY
@property
def real_night_cost(self):
return min(self.night_cost, 11)
@property
def nights_per_yl(self):
if self.excursion is None:
return 0
return float(self.excursion.night_count * self.real_night_cost)
@property
def total_per_yl(self):
return self.transportation_per_yl \
+ self.allowance_per_yl \
+ self.nights_per_yl
def total_transportation(self): @property
def total_staff(self):
return self.total_per_yl * self.real_staff_count
@property
def real_staff_count(self):
if self.excursion is None: if self.excursion is None:
return 0 return 0
exc = self.excursion return min(self.excursion.staff_count, self.admissible_staff_count)
return exc.kilometers_traveled * 0.2
@property
def admissible_staff_count(self):
"""An excursion can have as many youth leaders as the max bound on integers allows. Not all youth leaders
are refinanced though."""
if self.excursion is None:
return 0
#raw_staff_count = self.excursion.jugendleiter.count()
participant_count = self.excursion.participant_count
if participant_count < 4:
return 0
elif 4 <= participant_count <= 7:
return 2
else:
return 2 + math.ceil((participant_count - 7) / 7)
def total(self): def total(self):
return float(self.total_bills()) + self.total_transportation() return float(self.total_bills) + self.total_staff
class StatementSubmittedManager(models.Manager): class StatementSubmittedManager(models.Manager):
def get_queryset(self): def get_queryset(self):
return super().get_queryset().filter(submitted=True) return super().get_queryset().filter(submitted=True, confirmed=False)
class StatementSubmitted(Statement): class StatementSubmitted(Statement):
@ -65,15 +166,31 @@ class StatementSubmitted(Statement):
verbose_name_plural = _('Submitted statements') verbose_name_plural = _('Submitted statements')
permissions = (('may_manage_submitted_statements', 'Can view and manage submitted statements.'),) permissions = (('may_manage_submitted_statements', 'Can view and manage submitted statements.'),)
class StatementConfirmedManager(models.Manager):
def get_queryset(self):
return super().get_queryset().filter(confirmed=True)
class StatementConfirmed(Statement):
objects = StatementConfirmedManager()
class Meta:
proxy = True
verbose_name = _('Confirmed statement')
verbose_name_plural = _('Confirmed statements')
permissions = (('may_manage_confirmed_statements', 'Can view and manage confirmed statements.'),)
class Bill(models.Model): class Bill(models.Model):
statement = models.ForeignKey(Statement, verbose_name=_('Statement'), statement = models.ForeignKey(Statement, verbose_name=_('Statement'), on_delete=models.CASCADE)
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)
explanation = models.TextField(verbose_name=_('Explanation'), blank=True) explanation = models.TextField(verbose_name=_('Explanation'), blank=True)
amount = models.DecimalField(max_digits=6, decimal_places=2, default=0) amount = models.DecimalField(max_digits=6, decimal_places=2, default=0)
paid_by = models.ForeignKey(Member, verbose_name=_('Paid by'), null=True, paid_by = models.ForeignKey(Member, verbose_name=_('Paid by'), null=True,
on_delete=models.SET_NULL) on_delete=models.SET_NULL)
costs_covered = models.BooleanField(verbose_name=_('Covered'), default=False)
refunded = models.BooleanField(verbose_name=_('Refunded'), default=False) refunded = models.BooleanField(verbose_name=_('Refunded'), default=False)
proof = models.ImageField(_('Proof'), upload_to='bill_images', blank=True) proof = models.ImageField(_('Proof'), upload_to='bill_images', blank=True)
@ -87,6 +204,7 @@ class Bill(models.Model):
class Transaction(models.Model): class Transaction(models.Model):
reference = models.TextField(verbose_name=_('Reference'))
amount = models.DecimalField(max_digits=6, decimal_places=2) amount = models.DecimalField(max_digits=6, decimal_places=2)
member = models.ForeignKey(Member, verbose_name=_('Recipient'), member = models.ForeignKey(Member, verbose_name=_('Recipient'),
on_delete=models.CASCADE) on_delete=models.CASCADE)
@ -94,7 +212,7 @@ class Transaction(models.Model):
statement = models.ForeignKey(Statement, verbose_name=_('Statement'), statement = models.ForeignKey(Statement, verbose_name=_('Statement'),
on_delete=models.CASCADE) on_delete=models.CASCADE)
confirmed = models.BooleanField(verbose_name=_('Confirmed')) confirmed = models.BooleanField(verbose_name=_('Confirmed'), default=False)
class Receipt(models.Model): class Receipt(models.Model):

@ -1,6 +1,8 @@
{% extends "admin/base_site.html" %} {% extends "admin/base_site.html" %}
{% load i18n admin_urls static %} {% load i18n admin_urls static %}
{% load finance_extras %}
{% block extrahead %} {% block extrahead %}
{{ block.super }} {{ block.super }}
{{ media }} {{ media }}
@ -18,38 +20,68 @@
&rsaquo; <a href="{% url 'admin:app_list' app_label=opts.app_label %}">{{ opts.app_config.verbose_name }}</a> &rsaquo; <a href="{% url 'admin:app_list' app_label=opts.app_label %}">{{ opts.app_config.verbose_name }}</a>
&rsaquo; <a href="{% url opts|admin_urlname:'changelist' %}">{{ opts.verbose_name_plural|capfirst }}</a> &rsaquo; <a href="{% url opts|admin_urlname:'changelist' %}">{{ opts.verbose_name_plural|capfirst }}</a>
&rsaquo; <a href="{% url opts|admin_urlname:'change' statement.pk|admin_urlquote %}">{{ statement|truncatewords:"18" }}</a> &rsaquo; <a href="{% url opts|admin_urlname:'change' statement.pk|admin_urlquote %}">{{ statement|truncatewords:"18" }}</a>
&rsaquo; {% translate 'Submit' %} &rsaquo; {% translate 'Overview' %}
</div> </div>
{% endblock %} {% endblock %}
{% block content %} {% block content %}
<h2>{% translate "Overview" %}</h2> <h2>{% translate "Bills" %}</h2>
<p>
<ul> <ul>
{% for bill in statement.bill_set.all %} {% for bill in statement.bill_set.all %}
<li> <li>
{{bill.short_description}} {{bill.short_description}} {%trans "for" %} {{ bill.amount }}€.
{% if not bill.costs_covered %}
This bill is not covered by the association.
{% endif %}
</li> </li>
{% endfor %} {% endfor %}
</ul> </ul>
</p>
<p>{% blocktrans %}The total amount is {{total_bills}} €.{% endblocktrans %}</p> <p>{% blocktrans %}The total amount is {{total_bills}} €.{% endblocktrans %}</p>
{% if statement.excursion %} {% if statement.excursion %}
<h3>{% trans "Excursion" %}</h3> <h2>{% trans "Excursion" %}</h2>
<p>{% blocktrans %}Total distance traveled: {{ statement.excursion.kilometers_traveled }} km by
{{ statement.excursion.tour_approach }}. This results in {{ total_transportation }} €.{% endblocktrans %} {% blocktrans %}
<p>
This excursion featured {{ staff_count }} youth leader(s), each costing
</p>
<p>
<ul>
<li>
{{ nights }} nights for {{ price_per_night }}€ per night making a total of {{ nights_per_yl }}€.
</li>
<li>
{{ duration }} days for {{ allowance_per_day }}€ per day making a total of {{ allowance_per_yl }}€.
</li>
<li>
{{ kilometers_traveled }} km by {{ means_of_transport }} ({{euro_per_km}} € / km) making a total of {{ transportation_per_yl }}€.
</li>
</ul>
</p>
<p>
In total this is {{ total_per_yl }}€ times {{ staff_count }}, giving {{ total_staff }}€.
</p> </p>
{% endblocktrans %}
{% endif %} {% endif %}
<h2>{% trans "Total" %}</h2>
<p>
{% blocktrans %} This results in a total amount of {{ total }} € {% endblocktrans %} {% blocktrans %} This results in a total amount of {{ total }} € {% endblocktrans %}
</p>
<form action="" method="post"> <form action="" method="post">
{% csrf_token %} {% csrf_token %}
<input type="hidden" name="action" value="submit_statement"> <input type="hidden" name="action" value="submit_statement">
<input class="default" style="color: $default-link-color" type="submit" name="apply" value="{% translate 'Submit' %}"> <input class="default" type="submit" name="confirm" value="{% translate 'Accept' %}">
<input class="default" type="submit" name="reject" value="{% translate 'Reject' %}">
<input class="default" type="submit" name="generate_transactions" value="{% translate 'Generate transactions' %}">
<a href="#" class="button cancel-link">{% translate "Cancel" %}</a> <a href="#" class="button cancel-link">{% translate "Cancel" %}</a>
</form> </form>
{% endblock %} {% endblock %}

@ -278,7 +278,7 @@ class Member(Person):
lists = Freizeit.objects.filter(activity=kind, lists = Freizeit.objects.filter(activity=kind,
membersonlist__member=self) membersonlist__member=self)
skills[kind.name] = sum([l.difficulty * 3 for l in lists skills[kind.name] = sum([l.difficulty * 3 for l in lists
if l.date < datetime.now().date()]) if l.date < timezone.now()])
return skills return skills
def get_activities(self): def get_activities(self):
@ -509,8 +509,8 @@ class Freizeit(models.Model):
place = models.CharField(verbose_name=_('Place'), default='', max_length=50) place = models.CharField(verbose_name=_('Place'), default='', max_length=50)
destination = models.CharField(verbose_name=_('Destination (optional)'), destination = models.CharField(verbose_name=_('Destination (optional)'),
default='', max_length=50, blank=True) default='', max_length=50, blank=True)
date = models.DateField(default=datetime.today, verbose_name=_('Date')) date = models.DateTimeField(default=datetime.today, verbose_name=_('Begin'))
end = models.DateField(verbose_name=_('End (optional)'), blank=True, default=datetime.today) end = models.DateTimeField(verbose_name=_('End (optional)'), default=datetime.today)
# comment = models.TextField(_('Comments'), default='', blank=True) # comment = models.TextField(_('Comments'), default='', blank=True)
groups = models.ManyToManyField(Group, verbose_name=_('Groups')) groups = models.ManyToManyField(Group, verbose_name=_('Groups'))
jugendleiter = models.ManyToManyField(Member) jugendleiter = models.ManyToManyField(Member)
@ -554,13 +554,45 @@ class Freizeit(models.Model):
if self.tour_approach == MUSKELKRAFT_ANREISE: if self.tour_approach == MUSKELKRAFT_ANREISE:
return "Muskelkraft" return "Muskelkraft"
elif self.tour_approach == OEFFENTLICHE_ANREISE: elif self.tour_approach == OEFFENTLICHE_ANREISE:
return "Öffentliche VM" return "ÖPNV"
else: else:
return "Fahrgemeinschaften" return "Fahrgemeinschaften"
def get_absolute_url(self): def get_absolute_url(self):
return reverse('admin:members_freizeit_change', args=[str(self.id)]) return reverse('admin:members_freizeit_change', args=[str(self.id)])
@property
def night_count(self):
# convert to date first, since we might start at 11pm and end at 1am, which is one night
return (self.end.date() - self.date.date()).days
@property
def duration(self):
# number of nights is number of full days + 1
full_days = self.night_count - 1
extra_days = 0
if self.date.hour <= 12:
extra_days += 1.0
else:
extra_days += 0.5
if self.end.hour <= 12:
extra_days += 1.0
else:
extra_days += 0.5
return full_days + extra_days
@property
def staff_count(self):
return self.jugendleiter.count()
@property
def participant_count(self):
ps = set(map(lambda x: x.member, self.membersonlist.distinct()))
jls = set(self.jugendleiter.distinct())
return len(ps - jls)
class MemberNoteList(models.Model): class MemberNoteList(models.Model):
""" """

@ -3,6 +3,10 @@
{% block object-tools-items %} {% block object-tools-items %}
<li>
{% url opts|admin_urlname:'reduce_transactions' original.pk|admin_urlquote as invite_url %}
<a class="historylink" href="{% add_preserved_filters invite_url %}">{% trans 'Reduce transactions' %}</a>
</li>
<li> <li>
{% url opts|admin_urlname:'overview' original.pk|admin_urlquote as invite_url %} {% url opts|admin_urlname:'overview' original.pk|admin_urlquote as invite_url %}
<a class="historylink" href="{% add_preserved_filters invite_url %}">{% trans 'Overview' %}</a> <a class="historylink" href="{% add_preserved_filters invite_url %}">{% trans 'Overview' %}</a>

Loading…
Cancel
Save