From 5010d4f4b3f0cf218759df8296e6973412638e02 Mon Sep 17 00:00:00 2001
From: Valentin Rigal <rigal@teklia.com>
Date: Wed, 15 Feb 2023 09:01:48 +0000
Subject: [PATCH] Start corpus indexation

---
 arkindex/documents/api/search.py              |  43 ++++-
 arkindex/documents/serializers/search.py      |  24 +++
 arkindex/documents/tasks.py                   |  34 ++++
 .../tests/tasks/test_reindex_corpus.py        |  77 +++++++++
 arkindex/documents/tests/test_search_api.py   | 148 +++++++++++++++++-
 arkindex/project/api_v1.py                    |   3 +-
 arkindex/project/config.py                    |   1 +
 arkindex/project/rq_overrides.py              |  13 ++
 .../tests/config_samples/defaults.yaml        |   1 +
 .../project/tests/config_samples/errors.yaml  |   1 +
 .../tests/config_samples/expected_errors.yaml |   1 +
 .../tests/config_samples/override.yaml        |   3 +-
 arkindex/project/triggers.py                  |  13 ++
 arkindex/templates/reindex_corpus.html        |  10 ++
 14 files changed, 367 insertions(+), 5 deletions(-)
 create mode 100644 arkindex/documents/tests/tasks/test_reindex_corpus.py
 create mode 100644 arkindex/templates/reindex_corpus.html

diff --git a/arkindex/documents/api/search.py b/arkindex/documents/api/search.py
index a11358737f..4535efea25 100644
--- a/arkindex/documents/api/search.py
+++ b/arkindex/documents/api/search.py
@@ -1,18 +1,25 @@
 import math
 
 from django.conf import settings
+from django.utils.functional import cached_property
 from drf_spectacular.utils import extend_schema, extend_schema_view
 from rest_framework import status
 from rest_framework.exceptions import NotFound, ValidationError
-from rest_framework.generics import RetrieveAPIView
+from rest_framework.generics import CreateAPIView, RetrieveAPIView
 from rest_framework.response import Response
 from rest_framework.utils.urls import replace_query_param
 from SolrClient import SolrClient
 from SolrClient.exceptions import SolrError
 
 from arkindex.documents.models import EntityType, MetaType
-from arkindex.documents.serializers.search import CorpusSearchQuerySerializer, CorpusSearchResultSerializer
+from arkindex.documents.serializers.search import (
+    CorpusSearchQuerySerializer,
+    CorpusSearchResultSerializer,
+    ReindexCorpusSerializer,
+)
 from arkindex.project.mixins import CorpusACLMixin
+from arkindex.project.permissions import IsVerified
+from arkindex.users.models import Role
 
 solr = SolrClient(settings.SOLR_API_URL)
 
@@ -123,3 +130,35 @@ class CorpusSearch(CorpusACLMixin, RetrieveAPIView):
             'results': results.docs if not only_facets else None,
             'facets': results.get_facets()
         }).data, status=status.HTTP_200_OK)
+
+
+@extend_schema_view(post=extend_schema(operation_id="BuildSearchIndex", tags=['search']))
+class SearchIndexBuild(CorpusACLMixin, CreateAPIView):
+    """
+    Starts an indexation task for a specific corpus, ran by RQ
+    """
+    permission_classes = (IsVerified, )
+    serializer_class = ReindexCorpusSerializer
+
+    @cached_property
+    def corpus(self):
+        corpus = self.get_corpus(self.kwargs['pk'], role=Role.Admin)
+        if not corpus.indexable:
+            raise ValidationError({'__all__': ['This project is not indexable.']})
+        if not corpus.types.filter(indexable=True).exists():
+            raise ValidationError({'__all__': ['There are no indexable element types for this project.']})
+        return corpus
+
+    def get_serializer_context(self):
+        return {
+            **super().get_serializer_context(),
+            'corpus_id': self.corpus.id,
+            'user_id': self.request.user.id,
+        }
+
+    def create(self, request, *args, **kwargs):
+        if not settings.ARKINDEX_FEATURES['search']:
+            raise ValidationError({
+                '__all__': ['Building search index is unavailable due to the search feature being disabled.']
+            })
+        return super().create(request, *args, **kwargs)
diff --git a/arkindex/documents/serializers/search.py b/arkindex/documents/serializers/search.py
index 145f76d154..39f7c78c11 100644
--- a/arkindex/documents/serializers/search.py
+++ b/arkindex/documents/serializers/search.py
@@ -1,9 +1,12 @@
 from textwrap import dedent
 
 from rest_framework import serializers
