Skip to content

Commit b83a336

Browse files
committed
feat(kesaseteli,admin): admin youth applications and summer vouchers
YJDH-791 YJDH-792. Register `YouthApplication` and `YouthSummerVoucher` with multiple filters and search fields to Django's admin site. A `YouthApplication` can only be updated from it's contact information part, it cannot be deleted and a new one cannot be created.
1 parent 42dbb3f commit b83a336

File tree

4 files changed

+392
-1
lines changed

4 files changed

+392
-1
lines changed

backend/kesaseteli/applications/admin.py

Lines changed: 173 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,17 @@
88
from django.shortcuts import render
99
from django.urls import path, reverse
1010
from django.utils import timezone
11+
from django.utils.safestring import mark_safe
1112
from django.utils.translation import gettext_lazy as _
1213
from django.utils.translation import ngettext
1314

14-
from applications.models import EmailTemplate, School, SummerVoucherConfiguration
15+
from applications.models import (
16+
EmailTemplate,
17+
School,
18+
SummerVoucherConfiguration,
19+
YouthApplication,
20+
YouthSummerVoucher,
21+
)
1522
from applications.services import EmailTemplateService
1623
from applications.target_groups import get_target_group_choices
1724

@@ -250,7 +257,172 @@ def reinitialize_from_file(self, request, queryset):
250257
)
251258

252259

