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()