add new feature finance (submit bill)

This commit is contained in:
2023-08-22 20:10:38 +00:00
parent eeeeaa50dc
commit 7d2091b0a9
17 changed files with 1079 additions and 0 deletions

View File

183
fet2020/finance/admin.py Normal file
View File

@@ -0,0 +1,183 @@
from django.contrib import admin
from .forms import BankDataAdminForm, BillAdminForm, ResolutionAdminForm
from .models import BankData, Bill, Resolution
from django.contrib import messages
from django.utils.translation import ngettext
class BankDataAdmin(admin.ModelAdmin):
form = BankDataAdminForm
model = BankData
list_display = [
"name",
"iban",
]
class BillAdmin(admin.ModelAdmin):
form = BillAdminForm
model = Bill
list_display = [
"id",
"only_digital",
"amount",
"purpose",
"resolution",
"status",
"bill_creator",
"affiliation",
"wiref_id",
]
actions = ["make_cleared", "make_finished"]
list_filter = ["status", "affiliation"]
search_fields = ["wiref_id", "purpose"]
ordering = ["date_created"]
readonly_fields = ["get_bankdata_name", "get_bankdata_iban", "get_bankdata_bic"]
fieldsets = (
(
None,
{
"fields": (
"bill_creator",
"resolution",
)
},
),
(
"Bankdaten",
{
"fields": (
"payer",
"bankdata",
("get_bankdata_name", "get_bankdata_iban", "get_bankdata_bic"),
)
},
),
(
"Rechnung",
{
"fields": (
"affiliation",
"date",
"invoice",
"purpose",
"amount",
"only_digital",
"file_field",
)
},
),
(
"Sonstiges",
{
"fields": (
"comment",
"wiref_id",
"status",
)
},
),
)
def add_view(self, request, form_url="", extra_context=None):
extra_context = extra_context or {}
extra_context["help_text"] = "Fette Schriften sind Pflichtfelder."
return super().add_view(
request,
form_url,
extra_context=extra_context,
)
def change_view(self, request, object_id, form_url="", extra_context=None):
extra_context = extra_context or {}
extra_context["help_text"] = "Fette Schriften sind Pflichtfelder."
return super().change_view(
request,
object_id,
form_url,
extra_context=extra_context,
)
def save_model(self, request, obj, form, change):
obj.author = request.user
super().save_model(request, obj, form, change)
def get_bankdata_name(self, obj):
try:
tmp = BankData.objects.get(id=obj.bankdata.id)
except Exception:
return "-"
return f"{ tmp.name }"
get_bankdata_name.short_description = "Kontoinhaber:in"
def get_bankdata_iban(self, obj):
try:
tmp = BankData.objects.get(id=obj.bankdata.id)
except Exception:
return "-"
return f"{ tmp.iban }"
get_bankdata_iban.short_description = "IBAN"
def get_bankdata_bic(self, obj):
try:
tmp = BankData.objects.get(id=obj.bankdata.id)
except Exception:
return "-"
return f"{ tmp.bic }"
get_bankdata_bic.short_description = "BIC"
@admin.action(description="Als 'Abgerechnet' markieren.")
def make_cleared(self, request, queryset):
updated = queryset.update(status="C")
self.message_user(
request,
ngettext(
"%d Rechnung wurde als 'Abgerechnet' markiert.",
"%d Rechnungen wurde als 'Abgerechnet' markiert.",
updated,
)
% updated,
messages.SUCCESS,
)
@admin.action(description="Als 'Abgeschlossen' markieren.")
def make_finished(self, request, queryset):
updated = queryset.update(status="F")
self.message_user(
request,
ngettext(
"%d Rechnung wurde als 'Abgeschlossen' markiert.",
"%d Rechnungen wurde als 'Abgeschlossen' markiert.",
updated,
)
% updated,
messages.SUCCESS,
)
class ResolutionAdmin(admin.ModelAdmin):
form = ResolutionAdminForm
model = Resolution
list_display = [
"name",
"id",
"is_visible",
]
admin.site.register(BankData, BankDataAdmin)
admin.site.register(Bill, BillAdmin)
admin.site.register(Resolution, ResolutionAdmin)

12
fet2020/finance/apps.py Normal file
View File