+from rest_framework.exceptions import ValidationError
 
 from arkindex.documents.models import MetaType
+from arkindex.project.rq_overrides import get_existing_job
 from arkindex.project.serializer_fields import EnumField
+from arkindex.project.triggers import reindex_corpus
 
 
 class SolrDocumentSerializer(serializers.Serializer):
@@ -164,3 +167,24 @@ class CorpusSearchQuerySerializer(serializers.Serializer):
         required=False,
         help_text='Filter by name of the worker that created the entity.',
     )
+
+
+class ReindexCorpusSerializer(serializers.Serializer):
+    drop = serializers.BooleanField(default=True, help_text='Drop existing collections for this corpus.')
+
+    def save(self, **kwargs):
+        corpus_id = self.context.get('corpus_id')
+        user_id = self.context.get('user_id')
+        assert corpus_id and user_id, 'corpus_id and user_id must be passed in the serializer context'
+
+        # Ensure the reindex job has not already been started
+        job_id = f'reindex-{corpus_id}'
+        if (job := get_existing_job(job_id)) is not None:
+            # A previous job can only be removed if finished
+            if job.ended_at is None:
+                raise ValidationError({
+                    '__all__': [f'A job is already running to build search index on corpus {corpus_id}.']
+                })
+            job.delete()
+
+        reindex_corpus(**self.validated_data, corpus_id=corpus_id, user_id=user_id, job_id=job_id)
diff --git a/arkindex/documents/tasks.py b/arkindex/documents/tasks.py
index 49c624e4f0..d42a9cbfb0 100644
--- a/arkindex/documents/tasks.py
+++ b/arkindex/documents/tasks.py
@@ -3,10 +3,13 @@ from typing import Optional
 from uuid import UUID
 
 from django.conf import settings
+from django.core.mail import send_mail
 from django.db.models import Q
+from django.template.loader import render_to_string
 from django_rq import job
 from rq import Retry, get_current_job
 
