From 9d7f8767051658cbd76eaec1fa186c0106f9e012 Mon Sep 17 00:00:00 2001 From: charles Date: Thu, 26 Mar 2026 17:37:27 +0100 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8(backend)=20add=20command=20admin?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit we want to run the indexing from the admin. in `dmin/core/runindexing/`is a form to do so. Signed-off-by: charles ✨(backend) add async_mode flag the command must be killable. this adds a async_mode flag to preserve async feature and allow running sync. Signed-off-by: charles --- src/backend/core/admin.py | 77 ++++++++++++++++++- src/backend/core/forms.py | 42 ++++++++++ src/backend/core/templates/runindexing.html | 22 ++++++ .../core/tests/test_admin_run_indexing.py | 54 +++++++++++++ src/backend/impress/urls.py | 3 + 5 files changed, 197 insertions(+), 1 deletion(-) create mode 100644 src/backend/core/forms.py create mode 100644 src/backend/core/templates/runindexing.html create mode 100644 src/backend/core/tests/test_admin_run_indexing.py diff --git a/src/backend/core/admin.py b/src/backend/core/admin.py index 3d2b0ef78..b8740a7b7 100644 --- a/src/backend/core/admin.py +++ b/src/backend/core/admin.py @@ -1,15 +1,54 @@ """Admin classes and registrations for core app.""" from django.contrib import admin, messages +from django.contrib.admin.views.decorators import staff_member_required from django.contrib.auth import admin as auth_admin -from django.shortcuts import redirect +from django.core.management import call_command +from django.http import HttpRequest +from django.shortcuts import redirect, render from django.utils.translation import gettext_lazy as _ from treebeard.admin import TreeAdmin from core import models +from core.forms import RunIndexingForm from core.tasks.user_reconciliation import user_reconciliation_csv_import_job +# Customize the default admin site's get_app_list method +_original_get_app_list = admin.site.get_app_list + + +def custom_get_app_list(_self, request, app_label=None): + """Add custom commands to the app list.""" + app_list = _original_get_app_list(request, app_label) + + # Add Commands app with Run Indexing command + commands_app = { + "name": _("Commands"), + "app_label": "commands", + "app_url": "#", + "has_module_perms": True, + "models": [ + { + "name": _("Run indexing"), + "object_name": "RunIndexing", + "admin_url": "/admin/run-indexing/", + "view_only": False, + "add_url": None, + "change_url": None, + } + ], + } + + app_list.append(commands_app) + return app_list + + +# Monkey-patch the admin site +admin.site.get_app_list = lambda request, app_label=None: custom_get_app_list( + admin.site, request, app_label +) + @admin.register(models.User) class UserAdmin(auth_admin.UserAdmin): @@ -227,3 +266,39 @@ class InvitationAdmin(admin.ModelAdmin): def save_model(self, request, obj, form, change): obj.issuer = request.user obj.save() + + +@staff_member_required +def run_indexing_view(request: HttpRequest): + """Custom admin view for running indexing commands.""" + if request.method == "POST": + form = RunIndexingForm(request.POST) + if form.is_valid(): + lower_time_bound = form.cleaned_data.get("lower_time_bound") + upper_time_bound = form.cleaned_data.get("upper_time_bound") + call_command( + "index", + batch_size=form.cleaned_data["batch_size"], + lower_time_bound=lower_time_bound.isoformat() + if lower_time_bound + else None, + upper_time_bound=upper_time_bound.isoformat() + if upper_time_bound + else None, + async_mode=True, + ) + messages.success(request, _("Indexing triggered!")) + return redirect("run_indexing") + messages.error(request, _("Please correct the errors below.")) + else: + form = RunIndexingForm() + + return render( + request=request, + template_name="runindexing.html", + context={ + **admin.site.each_context(request), + "title": "Run Indexing Command", + "form": form, + }, + ) diff --git a/src/backend/core/forms.py b/src/backend/core/forms.py new file mode 100644 index 000000000..00ff2500a --- /dev/null +++ b/src/backend/core/forms.py @@ -0,0 +1,42 @@ +"""Forms for the core app.""" + +from django import forms +from django.conf import settings +from django.utils.translation import gettext_lazy as _ + + +class RunIndexingForm(forms.Form): + """ + Form for running the indexing process. + """ + + batch_size = forms.IntegerField( + min_value=1, + initial=settings.SEARCH_INDEXER_BATCH_SIZE, + ) + lower_time_bound = forms.DateTimeField( + required=False, widget=forms.TextInput(attrs={"type": "datetime-local"}) + ) + upper_time_bound = forms.DateTimeField( + required=False, widget=forms.TextInput(attrs={"type": "datetime-local"}) + ) + + def clean(self): + """Override clean to validate time bounds.""" + cleaned_data = super().clean() + self.check_time_bounds() + return cleaned_data + + def check_time_bounds(self): + """Validate that lower_time_bound is before upper_time_bound.""" + lower_time_bound = self.cleaned_data.get("lower_time_bound") + upper_time_bound = self.cleaned_data.get("upper_time_bound") + if ( + lower_time_bound + and upper_time_bound + and lower_time_bound > upper_time_bound + ): + self.add_error( + "upper_time_bound", + _("Upper time bound must be after lower time bound."), + ) diff --git a/src/backend/core/templates/runindexing.html b/src/backend/core/templates/runindexing.html new file mode 100644 index 000000000..bb2c6c7cf --- /dev/null +++ b/src/backend/core/templates/runindexing.html @@ -0,0 +1,22 @@ +{% extends "admin/base_site.html" %} +{% load i18n %} + +{% block content %} + +
+ {% csrf_token %} + +
+ +