@@ -0,0 +1,12 @@
from django.apps import AppConfig
from django.db.models.signals import post_migrate
from fet2020.utils import create_perms
class FinanceConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "finance"
def ready(self):
post_migrate.connect(create_perms, sender=self)

264
fet2020/finance/forms.py Normal file
View File

@@ -0,0 +1,264 @@
from django import forms
from django.core.validators import ValidationError
from django.forms import DateInput
from members.models import Member
from .models import BankData, Bill, Resolution
class DateInput(DateInput):
input_type = "date"
class BankDataForm(forms.ModelForm):
class Meta:
model = BankData
fields = ["iban", "bic", "name"]
labels = {"iban": "IBAN", "bic": "BIC", "name": "Kontoinhaber:in"}
class BillCreateForm(forms.ModelForm):
resolution_text = forms.CharField(max_length=128)
name_text = forms.CharField(max_length=128)
iban_text = forms.CharField(max_length=34)
bic_text = forms.CharField(max_length=11)
class Meta:
model = Bill
fields = [
"bill_creator",
"date",
"invoice",
"amount",
"purpose",
"affiliation",
"payer",
"only_digital",
"file_field",
"comment",
"resolution",
]
labels = {
"bill_creator": "Verantwortliche:r für die Einreichung",
"date": "Rechnungsdatum",
"invoice": "Rechnungsaussteller",
"amount": "Betrag (EUR)",
"purpose": "Verwendungszweck",
"affiliation": "Abrechnungsbudget",
"payer": "Wie wurde die Rechnung bezahlt?",
"only_digital": "Ich habe nur eine digitale Rechnung.",
"file_field": "Rechnung hochladen (PDF)",
"comment": "Kommentar",
}
widgets = {
"date": DateInput(format=("%Y-%m-%d")),
}
def __init__(self, *args, **kwargs):
if "user" in kwargs:
user = kwargs.pop("user")
else:
user = None
super().__init__(*args, **kwargs) # to get the self.fields set
self.fields["bill_creator"].initial = Member.objects.get(username=user.username)
self.fields["bill_creator"].disabled = True
self.fields["invoice"].placeholder = "Firmenname\nStraße\nPLZ Ort"
self.fields["invoice"].rows = 3
# bank data fields
self.fields["name_text"].label = "Kontoinhaber:in"
self.fields["name_text"].required = False
self.fields["iban_text"].label = "IBAN"
self.fields["iban_text"].required = False
self.fields["bic_text"].label = "BIC"
self.fields["bic_text"].required = False
# resolution fields
self.fields["resolution_text"].label = "Beschlussnummer"
self.fields["resolution_text"].required = False
def clean(self):
cleaned_data = super().clean()
amount = cleaned_data.get("amount")
resolution = cleaned_data.get("resolution_text")
payer = cleaned_data.get("payer")
name = cleaned_data.get("name_text")
iban = cleaned_data.get("iban_text")
bic = cleaned_data.get("bic_text")
only_digital = cleaned_data.get("only_digital")
file_field = cleaned_data.get("file_field")
# check that amount is valid because invalid amount is a NoneType.
if amount:
if amount > 30 and resolution == "":
raise ValidationError(
"Die Beschlussnummer fehlt, weil der Betrag über 30€ beträgt "
f"(Betrag: {amount}€)."
)
if payer == "M":
if name == "" or iban == "":
raise ValidationError(
f"Bankdaten unvollständig (Kontoinhaber: {name}, IBAN: {iban})."
)
if payer == "V":
cleaned_data["name_text"] = ""
cleaned_data["iban_text"] = ""
cleaned_data["bic_text"] = ""
if only_digital and file_field is None:
raise ValidationError(f"Digitale Rechnung fehlt.")
return cleaned_data
class BillUpdateForm(forms.ModelForm):
resolution_text = forms.CharField(max_length=128)
name_text = forms.CharField(max_length=128)
iban_text = forms.CharField(max_length=34)
bic_text = forms.CharField(max_length=11)
class Meta:
model = Bill
fields = [
"bill_creator",
"date",
"invoice",
"amount",
"purpose",
"affiliation",
"payer",
"only_digital",
"file_field",
"comment",
"resolution",
]
labels = {
"bill_creator": "Verantwortliche:r für die Einreichung",
"date": "Rechnungsdatum",
"invoice": "Rechnungsaussteller",
"amount": "Betrag (EUR)",
"purpose": "Verwendungszweck",
"affiliation": "Abrechnungsbudget",
"payer": "Wie wurde die Rechnung bezahlt?",
"only_digital": "Ich habe nur eine digitale Rechnung.",
"file_field": "Rechnung hochladen (PDF)",
"comment": "Kommentar",
}
widgets = {
"date": DateInput(format=("%Y-%m-%d")),
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) # to get the self.fields set
self.fields["bill_creator"].disabled = True
self.fields["date"].disabled = True
self.fields["invoice"].disabled = True
self.fields["amount"].disabled = True
self.fields["purpose"].disabled = True
self.fields["affiliation"].disabled = True
self.fields["payer"].disabled = True
self.fields["only_digital"].disabled = True
self.fields["file_field"].disabled = True
self.fields["resolution"].disabled = True
# bank data fields
if kwargs["instance"].bankdata:
self.fields["name_text"].initial = kwargs["instance"].bankdata.name
self.fields["iban_text"].initial = kwargs["instance"].bankdata.iban
self.fields["bic_text"].initial = kwargs["instance"].bankdata.bic
self.fields["name_text"].disabled = True
self.fields["name_text"].label = "Kontoinhaber:in"
self.fields["name_text"].required = False
self.fields["iban_text"].disabled = True
self.fields["iban_text"].label = "IBAN"
self.fields["iban_text"].required = False
self.fields["bic_text"].disabled = True
self.fields["bic_text"].label = "BIC"
self.fields["bic_text"].required = False
# resolution fields
if kwargs["instance"].resolution:
self.fields["resolution_text"].initial = kwargs["instance"].resolution.name
self.fields["resolution_text"].disabled = True
self.fields["resolution_text"].label = "Beschlussnummer"
self.fields["resolution_text"].required = False
# comment disabled when bill is cleared or finished
if kwargs["instance"].status != "S":
self.fields["comment"].disabled = True
class BankDataAdminForm(forms.ModelForm):
class Meta:
model = BankData
fields = "__all__"
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) # to get the self.fields set
self.fields["bankdata_creator"].widget.can_add_related = False
self.fields["bankdata_creator"].widget.can_change_related = False
self.fields["bankdata_creator"].widget.can_delete_related = False
class BillAdminForm(forms.ModelForm):
class Meta:
model = Bill
fields = "__all__"
labels = {
"affiliation": "Abrechnungsbudget",
"amount": "Betrag (EUR)",
"comment": "Kommentar",
"date": "Rechnungsdatum",
"file_field": "Rechnung hochladen (PDF)",
"invoice": "Rechnungsaussteller",
"only_digital": "Ich habe nur eine digitale Rechnung.",
"payer": "Wie wurde die Rechnung bezahlt?",
"purpose": "Verwendungszweck",
"resolution": "Beschlussnummer",
}
widgets = {
"date": DateInput(format=("%Y-%m-%d")),
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) # to get the self.fields set
self.fields["bill_creator"].widget.can_add_related = False
self.fields["bill_creator"].widget.can_change_related = False
self.fields["bill_creator"].widget.can_delete_related = False
class ResolutionAdminForm(forms.ModelForm):
class Meta:
model = Resolution
fields = "__all__"

