refactor(finance/models): replace `submitted` and `confirmed` by a `status` field (#3)

This is in preparation for a [new statement admin
view](#179).
testing
Christian Merten 2 months ago committed by GitHub
parent c6bf9fe915
commit 69a4560ea4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -64,7 +64,7 @@ def decorate_statement_view(model, perm=None):
@admin.register(StatementUnSubmitted)
class StatementUnSubmittedAdmin(CommonAdminMixin, admin.ModelAdmin):
fields = ['short_description', 'explanation', 'excursion', 'submitted']
fields = ['short_description', 'explanation', 'excursion', 'status']
list_display = ['__str__', 'excursion', 'created_by']
inlines = [BillOnStatementInline]
@ -74,7 +74,7 @@ class StatementUnSubmittedAdmin(CommonAdminMixin, admin.ModelAdmin):
super().save_model(request, obj, form, change)
def get_readonly_fields(self, request, obj=None):
readonly_fields = ['submitted', 'excursion']
readonly_fields = ['status', 'excursion']
if obj is not None and obj.submitted:
return readonly_fields + self.fields
else:
@ -167,7 +167,7 @@ class BillOnSubmittedStatementInline(BillOnStatementInline):
@admin.register(StatementSubmitted)
class StatementSubmittedAdmin(admin.ModelAdmin):
fields = ['short_description', 'explanation', 'excursion', 'submitted']
fields = ['short_description', 'explanation', 'excursion', 'status']
list_display = ['__str__', 'is_valid', 'submitted_date', 'submitted_by']
ordering = ('-submitted_date',)
inlines = [BillOnSubmittedStatementInline, TransactionOnSubmittedStatementInline]
@ -186,7 +186,7 @@ class StatementSubmittedAdmin(admin.ModelAdmin):
return False
def get_readonly_fields(self, request, obj=None):
readonly_fields = ['submitted']
readonly_fields = ['status']
if obj is not None and obj.submitted:
return readonly_fields + self.fields
else:
@ -274,7 +274,7 @@ class StatementSubmittedAdmin(admin.ModelAdmin):
return HttpResponseRedirect(reverse('admin:%s_%s_changelist' % (self.opts.app_label, self.opts.model_name)))
if "reject" in request.POST:
statement.submitted = False
statement.status = Statement.UNSUBMITTED
statement.save()
messages.success(request,
_("Successfully rejected %(name)s. The requestor can reapply, when needed.")
@ -315,7 +315,7 @@ class StatementSubmittedAdmin(admin.ModelAdmin):
@admin.register(StatementConfirmed)
class StatementConfirmedAdmin(admin.ModelAdmin):
fields = ['short_description', 'explanation', 'excursion', 'confirmed']
fields = ['short_description', 'explanation', 'excursion', 'status']
#readonly_fields = fields
list_display = ['__str__', 'total_pretty', 'confirmed_date', 'confirmed_by']
ordering = ('-confirmed_date',)
@ -365,7 +365,7 @@ class StatementConfirmedAdmin(admin.ModelAdmin):
_("%(name)s is not yet confirmed.") % {'name': str(statement)})
return HttpResponseRedirect(reverse('admin:%s_%s_change' % (self.opts.app_label, self.opts.model_name), args=(statement.pk,)))
if "unconfirm" in request.POST:
statement.confirmed = False
statement.status = Statement.SUBMITTED
statement.confirmed_date = None
statement.confired_by = None
statement.save()

@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-05-03 19:06+0200\n"
"POT-Creation-Date: 2025-10-12 11:37+0200\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@ -18,11 +18,11 @@ msgstr ""
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
#: finance/admin.py
#: finance/admin.py finance/tests/admin.py
msgid "Statement not found."
msgstr "Abrechnung nicht gefunden."
#: finance/admin.py
#: finance/admin.py finance/tests/admin.py
msgid "Insufficient permissions."
msgstr "Unzureichende Berechtigungen."
@ -31,7 +31,7 @@ msgstr "Unzureichende Berechtigungen."
msgid "%(name)s is already submitted."
msgstr "%(name)s ist bereits eingereicht."
#: finance/admin.py
#: finance/admin.py finance/tests/admin.py
#, python-format
msgid ""
"Successfully submited %(name)s. The finance department will notify the "
@ -40,11 +40,11 @@ msgstr ""
"Rechnung %(name)s erfolgreich eingereicht. Das Finanzreferat wird auf dich "
"sobald wie möglich zukommen."
#: finance/admin.py
#: finance/admin.py finance/tests/admin.py
msgid "Finance overview"
msgstr "Kostenübersicht"
#: finance/admin.py
#: finance/admin.py finance/tests/admin.py
msgid "Submit statement"
msgstr "Rechnung einreichen"
@ -64,11 +64,11 @@ msgstr ""
"Beim Abwickeln von %(name)s ist ein Fehler aufgetreten. Bitte versuche es "
"erneut."
#: finance/admin.py
#: finance/admin.py finance/tests/admin.py
msgid "Successfully sent receipt to the office."
msgstr "Abrechnungsbeleg an die Geschäftsstelle gesendet."
#: finance/admin.py
#: finance/admin.py finance/tests/admin.py
#, python-format
msgid ""
"Successfully confirmed %(name)s. I hope you executed the associated "
@ -84,11 +84,11 @@ msgstr ""
"Hier kannst du den Abrechnungsbeleg <a href='%(link)s', "
"target='_blank'>herunterladen</a>."
#: finance/admin.py
#: finance/admin.py finance/tests/admin.py
msgid "Statement confirmed"
msgstr "Abrechnung abgewickelt"
#: finance/admin.py
#: finance/admin.py finance/tests/admin.py
msgid ""
"Transactions do not match the covered expenses. Please correct the mistakes "
"listed below."
@ -96,12 +96,12 @@ msgstr ""
"Überweisungen stimmen nicht mit den übernommenen Kosten überein. Bitte "
"korrigiere die unten aufgeführten Fehler."
#: finance/admin.py
#: finance/admin.py finance/tests/admin.py
msgid "Some transactions have no ledger configured. Please fill in the gaps."
msgstr ""
"Manche Überweisungen haben kein Geldtopf eingestellt. Bitte trage das nach."
#: finance/admin.py
#: finance/admin.py finance/tests/admin.py
msgid ""
"The configured recipients for the allowance don't match the regulations. "
"Please correct this on the excursion."
@ -117,14 +117,14 @@ msgstr ""
"Der berechnete Gesamtbetrag stimmt nicht mit der Summe aller Überweisungen "
"überein. Das ist höchstwahrscheinlich ein Fehler in der Implementierung."
#: finance/admin.py
#: finance/admin.py finance/tests/admin.py
#, python-format
msgid "Successfully rejected %(name)s. The requestor can reapply, when needed."
msgstr ""
"Die Rechnung %(name)s wurde abgelehnt. Die Person kann die Rechnung erneut "
"einstellen, wenn es benötigt wird."
#: finance/admin.py
#: finance/admin.py finance/tests/admin.py
#, python-format
msgid ""
"%(name)s already has transactions. Please delete them first, if you want to "
@ -133,12 +133,12 @@ msgstr ""
"%(name)s hat bereits Überweisungen. Bitte lösche diese zunächst, bevor du "
"neue generierst."
#: finance/admin.py
#: finance/admin.py finance/tests/admin.py
#, python-format
msgid "Successfully generated transactions for %(name)s"
msgstr "Automatisch Überweisungsträger für %(name)s generiert."
#: finance/admin.py
#: finance/admin.py finance/tests/admin.py
#, python-format
msgid ""
"Error while generating transactions for %(name)s. Do all bills have a payer "
@ -150,11 +150,11 @@ msgstr ""
"einer Ausfahrt gehört, wurde eine Person als Empfänger*in der Übernachtungs- "
"und Fahrtkostenzuschüsse ausgewählt?"
#: finance/admin.py
#: finance/admin.py finance/tests/admin.py
msgid "View submitted statement"
msgstr "Eingereichte Abrechnung einsehen"
#: finance/admin.py
#: finance/admin.py finance/tests/admin.py
#, python-format
msgid "Successfully reduced transactions for %(name)s."
msgstr "Überweisungsträger für %(name)s minimiert."
@ -172,6 +172,7 @@ msgstr ""
"was du machst."
#: finance/admin.py finance/templates/admin/unconfirm_statement.html
#: finance/tests/admin.py
msgid "Unconfirm statement"
msgstr "Bestätigung zurücknehmen"
@ -196,6 +197,18 @@ msgstr "Geldtopf"
msgid "Ledgers"
msgstr "Geldtöpfe"
#: finance/models.py
msgid "In preparation"
msgstr "In Vorbereitung"
#: finance/models.py
msgid "Submitted"
msgstr "Eingereicht"
#: finance/models.py
msgid "Confirmed"
msgstr "Abgewickelt"
#: finance/models.py
msgid "Short description"
msgstr "Kurzbeschreibung"
@ -247,17 +260,13 @@ msgid "Price per night"
msgstr "Preis pro Nacht"
#: finance/models.py
msgid "Submitted"
msgstr "Eingereicht"
msgid "Status"
msgstr "Status"
#: finance/models.py
msgid "Submitted on"
msgstr "Eingereicht am"
#: finance/models.py
msgid "Confirmed"
msgstr "Abgewickelt"
#: finance/models.py
msgid "Paid on"
msgstr "Bezahlt am"
@ -288,6 +297,7 @@ msgid "Statement: %(excursion)s"
msgstr "Abrechnung: %(excursion)s"
#: finance/models.py
#, python-format
msgid "Excursion %(excursion)s"
msgstr "Ausfahrt %(excursion)s"
@ -305,7 +315,7 @@ msgstr "Aufwandsentschädigung für %(excu)s"
msgid "Night and travel costs for %(excu)s"
msgstr "Übernachtungs- und Fahrtkosten für %(excu)s"
#: finance/models.py
#: finance/models.py finance/tests/models.py
msgid "reduced by org fee"
msgstr "reduziert um Org-Beitrag"

@ -0,0 +1,39 @@
# Generated by Django 4.2.20 on 2025-10-11 15:43
from django.db import migrations, models
def set_status_from_old_fields(apps, schema_editor):
"""
Set the status field based on the existing submitted and confirmed fields.
- If confirmed is True, status = CONFIRMED (2)
- If submitted is True but confirmed is False, status = SUBMITTED (1)
- Otherwise, status = UNSUBMITTED (0)
"""
Statement = apps.get_model('finance', 'Statement')
UNSUBMITTED, SUBMITTED, CONFIRMED = 0, 1, 2
for statement in Statement.objects.all():
if statement.confirmed:
statement.status = CONFIRMED
elif statement.submitted:
statement.status = SUBMITTED
else:
statement.status = UNSUBMITTED
statement.save(update_fields=['status'])
class Migration(migrations.Migration):
dependencies = [
('finance', '0009_statement_ljp_to'),
]
operations = [
migrations.AddField(
model_name='statement',
name='status',
field=models.IntegerField(choices=[(0, 'In preparation'), (1, 'Submitted'), (2, 'Confirmed')], default=0, verbose_name='Status'),
),
migrations.RunPython(set_status_from_old_fields, reverse_code=migrations.RunPython.noop),
]

@ -0,0 +1,21 @@
# Generated by Django 4.2.20 on 2025-10-11 16:01
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('finance', '0010_statement_status'),
]
operations = [
migrations.RemoveField(
model_name='statement',
name='confirmed',
),
migrations.RemoveField(
model_name='statement',
name='submitted',
),
]

@ -46,11 +46,15 @@ class TransactionIssue:
class StatementManager(models.Manager):
def get_queryset(self):
return super().get_queryset().filter(submitted=False, confirmed=False)
return super().get_queryset().filter(status=Statement.UNSUBMITTED)
class Statement(CommonModel):
MISSING_LEDGER, NON_MATCHING_TRANSACTIONS, INVALID_ALLOWANCE_TO, INVALID_TOTAL, VALID = 0, 1, 2, 3, 4
UNSUBMITTED, SUBMITTED, CONFIRMED = 0, 1, 2
STATUS_CHOICES = [(UNSUBMITTED, _('In preparation')),
(SUBMITTED, _('Submitted')),
(CONFIRMED, _('Confirmed'))]
short_description = models.CharField(verbose_name=_('Short description'),
max_length=30,
@ -82,9 +86,10 @@ class Statement(CommonModel):
night_cost = models.DecimalField(verbose_name=_('Price per night'), default=0, decimal_places=2, max_digits=5)
submitted = models.BooleanField(verbose_name=_('Submitted'), default=False)
status = models.IntegerField(verbose_name=_('Status'),
choices=STATUS_CHOICES,
default=UNSUBMITTED)
submitted_date = models.DateTimeField(verbose_name=_('Submitted on'), default=None, null=True)
confirmed = models.BooleanField(verbose_name=_('Confirmed'), default=False)
confirmed_date = models.DateTimeField(verbose_name=_('Paid on'), default=None, null=True)
created_by = models.ForeignKey(Member, verbose_name=_('Created by'),
@ -131,8 +136,16 @@ class Statement(CommonModel):
else:
return self.short_description
@property
def submitted(self):
return self.status == Statement.SUBMITTED or self.status == Statement.CONFIRMED
@property
def confirmed(self):
return self.status == Statement.CONFIRMED
def submit(self, submitter=None):
self.submitted = True
self.status = self.SUBMITTED
self.submitted_date = timezone.now()
self.submitted_by = submitter
self.save()
@ -231,7 +244,7 @@ class Statement(CommonModel):
if not self.validity == Statement.VALID:
return False
self.confirmed = True
self.status = self.CONFIRMED
self.confirmed_date = timezone.now()
self.confirmed_by = confirmer
for trans in self.transaction_set.all():
@ -569,7 +582,7 @@ class Statement(CommonModel):
class StatementUnSubmittedManager(models.Manager):
def get_queryset(self):
return super().get_queryset().filter(submitted=False, confirmed=False)
return super().get_queryset().filter(status=Statement.UNSUBMITTED)
class StatementUnSubmitted(Statement):
@ -589,7 +602,7 @@ class StatementUnSubmitted(Statement):
class StatementSubmittedManager(models.Manager):
def get_queryset(self):
return super().get_queryset().filter(submitted=True, confirmed=False)
return super().get_queryset().filter(status=Statement.SUBMITTED)
class StatementSubmitted(Statement):
@ -606,7 +619,7 @@ class StatementSubmitted(Statement):
class StatementConfirmedManager(models.Manager):
def get_queryset(self):
return super().get_queryset().filter(confirmed=True)
return super().get_queryset().filter(status=Statement.CONFIRMED)
class StatementConfirmed(Statement):

@ -1,3 +1,4 @@
from .admin import *
from .models import *
from .rules import *
from .migrations import *

@ -99,16 +99,16 @@ class StatementUnSubmittedAdminTestCase(AdminTestCase):
def test_get_readonly_fields_submitted(self):
"""Test readonly fields when statement is submitted"""
# Mark statement as submitted
self.statement.submitted = True
self.statement.status = Statement.SUBMITTED
readonly_fields = self.admin.get_readonly_fields(None, self.statement)
self.assertIn('submitted', readonly_fields)
self.assertIn('status', readonly_fields)
self.assertIn('excursion', readonly_fields)
self.assertIn('short_description', readonly_fields)
def test_get_readonly_fields_not_submitted(self):
"""Test readonly fields when statement is not submitted"""
readonly_fields = self.admin.get_readonly_fields(None, self.statement)
self.assertEqual(readonly_fields, ['submitted', 'excursion'])
self.assertEqual(readonly_fields, ['status', 'excursion'])
def test_submit_view_insufficient_permission(self):
url = reverse('admin:finance_statementunsubmitted_submit',
@ -163,7 +163,7 @@ class StatementSubmittedAdminTestCase(AdminTestCase):
self.statement = Statement.objects.create(
short_description='Submitted Statement',
explanation='Test explanation',
submitted=True,
status=Statement.SUBMITTED,
submitted_by=self.member,
submitted_date=timezone.now(),
night_cost=25
@ -198,7 +198,7 @@ class StatementSubmittedAdminTestCase(AdminTestCase):
self.statement_no_trans_success = Statement.objects.create(
short_description='No Transactions Success',
explanation='Test explanation',
submitted=True,
status=Statement.SUBMITTED,
submitted_by=self.member,
submitted_date=timezone.now(),
night_cost=25
@ -206,7 +206,7 @@ class StatementSubmittedAdminTestCase(AdminTestCase):
self.statement_no_trans_error = Statement.objects.create(
short_description='No Transactions Error',
explanation='Test explanation',
submitted=True,
status=Statement.SUBMITTED,
submitted_by=self.member,
submitted_date=timezone.now(),
night_cost=25
@ -294,7 +294,7 @@ class StatementSubmittedAdminTestCase(AdminTestCase):
"""Test overview_view with statement that can't be found in StatementSubmitted queryset"""
# When trying to access an unsubmitted statement via StatementSubmitted admin,
# the decorator will fail to find it and show "Statement not found"
self.statement.submitted = False
self.statement.status = Statement.UNSUBMITTED
self.statement.save()
url = reverse('admin:finance_statementsubmitted_overview', args=(self.statement.pk,))
@ -496,8 +496,7 @@ class StatementConfirmedAdminTestCase(AdminTestCase):
base_statement = Statement.objects.create(
short_description='Confirmed Statement',
explanation='Test explanation',
submitted=True,
confirmed=True,
status=Statement.CONFIRMED,
confirmed_by=self.member,
confirmed_date=timezone.now(),
night_cost=25
@ -510,8 +509,7 @@ class StatementConfirmedAdminTestCase(AdminTestCase):
self.unconfirmed_statement = Statement.objects.create(
short_description='Unconfirmed Statement',
explanation='Test explanation',
submitted=True,
confirmed=False,
status=Statement.SUBMITTED,
night_cost=25
)
@ -528,8 +526,7 @@ class StatementConfirmedAdminTestCase(AdminTestCase):
confirmed_with_excursion_base = Statement.objects.create(
short_description='Confirmed with Excursion',
explanation='Test explanation',
submitted=True,
confirmed=True,
status=Statement.CONFIRMED,
confirmed_by=self.member,
confirmed_date=timezone.now(),
excursion=self.excursion,

@ -0,0 +1,70 @@
import django.test
from django.apps import apps
from django.db import connection
from django.db.migrations.executor import MigrationExecutor
class StatusMigrationTestCase(django.test.TransactionTestCase):
"""Test the migration from submitted/confirmed fields to status field."""
app = 'finance'
migrate_from = [('finance', '0009_statement_ljp_to')]
migrate_to = [('finance', '0010_statement_status')]
def setUp(self):
# Get the state before migration
executor = MigrationExecutor(connection)
executor.migrate(self.migrate_from)
# Get the old models (before migration)
old_apps = executor.loader.project_state(self.migrate_from).apps
self.Statement = old_apps.get_model(self.app, 'Statement')
# Create statements with different combinations of submitted/confirmed
# created_by is nullable, so we don't need to create a Member
self.unsubmitted = self.Statement.objects.create(
short_description='Unsubmitted Statement',
submitted=False,
confirmed=False
)
self.submitted = self.Statement.objects.create(
short_description='Submitted Statement',
submitted=True,
confirmed=False
)
self.confirmed = self.Statement.objects.create(
short_description='Confirmed Statement',
submitted=True,
confirmed=True
)
def test_status_field_migration(self):
"""Test that status field is correctly set from old submitted/confirmed fields."""
# Run the migration
executor = MigrationExecutor(connection)
executor.loader.build_graph()
executor.migrate(self.migrate_to)
# Get the new models (after migration)
new_apps = executor.loader.project_state(self.migrate_to).apps
Statement = new_apps.get_model(self.app, 'Statement')
# Constants from the Statement model
UNSUBMITTED = 0
SUBMITTED = 1
CONFIRMED = 2
# Verify the migration worked correctly
unsubmitted = Statement.objects.get(pk=self.unsubmitted.pk)
self.assertEqual(unsubmitted.status, UNSUBMITTED,
'Statement with submitted=False, confirmed=False should have status=UNSUBMITTED')
submitted = Statement.objects.get(pk=self.submitted.pk)
self.assertEqual(submitted.status, SUBMITTED,
'Statement with submitted=True, confirmed=False should have status=SUBMITTED')
confirmed = Statement.objects.get(pk=self.confirmed.pk)
self.assertEqual(confirmed.status, CONFIRMED,
'Statement with submitted=True, confirmed=True should have status=CONFIRMED')

@ -505,11 +505,11 @@ class ManagerTestCase(TestCase):
self.st_submitted = Statement.objects.create(short_description='A statement',
explanation='Important!',
night_cost=0,
submitted=True)
status=Statement.SUBMITTED)
self.st_confirmed = Statement.objects.create(short_description='A statement',
explanation='Important!',
night_cost=0,
confirmed=True)
status=Statement.CONFIRMED)
def test_get_queryset(self):
# TODO: remove this manager, since it is not used

@ -52,15 +52,15 @@ class FinanceRulesTestCase(TestCase):
def test_not_submitted_statement(self):
"""Test not_submitted predicate returns True when statement is not submitted"""
self.statement.submitted = False
self.statement.status = Statement.UNSUBMITTED
self.assertTrue(not_submitted(self.user1, self.statement))
self.statement.submitted = True
self.statement.status = Statement.SUBMITTED
self.assertFalse(not_submitted(self.user1, self.statement))
def test_not_submitted_freizeit_with_statement(self):
"""Test not_submitted predicate with Freizeit having unsubmitted statement"""
self.freizeit.statement = self.statement
self.statement.submitted = False
self.statement.status = Statement.UNSUBMITTED
self.assertTrue(not_submitted(self.user1, self.freizeit))
def test_not_submitted_freizeit_without_statement(self):

@ -88,12 +88,12 @@ class RulesTestCase(TestCase):
self.statement_unsubmitted = Statement.objects.create(
short_description='Unsubmitted Statement',
excursion=self.excursion,
submitted=False
status=Statement.UNSUBMITTED
)
self.statement_submitted = Statement.objects.create(
short_description='Submitted Statement',
submitted=True
status=Statement.SUBMITTED
)
def test_is_oneself(self):

Loading…
Cancel
Save