+from arkindex.documents.indexer import Indexer
 from arkindex.documents.managers import ElementQuerySet
 from arkindex.documents.models import (
     Classification,
@@ -20,6 +23,7 @@ from arkindex.documents.models import (
     TranscriptionEntity,
 )
 from arkindex.process.models import Process, ProcessElement, WorkerActivity, WorkerRun
+from arkindex.users.models import User
 
 logger = logging.getLogger(__name__)
 
@@ -227,3 +231,33 @@ def add_parent_selection(corpus_id: UUID, parent: Element) -> None:
     for i, item in enumerate(queryset):
         rq_job.set_progress(i / total)
         item.add_parent(parent)
+
+
+@job('default', timeout=settings.RQ_TIMEOUTS['reindex_corpus'])
+def reindex_corpus(corpus_id: UUID, drop: bool = True) -> None:
+    rq_job = get_current_job()
+    assert rq_job is not None, 'This task can only be run in a RQ job context.'
+    assert rq_job.user_id is not None, 'This task requires a user ID to be defined on the RQ job.'
+
+    indexer = Indexer(corpus_id)
+    if drop:
+        indexer.drop_index()
+    indexer.setup()
+    indexer.index()
+
+    # Report to the user that the index build finished
+    user = User.objects.get(id=rq_job.user_id)
+    corpus = Corpus.objects.get(id=corpus_id)
+    send_mail(
+        subject=f'Project {corpus.name} was successfully indexed',
+        message=render_to_string(
+            'reindex_corpus.html',
+            context={
+                'user': user,
+                'corpus': corpus,
+            },
+        ),
+        from_email=None,
+        recipient_list=[user.email],
+        fail_silently=True,
+    )
diff --git a/arkindex/documents/tests/tasks/test_reindex_corpus.py b/arkindex/documents/tests/tasks/test_reindex_corpus.py
new file mode 100644
index 0000000000..81abde9ea0
--- /dev/null
+++ b/arkindex/documents/tests/tasks/test_reindex_corpus.py
@@ -0,0 +1,77 @@
+from unittest.mock import call, patch
+
+from arkindex.documents.tasks import reindex_corpus
+from arkindex.project.tests import FixtureTestCase
+
+EXPECTED_EMAIL_BODY = """
+Hello Test user,
+
+The search index build you started on project Unit Tests has been finished successfully.
+
+Indexed elements can be explored from the search interface on Arkindex.
+
+--
+Arkindex
+
+"""
+
+
+class TestReindexCorpus(FixtureTestCase):
+
+    def test_no_rq_job(self):
+        with self.assertRaises(AssertionError) as ctx:
+            reindex_corpus(corpus_id=self.corpus.id)
+        self.assertEqual(str(ctx.exception), 'This task can only be run in a RQ job context.')
+
+    @patch('arkindex.documents.tasks.get_current_job')
+    def test_no_user_id(self, job_mock):
+        job_mock.return_value.user_id = None
+        with self.assertRaises(AssertionError) as ctx:
+            reindex_corpus(corpus_id=self.corpus.id)
+        self.assertEqual(str(ctx.exception), 'This task requires a user ID to be defined on the RQ job.')
+
+    @patch('arkindex.documents.tasks.send_mail')
+    @patch('arkindex.documents.tasks.get_current_job')
+    @patch('arkindex.documents.tasks.Indexer')
+    def test_run_drop_false(self, indexer_mock, job_mock, send_mail_mock):
+        job_mock.return_value.user_id = self.user.id
+        reindex_corpus(corpus_id=self.corpus.id, drop=False)
+
+        self.assertEqual(indexer_mock.call_count, 1)
+        self.assertEqual(indexer_mock.call_args, call(self.corpus.id))
+        self.assertEqual(indexer_mock.return_value.drop_index.call_count, 0)
+        self.assertEqual(indexer_mock.return_value.setup.call_count, 1)
+        self.assertEqual(indexer_mock.return_value.index.call_count, 1)
+
+        self.assertListEqual(send_mail_mock.call_args_list, [
+            call(
+                subject=f'Project {self.corpus.name} was successfully indexed',
+                message=EXPECTED_EMAIL_BODY,
+                from_email=None,
+                recipient_list=[self.user.email],
+                fail_silently=True,
+            )
+        ])
+
+    @patch('arkindex.documents.tasks.send_mail')
+    @patch('arkindex.documents.tasks.get_current_job')
+    @patch('arkindex.documents.tasks.Indexer')
+    def test_run(self, indexer_mock, job_mock, send_mail_mock):
+        job_mock.return_value.user_id = self.user.id
+        reindex_corpus(corpus_id=self.corpus.id)
+
+        self.assertEqual(indexer_mock.call_count, 1)
+        self.assertEqual(indexer_mock.call_args, call(self.corpus.id))
+        self.assertEqual(indexer_mock.return_value.drop_index.call_count, 1)
+        self.assertEqual(indexer_mock.return_value.setup.call_count, 1)
+        self.assertEqual(indexer_mock.return_value.index.call_count, 1)
+
+        self.assertListEqual(send_mail_mock.call_args_list, [
+            call(
+                subject=f'Project {self.corpus.name} was successfully indexed',
+                message=EXPECTED_EMAIL_BODY,
+                from_email=None,
+                recipient_list=[self.user.email],
+                fail_silently=True,
+            )
+        ])
diff --git a/arkindex/documents/tests/test_search_api.py b/arkindex/documents/tests/test_search_api.py
index 7e2997f86b..7899c731a2 100644
--- a/arkindex/documents/tests/test_search_api.py
+++ b/arkindex/documents/tests/test_search_api.py
@@ -1,13 +1,15 @@
-from unittest.mock import patch
+from unittest.mock import call, patch
 
 from django.test import override_settings
 from django.urls import reverse
 from rest_framework import status
+from rq.exceptions import NoSuchJobError
 from SolrClient import SolrResponse
 from SolrClient.exceptions import SolrError
 
 from arkindex.documents.models import Corpus
 from arkindex.project.tests import FixtureAPITestCase
+from arkindex.users.models import Role
 
 
 class TestSearchApi(FixtureAPITestCase):
@@ -16,6 +18,7 @@ class TestSearchApi(FixtureAPITestCase):
     def setUpTestData(cls):
         super().setUpTestData()
         cls.corpus.indexable = True
+        cls.corpus.types.filter(slug="page").update(indexable=True)
         cls.corpus.save()
 
     def build_solr_response(
@@ -313,3 +316,146 @@ class TestSearchApi(FixtureAPITestCase):
             'sort': ['"element_atomic_number" is not a valid choice.']
         })
         self.assertFalse(mock_solr.query.called)