View File

@@ -0,0 +1,178 @@
# Generated by Django 4.2.2 on 2023-08-22 16:57
import django.core.validators
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
initial = True
dependencies = [
("members", "0007_alter_member_username"),
]
operations = [
migrations.CreateModel(
name="BankData",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"name",
models.CharField(max_length=128, verbose_name="Kontoinhaber:in"),
),
("iban", models.CharField(max_length=34, verbose_name="IBAN")),
("bic", models.CharField(max_length=11, verbose_name="BIC")),
(
"is_disabled",
models.BooleanField(default=False, verbose_name="deaktiviert"),
),
(
"bankdata_creator",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
to="members.member",
verbose_name="Verknüpfung zum Mitglied",
),
),
],
options={
"verbose_name": "Bankdaten",
"verbose_name_plural": "Bankdaten",
},
),
migrations.CreateModel(
name="Resolution",
fields=[
(
"id",
models.CharField(max_length=128, primary_key=True, serialize=False),
),
("name", models.CharField(max_length=128)),
(
"is_visible",
models.BooleanField(default=False, verbose_name="sichtbar"),
),
],
options={
"verbose_name": "Beschluss",
"verbose_name_plural": "Beschlüsse",
},
),
migrations.CreateModel(
name="Bill",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("date", models.DateField()),
("invoice", models.TextField()),
(
"amount",
models.DecimalField(
decimal_places=2, max_digits=7, verbose_name="Betrag (EUR)"
),
),
(
"purpose",
models.CharField(max_length=140, verbose_name="Verwendungszweck"),
),
(
"affiliation",
models.CharField(
choices=[("V", "Vereinsbudget"), ("B", "Offizielles Budget")],
max_length=1,
verbose_name="Abrechnungsbudget",
),
),
(
"payer",
models.CharField(
choices=[("M", "Privat"), ("V", "Verein (Safe/Kreditkarte)")],
max_length=1,
),
),
(
"only_digital",
models.BooleanField(
default=False, verbose_name="Digitale Rechnung"
),
),
(
"file_field",
models.FileField(
blank=True,
null=True,
upload_to="uploads/finance/files/",
validators=[
django.core.validators.FileExtensionValidator(["pdf"])
],
),
),
("comment", models.TextField(blank=True, null=True)),
(
"status",
models.CharField(
choices=[
("S", "Eingereicht"),
("C", "Abgerechnet"),
("F", "Abgeschlossen"),
],
default="S",
max_length=1,
verbose_name="Status",
),
),
("wiref_id", models.CharField(blank=True, max_length=10, null=True)),
("date_created", models.DateTimeField(auto_now_add=True)),
(
"bankdata",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
to="finance.bankdata",
verbose_name="Kontodaten",
),
),
(
"bill_creator",
models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT,
to="members.member",
verbose_name="Verantwortliche:r",
),
),
(
"resolution",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
to="finance.resolution",
verbose_name="Beschlussnummer",
),
),
],
options={
"verbose_name": "Rechnung",
"verbose_name_plural": "Rechnungen",
},
),
]

