diff --git a/src/backend/core/admin.py b/src/backend/core/admin.py index 25fbdaca..cfffbfaa 100644 --- a/src/backend/core/admin.py +++ b/src/backend/core/admin.py @@ -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.""" diff --git a/src/backend/core/tests/test_admin_user_reconciliation.py b/src/backend/core/tests/test_admin_user_reconciliation.py new file mode 100644 index 00000000..084c6e42 --- /dev/null +++ b/src/backend/core/tests/test_admin_user_reconciliation.py @@ -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