+
+    def test_build_index_requires_auth(self):
+        with self.assertNumQueries(0):
+            response = self.client.post(reverse('api:build-search-index', kwargs={'pk': self.corpus.id}))
+            self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
+
+    def test_build_index_requires_verified(self):
+        self.user.verified_email = False
+        self.user.save()
+        self.client.force_login(self.user)
+        with self.assertNumQueries(2):
+            response = self.client.post(reverse('api:build-search-index', kwargs={'pk': self.corpus.id}))
+            self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
+
+    @override_settings(ARKINDEX_FEATURES={'search': True})
+    def test_build_index_requires_corpus_admin(self):
+        self.corpus.memberships.update(level=Role.Contributor.value)
+        self.client.force_login(self.user)
+        with self.assertNumQueries(5):
+            response = self.client.post(reverse('api:build-search-index', kwargs={'pk': self.corpus.id}))
+            self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
+
+    @override_settings(ARKINDEX_FEATURES={'search': False})
+    @patch('arkindex.documents.tasks.reindex_corpus.delay')
+    def test_build_index_requires_search_feature(self, reindex_trigger_mock):
+        self.client.force_login(self.user)
+        with self.assertNumQueries(2):
+            response = self.client.post(reverse('api:build-search-index', kwargs={'pk': self.corpus.id}))
+            self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
+        self.assertDictEqual(response.json(), {
+            '__all__': ['Building search index is unavailable due to the search feature being disabled.']
+        })
+        self.assertFalse(reindex_trigger_mock.called)
+
+    @override_settings(ARKINDEX_FEATURES={'search': True})
+    @patch('arkindex.documents.tasks.reindex_corpus.delay')
+    @patch('arkindex.project.rq_overrides.Job')
+    def test_build_index_existing_running_job(self, job_mock, reindex_trigger_mock):
+        existing_job = job_mock()
+        existing_job.ended_at = None
+        job_mock.fetch.return_value = existing_job
+        self.client.force_login(self.user)
+        with self.assertNumQueries(7):
+            response = self.client.post(reverse('api:build-search-index', kwargs={'pk': self.corpus.id}))
+            self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
+        self.assertDictEqual(response.json(), {
+            '__all__': [f'A job is already running to build search index on corpus {self.corpus.id}.']
+        })
+        self.assertFalse(reindex_trigger_mock.called)
+
+    @override_settings(ARKINDEX_FEATURES={'search': True})
+    @patch('arkindex.documents.tasks.reindex_corpus.delay')
+    @patch('arkindex.project.rq_overrides.Job')
+    def test_build_index_non_indexable_corpus(self, job_mock, reindex_trigger_mock):
+        self.corpus.indexable = False
+        self.corpus.save()
+        self.client.force_login(self.user)
+        with self.assertNumQueries(6):
+            response = self.client.post(reverse('api:build-search-index', kwargs={'pk': self.corpus.id}))
+            self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
+        self.assertDictEqual(response.json(), {
+            '__all__': ['This project is not indexable.']
+        })
+        self.assertFalse(reindex_trigger_mock.called)
+
+    @override_settings(ARKINDEX_FEATURES={'search': True})
+    @patch('arkindex.documents.tasks.reindex_corpus.delay')
+    @patch('arkindex.project.rq_overrides.Job')
+    def test_build_index_non_indexable_element_types(self, job_mock, reindex_trigger_mock):
+        self.corpus.types.update(indexable=False)
+        self.client.force_login(self.user)
+        with self.assertNumQueries(7):
+            response = self.client.post(reverse('api:build-search-index', kwargs={'pk': self.corpus.id}))
+            self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
+        self.assertDictEqual(response.json(), {
+            '__all__': ['There are no indexable element types for this project.']
+        })
+        self.assertFalse(reindex_trigger_mock.called)
+
+    @override_settings(ARKINDEX_FEATURES={'search': True})
+    @patch('arkindex.documents.tasks.reindex_corpus.delay')
+    @patch('arkindex.project.rq_overrides.Job')
+    def test_build_index_existing_finished_job(self, job_mock, reindex_trigger_mock):
+        """In case a job exists but is finished, remove it and index the corpus again"""
+        existing_job = job_mock()
+        existing_job.ended_at = "2000-01-01"
+        job_mock.fetch.return_value = existing_job
+        self.client.force_login(self.user)
+        with self.assertNumQueries(7):
+            response = self.client.post(reverse('api:build-search-index', kwargs={'pk': self.corpus.id}))
+            self.assertEqual(response.status_code, status.HTTP_201_CREATED)
+        self.assertDictEqual(response.json(), {"drop": True})
+        self.assertListEqual(reindex_trigger_mock.call_args_list, [call(
+            corpus_id=self.corpus.id,
+            drop=True,
+            user_id=self.user.id,
+            description=f'Reindexing project {self.corpus.id}',
+            job_id=f'reindex-{self.corpus.id}',
+        )])
+        # The previous Job has been deleted
+        self.assertTrue(existing_job.delete.called)
+
+    @override_settings(ARKINDEX_FEATURES={'search': True})
+    @patch('arkindex.documents.tasks.reindex_corpus.delay')
+    @patch('arkindex.project.rq_overrides.Job')
+    def test_build_index(self, job_mock, reindex_trigger_mock):
+        job_mock.fetch.side_effect = NoSuchJobError
+        self.client.force_login(self.user)
+        with self.assertNumQueries(7):
+            response = self.client.post(
+                reverse('api:build-search-index', kwargs={'pk': self.corpus.id}),
+                data={"drop": True},
+            )
+            self.assertEqual(response.status_code, status.HTTP_201_CREATED)
+        self.assertDictEqual(response.json(), {"drop": True})
+        self.assertListEqual(reindex_trigger_mock.call_args_list, [call(
+            corpus_id=self.corpus.id,
+            drop=True,
+            user_id=self.user.id,
+            description=f'Reindexing project {self.corpus.id}',
+            job_id=f'reindex-{self.corpus.id}',
+        )])
+
+    @override_settings(ARKINDEX_FEATURES={'search': True})
+    @patch('arkindex.documents.tasks.reindex_corpus.delay')
+    @patch('arkindex.project.rq_overrides.Job')
+    def test_build_index_drop_false(self, job_mock, reindex_trigger_mock):
+        job_mock.fetch.side_effect = NoSuchJobError
+        self.client.force_login(self.user)
+        with self.assertNumQueries(7):
+            response = self.client.post(
+                reverse('api:build-search-index', kwargs={'pk': self.corpus.id}),
+                data={"drop": False},
+            )
+            self.assertEqual(response.status_code, status.HTTP_201_CREATED)
+        self.assertDictEqual(response.json(), {"drop": False})
+        self.assertListEqual(reindex_trigger_mock.call_args_list, [call(
+            corpus_id=self.corpus.id,
+            drop=False,
+            user_id=self.user.id,
+            description=f'Reindexing project {self.corpus.id}',
+            job_id=f'reindex-{self.corpus.id}',
+        )])
diff --git a/arkindex/project/api_v1.py b/arkindex/project/api_v1.py
index a86c98c529..7f71df176f 100644
--- a/arkindex/project/api_v1.py
+++ b/arkindex/project/api_v1.py
@@ -56,7 +56,7 @@ from arkindex.documents.api.ml import (
     TranscriptionCreate,
     TranscriptionEdit,
 )