View File

121
fet2020/finance/models.py Normal file
View File

@@ -0,0 +1,121 @@
from django.core.validators import FileExtensionValidator, ValidationError
from django.db import models
from members.models import Member
class BankData(models.Model):
# members can be deleted but never their bank datas
bankdata_creator = models.ForeignKey(
Member,
on_delete=models.SET_NULL,
null=True,
verbose_name="Verknüpfung zum Mitglied",
)
name = models.CharField(max_length=128, verbose_name="Kontoinhaber:in")
iban = models.CharField(max_length=34, verbose_name="IBAN")
bic = models.CharField(max_length=11, verbose_name="BIC")
is_disabled = models.BooleanField(default=False, verbose_name="deaktiviert")
class Meta:
verbose_name = "Bankdaten"
verbose_name_plural = "Bankdaten"
def __str__(self):
return f"{self.name} - {self.iban}"
class Resolution(models.Model):
id = models.CharField(primary_key=True, max_length=128)
name = models.CharField(max_length=128)
is_visible = models.BooleanField(default=False, verbose_name="sichtbar")
class Meta:
verbose_name = "Beschluss"
verbose_name_plural = "Beschlüsse"
def __str__(self):
return f"{self.name}"
class Bill(models.Model):
# members can be deleted but never their bills
bill_creator = models.ForeignKey(
Member, on_delete=models.PROTECT, verbose_name="Verantwortliche:r"
)
bankdata = models.ForeignKey(
BankData,
on_delete=models.SET_NULL,
blank=True,
null=True,
verbose_name="Kontodaten",
)
resolution = models.ForeignKey(
Resolution,
on_delete=models.SET_NULL,
blank=True,
null=True,
verbose_name="Beschlussnummer",
)
date = models.DateField()
invoice = models.TextField()
amount = models.DecimalField(
max_digits=7, decimal_places=2, verbose_name="Betrag (EUR)"
)
purpose = models.CharField(max_length=140, verbose_name="Verwendungszweck")
class Affiliation(models.TextChoices):
VEREIN = "V", "Vereinsbudget"
OFFICIAL = "B", "Offizielles Budget"
affiliation = models.CharField(
max_length=1, choices=Affiliation.choices, verbose_name="Abrechnungsbudget"
)
class Payer(models.TextChoices):
ME = "M", "Privat"
VEREIN = "V", "Verein (Safe/Kreditkarte)"
payer = models.CharField(max_length=1, choices=Payer.choices)
only_digital = models.BooleanField(default=False, verbose_name="Digitale Rechnung")
file_field = models.FileField(
upload_to="uploads/finance/files/",
validators=[FileExtensionValidator(["pdf"])],
blank=True,
null=True,
)
comment = models.TextField(blank=True, null=True)
class Status(models.TextChoices):
SUBMITTED = "S", "Eingereicht"
CLEARED = "C", "Abgerechnet"
FINISHED = "F", "Abgeschlossen"
status = models.CharField(
max_length=1,
choices=Status.choices,
default=Status.SUBMITTED,
verbose_name="Status",
)
wiref_id = models.CharField(max_length=10, blank=True, null=True)
date_created = models.DateTimeField(auto_now_add=True)
class Meta:
verbose_name = "Rechnung"
verbose_name_plural = "Rechnungen"
def __str__(self):
return f"{self.purpose}"
def clean(self):
if self.status is None:
self.status = Bill.Status.SUBMITTED

