(backend) add command admin

we want to run the indexing from the admin.
in `dmin/core/runindexing/`is a form to do so.

Signed-off-by: charles <charles.englebert@protonmail.com>

(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 <charles.englebert@protonmail.com>
This commit is contained in:
charles
2026-04-24 12:02:34 +02:00
parent 71ff92097c
commit 9d7f876705
5 changed files with 197 additions and 1 deletions
+76 -1
View File
@@ -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,
},
)
+42
View File
@@ -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."),
)
@@ -0,0 +1,22 @@
{% extends "admin/base_site.html" %}
{% load i18n %}
{% block content %}
<form method="POST" >
{% csrf_token %}
<hr style="margin-bottom: 10px;">
<p>
{% translate "This command triggers the indexing of all documents within the specified time bound." %}
</p>
<hr style="margin-bottom: 20px;">
{{ form.as_p }}
<input type="submit" value="{% translate 'Run Indexing' %}" style="margin-top: 20px;">
</form>
{% endblock %}
@@ -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()
+3
View File
@@ -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")),
]