finance: add initial structre, including models and admin page, add some customization
parent
de2c5081e2
commit
cafc7f4f97
@ -0,0 +1,152 @@
|
|||||||
|
from django.contrib import admin, messages
|
||||||
|
from django.forms import Textarea
|
||||||
|
from django.http import HttpResponse, HttpResponseRedirect
|
||||||
|
from django.db.models import TextField
|
||||||
|
from django.urls import path, reverse
|
||||||
|
from functools import update_wrapper
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
from django.shortcuts import render
|
||||||
|
|
||||||
|
from .models import Ledger, Statement, Receipt, Transaction, Bill, StatementSubmitted
|
||||||
|
|
||||||
|
@admin.register(Ledger)
|
||||||
|
class LedgerAdmin(admin.ModelAdmin):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class BillOnStatementInline(admin.TabularInline):
|
||||||
|
model = Bill
|
||||||
|
extra = 0
|
||||||
|
sortable_options = []
|
||||||
|
fields = ['short_description', 'explanation', 'amount', 'paid_by', 'proof']
|
||||||
|
formfield_overrides = {
|
||||||
|
TextField: {'widget': Textarea(attrs={'rows': 1, 'cols': 40})}
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_readonly_fields(self, request, obj=None):
|
||||||
|
if obj is not None and obj.submitted:
|
||||||
|
return self.fields
|
||||||
|
return super(BillOnStatementInline, self).get_readonly_fields(request, obj)
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Statement)
|
||||||
|
class StatementAdmin(admin.ModelAdmin):
|
||||||
|
fields = ['short_description', 'explanation', 'excursion', 'submitted']
|
||||||
|
inlines = [BillOnStatementInline]
|
||||||
|
|
||||||
|
def get_readonly_fields(self, request, obj=None):
|
||||||
|
readonly_fields = ['submitted']
|
||||||
|
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>/submit/",
|
||||||
|
wrap(self.submit_view),
|
||||||
|
name="%s_%s_submit" % (self.opts.app_label, self.opts.model_name),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
return custom_urls + urls
|
||||||
|
|
||||||
|
def submit_view(self, request, object_id):
|
||||||
|
statement = Statement.objects.get(pk=object_id)
|
||||||
|
if statement.submitted:
|
||||||
|
messages.error(request,
|
||||||
|
_("%(name)s is already submitted.") % {'name': str(statement)})
|
||||||
|
return HttpResponseRedirect(reverse('admin:%s_%s_change' % (statement._meta.app_label, statement._meta.model_name), args=(statement.pk,)))
|
||||||
|
|
||||||
|
if "apply" in request.POST:
|
||||||
|
statement.submit()
|
||||||
|
messages.success(request,
|
||||||
|
_("Successfully submited %(name)s. The finance department will notify the requestors as soon as possible.") % {'name': str(statement)})
|
||||||
|
return HttpResponseRedirect(reverse('admin:%s_%s_change' % (statement._meta.app_label, statement._meta.model_name), args=(statement.pk,)))
|
||||||
|
context = dict(self.admin_site.each_context(request),
|
||||||
|
title=_('Submit statement'),
|
||||||
|
opts=self.opts,
|
||||||
|
statement=statement)
|
||||||
|
|
||||||
|
return render(request, 'admin/submit_statement.html', context=context)
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(StatementSubmitted)
|
||||||
|
class StatementSubmittedAdmin(admin.ModelAdmin):
|
||||||
|
fields = ['short_description', 'explanation', 'excursion', 'submitted']
|
||||||
|
inlines = [BillOnStatementInline]
|
||||||
|
|
||||||
|
def has_add_permission(self, request, obj=None):
|
||||||
|
return False
|
||||||
|
|
||||||
|
def get_readonly_fields(self, request, obj=None):
|
||||||
|
readonly_fields = ['submitted']
|
||||||
|
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),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
return custom_urls + urls
|
||||||
|
|
||||||
|
def overview_view(self, request, object_id):
|
||||||
|
statement = Statement.objects.get(pk=object_id)
|
||||||
|
if not statement.submitted:
|
||||||
|
messages.error(request,
|
||||||
|
_("%(name)s is not yet submitted.") % {'name': str(statement)})
|
||||||
|
return HttpResponseRedirect(reverse('admin:%s_%s_change' % (self.opts.app_label, self.opts.model_name), args=(statement.pk,)))
|
||||||
|
|
||||||
|
if "apply" in request.POST:
|
||||||
|
#statement.submit()
|
||||||
|
#messages.success(request,
|
||||||
|
# _("Successfully submited %(name)s. The finance department will notify the requestors as soon as possible.") % {'name': str(statement)})
|
||||||
|
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),
|
||||||
|
title=_('View submitted statement'),
|
||||||
|
opts=self.opts,
|
||||||
|
statement=statement,
|
||||||
|
total_bills=statement.total_bills(),
|
||||||
|
total_transportation=statement.total_transportation(),
|
||||||
|
total=statement.total())
|
||||||
|
|
||||||
|
return render(request, 'admin/overview_submitted_statement.html', context=context)
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Receipt)
|
||||||
|
class ReceiptAdmin(admin.ModelAdmin):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Transaction)
|
||||||
|
class TransactionAdmin(admin.ModelAdmin):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Bill)
|
||||||
|
class BillAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ['short_description', 'pretty_amount', 'paid_by', 'refunded']
|
||||||
@ -0,0 +1,6 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class FinanceConfig(AppConfig):
|
||||||
|
default_auto_field = 'django.db.models.BigAutoField'
|
||||||
|
name = 'finance'
|
||||||
@ -0,0 +1,105 @@
|
|||||||
|
from django.db import models
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
from members.models import Member, Freizeit
|
||||||
|
|
||||||
|
# Create your models here.
|
||||||
|
|
||||||
|
class Ledger(models.Model):
|
||||||
|
name = models.CharField(verbose_name=_('Name'), max_length=30)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.name
|
||||||
|
|
||||||
|
|
||||||
|
class Statement(models.Model):
|
||||||
|
short_description = models.CharField(verbose_name=_('Short description'),
|
||||||
|
max_length=30,
|
||||||
|
blank=True)
|
||||||
|
explanation = models.TextField(verbose_name=_('Explanation'), blank=True)
|
||||||
|
|
||||||
|
excursion = models.OneToOneField(Freizeit, verbose_name=_('Associated excursion'),
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=models.SET_NULL)
|
||||||
|
|
||||||
|
submitted = models.BooleanField(verbose_name=_('Submitted'), default=False)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
permissions = [('may_edit_submitted_statements', 'Is allowed to edit submitted statements')]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
if self.excursion is not None:
|
||||||
|
return _('Statement: %(excursion)s') % {'excursion': str(self.excursion)}
|
||||||
|
else:
|
||||||
|
return self.short_description
|
||||||
|
|
||||||
|
def submit(self):
|
||||||
|
self.submitted = True
|
||||||
|
self.save()
|
||||||
|
|
||||||
|
def total_bills(self):
|
||||||
|
return sum([bill.amount for bill in self.bill_set.all()])
|
||||||
|
|
||||||
|
def total_transportation(self):
|
||||||
|
if self.excursion is None:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
exc = self.excursion
|
||||||
|
return exc.kilometers_traveled * 0.2
|
||||||
|
|
||||||
|
def total(self):
|
||||||
|
return float(self.total_bills()) + self.total_transportation()
|
||||||
|
|
||||||
|
|
||||||
|
class StatementSubmittedManager(models.Manager):
|
||||||
|
def get_queryset(self):
|
||||||
|
return super().get_queryset().filter(submitted=True)
|
||||||
|
|
||||||
|
|
||||||
|
class StatementSubmitted(Statement):
|
||||||
|
objects = StatementSubmittedManager()
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
proxy = True
|
||||||
|
verbose_name = _('Submitted statement')
|
||||||
|
verbose_name_plural = _('Submitted statements')
|
||||||
|
permissions = (('may_manage_submitted_statements', 'Can view and manage submitted statements.'),)
|
||||||
|
|
||||||
|
class Bill(models.Model):
|
||||||
|
statement = models.ForeignKey(Statement, verbose_name=_('Statement'),
|
||||||
|
on_delete=models.CASCADE)
|
||||||
|
short_description = models.CharField(verbose_name=_('Short description'), max_length=30)
|
||||||
|
explanation = models.TextField(verbose_name=_('Explanation'), blank=True)
|
||||||
|
|
||||||
|
amount = models.DecimalField(max_digits=6, decimal_places=2, default=0)
|
||||||
|
paid_by = models.ForeignKey(Member, verbose_name=_('Paid by'), null=True,
|
||||||
|
on_delete=models.SET_NULL)
|
||||||
|
refunded = models.BooleanField(verbose_name=_('Refunded'), default=False)
|
||||||
|
|
||||||
|
proof = models.ImageField(_('Proof'), upload_to='bill_images', blank=True)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return "{} ({}€)".format(self.short_description, self.amount)
|
||||||
|
|
||||||
|
def pretty_amount(self):
|
||||||
|
return "{} €".format(self.amount)
|
||||||
|
pretty_amount.admin_order_field = 'amount'
|
||||||
|
|
||||||
|
|
||||||
|
class Transaction(models.Model):
|
||||||
|
amount = models.DecimalField(max_digits=6, decimal_places=2)
|
||||||
|
member = models.ForeignKey(Member, verbose_name=_('Recipient'),
|
||||||
|
on_delete=models.CASCADE)
|
||||||
|
|
||||||
|
statement = models.ForeignKey(Statement, verbose_name=_('Statement'),
|
||||||
|
on_delete=models.CASCADE)
|
||||||
|
|
||||||
|
confirmed = models.BooleanField(verbose_name=_('Confirmed'))
|
||||||
|
|
||||||
|
|
||||||
|
class Receipt(models.Model):
|
||||||
|
short_description = models.CharField(verbose_name=_('Short description'), max_length=30)
|
||||||
|
ledger = models.ForeignKey(Ledger, blank=False, null=False, verbose_name=_('Ledger'),
|
||||||
|
on_delete=models.CASCADE)
|
||||||
|
amount = models.DecimalField(max_digits=6, decimal_places=2)
|
||||||
|
comments = models.TextField()
|
||||||
@ -0,0 +1,55 @@
|
|||||||
|
{% extends "admin/base_site.html" %}
|
||||||
|
{% load i18n admin_urls static %}
|
||||||
|
|
||||||
|
{% block extrahead %}
|
||||||
|
{{ block.super }}
|
||||||
|
{{ media }}
|
||||||
|
<script src="{% static 'admin/js/cancel.js' %}" async></script>
|
||||||
|
<script type="text/javascript" src="{% static "admin/js/vendor/jquery/jquery.js" %}"></script>
|
||||||
|
<script type="text/javascript" src="{% static "admin/js/jquery.init.js" %}"></script>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block bodyclass %}{{ block.super }} app-{{ opts.app_label }} model-{{ opts.model_name }} invite-waiter
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block breadcrumbs %}
|
||||||
|
<div class="breadcrumbs">
|
||||||
|
<a href="{% url 'admin:index' %}">{% translate 'Home' %}</a>
|
||||||
|
› <a href="{% url 'admin:app_list' app_label=opts.app_label %}">{{ opts.app_config.verbose_name }}</a>
|
||||||
|
› <a href="{% url opts|admin_urlname:'changelist' %}">{{ opts.verbose_name_plural|capfirst }}</a>
|
||||||
|
› <a href="{% url opts|admin_urlname:'change' statement.pk|admin_urlquote %}">{{ statement|truncatewords:"18" }}</a>
|
||||||
|
› {% translate 'Submit' %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<h2>{% translate "Overview" %}</h2>
|
||||||
|
|
||||||
|
<ul>
|
||||||
|
{% for bill in statement.bill_set.all %}
|
||||||
|
<li>
|
||||||
|
{{bill.short_description}}
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<p>{% blocktrans %}The total amount is {{total_bills}} €.{% endblocktrans %}</p>
|
||||||
|
|
||||||
|
{% if statement.excursion %}
|
||||||
|
|
||||||
|
<h3>{% trans "Excursion" %}</h3>
|
||||||
|
<p>{% blocktrans %}Total distance traveled: {{ statement.excursion.kilometers_traveled }} km by
|
||||||
|
{{ statement.excursion.tour_approach }}. This results in {{ total_transportation }} €.{% endblocktrans %}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% blocktrans %} This results in a total amount of {{ total }} € {% endblocktrans %}
|
||||||
|
|
||||||
|
<form action="" method="post">
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="hidden" name="action" value="submit_statement">
|
||||||
|
<input class="default" style="color: $default-link-color" type="submit" name="apply" value="{% translate 'Submit' %}">
|
||||||
|
<a href="#" class="button cancel-link">{% translate "Cancel" %}</a>
|
||||||
|
</form>
|
||||||
|
{% endblock %}
|
||||||
@ -0,0 +1,37 @@
|
|||||||
|
{% extends "admin/base_site.html" %}
|
||||||
|
{% load i18n admin_urls static %}
|
||||||
|
|
||||||
|
{% block extrahead %}
|
||||||
|
{{ block.super }}
|
||||||
|
{{ media }}
|
||||||
|
<script src="{% static 'admin/js/cancel.js' %}" async></script>
|
||||||
|
<script type="text/javascript" src="{% static "admin/js/vendor/jquery/jquery.js" %}"></script>
|
||||||
|
<script type="text/javascript" src="{% static "admin/js/jquery.init.js" %}"></script>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block bodyclass %}{{ block.super }} app-{{ opts.app_label }} model-{{ opts.model_name }} invite-waiter
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block breadcrumbs %}
|
||||||
|
<div class="breadcrumbs">
|
||||||
|
<a href="{% url 'admin:index' %}">{% translate 'Home' %}</a>
|
||||||
|
› <a href="{% url 'admin:app_list' app_label=opts.app_label %}">{{ opts.app_config.verbose_name }}</a>
|
||||||
|
› <a href="{% url opts|admin_urlname:'changelist' %}">{{ opts.verbose_name_plural|capfirst }}</a>
|
||||||
|
› <a href="{% url opts|admin_urlname:'change' statement.pk|admin_urlquote %}">{{ statement|truncatewords:"18" }}</a>
|
||||||
|
› {% translate 'Submit' %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<h2>{% translate "Submit to the finance department" %}</h2>
|
||||||
|
<p>
|
||||||
|
{% trans "Do you want to submit the statement for further processing by the finance department? If you proceed, no further changes to the statement are possible." %}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form action="" method="post">
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="hidden" name="action" value="submit_statement">
|
||||||
|
<input class="default" style="color: $default-link-color" type="submit" name="apply" value="{% translate 'Submit' %}">
|
||||||
|
<a href="#" class="button cancel-link">{% translate "Cancel" %}</a>
|
||||||
|
</form>
|
||||||
|
{% endblock %}
|
||||||
@ -0,0 +1,3 @@
|
|||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
# Create your tests here.
|
||||||
@ -0,0 +1,3 @@
|
|||||||
|
from django.shortcuts import render
|
||||||
|
|
||||||
|
# Create your views here.
|
||||||
@ -0,0 +1,15 @@
|
|||||||
|
{% extends "admin/change_form_object_tools.html" %}
|
||||||
|
{% load i18n admin_urls %}
|
||||||
|
|
||||||
|
{% block object-tools-items %}
|
||||||
|
|
||||||
|
{% if 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 %}
|
||||||
@ -0,0 +1,13 @@
|
|||||||
|
{% extends "admin/change_form_object_tools.html" %}
|
||||||
|
{% load i18n admin_urls %}
|
||||||
|
|
||||||
|
{% block object-tools-items %}
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
|
{{block.super}}
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
Loading…
Reference in New Issue