(backend) add user reconciliation admin

Give admins a UI to import reconciliation CSVs and trigger the actual
merge once both addresses are confirmed.
This commit is contained in:
Nicolas Clerc
2026-05-27 17:36:47 +02:00
parent 0ce6c56bd4
commit e2538680de
2 changed files with 130 additions and 1 deletions
+45 -1
View File
@@ -1,12 +1,17 @@
"""Admin classes and registrations for core app."""
from django.contrib import admin
from functools import partial
from django.contrib import admin, messages
from django.contrib.auth import admin as auth_admin
from django.db import transaction
from django.shortcuts import redirect
from django.utils.translation import gettext_lazy as _
from lasuite.malware_detection import malware_detection
from core import models
from core.tasks.user_reconciliation import user_reconciliation_csv_import_job
@admin.register(models.User)
@@ -95,6 +100,45 @@ class UserAdmin(auth_admin.UserAdmin):
search_fields = ("id", "sub", "admin_email", "email", "full_name")
@admin.register(models.UserReconciliationCsvImport)
class UserReconciliationCsvImportAdmin(admin.ModelAdmin):
"""Admin class for UserReconciliationCsvImport model."""
list_display = ("id", "__str__", "created_at", "status")
def save_model(self, request, obj, form, change):
"""Override save_model to trigger the import task on creation."""
super().save_model(request, obj, form, change)
if not change:
# Defer to commit so the task does not run before the row is visible.
transaction.on_commit(partial(user_reconciliation_csv_import_job.delay, obj.pk))
messages.success(request, _("Import job created and queued."))
return redirect("..")
@admin.action(description=_("Process selected user reconciliations"))
def process_reconciliation(_modeladmin, _request, queryset):
"""
Admin action to process selected user reconciliations.
The action will process only entries that are ready and have both emails checked.
"""
processable_entries = queryset.filter(
status="ready", active_email_checked=True, inactive_email_checked=True
)
for entry in processable_entries:
entry.process_reconciliation_request()
@admin.register(models.UserReconciliation)
class UserReconciliationAdmin(admin.ModelAdmin):
"""Admin class for UserReconciliation model."""
list_display = ["id", "__str__", "created_at", "status"]
actions = [process_reconciliation]
class ItemAccessInline(admin.TabularInline):
"""Inline admin class for item accesses."""
@@ -0,0 +1,85 @@
"""Tests for the user reconciliation admin classes."""
from pathlib import Path
from django.contrib.admin.sites import AdminSite
from django.contrib.messages.storage.fallback import FallbackStorage
from django.contrib.sessions.middleware import SessionMiddleware
from django.core.files.base import ContentFile
from django.test import RequestFactory
import pytest
from core import factories, models
from core.admin import UserReconciliationCsvImportAdmin, process_reconciliation
pytestmark = pytest.mark.django_db
DATA_DIR = Path(__file__).parent / "data"
def _request_with_messages():
"""Build a request carrying a session and the messages framework."""
request = RequestFactory().post("/")
SessionMiddleware(lambda r: None).process_request(request)
request.session.save()
request._messages = FallbackStorage(request) # pylint: disable=protected-access
return request
def test_admin_process_reconciliation_processes_ready_entry():
"""The action processes entries that are ready with both emails checked."""
active = factories.UserFactory(email="active@example.com")
inactive = factories.UserFactory(email="inactive@example.com")
item = factories.ItemFactory(creator=inactive)
reconciliation = models.UserReconciliation.objects.create(
active_email=active.email,
inactive_email=inactive.email,
active_email_checked=True,
inactive_email_checked=True,
)
process_reconciliation(None, None, models.UserReconciliation.objects.all())
reconciliation.refresh_from_db()
item.refresh_from_db()
assert reconciliation.status == "done"
assert item.creator == active
def test_admin_process_reconciliation_skips_unconfirmed_entry():
"""The action ignores entries whose emails are not both checked."""
active = factories.UserFactory(email="active@example.com")
inactive = factories.UserFactory(email="inactive@example.com")
reconciliation = models.UserReconciliation.objects.create(
active_email=active.email,
inactive_email=inactive.email,
active_email_checked=True,
inactive_email_checked=False,
)
process_reconciliation(None, None, models.UserReconciliation.objects.all())
reconciliation.refresh_from_db()
assert reconciliation.status == "ready"
def test_admin_csv_import_save_model_defers_job_to_commit(django_capture_on_commit_callbacks):
"""Saving a new CSV import schedules the job on transaction commit."""
for email in ["active1", "inactive1", "active2", "inactive2"]:
factories.UserFactory(email=f"{email}@example.com")
with open(DATA_DIR / "example_reconciliation_basic.csv", "rb") as file:
csv_file = ContentFile(file.read(), name="example_reconciliation_basic.csv")
obj = models.UserReconciliationCsvImport(file=csv_file)
admin_instance = UserReconciliationCsvImportAdmin(
models.UserReconciliationCsvImport, AdminSite()
)
with django_capture_on_commit_callbacks(execute=True) as callbacks:
admin_instance.save_model(_request_with_messages(), obj, None, change=False)
# The job is deferred to commit, not run inline
assert len(callbacks) == 1
assert models.UserReconciliation.objects.count() == 2