diff --git a/pyproject.toml b/pyproject.toml
index 05af81105..61afcb038 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -125,6 +125,7 @@ known-first-party = [
"promotion",
"pushnotifications",
"registrations",
+ "reimbursements",
"sales",
"shortlinks",
"singlepages",
diff --git a/website/moneybirdsynchronization/models.py b/website/moneybirdsynchronization/models.py
index b4b3e2cd0..7b6cb7330 100644
--- a/website/moneybirdsynchronization/models.py
+++ b/website/moneybirdsynchronization/models.py
@@ -15,6 +15,7 @@
from payments.payables import payables
from pizzas.models import FoodOrder
from registrations.models import Registration, Renewal
+from reimbursements.models import Reimbursement
from sales.models.order import Order
@@ -346,6 +347,109 @@ class Meta:
unique_together = ("payable_model", "object_id")
+class MoneybirdReceipt(models.Model):
+ reimbursement = models.OneToOneField(Reimbursement, on_delete=models.CASCADE)
+ object_id = models.CharField(max_length=255)
+
+ moneybird_receipt_id = models.CharField(
+ verbose_name=_("moneybird receipt id"),
+ max_length=255,
+ blank=True,
+ null=True,
+ )
+
+ moneybird_details_attribute_id = models.CharField(
+ verbose_name=_("moneybird details attribute id"),
+ max_length=255,
+ blank=True,
+ null=True,
+ ) # We need this id, so we can update the rows (otherwise, updates will create new rows without deleting).
+ # We only support one attribute for now, so this is the easiest way to store it
+
+ needs_synchronization = models.BooleanField(
+ default=True, # The field is set False only when it has been successfully synchronized.
+ help_text="Indicates that the invoice has to be synchronized (again).",
+ )
+
+ needs_deletion = models.BooleanField(
+ default=False,
+ help_text="Indicates that the invoice has to be deleted from moneybird.",
+ )
+
+ def to_moneybird(self):
+ moneybird = get_moneybird_api_service()
+
+ contact_id = settings.MONEYBIRD_UNKNOWN_PAYER_CONTACT_ID
+
+ if self.reimbursement.owner is not None:
+ try:
+ moneybird_contact = MoneybirdContact.objects.get(
+ member=self.reimbursement.owner
+ )
+ contact_id = moneybird_contact.moneybird_id
+ except MoneybirdContact.DoesNotExist:
+ pass
+
+ receipt_date = self.reimbursement.date_incurred.strftime("%Y-%m-%d")
+
+ project_name = f"Reimbursement [{receipt_date}]"
+
+ project_id = None
+ if project_name is not None:
+ project, __ = MoneybirdProject.objects.get_or_create(name=project_name)
+ if project.moneybird_id is None:
+ response = moneybird.create_project(project.to_moneybird())
+ project.moneybird_id = response["id"]
+ project.save()
+
+ project_id = project.moneybird_id
+
+ source_url = settings.BASE_URL + reverse(
+ f"admin:{self.reimbursement._meta.app_label}_{self.reimbursement._meta.model_name}_change",
+ args=(self.object_id,),
+ )
+
+ data = {
+ "receipt": {
+ "contact_id": int(contact_id),
+ "reference": f"Receipt [{self.reimbursement.pk}]",
+ "source": f"Concrexit ({settings.SITE_DOMAIN})",
+ "date": receipt_date,
+ "currency": "EUR",
+ "prices_are_incl_tax": True,
+ "details_attributes": [
+ {
+ "description": self.reimbursement.description,
+ "price": str(self.reimbursement.amount),
+ },
+ ],
+ }
+ }
+
+ if source_url is not None:
+ data["external_sales_invoice"]["source_url"] = source_url
+
+ if project_id is not None:
+ data["external_sales_invoice"]["details_attributes"][0]["project_id"] = int(
+ project_id
+ )
+
+ if self.moneybird_details_attribute_id is not None:
+ data["external_sales_invoice"]["details_attributes"][0]["id"] = int(
+ self.moneybird_details_attribute_id
+ )
+
+ return data
+
+ def __str__(self):
+ return f"Moneybird external invoice for {self.payable_object}"
+
+ class Meta:
+ verbose_name = _("moneybird external invoice")
+ verbose_name_plural = _("moneybird external invoices")
+ unique_together = ("payable_model", "object_id")
+
+
class MoneybirdPayment(models.Model):
payment = models.OneToOneField(
"payments.Payment",
diff --git a/website/moneybirdsynchronization/moneybird.py b/website/moneybirdsynchronization/moneybird.py
index 714ab6781..244bd6bd6 100644
--- a/website/moneybirdsynchronization/moneybird.py
+++ b/website/moneybirdsynchronization/moneybird.py
@@ -29,7 +29,7 @@ def update_external_sales_invoice(self, invoice_id, invoice_data):
f"external_sales_invoices/{invoice_id}", invoice_data
)
- def delete_external_invoice(self, invoice_id):
+ def delete_external_sales_invoice(self, invoice_id):
self._administration.delete(f"external_sales_invoices/{invoice_id}")
def register_external_invoice_payment(self, invoice_id, payment_data):
@@ -53,6 +53,25 @@ def update_financial_statement(self, statement_id, statement_data):
def delete_financial_statement(self, statement_id):
return self._administration.delete(f"financial_statements/{statement_id}")
+ def create_receipt(self, receipt_data):
+ return self._administration.post("receipts", receipt_data)
+
+ def delete_receipt(self, receipt_id):
+ return self._administration.delete(f"receipts/{receipt_id}")
+
+ def update_receipt(self, receipt_id, receipt_data):
+ return self._administration.patch(f"receipts/{receipt_id}", receipt_data)
+
+ def add_receipt_attachment(self, receipt_id, receipt_attachment):
+ return self._administration.post(
+ f"receipts/{receipt_id}/attachments", receipt_attachment
+ )
+
+ def delete_receipt_attachment(self, receipt_id, attachment_id):
+ return self._administration.delete(
+ f"receipts/{receipt_id}/attachments/{attachment_id}"
+ )
+
def link_mutation_to_booking(
self,
mutation_id: int,
diff --git a/website/moneybirdsynchronization/services.py b/website/moneybirdsynchronization/services.py
index 6c245c30d..f4c249d66 100644
--- a/website/moneybirdsynchronization/services.py
+++ b/website/moneybirdsynchronization/services.py
@@ -16,12 +16,14 @@
MoneybirdContact,
MoneybirdExternalInvoice,
MoneybirdPayment,
+ MoneybirdReceipt,
financial_account_id_for_payment_type,
)
from moneybirdsynchronization.moneybird import get_moneybird_api_service
from payments.models import BankAccount, Payment
from pizzas.models import FoodOrder
from registrations.models import Registration, Renewal
+from reimbursements.models import Reimbursement
from sales.models.order import Order
logger = logging.getLogger(__name__)
@@ -185,7 +187,7 @@ def delete_external_invoice(obj):
moneybird = get_moneybird_api_service()
try:
- moneybird.delete_external_invoice(external_invoice.moneybird_invoice_id)
+ moneybird.delete_external_sales_invoice(external_invoice.moneybird_invoice_id)
except Administration.NotFound:
# The invoice has probably been removed manually from moneybird.
# We can assume it no longer exists there, but still, this should not happen
@@ -199,6 +201,64 @@ def delete_external_invoice(obj):
external_invoice.delete()
+def create_or_update_receipt(reimbursement: Reimbursement):
+ """Create a receipt on Moneybird for a Reimbursement object."""
+ if not settings.MONEYBIRD_SYNC_ENABLED:
+ return
+
+ if reimbursement.verdict != Reimbursement.verdicts.APPROVED:
+ return
+
+ moneybird_receipt, _ = MoneybirdReceipt.objects.get_or_create(
+ reimbursement=reimbursement
+ )
+
+ moneybird = get_moneybird_api_service()
+
+ if moneybird_receipt.moneybird_receipt_id:
+ moneybird.update_receipt(
+ moneybird_receipt.moneybird_receipt_id, moneybird_receipt.to_moneybird()
+ )
+ else:
+ response = moneybird.create_receipt(moneybird_receipt.to_moneybird())
+ moneybird_receipt.moneybird_receipt_id = response["id"]
+ moneybird_receipt.moneybird_details_attribute_id = response["details"][0]["id"]
+ attachment_response = moneybird.add_receipt_attachment(
+ moneybird_receipt.moneybird_receipt_id, reimbursement.receipt
+ )
+ moneybird_receipt.moneybird_receipt_attachment_id = attachment_response["id"]
+
+ moneybird_receipt.needs_synchronization = False
+ moneybird_receipt.save()
+
+ return moneybird_receipt
+
+
+def delete_receipt(receipt: MoneybirdReceipt):
+ """Delete or archive a receipt on Moneybird, and delete our record of it."""
+ if not settings.MONEYBIRD_SYNC_ENABLED:
+ return
+
+ if receipt.moneybird_receipt_id is None:
+ receipt.delete()
+ return
+
+ moneybird = get_moneybird_api_service()
+
+ try:
+ if receipt.moneybird_receipt_attachment_id:
+ moneybird.delete_receipt_attachment(receipt.moneybird_receipt_attachment_id)
+ moneybird.delete_receipt(receipt.moneybird_id)
+ receipt.delete()
+ except Administration.InvalidData:
+ logger.warning(
+ "Receipt %s for reimbursement %s could not be deleted.",
+ receipt.moneybird_receipt_id,
+ receipt.reimbursement,
+ )
+ receipt.delete()
+
+
def synchronize_moneybird():
"""Perform all synchronization to moneybird."""
if not settings.MONEYBIRD_SYNC_ENABLED:
@@ -213,18 +273,20 @@ def synchronize_moneybird():
# already exist on moneybird.
_sync_moneybird_payments()
- # Delete invoices that have been marked for deletion.
+ # Delete invoices and receipts that have been marked for deletion.
_delete_invoices()
+ _delete_receipts()
# Resynchronize outdated invoices.
_sync_outdated_invoices()
- # Push all invoices to moneybird.
+ # Push all invoices and receipts to moneybird.
_sync_food_orders()
_sync_sales_orders()
_sync_registrations()
_sync_renewals()
_sync_event_registrations()
+ # _sync_receipts()
logger.info("Finished moneybird synchronization.")
@@ -238,13 +300,22 @@ def _delete_invoices():
for invoice in invoices:
try:
if invoice.moneybird_invoice_id is not None:
- moneybird.delete_external_invoice(invoice.moneybird_invoice_id)
+ moneybird.delete_external_sales_invoice(invoice.moneybird_invoice_id)
invoice.delete()
except Administration.Error as e:
logger.exception("Moneybird synchronization error: %s", e)
send_sync_error(e, invoice)
+def _delete_receipts():
+ """Delete the receipts that have been marked for deletion from moneybird."""
+ receipts = MoneybirdReceipt.objects.filter(needs_deletion=True)
+ logger.info("Deleting %d receipts.", receipts.count())
+
+ for receipt in receipts:
+ delete_receipt(receipt=receipt)
+
+
def _sync_outdated_invoices():
"""Resynchronize all invoices that have been marked as outdated."""
invoices = MoneybirdExternalInvoice.objects.filter(
@@ -477,6 +548,10 @@ def _sync_event_registrations():
send_sync_error(e, instance)
+# def _sync_receipts():
+# TODO
+
+
def _sync_moneybird_payments():
"""Create financial statements with all payments that haven't been synced yet.
diff --git a/website/moneybirdsynchronization/tests/test_services.py b/website/moneybirdsynchronization/tests/test_services.py
index f861aa88f..8a6089d19 100644
--- a/website/moneybirdsynchronization/tests/test_services.py
+++ b/website/moneybirdsynchronization/tests/test_services.py
@@ -225,8 +225,8 @@ def test_delete_invoices(self, mock_api):
moneybird_invoice_id="2",
)
- # _delete_invoices calls the delete_external_invoice API directly.
- mock_delete_invoice = mock_api.return_value.delete_external_invoice
+ # _delete_invoices calls the delete_external_sales_invoice API directly.
+ mock_delete_invoice = mock_api.return_value.delete_external_sales_invoice
with self.subTest("Invoices without needs_deletion are not deleted."):
services._delete_invoices()
@@ -458,7 +458,7 @@ def test_sync_renewals(self, mock_create_invoice, mock_api):
# Renewal 2 is not paid yet, so no invoice should be made for it.
mock_create_invoice.assert_called_once_with(renewal1)
- @mock.patch("moneybirdsynchronization.services.delete_external_invoice")
+ @mock.patch("moneybirdsynchronization.services.delete_external_sales_invoice")
@mock.patch("moneybirdsynchronization.services.create_or_update_external_invoice")
def test_sync_event_registrations(
self, mock_create_invoice, mock_delete_invoice, mock_api
diff --git a/website/reimbursements/__init__.py b/website/reimbursements/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/website/reimbursements/admin.py b/website/reimbursements/admin.py
new file mode 100644
index 000000000..fb4084f6d
--- /dev/null
+++ b/website/reimbursements/admin.py
@@ -0,0 +1,85 @@
+from django.contrib import admin
+from django.utils import timezone
+
+from utils.snippets import send_email
+
+from . import models
+
+
+@admin.register(models.Reimbursement)
+class ReimbursementsAdmin(admin.ModelAdmin):
+ list_display = (
+ "owner",
+ "created",
+ "date_incurred",
+ "amount",
+ "verdict",
+ "evaluated_by",
+ )
+ list_filter = ("verdict", "created", "date_incurred")
+ search_fields = (
+ "date_incurred",
+ "owner__first_name",
+ "owner__last_name",
+ "description",
+ )
+
+ readonly_fields = [
+ "evaluated_by",
+ "evaluated_at",
+ "created",
+ "owner",
+ ]
+
+ def send_verdict_email(self, obj):
+ send_email(
+ to=[obj.owner.email],
+ subject="Reimbursement request",
+ txt_template="reimbursements/email/verdict.txt",
+ html_template="reimbursements/email/verdict.html",
+ context={
+ "first_name": obj.owner.profile.firstname,
+ "description": obj.description,
+ "verdict": obj.verdict,
+ "verdict_clarification": obj.verdict_clarification,
+ },
+ )
+
+ def save_model(self, request, obj, form, change):
+ obj.owner = request.user
+ if obj.verdict is not None and obj.verdict != form.initial["verdict"]:
+ obj.evaluated_by = request.user
+ obj.evaluated_at = timezone.now()
+
+ # TODO: add moneybird integration
+
+ super().save_model(request, obj, form, change)
+
+ if obj.verdict is not None and obj.verdict != form.initial["verdict"]:
+ self.send_verdict_email(obj)
+
+ def get_readonly_fields(self, request, obj=None):
+ readonly = self.readonly_fields
+ if obj:
+ readonly += [
+ "description",
+ "amount",
+ "date_incurred",
+ "receipt",
+ ]
+ if not obj or obj.verdict:
+ readonly += [
+ "verdict",
+ "verdict_clarification",
+ ]
+ return readonly
+
+ def get_queryset(self, request):
+ if request.user.has_perm("reimbursements.change_reimbursement"):
+ return super().get_queryset(request)
+ return models.Reimbursement.objects.filter(owner=request.user)
+
+ def has_view_permission(self, request, obj=None) -> bool:
+ if obj and request.member and obj.owner == request.member:
+ return True
+ return super().has_view_permission(request, obj)
diff --git a/website/reimbursements/apps.py b/website/reimbursements/apps.py
new file mode 100644
index 000000000..3a3c31c37
--- /dev/null
+++ b/website/reimbursements/apps.py
@@ -0,0 +1,9 @@
+from django.apps import AppConfig
+from django.utils.translation import gettext_lazy as _
+
+
+class ReimbursementsConfig(AppConfig):
+ """AppConfig for the announcement package."""
+
+ name = "reimbursements"
+ verbose_name = _("Site reimbursements")
diff --git a/website/reimbursements/email/verdict.html b/website/reimbursements/email/verdict.html
new file mode 100644
index 000000000..e5a93a487
--- /dev/null
+++ b/website/reimbursements/email/verdict.html
@@ -0,0 +1,19 @@
+{% extends "email/html_email.html" %}
+
+{% block content %}
+ Dear {{ first_name }},
+
+
+
There has been a verdict on your reinbursement request with the following description:
+
{{ description }}+ +
The request was {{ verdict }}.
+ {% if not verdict_clarification %} +No clarification was given.
+ {% else %} +The following clarification was given:
+
{{ verdict_clarification }}+ {% endif %} + + For questions, please reach out to the treasurer at treasurer@thalia.nu. +{% endblock %} diff --git a/website/reimbursements/email/verdict.txt b/website/reimbursements/email/verdict.txt new file mode 100644 index 000000000..70fc55907 --- /dev/null +++ b/website/reimbursements/email/verdict.txt @@ -0,0 +1,20 @@ +Dear {{ first_name }}, + + +There has been a verdict on your reinbursement request with the following description: + {{ description }} + +The request was {{ verdict }}. +{% if not verdict_clarification %}No clarification was given. +{% else %}The following clarification was given: + {{ verdict_clarification }}{% endif %} + +For questions, please reach out to the treasurer at treasurer@thalia.nu. + + +With kind regards, + +The board of Study Association thalia + + +This email was automatically generated. diff --git a/website/reimbursements/migrations/0001_initial.py b/website/reimbursements/migrations/0001_initial.py new file mode 100644 index 000000000..838bf008e --- /dev/null +++ b/website/reimbursements/migrations/0001_initial.py @@ -0,0 +1,39 @@ +# Generated by Django 5.1.2 on 2024-12-04 18:57 + +import django.core.validators +import django.db.models.deletion +import payments.models +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('members', '0051_membership_study_long_until'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Reimbursement', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('amount', payments.models.PaymentAmountField(decimal_places=2, help_text='How much did you pay (in euros)?', max_digits=8, validators=[payments.models.validate_not_zero])), + ('date_incurred', models.DateField(help_text='When was this payment made?')), + ('description', models.TextField(help_text='Why did you make this payment?', max_length=512, validators=[django.core.validators.MinLengthValidator(10)])), + ('receipt', models.FileField(upload_to='receipts/')), + ('created', models.DateTimeField(auto_now_add=True)), + ('verdict', models.CharField(blank=True, choices=[('approved', 'Approved'), ('denied', 'Denied')], max_length=40, null=True)), + ('verdict_clarification', models.TextField(blank=True, help_text='Why did you choose this verdict?', null=True)), + ('evaluated_at', models.DateTimeField(null=True)), + ('evaluated_by', models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='reimbursements_approved', to=settings.AUTH_USER_MODEL)), + ('owner', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='reimbursements', to='members.member')), + ], + options={ + 'ordering': ['created'], + }, + ), + ] diff --git a/website/reimbursements/migrations/__init__.py b/website/reimbursements/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/website/reimbursements/models.py b/website/reimbursements/models.py new file mode 100644 index 000000000..2ee8030ce --- /dev/null +++ b/website/reimbursements/models.py @@ -0,0 +1,97 @@ +from django.core.exceptions import ValidationError +from django.core.validators import MinLengthValidator +from django.db import models + +from payments.models import BankAccount, PaymentAmountField +from utils.media.services import get_upload_to_function + + +class Reimbursement(models.Model): + class Verdict(models.TextChoices): + APPROVED = "approved", "Approved" + DENIED = "denied", "Denied" + + owner = models.ForeignKey( + "members.Member", + related_name="reimbursements", + on_delete=models.PROTECT, + ) + + amount = PaymentAmountField( + max_digits=5, + decimal_places=2, + help_text="How much did you pay (in euros)?", + ) + + date_incurred = models.DateField( + help_text="When was this payment made?", + ) + + description = models.TextField( + max_length=512, + help_text="Why did you make this payment?", + validators=[MinLengthValidator(10)], + ) + + # explicitly chose for FileField over an ImageField because companies often send invoices as pdf + + receipt = models.FileField( + upload_to=get_upload_to_function("reimbursements/receipts"), + ) + + created = models.DateTimeField(auto_now_add=True) + + verdict = models.CharField( + max_length=40, + choices=Verdict.choices, + null=True, + blank=True, + ) + verdict_clarification = models.TextField( + help_text="Why did you choose this verdict?", + null=True, + blank=True, + ) + + evaluated_at = models.DateTimeField(null=True) + evaluated_by = models.ForeignKey( + "auth.User", + related_name="reimbursements_approved", + on_delete=models.SET_NULL, + editable=False, + null=True, + ) + + class Meta: + ordering = ["created"] + + def clean(self): + super().clean() + bank = BankAccount.objects.filter(owner=self.owner).last() + errors = {} + if bank is None: + errors["owner"] = ( + "You must have a valid bank account to request a reimbursement." + ) + if ( + self.created is not None + and self.date_incurred is not None + and self.date_incurred > self.created.date() + ): + errors["date_incurred"] = "The date incurred cannot be in the future." + + if self.verdict == self.Verdict.DENIED and not self.verdict_clarification: + errors["verdict_clarification"] = ( + "You must provide a reason for the denial." + ) + + if ( + self.verdict == self.Verdict.APPROVED or self.verdict == self.Verdict.DENIED + ) and not self.evaluated_by: + errors["evaluated_by"] = "You must provide the evaluator." + + if errors: + raise ValidationError(errors) + + def __str__(self): + return f"Reimbursement #{self.id}" diff --git a/website/reimbursements/services.py b/website/reimbursements/services.py new file mode 100644 index 000000000..7491b9655 --- /dev/null +++ b/website/reimbursements/services.py @@ -0,0 +1,29 @@ +import logging +from datetime import timedelta + +from django.utils import timezone + +from .models import Reimbursement + +logger = logging.getLogger(__name__) +YEAR = timedelta(days=365) + + +def execute_data_minimisation(dry_run=False): + def _delete_old_reimbursements( + verdict: Reimbursement.Verdict, + years_until_deletion: int, + ): + old_reimbursements = Reimbursement.objects.filter( + verdict=verdict, + created__lt=timezone.now() - YEAR * years_until_deletion, + ) + + logger.info( + "Deleting %d %s reimbursements", old_reimbursements.count(), verdict + ) + if not dry_run: + old_reimbursements.delete() + + _delete_old_reimbursements(Reimbursement.Verdict.DENIED, years_until_deletion=2) + _delete_old_reimbursements(Reimbursement.Verdict.APPROVED, years_until_deletion=7) diff --git a/website/reimbursements/tests/__init__.py b/website/reimbursements/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/website/reimbursements/tests/test_admin.py b/website/reimbursements/tests/test_admin.py new file mode 100644 index 000000000..2a4627830 --- /dev/null +++ b/website/reimbursements/tests/test_admin.py @@ -0,0 +1,65 @@ +from django.contrib.admin.sites import AdminSite +from django.test import TestCase +from django.utils import timezone + +from members.models import Member +from reimbursements.admin import ReimbursementsAdmin +from reimbursements.models import Reimbursement + + +class MockRequest: + def __init__(self, user): + self.user = user + + +class ReimbursementsAdminTests(TestCase): + def setUp(self): + self.site = AdminSite() + self.user = Member.objects.create_user(username="testuser", password="12345") + self.superuser = Member.objects.create_superuser( + username="admin", password="12345", email="admin@example.com" + ) + self.reimbursement = Reimbursement.objects.create( + owner=self.user, + description="Test reimbursement", + amount=100, + date_incurred=timezone.now(), + ) + self.admin = ReimbursementsAdmin(model=Reimbursement, admin_site=self.site) + + def test_get_queryset_for_superuser(self): + request = MockRequest(self.superuser) + queryset = self.admin.get_queryset(request) + self.assertIn(self.reimbursement, queryset) + + def test_get_queryset_for_normal_user(self): + request = MockRequest(self.user) + queryset = self.admin.get_queryset(request) + self.assertIn(self.reimbursement, queryset) + + def test_save_model_sets_owner(self): + request = MockRequest(self.superuser) + reimbursement = Reimbursement( + description="Another test", amount=200, date_incurred=timezone.now() + ) + self.admin.save_model(request, reimbursement, None, False) + self.assertEqual(reimbursement.owner, self.superuser) + + def test_get_readonly_fields(self): + self.reimbursement.verdict = "approved" + self.reimbursement.verdict_clarification = "Approved by admin" + request = MockRequest(self.superuser) + readonly_fields = self.admin.get_readonly_fields(request, self.reimbursement) + self.assertIn("description", readonly_fields) + self.assertIn("amount", readonly_fields) + self.assertIn("date_incurred", readonly_fields) + self.assertIn("receipt", readonly_fields) + self.assertIn("verdict", readonly_fields) + self.assertIn("verdict_clarification", readonly_fields) + + def test_has_view_permission(self): + request = MockRequest(self.user) + request.member = self.user + self.assertTrue(self.admin.has_view_permission(request, self.reimbursement)) + request.user = self.superuser + self.assertTrue(self.admin.has_view_permission(request, self.reimbursement)) diff --git a/website/reimbursements/tests/test_models.py b/website/reimbursements/tests/test_models.py new file mode 100644 index 000000000..7083a353c --- /dev/null +++ b/website/reimbursements/tests/test_models.py @@ -0,0 +1,77 @@ +import datetime + +from django.core.exceptions import ValidationError +from django.test import TestCase +from django.utils import timezone + +from members.models import Member + +from ..models import Reimbursement + + +class ReimbursementModelTest(TestCase): + def setUp(self): + self.user = Member.objects.create_user(username="testuser", password="12345") + + def test_future_date_incurred(self): + reimbursement = Reimbursement( + created=timezone.now(), + date_incurred=timezone.now().date() + datetime.timedelta(days=1), + ) + with self.assertRaises(ValidationError) as context: + reimbursement.clean() + self.assertIn("date_incurred", context.exception.message_dict) + self.assertEqual( + context.exception.message_dict["date_incurred"], + ["The date incurred cannot be in the future."], + ) + + def test_denied_verdict_without_clarification(self): + reimbursement = Reimbursement( + created=timezone.now(), + verdict=Reimbursement.Verdict.DENIED, + ) + with self.assertRaises(ValidationError) as context: + reimbursement.clean() + self.assertIn("verdict_clarification", context.exception.message_dict) + self.assertEqual( + context.exception.message_dict["verdict_clarification"], + ["You must provide a reason for the denial."], + ) + + def test_approved_verdict_without_evaluator(self): + reimbursement = Reimbursement( + created=timezone.now(), + verdict=Reimbursement.Verdict.APPROVED, + ) + with self.assertRaises(ValidationError) as context: + reimbursement.clean() + self.assertIn("evaluated_by", context.exception.message_dict) + self.assertEqual( + context.exception.message_dict["evaluated_by"], + ["You must provide the evaluator."], + ) + + def test_denied_verdict_without_evaluator(self): + reimbursement = Reimbursement( + created=timezone.now(), + verdict=Reimbursement.Verdict.DENIED, + ) + with self.assertRaises(ValidationError) as context: + reimbursement.clean() + self.assertIn("evaluated_by", context.exception.message_dict) + self.assertEqual( + context.exception.message_dict["evaluated_by"], + ["You must provide the evaluator."], + ) + + def test_valid_reimbursement(self): + reimbursement = Reimbursement( + created=timezone.now(), + date_incurred=timezone.now().date(), + verdict=Reimbursement.Verdict.APPROVED, + evaluated_by=self.user, + verdict_clarification="daslkfjlkdsajfkdsajfldsakfjldska", + ) + + reimbursement.clean() diff --git a/website/thaliawebsite/settings.py b/website/thaliawebsite/settings.py index 979e14620..325b16329 100644 --- a/website/thaliawebsite/settings.py +++ b/website/thaliawebsite/settings.py @@ -638,6 +638,7 @@ def show_toolbar(request): "shortlinks.apps.ShortLinkConfig", "sales.apps.SalesConfig", "moneybirdsynchronization.apps.MoneybirdsynchronizationConfig", + "reimbursements.apps.ReimbursementsConfig", "two_factor.plugins.webauthn", ] diff --git a/website/thaliawebsite/tasks.py b/website/thaliawebsite/tasks.py index 41462f549..e3dca575e 100644 --- a/website/thaliawebsite/tasks.py +++ b/website/thaliawebsite/tasks.py @@ -11,6 +11,7 @@ from members import services as members_services from payments import services as payments_services from pizzas import services as pizzas_services +from reimbursements import services as reimbursements_services from sales import services as sales_services from utils.snippets import minimise_logentries_data @@ -48,6 +49,12 @@ def data_minimisation(): for p in processed: logger.info(f"Removed reference faces: {p}") + processed = members_services.execute_data_minimisation() + for p in processed: + logger.info(f"Removed data for {p}") + + reimbursements_services.execute_data_minimisation() + processed = minimise_logentries_data() logger.info(f"Removed {processed} log entries") @@ -61,3 +68,4 @@ def clean_up(): @shared_task def clear_tokens(): clear_expired() + clear_expired()