0
fet2020/finance/tests.py Normal file
View File

16
fet2020/finance/urls.py Normal file
View File

@@ -0,0 +1,16 @@
from django.urls import path
from . import apps, views
from .views import (
BillCreateView,
BillListView,
BillUpdateView,
)
app_name = apps.FinanceConfig.name
urlpatterns = [
path("", BillListView.as_view(), name="bill_list"),
path("<int:pk>/", BillUpdateView.as_view(), name="bill_update"),
path("create-bill/", BillCreateView.as_view(), name="bill_create"),
]

80
fet2020/finance/views.py Normal file
View File

@@ -0,0 +1,80 @@
from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin
from django.contrib.auth.models import User
from django.shortcuts import redirect
from django.urls import reverse_lazy
from django.views.generic import ListView
from django.views.generic.detail import DetailView
from django.views.generic.edit import CreateView, UpdateView
from fet2020.utils import add_log_action, create_formsets
from members.models import Member
from .forms import BankDataForm, BillCreateForm, BillUpdateForm
from .models import Bill, BankData, Resolution
class BillCreateView(LoginRequiredMixin, CreateView):
form_class = BillCreateForm
model = Bill
success_url = reverse_lazy("finance:bill_list")
template_name = "finance/bill_create.html"
def form_valid(self, form):
# get or create resolution object
resolution = form.cleaned_data["resolution_text"]
if resolution != "":
obj, created = Resolution.objects.get_or_create(
id=resolution, defaults={"name": resolution}
)
form.instance.resolution = obj
# get or create bankdata object
name = form.cleaned_data["name_text"]
iban = form.cleaned_data["iban_text"]
bic = form.cleaned_data["bic_text"]
if name != "" and iban != "" and bic != "":
obj, created = BankData.objects.get_or_create(
name=name,
iban=iban,
bic=bic,
defaults={"bankdata_creator": form.cleaned_data["bill_creator"]},
)
form.instance.bankdata = obj
add_log_action(self.request, form, "finance", "bill", True)
return super().form_valid(form)
def get_form_kwargs(self):
kwargs = super().get_form_kwargs()
kwargs["user"] = self.request.user
return kwargs
class BillListView(LoginRequiredMixin, ListView):
model = Bill
template_name = "finance/index.html"
def get_queryset(self):
return Bill.objects.filter(bill_creator__username=self.request.user)
class BillUpdateView(LoginRequiredMixin, UserPassesTestMixin, UpdateView):
form_class = BillUpdateForm
model = Bill
success_url = reverse_lazy("finance:bill_list")
template_name = "finance/bill_update.html"
def form_valid(self, form):
add_log_action(self.request, form, "finance", "bill", False)
return super().form_valid(form)
# call bill if it's only yours
def test_func(self):
if self.get_object().bill_creator.username == self.request.user.username:
return True
# call handle_no_permissions method
return False
def handle_no_permission(self):
return redirect("finance:bill_list")