260+
class SchoolFilter(admin.SimpleListFilter):
261+
title = _("school")
262+
parameter_name = "school"
263+
264+
def lookups(self, request, model_admin):
265+
schools = School.objects.values_list("name", flat=True).order_by("name")
266+
return [(school, school) for school in schools]
267+
268+
def queryset(self, request, queryset):
269+
if self.value():
270+
return queryset.filter(school=self.value())
271+
return queryset
272+
273+
274+
class IsValidSchoolFilter(admin.SimpleListFilter):
275+
title = _("is in school list")
276+
parameter_name = "is_valid_school"
277+
278+
def lookups(self, request, model_admin):
279+
return (
280+
("yes", _("Yes")),
281+
("no", _("No")),
282+
)
283+
284+
def queryset(self, request, queryset):
285+
if self.value() == "yes":
286+
return queryset.filter(
287+
school__in=School.objects.values_list("name", flat=True)
288+
)
289+
if self.value() == "no":
290+
return queryset.exclude(
291+
school__in=School.objects.values_list("name", flat=True)
292+
)
293+
return queryset
294+
295+
296+
class YouthApplicationAdmin(admin.ModelAdmin):
297+
list_display = [
298+
"id",
299+
"first_name",
300+
"last_name",
301+
"masked_social_security_number",
302+
"school",
303+
"is_valid_school",
304+
"status",
305+
"created_at",
306+
"modified_at",
307+
]
308+
list_filter = [
309+
"created_at",
310+
"modified_at",
311+
"status",
312+
IsValidSchoolFilter,
313+
SchoolFilter,
314+
]
315+
date_hierarchy = "created_at"
316+
search_fields = [
317+
"id",
318+
"first_name",
319+
"last_name",
320+
"school",
321+
]
322+
323+
# A custom field to list contact info fields
324+
# This is used to determine which fields should be readonly
325+
contact_info_fields = [
326+
"first_name",
327+
"last_name",
328+
"email",
329+
"phone_number",
330+
"postcode",
331+
"school",
332+
"is_unlisted_school",
333+
"language",
334+
]
335+
336+
def is_valid_school(self, obj):
337+
return School.objects.filter(name=obj.school).exists()
338+
339+
is_valid_school.boolean = True
340+
# The school list changes in time, so we can test only whether the value is
341+
# currently in school list.
342+
is_valid_school.short_description = _("is in current school list")
343+
344+
def masked_social_security_number(self, obj):
345+
"""Mask social security number for display."""
346+
if obj.social_security_number:
347+
return "******" + obj.social_security_number[-4:]
348+
return ""
349+
350+
masked_social_security_number.short_description = _("social security number")
351+
352+
def get_readonly_fields(self, request, obj=None):
353+
"""Make contact info fields readonly."""
354+
if obj:
355+
return [
356+
f.name
357+
for f in self.model._meta.fields
358+
if f.name not in self.contact_info_fields
359+
]
360+
return super().get_readonly_fields(request, obj)
361+
362+
def has_add_permission(self, request):
363+
"""Disable adding new applications."""
364+
return False
365+
366+
def has_delete_permission(self, request, obj=None):
367+
"""Disable deleting applications."""
368+
return False
369+
370+
371+
class YouthSummerVoucherAdmin(admin.ModelAdmin):
372+
list_display = [
373+
"id",
374+
"summer_voucher_serial_number",
375+
"target_group",
376+
"youth_application_link",
377+
"masked_social_security_number",
378+
"youth_application__email",
379+
"youth_application__phone_number",
380+
"created_at",
381+
"modified_at",
382+
]
383+
list_filter = [
384+
"target_group",
385+
"created_at",
386+
"modified_at",
387+
]
388+
date_hierarchy = "created_at"
389+
search_fields = [
390+
"youth_application__first_name",
391+
"youth_application__last_name",
392+
"youth_application__email",
393+
"youth_application__phone_number",
394+
"summer_voucher_serial_number",
395+
"id",
396+
]
397+
autocomplete_fields = [
398+
"youth_application",
399+
]
400+
401+
def queryset(self, request):
402+
return super().queryset(request).select_related("youth_application")
403+
404+
def masked_social_security_number(self, obj):
405+
"""Mask social security number for display."""
406+
if obj.youth_application.social_security_number:
407+
return "******" + obj.youth_application.social_security_number[-4:]
408+
return ""
409+
410+
masked_social_security_number.short_description = _("social security number")
411+
412+
def youth_application_link(self, obj):
413+
url = reverse(
414+
"admin:applications_youthapplication_change",
415+
args=[obj.youth_application.id],
416+
)
417+
link_text = obj.youth_application.name or obj.youth_application.email
418+
return mark_safe(f'<a href="{url}">{link_text}</a>')
419+
420+
youth_application_link.short_description = _("youth application")
421+
422+
253423
if apps.is_installed("django.contrib.admin"):
254424
admin.site.register(SummerVoucherConfiguration, SummerVoucherConfigurationAdmin)
255425
admin.site.register(School, SchoolAdmin)
256426
admin.site.register(EmailTemplate, EmailTemplateAdmin)
427+
admin.site.register(YouthApplication, YouthApplicationAdmin)
428+
admin.site.register(YouthSummerVoucher, YouthSummerVoucherAdmin)
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import pytest
2+
from django.contrib.admin.sites import AdminSite
3+
4+
from applications.admin import YouthApplicationAdmin
5+
from applications.models import YouthApplication
6+
from common.tests.factories import YouthApplicationFactory
7+
8+
9+
@pytest.fixture
10+
def youth_application_admin():
11+
return YouthApplicationAdmin(YouthApplication, AdminSite())
12+
13+
14+
@pytest.mark.django_db
15+
def test_masked_social_security_number(youth_application_admin):
16+
# Test with valid SSN
17+
app = YouthApplicationFactory.build(social_security_number="010101-1234")
18+
assert youth_application_admin.masked_social_security_number(app) == "******1234"
19+
20+
# Test with another valid SSN
21+
app = YouthApplicationFactory.build(social_security_number="311299A9876")
22+
assert youth_application_admin.masked_social_security_number(app) == "******9876"
23+
24+
25+
@pytest.mark.django_db
26+
def test_masked_social_security_number_empty(youth_application_admin):
27+
# Test with empty SSN
28+
app = YouthApplicationFactory.build(social_security_number="")
29+
assert youth_application_admin.masked_social_security_number(app) == ""
30+
31+
# Test with None SSN
32+
app = YouthApplicationFactory.build(social_security_number=None)
33+
assert youth_application_admin.masked_social_security_number(app) == ""
34+
35+
36+
@pytest.mark.django_db
37+
def test_masked_social_security_number_short(youth_application_admin):
38+
# Test with short SSN (should verify behavior, might just take last 4)
39+
app = YouthApplicationFactory.build(social_security_number="123")
40+
assert youth_application_admin.masked_social_security_number(app) == "******123"
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import pytest
2+
from django.contrib.admin.sites import AdminSite
3+
4+
from applications.admin import YouthApplicationAdmin
5+
from applications.models import YouthApplication
6+
from common.tests.factories import YouthApplicationFactory
7+
8+
9+
@pytest.fixture
10+
def youth_application_admin():
11+
return YouthApplicationAdmin(YouthApplication, AdminSite())
12+
13+
14+
@pytest.mark.django_db
15+
def test_has_add_permission(youth_application_admin):
16+
assert youth_application_admin.has_add_permission(None) is False
17+
18+
19+
@pytest.mark.django_db
20+
def test_get_readonly_fields(youth_application_admin):
21+
app = YouthApplicationFactory.build()
22+
readonly_fields = youth_application_admin.get_readonly_fields(None, obj=app)
23+
24+
# Check that contact fields are NOT readonly
25+
editable_fields = [
26+
"first_name",
27+
"last_name",
28+
"email",
29+
"phone_number",
30+
"postcode",
31+
"school",
32+
"is_unlisted_school",
33+
"language",
34+
]
35+
for field in editable_fields:
36+
assert field not in readonly_fields
37+
38+
# Check that other fields are readonly
39+
# Just checking a few key ones to verify the logic
40+
assert "social_security_number" in readonly_fields
41+
assert "encrypted_social_security_number" in readonly_fields
42+
assert "status" in readonly_fields
43+
assert "created_at" in readonly_fields
44+
45+
46+
@pytest.mark.django_db
47+
def test_get_readonly_fields_add_view(youth_application_admin):
48+
# When obj is None (add view), readonly fields should be default (empty or whatever is defined in super)
49+
# But since we disabled add permission, this is less critical, but good to ensure logic doesn't crash
50+
readonly_fields = youth_application_admin.get_readonly_fields(None, obj=None)
51+
# Based on our implementation, it returns super().get_readonly_fields(request, obj) which is likely empty list or configured readonly_fields
52+
# We didn't configure readonly_fields in the class, so it should be empty list usually.
53+
assert isinstance(readonly_fields, (list, tuple))

0 commit comments

Comments
 (0)