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