+ {% translate "This command triggers the indexing of all documents within the specified time bound." %} +

+ +
+ + {{ form.as_p }} + + +
+ +{% endblock %} diff --git a/src/backend/core/tests/test_admin_run_indexing.py b/src/backend/core/tests/test_admin_run_indexing.py new file mode 100644 index 000000000..d37885557 --- /dev/null +++ b/src/backend/core/tests/test_admin_run_indexing.py @@ -0,0 +1,54 @@ +"""Tests for run_indexing_view admin endpoint.""" + +from unittest.mock import patch + +from django.http import HttpResponse + +import pytest + +from core import factories + + +@pytest.mark.usefixtures("indexer_settings") +@pytest.mark.django_db +@pytest.mark.parametrize( + "is_authenticated,is_staff,should_call_command", + [ + (False, False, False), + (True, False, False), + (True, True, True), + ], +) +def test_run_indexing_view_post_authentication( + client, + is_authenticated, + is_staff, + should_call_command, +): + """Test that POST to run_indexing_view requires staff authentication.""" + + if is_authenticated: + user = factories.UserFactory(is_staff=is_staff) + client.force_login(user) + + batch_size = 100 + with patch("core.admin.call_command") as mock_call_command: + mock_call_command.return_value = HttpResponse("Mocked render") + response = client.post("/admin/run-indexing/", {"batch_size": batch_size}) + + # redirects in all cases + assert response.status_code == 302 + + if should_call_command: + assert "/admin/run-indexing/" == response.url + mock_call_command.assert_called_once() + assert mock_call_command.call_args.kwargs == { + "batch_size": batch_size, + "lower_time_bound": None, + "upper_time_bound": None, + "async_mode": True, + } + + else: + assert "/admin/login/" in response.url + mock_call_command.assert_not_called() diff --git a/src/backend/impress/urls.py b/src/backend/impress/urls.py index 2c5964d42..bc1987674 100644 --- a/src/backend/impress/urls.py +++ b/src/backend/impress/urls.py @@ -12,7 +12,10 @@ from drf_spectacular.views import ( SpectacularSwaggerView, ) +from core.admin import run_indexing_view + urlpatterns = [ + path("admin/run-indexing/", run_indexing_view, name="run_indexing"), path("admin/", admin.site.urls), path("", include("core.urls")), ]