-from arkindex.documents.api.search import CorpusSearch
+from arkindex.documents.api.search import CorpusSearch, SearchIndexBuild
 from arkindex.images.api import IIIFInformationCreate, IIIFURLCreate, ImageCreate, ImageElements, ImageRetrieve
 from arkindex.ponos.api import (
     AgentActions,
@@ -195,6 +195,7 @@ api = [
     path('corpus/<uuid:corpus>/worker-results/', WorkerResultsDestroy.as_view(), name='worker-delete-results'),
     path('corpus/<uuid:corpus>/activity-stats/', CorpusWorkersActivity.as_view(), name='corpus-activity-stats'),
     path('corpus/<uuid:corpus>/activity/', WorkerActivityList.as_view(), name='corpus-activity'),
+    path('corpus/<uuid:pk>/index/', SearchIndexBuild.as_view(), name='build-search-index'),
     path('export/<uuid:pk>/', DownloadExport.as_view(), name='download-export'),
 
     # Moderation
diff --git a/arkindex/project/config.py b/arkindex/project/config.py
index 301c39d28c..57fa7ba84d 100644
--- a/arkindex/project/config.py
+++ b/arkindex/project/config.py
@@ -152,6 +152,7 @@ def get_settings_parser(base_dir):
     job_timeouts_parser.add_option('move_element', type=int, default=3600)
     job_timeouts_parser.add_option('initialize_activity', type=int, default=3600)
     job_timeouts_parser.add_option('process_delete', type=int, default=3600)
+    job_timeouts_parser.add_option('reindex_corpus', type=int, default=7200)
 
     csrf_parser = parser.add_subparser('csrf', default={})
     csrf_parser.add_option('cookie_name', type=str, default='arkindex.csrf')
diff --git a/arkindex/project/rq_overrides.py b/arkindex/project/rq_overrides.py
index 9b45dc9ccc..f9b5c80cba 100644
--- a/arkindex/project/rq_overrides.py
+++ b/arkindex/project/rq_overrides.py
@@ -1,7 +1,9 @@
 from typing import Optional
 
+from django_rq import get_connection
 from django_rq.queues import DjangoRQ
 from rq.compat import as_text, decode_redis_hash
+from rq.exceptions import NoSuchJobError
 from rq.job import Job as BaseJob
 from rq.registry import BaseRegistry
 
@@ -18,6 +20,17 @@ def as_float(value) -> Optional[float]:
     return float(value)
 
 
+def get_existing_job(job_id):
+    """
+    Returns a job identified by its ID, None otherwise
+    """
+    try:
+        job = Job.fetch(job_id, connection=get_connection())
+    except NoSuchJobError:
+        return None
+    return job
+
+
 class Job(BaseJob):
     """
     Extension of RQ jobs to provide description updates and completion percentage
diff --git a/arkindex/project/tests/config_samples/defaults.yaml b/arkindex/project/tests/config_samples/defaults.yaml
index 54b67ee979..e4f6a03daf 100644
--- a/arkindex/project/tests/config_samples/defaults.yaml
+++ b/arkindex/project/tests/config_samples/defaults.yaml
@@ -57,6 +57,7 @@ job_timeouts:
   initialize_activity: 3600
   move_element: 3600
   process_delete: 3600
+  reindex_corpus: 7200
   worker_results_delete: 3600
 jwt_signing_key: null
 local_imageserver_id: 1
diff --git a/arkindex/project/tests/config_samples/errors.yaml b/arkindex/project/tests/config_samples/errors.yaml
index 95c6c6c5aa..c8f2a4cef1 100644
--- a/arkindex/project/tests/config_samples/errors.yaml
+++ b/arkindex/project/tests/config_samples/errors.yaml
@@ -40,6 +40,7 @@ job_timeouts:
   export_corpus: []
   move_element:
     a: b
+  reindex_corpus: {}
   worker_results_delete: null
 jwt_signing_key: null
 local_imageserver_id: 1
diff --git a/arkindex/project/tests/config_samples/expected_errors.yaml b/arkindex/project/tests/config_samples/expected_errors.yaml
index ace4d8ec7a..9f9cdfe45d 100644
--- a/arkindex/project/tests/config_samples/expected_errors.yaml
+++ b/arkindex/project/tests/config_samples/expected_errors.yaml
@@ -21,6 +21,7 @@ job_timeouts:
   corpus_delete: "invalid literal for int() with base 10: 'lol'"
   export_corpus: "int() argument must be a string, a bytes-like object or a number, not 'list'"
   move_element: "int() argument must be a string, a bytes-like object or a number, not 'dict'"
+  reindex_corpus: "int() argument must be a string, a bytes-like object or a number, not 'dict'"
   worker_results_delete: "int() argument must be a string, a bytes-like object or a number, not 'NoneType'"
 ponos:
   artifact_max_size: cannot convert float NaN to integer
diff --git a/arkindex/project/tests/config_samples/override.yaml b/arkindex/project/tests/config_samples/override.yaml
index 7f0cce913a..562963c8c7 100644
--- a/arkindex/project/tests/config_samples/override.yaml
+++ b/arkindex/project/tests/config_samples/override.yaml
@@ -71,7 +71,8 @@ job_timeouts:
   initialize_activity: 4
   move_element: 5
   process_delete: 6
-  worker_results_delete: 7
+  reindex_corpus: 7
+  worker_results_delete: 8
 jwt_signing_key: deadbeef
 local_imageserver_id: 45
 ponos:
diff --git a/arkindex/project/triggers.py b/arkindex/project/triggers.py
index e569952dae..c73338e817 100644
--- a/arkindex/project/triggers.py
+++ b/arkindex/project/triggers.py
@@ -121,6 +121,19 @@ def add_parent_selection(corpus_id: UUID, parent: Element, user_id: int) -> None
     )
 
 
+def reindex_corpus(corpus_id: UUID, user_id: int, drop: bool = True, job_id: Optional[str] = None) -> None:
+    """
+    Index elements of a given project
+    """
+    documents_tasks.reindex_corpus.delay(
+        corpus_id=corpus_id,
+        drop=drop,
+        user_id=user_id,
+        description=f'Reindexing project {corpus_id}',
+        job_id=job_id,
+    )
+
+
 def initialize_activity(process: Process):
     """
     Initialize activity on every process elements for worker versions that are part of its workflow
diff --git a/arkindex/templates/reindex_corpus.html b/arkindex/templates/reindex_corpus.html
new file mode 100644
index 0000000000..581e0a2ca5
--- /dev/null
+++ b/arkindex/templates/reindex_corpus.html
@@ -0,0 +1,10 @@
+{% autoescape off %}
+Hello {{ user.display_name }},
+
+The search index build you started on project {{ corpus.name }} has been finished successfully.
+
+Indexed elements can be explored from the search interface on Arkindex.
+
+--
+Arkindex
+{% endautoescape %}
-- 
GitLab