mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2025-07-28 18:24:38 -05:00
Enhancement: system status report sanity check, simpler classifier check, styling updates (#9106)
This commit is contained in:
@@ -3,7 +3,6 @@ from __future__ import annotations
|
||||
import logging
|
||||
import pickle
|
||||
import re
|
||||
import time
|
||||
import warnings
|
||||
from hashlib import sha256
|
||||
from pathlib import Path
|
||||
@@ -144,19 +143,6 @@ class DocumentClassifier:
|
||||
):
|
||||
raise IncompatibleClassifierVersionError("sklearn version update")
|
||||
|
||||
def set_last_checked(self) -> None:
|
||||
# save a timestamp of the last time we checked for retraining to a file
|
||||
with Path(settings.MODEL_FILE.with_suffix(".last_checked")).open("w") as f:
|
||||
f.write(str(time.time()))
|
||||
|
||||
def get_last_checked(self) -> float | None:
|
||||
# load the timestamp of the last time we checked for retraining
|
||||
try:
|
||||
with Path(settings.MODEL_FILE.with_suffix(".last_checked")).open("r") as f:
|
||||
return float(f.read())
|
||||
except FileNotFoundError: # pragma: no cover
|
||||
return None
|
||||
|
||||
def save(self) -> None:
|
||||
target_file: Path = settings.MODEL_FILE
|
||||
target_file_temp: Path = target_file.with_suffix(".pickle.part")
|
||||
@@ -177,7 +163,6 @@ class DocumentClassifier:
|
||||
pickle.dump(self.storage_path_classifier, f)
|
||||
|
||||
target_file_temp.rename(target_file)
|
||||
self.set_last_checked()
|
||||
|
||||
def train(self) -> bool:
|
||||
# Get non-inbox documents
|
||||
@@ -246,7 +231,6 @@ class DocumentClassifier:
|
||||
and self.last_doc_change_time >= latest_doc_change
|
||||
) and self.last_auto_type_hash == hasher.digest():
|
||||
logger.info("No updates since last training")
|
||||
self.set_last_checked()
|
||||
# Set the classifier information into the cache
|
||||
# Caching for 50 minutes, so slightly less than the normal retrain time
|
||||
cache.set(
|
||||
|
@@ -37,6 +37,7 @@ from documents.models import CustomFieldInstance
|
||||
from documents.models import Document
|
||||
from documents.models import DocumentType
|
||||
from documents.models import Log
|
||||
from documents.models import PaperlessTask
|
||||
from documents.models import ShareLink
|
||||
from documents.models import StoragePath
|
||||
from documents.models import Tag
|
||||
@@ -775,6 +776,21 @@ class ShareLinkFilterSet(FilterSet):
|
||||
}
|
||||
|
||||
|
||||
class PaperlessTaskFilterSet(FilterSet):
|
||||
acknowledged = BooleanFilter(
|
||||
label="Acknowledged",
|
||||
field_name="acknowledged",
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = PaperlessTask
|
||||
fields = {
|
||||
"type": ["exact"],
|
||||
"task_name": ["exact"],
|
||||
"status": ["exact"],
|
||||
}
|
||||
|
||||
|
||||
class ObjectOwnedOrGrantedPermissionsFilter(ObjectPermissionsFilter):
|
||||
"""
|
||||
A filter backend that limits results to those where the requesting user
|
||||
|
@@ -10,4 +10,4 @@ class Command(BaseCommand):
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
train_classifier()
|
||||
train_classifier(scheduled=False)
|
||||
|
@@ -12,6 +12,6 @@ class Command(ProgressBarMixin, BaseCommand):
|
||||
|
||||
def handle(self, *args, **options):
|
||||
self.handle_progress_bar_mixin(**options)
|
||||
messages = check_sanity(progress=self.use_progress_bar)
|
||||
messages = check_sanity(progress=self.use_progress_bar, scheduled=False)
|
||||
|
||||
messages.log_messages()
|
||||
|
@@ -1,4 +1,4 @@
|
||||
# Generated by Django 5.1.6 on 2025-02-20 04:55
|
||||
# Generated by Django 5.1.6 on 2025-02-21 16:34
|
||||
|
||||
import multiselectfield.db.fields
|
||||
from django.db import migrations
|
||||
@@ -16,12 +16,52 @@ def update_workflow_sources(apps, schema_editor):
|
||||
trigger.save()
|
||||
|
||||
|
||||
def make_existing_tasks_consume_auto(apps, schema_editor):
|
||||
PaperlessTask = apps.get_model("documents", "PaperlessTask")
|
||||
PaperlessTask.objects.all().update(type="auto_task", task_name="consume_file")
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("documents", "1062_alter_savedviewfilterrule_rule_type"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="paperlesstask",
|
||||
name="type",
|
||||
field=models.CharField(
|
||||
choices=[
|
||||
("auto_task", "Auto Task"),
|
||||
("scheduled_task", "Scheduled Task"),
|
||||
("manual_task", "Manual Task"),
|
||||
],
|
||||
default="auto_task",
|
||||
help_text="The type of task that was run",
|
||||
max_length=30,
|
||||
verbose_name="Task Type",
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="paperlesstask",
|
||||
name="task_name",
|
||||
field=models.CharField(
|
||||
choices=[
|
||||
("consume_file", "Consume File"),
|
||||
("train_classifier", "Train Classifier"),
|
||||
("check_sanity", "Check Sanity"),
|
||||
("index_optimize", "Index Optimize"),
|
||||
],
|
||||
help_text="Name of the task that was run",
|
||||
max_length=255,
|
||||
null=True,
|
||||
verbose_name="Task Name",
|
||||
),
|
||||
),
|
||||
migrations.RunPython(
|
||||
code=make_existing_tasks_consume_auto,
|
||||
reverse_code=migrations.RunPython.noop,
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="workflowactionwebhook",
|
||||
name="url",
|
@@ -650,6 +650,17 @@ class PaperlessTask(ModelWithOwner):
|
||||
ALL_STATES = sorted(states.ALL_STATES)
|
||||
TASK_STATE_CHOICES = sorted(zip(ALL_STATES, ALL_STATES))
|
||||
|
||||
class TaskType(models.TextChoices):
|
||||
AUTO = ("auto_task", _("Auto Task"))
|
||||
SCHEDULED_TASK = ("scheduled_task", _("Scheduled Task"))
|
||||
MANUAL_TASK = ("manual_task", _("Manual Task"))
|
||||
|
||||
class TaskName(models.TextChoices):
|
||||
CONSUME_FILE = ("consume_file", _("Consume File"))
|
||||
TRAIN_CLASSIFIER = ("train_classifier", _("Train Classifier"))
|
||||
CHECK_SANITY = ("check_sanity", _("Check Sanity"))
|
||||
INDEX_OPTIMIZE = ("index_optimize", _("Index Optimize"))
|
||||
|
||||
task_id = models.CharField(
|
||||
max_length=255,
|
||||
unique=True,
|
||||
@@ -673,8 +684,9 @@ class PaperlessTask(ModelWithOwner):
|
||||
task_name = models.CharField(
|
||||
null=True,
|
||||
max_length=255,
|
||||
choices=TaskName.choices,
|
||||
verbose_name=_("Task Name"),
|
||||
help_text=_("Name of the Task which was run"),
|
||||
help_text=_("Name of the task that was run"),
|
||||
)
|
||||
|
||||
status = models.CharField(
|
||||
@@ -684,24 +696,28 @@ class PaperlessTask(ModelWithOwner):
|
||||
verbose_name=_("Task State"),
|
||||
help_text=_("Current state of the task being run"),
|
||||
)
|
||||
|
||||
date_created = models.DateTimeField(
|
||||
null=True,
|
||||
default=timezone.now,
|
||||
verbose_name=_("Created DateTime"),
|
||||
help_text=_("Datetime field when the task result was created in UTC"),
|
||||
)
|
||||
|
||||
date_started = models.DateTimeField(
|
||||
null=True,
|
||||
default=None,
|
||||
verbose_name=_("Started DateTime"),
|
||||
help_text=_("Datetime field when the task was started in UTC"),
|
||||
)
|
||||
|
||||
date_done = models.DateTimeField(
|
||||
null=True,
|
||||
default=None,
|
||||
verbose_name=_("Completed DateTime"),
|
||||
help_text=_("Datetime field when the task was completed in UTC"),
|
||||
)
|
||||
|
||||
result = models.TextField(
|
||||
null=True,
|
||||
default=None,
|
||||
@@ -711,6 +727,14 @@ class PaperlessTask(ModelWithOwner):
|
||||
),
|
||||
)
|
||||
|
||||
type = models.CharField(
|
||||
max_length=30,
|
||||
choices=TaskType.choices,
|
||||
default=TaskType.AUTO,
|
||||
verbose_name=_("Task Type"),
|
||||
help_text=_("The type of task that was run"),
|
||||
)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"Task {self.task_id}"
|
||||
|
||||
|
@@ -1,13 +1,17 @@
|
||||
import hashlib
|
||||
import logging
|
||||
import uuid
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
from typing import Final
|
||||
|
||||
from celery import states
|
||||
from django.conf import settings
|
||||
from django.utils import timezone
|
||||
from tqdm import tqdm
|
||||
|
||||
from documents.models import Document
|
||||
from documents.models import PaperlessTask
|
||||
|
||||
|
||||
class SanityCheckMessages:
|
||||
@@ -57,7 +61,17 @@ class SanityCheckFailedException(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def check_sanity(*, progress=False) -> SanityCheckMessages:
|
||||
def check_sanity(*, progress=False, scheduled=True) -> SanityCheckMessages:
|
||||
paperless_task = PaperlessTask.objects.create(
|
||||
task_id=uuid.uuid4(),
|
||||
type=PaperlessTask.TaskType.SCHEDULED_TASK
|
||||
if scheduled
|
||||
else PaperlessTask.TaskType.MANUAL_TASK,
|
||||
task_name=PaperlessTask.TaskName.CHECK_SANITY,
|
||||
status=states.STARTED,
|
||||
date_created=timezone.now(),
|
||||
date_started=timezone.now(),
|
||||
)
|
||||
messages = SanityCheckMessages()
|
||||
|
||||
present_files = {
|
||||
@@ -142,4 +156,11 @@ def check_sanity(*, progress=False) -> SanityCheckMessages:
|
||||
for extra_file in present_files:
|
||||
messages.warning(None, f"Orphaned file in media dir: {extra_file}")
|
||||
|
||||
paperless_task.status = states.SUCCESS if not messages.has_error else states.FAILURE
|
||||
# result is concatenated messages
|
||||
paperless_task.result = f"{len(messages)} issues found."
|
||||
if messages.has_error:
|
||||
paperless_task.result += " Check logs for details."
|
||||
paperless_task.date_done = timezone.now()
|
||||
paperless_task.save(update_fields=["status", "result", "date_done"])
|
||||
return messages
|
||||
|
@@ -1710,6 +1710,7 @@ class TasksViewSerializer(OwnedObjectSerializer):
|
||||
fields = (
|
||||
"id",
|
||||
"task_id",
|
||||
"task_name",
|
||||
"task_file_name",
|
||||
"date_created",
|
||||
"date_done",
|
||||
@@ -1721,12 +1722,6 @@ class TasksViewSerializer(OwnedObjectSerializer):
|
||||
"owner",
|
||||
)
|
||||
|
||||
type = serializers.SerializerMethodField()
|
||||
|
||||
def get_type(self, obj) -> str:
|
||||
# just file tasks, for now
|
||||
return "file"
|
||||
|
||||
related_document = serializers.SerializerMethodField()
|
||||
created_doc_re = re.compile(r"New document id (\d+) created")
|
||||
duplicate_doc_re = re.compile(r"It is a duplicate of .* \(#(\d+)\)")
|
||||
@@ -1734,24 +1729,33 @@ class TasksViewSerializer(OwnedObjectSerializer):
|
||||
def get_related_document(self, obj) -> str | None:
|
||||
result = None
|
||||
re = None
|
||||
match obj.status:
|
||||
case states.SUCCESS:
|
||||
re = self.created_doc_re
|
||||
case states.FAILURE:
|
||||
re = (
|
||||
self.duplicate_doc_re
|
||||
if "existing document is in the trash" not in obj.result
|
||||
else None
|
||||
)
|
||||
if re is not None:
|
||||
try:
|
||||
result = re.search(obj.result).group(1)
|
||||
except Exception:
|
||||
pass
|
||||
if obj.result:
|
||||
match obj.status:
|
||||
case states.SUCCESS:
|
||||
re = self.created_doc_re
|
||||
case states.FAILURE:
|
||||
re = (
|
||||
self.duplicate_doc_re
|
||||
if "existing document is in the trash" not in obj.result
|
||||
else None
|
||||
)
|
||||
if re is not None:
|
||||
try:
|
||||
result = re.search(obj.result).group(1)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return result
|
||||
|
||||
|
||||
class RunTaskViewSerializer(serializers.Serializer):
|
||||
task_name = serializers.ChoiceField(
|
||||
choices=PaperlessTask.TaskName.choices,
|
||||
label="Task Name",
|
||||
write_only=True,
|
||||
)
|
||||
|
||||
|
||||
class AcknowledgeTasksViewSerializer(serializers.Serializer):
|
||||
tasks = serializers.ListField(
|
||||
required=True,
|
||||
|
@@ -1255,10 +1255,11 @@ def before_task_publish_handler(sender=None, headers=None, body=None, **kwargs):
|
||||
user_id = overrides.owner_id if overrides else None
|
||||
|
||||
PaperlessTask.objects.create(
|
||||
type=PaperlessTask.TaskType.AUTO,
|
||||
task_id=headers["id"],
|
||||
status=states.PENDING,
|
||||
task_file_name=task_file_name,
|
||||
task_name=headers["task"],
|
||||
task_name=PaperlessTask.TaskName.CONSUME_FILE,
|
||||
result=None,
|
||||
date_created=timezone.now(),
|
||||
date_started=None,
|
||||
|
@@ -9,6 +9,7 @@ from tempfile import TemporaryDirectory
|
||||
import tqdm
|
||||
from celery import Task
|
||||
from celery import shared_task
|
||||
from celery import states
|
||||
from django.conf import settings
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.db import models
|
||||
@@ -35,6 +36,7 @@ from documents.models import Correspondent
|
||||
from documents.models import CustomFieldInstance
|
||||
from documents.models import Document
|
||||
from documents.models import DocumentType
|
||||
from documents.models import PaperlessTask
|
||||
from documents.models import StoragePath
|
||||
from documents.models import Tag
|
||||
from documents.models import Workflow
|
||||
@@ -74,19 +76,34 @@ def index_reindex(*, progress_bar_disable=False):
|
||||
|
||||
|
||||
@shared_task
|
||||
def train_classifier():
|
||||
def train_classifier(*, scheduled=True):
|
||||
task = PaperlessTask.objects.create(
|
||||
type=PaperlessTask.TaskType.SCHEDULED_TASK
|
||||
if scheduled
|
||||
else PaperlessTask.TaskType.MANUAL_TASK,
|
||||
task_id=uuid.uuid4(),
|
||||
task_name=PaperlessTask.TaskName.TRAIN_CLASSIFIER,
|
||||
status=states.STARTED,
|
||||
date_created=timezone.now(),
|
||||
date_started=timezone.now(),
|
||||
)
|
||||
if (
|
||||
not Tag.objects.filter(matching_algorithm=Tag.MATCH_AUTO).exists()
|
||||
and not DocumentType.objects.filter(matching_algorithm=Tag.MATCH_AUTO).exists()
|
||||
and not Correspondent.objects.filter(matching_algorithm=Tag.MATCH_AUTO).exists()
|
||||
and not StoragePath.objects.filter(matching_algorithm=Tag.MATCH_AUTO).exists()
|
||||
):
|
||||
logger.info("No automatic matching items, not training")
|
||||
result = "No automatic matching items, not training"
|
||||
logger.info(result)
|
||||
# Special case, items were once auto and trained, so remove the model
|
||||
# and prevent its use again
|
||||
if settings.MODEL_FILE.exists():
|
||||
logger.info(f"Removing {settings.MODEL_FILE} so it won't be used")
|
||||
settings.MODEL_FILE.unlink()
|
||||
task.status = states.SUCCESS
|
||||
task.result = result
|
||||
task.date_done = timezone.now()
|
||||
task.save()
|
||||
return
|
||||
|
||||
classifier = load_classifier()
|
||||
@@ -100,11 +117,19 @@ def train_classifier():
|
||||
f"Saving updated classifier model to {settings.MODEL_FILE}...",
|
||||
)
|
||||
classifier.save()
|
||||
task.result = "Training completed successfully"
|
||||
else:
|
||||
logger.debug("Training data unchanged.")
|
||||
task.result = "Training data unchanged"
|
||||
|
||||
task.status = states.SUCCESS
|
||||
task.date_done = timezone.now()
|
||||
task.save(update_fields=["status", "result", "date_done"])
|
||||
|
||||
except Exception as e:
|
||||
logger.warning("Classifier error: " + str(e))
|
||||
task.status = states.FAILURE
|
||||
task.result = str(e)
|
||||
|
||||
|
||||
@shared_task(bind=True)
|
||||
@@ -176,13 +201,16 @@ def consume_file(
|
||||
|
||||
|
||||
@shared_task
|
||||
def sanity_check():
|
||||
messages = sanity_checker.check_sanity()
|
||||
def sanity_check(*, scheduled=True, raise_on_error=True):
|
||||
messages = sanity_checker.check_sanity(scheduled=scheduled)
|
||||
|
||||
messages.log_messages()
|
||||
|
||||
if messages.has_error:
|
||||
raise SanityCheckFailedException("Sanity check failed with errors. See log.")
|
||||
message = "Sanity check exited with errors. See log."
|
||||
if raise_on_error:
|
||||
raise SanityCheckFailedException(message)
|
||||
return message
|
||||
elif messages.has_warning:
|
||||
return "Sanity check exited with warnings. See log."
|
||||
elif len(messages) > 0:
|
||||
|
@@ -1,18 +1,14 @@
|
||||
import os
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
from celery import states
|
||||
from django.contrib.auth.models import User
|
||||
from django.test import override_settings
|
||||
from rest_framework import status
|
||||
from rest_framework.test import APITestCase
|
||||
|
||||
from documents.classifier import ClassifierModelCorruptError
|
||||
from documents.classifier import DocumentClassifier
|
||||
from documents.classifier import load_classifier
|
||||
from documents.models import Document
|
||||
from documents.models import Tag
|
||||
from documents.models import PaperlessTask
|
||||
from paperless import version
|
||||
|
||||
|
||||
@@ -193,7 +189,6 @@ class TestSystemStatus(APITestCase):
|
||||
self.assertEqual(response.data["tasks"]["index_status"], "ERROR")
|
||||
self.assertIsNotNone(response.data["tasks"]["index_error"])
|
||||
|
||||
@override_settings(DATA_DIR=Path("/tmp/does_not_exist/data/"))
|
||||
def test_system_status_classifier_ok(self):
|
||||
"""
|
||||
GIVEN:
|
||||
@@ -203,9 +198,11 @@ class TestSystemStatus(APITestCase):
|
||||
THEN:
|
||||
- The response contains an OK classifier status
|
||||
"""
|
||||
load_classifier()
|
||||
test_classifier = DocumentClassifier()
|
||||
test_classifier.save()
|
||||
PaperlessTask.objects.create(
|
||||
type=PaperlessTask.TaskType.SCHEDULED_TASK,
|
||||
status=states.SUCCESS,
|
||||
task_name=PaperlessTask.TaskName.TRAIN_CLASSIFIER,
|
||||
)
|
||||
self.client.force_login(self.user)
|
||||
response = self.client.get(self.ENDPOINT)
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
@@ -215,73 +212,101 @@ class TestSystemStatus(APITestCase):
|
||||
def test_system_status_classifier_warning(self):
|
||||
"""
|
||||
GIVEN:
|
||||
- The classifier does not exist yet
|
||||
- > 0 documents and tags with auto matching exist
|
||||
- No classifier task is found
|
||||
WHEN:
|
||||
- The user requests the system status
|
||||
THEN:
|
||||
- The response contains an WARNING classifier status
|
||||
- The response contains a WARNING classifier status
|
||||
"""
|
||||
with override_settings(MODEL_FILE=Path("does_not_exist")):
|
||||
Document.objects.create(
|
||||
title="Test Document",
|
||||
)
|
||||
Tag.objects.create(name="Test Tag", matching_algorithm=Tag.MATCH_AUTO)
|
||||
self.client.force_login(self.user)
|
||||
response = self.client.get(self.ENDPOINT)
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(response.data["tasks"]["classifier_status"], "WARNING")
|
||||
self.assertIsNotNone(response.data["tasks"]["classifier_error"])
|
||||
self.client.force_login(self.user)
|
||||
response = self.client.get(self.ENDPOINT)
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(
|
||||
response.data["tasks"]["classifier_status"],
|
||||
"WARNING",
|
||||
)
|
||||
|
||||
@mock.patch(
|
||||
"documents.classifier.load_classifier",
|
||||
side_effect=ClassifierModelCorruptError(),
|
||||
)
|
||||
def test_system_status_classifier_error(self, mock_load_classifier):
|
||||
def test_system_status_classifier_error(self):
|
||||
"""
|
||||
GIVEN:
|
||||
- The classifier does exist but is corrupt
|
||||
- > 0 documents and tags with auto matching exist
|
||||
- An error occurred while loading the classifier
|
||||
WHEN:
|
||||
- The user requests the system status
|
||||
THEN:
|
||||
- The response contains an ERROR classifier status
|
||||
"""
|
||||
with (
|
||||
tempfile.NamedTemporaryFile(
|
||||
dir="/tmp",
|
||||
delete=False,
|
||||
) as does_exist,
|
||||
override_settings(MODEL_FILE=Path(does_exist.name)),
|
||||
):
|
||||
Document.objects.create(
|
||||
title="Test Document",
|
||||
)
|
||||
Tag.objects.create(
|
||||
name="Test Tag",
|
||||
matching_algorithm=Tag.MATCH_AUTO,
|
||||
)
|
||||
self.client.force_login(self.user)
|
||||
response = self.client.get(self.ENDPOINT)
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(
|
||||
response.data["tasks"]["classifier_status"],
|
||||
"ERROR",
|
||||
)
|
||||
self.assertIsNotNone(response.data["tasks"]["classifier_error"])
|
||||
PaperlessTask.objects.create(
|
||||
type=PaperlessTask.TaskType.SCHEDULED_TASK,
|
||||
status=states.FAILURE,
|
||||
task_name=PaperlessTask.TaskName.TRAIN_CLASSIFIER,
|
||||
result="Classifier training failed",
|
||||
)
|
||||
self.client.force_login(self.user)
|
||||
response = self.client.get(self.ENDPOINT)
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(
|
||||
response.data["tasks"]["classifier_status"],
|
||||
"ERROR",
|
||||
)
|
||||
self.assertIsNotNone(response.data["tasks"]["classifier_error"])
|
||||
|
||||
def test_system_status_classifier_ok_no_objects(self):
|
||||
def test_system_status_sanity_check_ok(self):
|
||||
"""
|
||||
GIVEN:
|
||||
- The classifier does not exist (and should not)
|
||||
- No documents nor objects with auto matching exist
|
||||
- The sanity check is successful
|
||||
WHEN:
|
||||
- The user requests the system status
|
||||
THEN:
|
||||
- The response contains an OK classifier status
|
||||
- The response contains an OK sanity check status
|
||||
"""
|
||||
with override_settings(MODEL_FILE=Path("does_not_exist")):
|
||||
self.client.force_login(self.user)
|
||||
response = self.client.get(self.ENDPOINT)
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(response.data["tasks"]["classifier_status"], "OK")
|
||||
PaperlessTask.objects.create(
|
||||
type=PaperlessTask.TaskType.SCHEDULED_TASK,
|
||||
status=states.SUCCESS,
|
||||
task_name=PaperlessTask.TaskName.CHECK_SANITY,
|
||||
)
|
||||
self.client.force_login(self.user)
|
||||
response = self.client.get(self.ENDPOINT)
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(response.data["tasks"]["sanity_check_status"], "OK")
|
||||
self.assertIsNone(response.data["tasks"]["sanity_check_error"])
|
||||
|
||||
def test_system_status_sanity_check_warning(self):
|
||||
"""
|
||||
GIVEN:
|
||||
- No sanity check task is found
|
||||
WHEN:
|
||||
- The user requests the system status
|
||||
THEN:
|
||||
- The response contains a WARNING sanity check status
|
||||
"""
|
||||
self.client.force_login(self.user)
|
||||
response = self.client.get(self.ENDPOINT)
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(
|
||||
response.data["tasks"]["sanity_check_status"],
|
||||
"WARNING",
|
||||
)
|
||||
|
||||
def test_system_status_sanity_check_error(self):
|
||||
"""
|
||||
GIVEN:
|
||||
- The sanity check failed
|
||||
WHEN:
|
||||
- The user requests the system status
|
||||
THEN:
|
||||
- The response contains an ERROR sanity check status
|
||||
"""
|
||||
PaperlessTask.objects.create(
|
||||
type=PaperlessTask.TaskType.SCHEDULED_TASK,
|
||||
status=states.FAILURE,
|
||||
task_name=PaperlessTask.TaskName.CHECK_SANITY,
|
||||
result="5 issues found.",
|
||||
)
|
||||
self.client.force_login(self.user)
|
||||
response = self.client.get(self.ENDPOINT)
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(
|
||||
response.data["tasks"]["sanity_check_status"],
|
||||
"ERROR",
|
||||
)
|
||||
self.assertIsNotNone(response.data["tasks"]["sanity_check_error"])
|
||||
|
@@ -1,4 +1,5 @@
|
||||
import uuid
|
||||
from unittest import mock
|
||||
|
||||
import celery
|
||||
from django.contrib.auth.models import Permission
|
||||
@@ -8,6 +9,7 @@ from rest_framework.test import APITestCase
|
||||
|
||||
from documents.models import PaperlessTask
|
||||
from documents.tests.utils import DirectoriesMixin
|
||||
from documents.views import TasksViewSet
|
||||
|
||||
|
||||
class TestTasks(DirectoriesMixin, APITestCase):
|
||||
@@ -130,7 +132,7 @@ class TestTasks(DirectoriesMixin, APITestCase):
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
|
||||
response = self.client.get(self.ENDPOINT)
|
||||
response = self.client.get(self.ENDPOINT + "?acknowledged=false")
|
||||
self.assertEqual(len(response.data), 0)
|
||||
|
||||
def test_tasks_owner_aware(self):
|
||||
@@ -246,7 +248,7 @@ class TestTasks(DirectoriesMixin, APITestCase):
|
||||
PaperlessTask.objects.create(
|
||||
task_id=str(uuid.uuid4()),
|
||||
task_file_name="test.pdf",
|
||||
task_name="documents.tasks.some_task",
|
||||
task_name=PaperlessTask.TaskName.CONSUME_FILE,
|
||||
status=celery.states.SUCCESS,
|
||||
)
|
||||
|
||||
@@ -272,7 +274,7 @@ class TestTasks(DirectoriesMixin, APITestCase):
|
||||
PaperlessTask.objects.create(
|
||||
task_id=str(uuid.uuid4()),
|
||||
task_file_name="anothertest.pdf",
|
||||
task_name="documents.tasks.some_task",
|
||||
task_name=PaperlessTask.TaskName.CONSUME_FILE,
|
||||
status=celery.states.SUCCESS,
|
||||
)
|
||||
|
||||
@@ -309,3 +311,62 @@ class TestTasks(DirectoriesMixin, APITestCase):
|
||||
returned_data = response.data[0]
|
||||
|
||||
self.assertEqual(returned_data["related_document"], "1234")
|
||||
|
||||
def test_run_train_classifier_task(self):
|
||||
"""
|
||||
GIVEN:
|
||||
- A superuser
|
||||
WHEN:
|
||||
- API call is made to run the train classifier task
|
||||
THEN:
|
||||
- The task is run
|
||||
"""
|
||||
mock_train_classifier = mock.Mock(return_value="Task started")
|
||||
TasksViewSet.TASK_AND_ARGS_BY_NAME = {
|
||||
PaperlessTask.TaskName.TRAIN_CLASSIFIER: (
|
||||
mock_train_classifier,
|
||||
{"scheduled": False},
|
||||
),
|
||||
}
|
||||
response = self.client.post(
|
||||
self.ENDPOINT + "run/",
|
||||
{"task_name": PaperlessTask.TaskName.TRAIN_CLASSIFIER},
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(response.data, {"result": "Task started"})
|
||||
mock_train_classifier.assert_called_once_with(scheduled=False)
|
||||
|
||||
# mock error
|
||||
mock_train_classifier.reset_mock()
|
||||
mock_train_classifier.side_effect = Exception("Error")
|
||||
response = self.client.post(
|
||||
self.ENDPOINT + "run/",
|
||||
{"task_name": PaperlessTask.TaskName.TRAIN_CLASSIFIER},
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
mock_train_classifier.assert_called_once_with(scheduled=False)
|
||||
|
||||
@mock.patch("documents.tasks.sanity_check")
|
||||
def test_run_task_requires_superuser(self, mock_check_sanity):
|
||||
"""
|
||||
GIVEN:
|
||||
- A regular user
|
||||
WHEN:
|
||||
- API call is made to run a task
|
||||
THEN:
|
||||
- The task is not run
|
||||
"""
|
||||
regular_user = User.objects.create_user(username="test")
|
||||
regular_user.user_permissions.add(*Permission.objects.all())
|
||||
self.client.logout()
|
||||
self.client.force_authenticate(user=regular_user)
|
||||
|
||||
response = self.client.post(
|
||||
self.ENDPOINT + "run/",
|
||||
{"task_name": PaperlessTask.TaskName.CHECK_SANITY},
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
||||
mock_check_sanity.assert_not_called()
|
||||
|
@@ -68,7 +68,7 @@ class TestTaskSignalHandler(DirectoriesMixin, TestCase):
|
||||
self.assertIsNotNone(task)
|
||||
self.assertEqual(headers["id"], task.task_id)
|
||||
self.assertEqual("hello-999.pdf", task.task_file_name)
|
||||
self.assertEqual("documents.tasks.consume_file", task.task_name)
|
||||
self.assertEqual(PaperlessTask.TaskName.CONSUME_FILE, task.task_name)
|
||||
self.assertEqual(1, task.owner_id)
|
||||
self.assertEqual(celery.states.PENDING, task.status)
|
||||
|
||||
|
@@ -118,6 +118,19 @@ class TestSanityCheck(DirectoriesMixin, TestCase):
|
||||
self.assertRaises(SanityCheckFailedException, tasks.sanity_check)
|
||||
m.assert_called_once()
|
||||
|
||||
@mock.patch("documents.tasks.sanity_checker.check_sanity")
|
||||
def test_sanity_check_error_no_raise(self, m):
|
||||
messages = SanityCheckMessages()
|
||||
messages.error(None, "Some error")
|
||||
m.return_value = messages
|
||||
# No exception should be raised
|
||||
result = tasks.sanity_check(raise_on_error=False)
|
||||
self.assertEqual(
|
||||
result,
|
||||
"Sanity check exited with errors. See log.",
|
||||
)
|
||||
m.assert_called_once()
|
||||
|
||||
@mock.patch("documents.tasks.sanity_checker.check_sanity")
|
||||
def test_sanity_check_warning(self, m):
|
||||
messages = SanityCheckMessages()
|
||||
|
@@ -14,6 +14,7 @@ from urllib.parse import urlparse
|
||||
|
||||
import httpx
|
||||
import pathvalidate
|
||||
from celery import states
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import Group
|
||||
from django.contrib.auth.models import User
|
||||
@@ -103,6 +104,7 @@ from documents.filters import DocumentsOrderingFilter
|
||||
from documents.filters import DocumentTypeFilterSet
|
||||
from documents.filters import ObjectOwnedOrGrantedPermissionsFilter
|
||||
from documents.filters import ObjectOwnedPermissionsFilter
|
||||
from documents.filters import PaperlessTaskFilterSet
|
||||
from documents.filters import ShareLinkFilterSet
|
||||
from documents.filters import StoragePathFilterSet
|
||||
from documents.filters import TagFilterSet
|
||||
@@ -144,6 +146,7 @@ from documents.serialisers import DocumentListSerializer
|
||||
from documents.serialisers import DocumentSerializer
|
||||
from documents.serialisers import DocumentTypeSerializer
|
||||
from documents.serialisers import PostDocumentSerializer
|
||||
from documents.serialisers import RunTaskViewSerializer
|
||||
from documents.serialisers import SavedViewSerializer
|
||||
from documents.serialisers import SearchResultSerializer
|
||||
from documents.serialisers import ShareLinkSerializer
|
||||
@@ -160,6 +163,9 @@ from documents.serialisers import WorkflowTriggerSerializer
|
||||
from documents.signals import document_updated
|
||||
from documents.tasks import consume_file
|
||||
from documents.tasks import empty_trash
|
||||
from documents.tasks import index_optimize
|
||||
from documents.tasks import sanity_check
|
||||
from documents.tasks import train_classifier
|
||||
from documents.templating.filepath import validate_filepath_template_and_render
|
||||
from paperless import version
|
||||
from paperless.celery import app as celery_app
|
||||
@@ -2276,16 +2282,27 @@ class RemoteVersionView(GenericAPIView):
|
||||
class TasksViewSet(ReadOnlyModelViewSet):
|
||||
permission_classes = (IsAuthenticated, PaperlessObjectPermissions)
|
||||
serializer_class = TasksViewSerializer
|
||||
filter_backends = (ObjectOwnedOrGrantedPermissionsFilter,)
|
||||
filter_backends = (
|
||||
DjangoFilterBackend,
|
||||
OrderingFilter,
|
||||
ObjectOwnedOrGrantedPermissionsFilter,
|
||||
)
|
||||
filterset_class = PaperlessTaskFilterSet
|
||||
|
||||
TASK_AND_ARGS_BY_NAME = {
|
||||
PaperlessTask.TaskName.INDEX_OPTIMIZE: (index_optimize, {}),
|
||||
PaperlessTask.TaskName.TRAIN_CLASSIFIER: (
|
||||
train_classifier,
|
||||
{"scheduled": False},
|
||||
),
|
||||
PaperlessTask.TaskName.CHECK_SANITY: (
|
||||
sanity_check,
|
||||
{"scheduled": False, "raise_on_error": False},
|
||||
),
|
||||
}
|
||||
|
||||
def get_queryset(self):
|
||||
queryset = (
|
||||
PaperlessTask.objects.filter(
|
||||
acknowledged=False,
|
||||
)
|
||||
.order_by("date_created")
|
||||
.reverse()
|
||||
)
|
||||
queryset = PaperlessTask.objects.all().order_by("-date_created")
|
||||
task_id = self.request.query_params.get("task_id")
|
||||
if task_id is not None:
|
||||
queryset = PaperlessTask.objects.filter(task_id=task_id)
|
||||
@@ -2308,6 +2325,25 @@ class TasksViewSet(ReadOnlyModelViewSet):
|
||||
except Exception:
|
||||
return HttpResponseBadRequest()
|
||||
|
||||
@action(methods=["post"], detail=False)
|
||||
def run(self, request):
|
||||
serializer = RunTaskViewSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
task_name = serializer.validated_data.get("task_name")
|
||||
|
||||
if not request.user.is_superuser:
|
||||
return HttpResponseForbidden("Insufficient permissions")
|
||||
|
||||
try:
|
||||
task_func, task_args = self.TASK_AND_ARGS_BY_NAME[task_name]
|
||||
result = task_func(**task_args)
|
||||
return Response({"result": result})
|
||||
except Exception as e:
|
||||
logger.warning(f"An error occurred running task: {e!s}")
|
||||
return HttpResponseServerError(
|
||||
"Error running task, check logs for more detail.",
|
||||
)
|
||||
|
||||
|
||||
class ShareLinkViewSet(ModelViewSet, PassUserMixin):
|
||||
model = ShareLink
|
||||
@@ -2614,6 +2650,14 @@ class CustomFieldViewSet(ModelViewSet):
|
||||
"last_trained": serializers.DateTimeField(),
|
||||
},
|
||||
),
|
||||
"sanity_check": inline_serializer(
|
||||
name="SanityCheck",
|
||||
fields={
|
||||
"status": serializers.CharField(),
|
||||
"error": serializers.CharField(),
|
||||
"last_run": serializers.DateTimeField(),
|
||||
},
|
||||
),
|
||||
},
|
||||
),
|
||||
},
|
||||
@@ -2674,13 +2718,20 @@ class SystemStatusView(PassUserMixin):
|
||||
)
|
||||
redis_error = "Error connecting to redis, check logs for more detail."
|
||||
|
||||
celery_error = None
|
||||
celery_url = None
|
||||
try:
|
||||
celery_ping = celery_app.control.inspect().ping()
|
||||
first_worker_ping = celery_ping[next(iter(celery_ping.keys()))]
|
||||
celery_url = next(iter(celery_ping.keys()))
|
||||
first_worker_ping = celery_ping[celery_url]
|
||||
if first_worker_ping["ok"] == "pong":
|
||||
celery_active = "OK"
|
||||
except Exception:
|
||||
except Exception as e:
|
||||
celery_active = "ERROR"
|
||||
logger.exception(
|
||||
f"System status detected a possible problem while connecting to celery: {e}",
|
||||
)
|
||||
celery_error = "Error connecting to celery, check logs for more detail."
|
||||
|
||||
index_error = None
|
||||
try:
|
||||
@@ -2697,55 +2748,43 @@ class SystemStatusView(PassUserMixin):
|
||||
)
|
||||
index_last_modified = None
|
||||
|
||||
last_trained_task = (
|
||||
PaperlessTask.objects.filter(
|
||||
task_name=PaperlessTask.TaskName.TRAIN_CLASSIFIER,
|
||||
)
|
||||
.order_by("-date_done")
|
||||
.first()
|
||||
)
|
||||
classifier_status = "OK"
|
||||
classifier_error = None
|
||||
classifier_status = None
|
||||
try:
|
||||
classifier = load_classifier(raise_exception=True)
|
||||
if classifier is None:
|
||||
# Make sure classifier should exist
|
||||
docs_queryset = Document.objects.exclude(
|
||||
tags__is_inbox_tag=True,
|
||||
)
|
||||
if (
|
||||
docs_queryset.count() > 0
|
||||
and (
|
||||
Tag.objects.filter(matching_algorithm=Tag.MATCH_AUTO).exists()
|
||||
or DocumentType.objects.filter(
|
||||
matching_algorithm=Tag.MATCH_AUTO,
|
||||
).exists()
|
||||
or Correspondent.objects.filter(
|
||||
matching_algorithm=Tag.MATCH_AUTO,
|
||||
).exists()
|
||||
or StoragePath.objects.filter(
|
||||
matching_algorithm=Tag.MATCH_AUTO,
|
||||
).exists()
|
||||
)
|
||||
and not settings.MODEL_FILE.exists()
|
||||
):
|
||||
# if classifier file doesn't exist just classify as a warning
|
||||
classifier_error = "Classifier file does not exist (yet). Re-training may be pending."
|
||||
classifier_status = "WARNING"
|
||||
raise FileNotFoundError(classifier_error)
|
||||
classifier_status = "OK"
|
||||
classifier_last_trained = (
|
||||
make_aware(
|
||||
datetime.fromtimestamp(classifier.get_last_checked()),
|
||||
)
|
||||
if settings.MODEL_FILE.exists()
|
||||
and classifier.get_last_checked() is not None
|
||||
else None
|
||||
)
|
||||
except Exception as e:
|
||||
if classifier_status is None:
|
||||
classifier_status = "ERROR"
|
||||
classifier_last_trained = None
|
||||
if classifier_error is None:
|
||||
classifier_error = (
|
||||
"Unable to load classifier, check logs for more detail."
|
||||
)
|
||||
logger.exception(
|
||||
f"System status detected a possible problem while loading the classifier: {e}",
|
||||
if last_trained_task is None:
|
||||
classifier_status = "WARNING"
|
||||
classifier_error = "No classifier training tasks found"
|
||||
elif last_trained_task and last_trained_task.status == states.FAILURE:
|
||||
classifier_status = "ERROR"
|
||||
classifier_error = last_trained_task.result
|
||||
classifier_last_trained = (
|
||||
last_trained_task.date_done if last_trained_task else None
|
||||
)
|
||||
|
||||
last_sanity_check = (
|
||||
PaperlessTask.objects.filter(
|
||||
task_name=PaperlessTask.TaskName.CHECK_SANITY,
|
||||
)
|
||||
.order_by("-date_done")
|
||||
.first()
|
||||
)
|
||||
sanity_check_status = "OK"
|
||||
sanity_check_error = None
|
||||
if last_sanity_check is None:
|
||||
sanity_check_status = "WARNING"
|
||||
sanity_check_error = "No sanity check tasks found"
|
||||
elif last_sanity_check and last_sanity_check.status == states.FAILURE:
|
||||
sanity_check_status = "ERROR"
|
||||
sanity_check_error = last_sanity_check.result
|
||||
sanity_check_last_run = (
|
||||
last_sanity_check.date_done if last_sanity_check else None
|
||||
)
|
||||
|
||||
return Response(
|
||||
{
|
||||
@@ -2773,12 +2812,17 @@ class SystemStatusView(PassUserMixin):
|
||||
"redis_status": redis_status,
|
||||
"redis_error": redis_error,
|
||||
"celery_status": celery_active,
|
||||
"celery_url": celery_url,
|
||||
"celery_error": celery_error,
|
||||
"index_status": index_status,
|
||||
"index_last_modified": index_last_modified,
|
||||
"index_error": index_error,
|
||||
"classifier_status": classifier_status,
|
||||
"classifier_last_trained": classifier_last_trained,
|
||||
"classifier_error": classifier_error,
|
||||
"sanity_check_status": sanity_check_status,
|
||||
"sanity_check_last_run": sanity_check_last_run,
|
||||
"sanity_check_error": sanity_check_error,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
@@ -2,7 +2,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: paperless-ngx\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-02-11 18:43-0800\n"
|
||||
"POT-Creation-Date: 2025-02-25 11:07-0800\n"
|
||||
"PO-Revision-Date: 2022-02-17 04:17\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: English\n"
|
||||
@@ -21,67 +21,67 @@ msgstr ""
|
||||
msgid "Documents"
|
||||
msgstr ""
|
||||
|
||||
#: documents/filters.py:369
|
||||
#: documents/filters.py:370
|
||||
msgid "Value must be valid JSON."
|
||||
msgstr ""
|
||||
|
||||
#: documents/filters.py:388
|
||||
#: documents/filters.py:389
|
||||
msgid "Invalid custom field query expression"
|
||||
msgstr ""
|
||||
|
||||
#: documents/filters.py:398
|
||||
#: documents/filters.py:399
|
||||
msgid "Invalid expression list. Must be nonempty."
|
||||
msgstr ""
|
||||
|
||||
#: documents/filters.py:419
|
||||
#: documents/filters.py:420
|
||||
msgid "Invalid logical operator {op!r}"
|
||||
msgstr ""
|
||||
|
||||
#: documents/filters.py:433
|
||||
#: documents/filters.py:434
|
||||
msgid "Maximum number of query conditions exceeded."
|
||||
msgstr ""
|
||||
|
||||
#: documents/filters.py:498
|
||||
#: documents/filters.py:499
|
||||
msgid "{name!r} is not a valid custom field."
|
||||
msgstr ""
|
||||
|
||||
#: documents/filters.py:535
|
||||
#: documents/filters.py:536
|
||||
msgid "{data_type} does not support query expr {expr!r}."
|
||||
msgstr ""
|
||||
|
||||
#: documents/filters.py:643
|
||||
#: documents/filters.py:644
|
||||
msgid "Maximum nesting depth exceeded."
|
||||
msgstr ""
|
||||
|
||||
#: documents/filters.py:813
|
||||
#: documents/filters.py:829
|
||||
msgid "Custom field not found"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:41 documents/models.py:806
|
||||
#: documents/models.py:41 documents/models.py:830
|
||||
msgid "owner"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:58 documents/models.py:1017
|
||||
#: documents/models.py:58 documents/models.py:1041
|
||||
msgid "None"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:59 documents/models.py:1018
|
||||
#: documents/models.py:59 documents/models.py:1042
|
||||
msgid "Any word"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:60 documents/models.py:1019
|
||||
#: documents/models.py:60 documents/models.py:1043
|
||||
msgid "All words"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:61 documents/models.py:1020
|
||||
#: documents/models.py:61 documents/models.py:1044
|
||||
msgid "Exact match"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:62 documents/models.py:1021
|
||||
#: documents/models.py:62 documents/models.py:1045
|
||||
msgid "Regular expression"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:63 documents/models.py:1022
|
||||
#: documents/models.py:63 documents/models.py:1046
|
||||
msgid "Fuzzy word"
|
||||
msgstr ""
|
||||
|
||||
@@ -89,20 +89,20 @@ msgstr ""
|
||||
msgid "Automatic"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:67 documents/models.py:433 documents/models.py:1498
|
||||
#: documents/models.py:67 documents/models.py:433 documents/models.py:1526
|
||||
#: paperless_mail/models.py:23 paperless_mail/models.py:143
|
||||
msgid "name"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:69 documents/models.py:1085
|
||||
#: documents/models.py:69 documents/models.py:1110
|
||||
msgid "match"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:72 documents/models.py:1088
|
||||
#: documents/models.py:72 documents/models.py:1113
|
||||
msgid "matching algorithm"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:77 documents/models.py:1093
|
||||
#: documents/models.py:77 documents/models.py:1118
|
||||
msgid "is insensitive"
|
||||
msgstr ""
|
||||
|
||||
@@ -168,7 +168,7 @@ msgstr ""
|
||||
msgid "title"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:175 documents/models.py:720
|
||||
#: documents/models.py:175 documents/models.py:744
|
||||
msgid "content"
|
||||
msgstr ""
|
||||
|
||||
@@ -206,8 +206,8 @@ msgstr ""
|
||||
msgid "The number of pages of the document."
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:221 documents/models.py:401 documents/models.py:726
|
||||
#: documents/models.py:764 documents/models.py:835 documents/models.py:893
|
||||
#: documents/models.py:221 documents/models.py:401 documents/models.py:750
|
||||
#: documents/models.py:788 documents/models.py:859 documents/models.py:917
|
||||
msgid "created"
|
||||
msgstr ""
|
||||
|
||||
@@ -255,8 +255,8 @@ msgstr ""
|
||||
msgid "The position of this document in your physical document archive."
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:295 documents/models.py:737 documents/models.py:791
|
||||
#: documents/models.py:1541
|
||||
#: documents/models.py:295 documents/models.py:761 documents/models.py:815
|
||||
#: documents/models.py:1569
|
||||
msgid "document"
|
||||
msgstr ""
|
||||
|
||||
@@ -320,11 +320,11 @@ msgstr ""
|
||||
msgid "Title"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:420 documents/models.py:1037
|
||||
#: documents/models.py:420 documents/models.py:1062
|
||||
msgid "Created"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:421 documents/models.py:1036
|
||||
#: documents/models.py:421 documents/models.py:1061
|
||||
msgid "Added"
|
||||
msgstr ""
|
||||
|
||||
@@ -608,581 +608,621 @@ msgstr ""
|
||||
msgid "filter rules"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:656
|
||||
msgid "Task ID"
|
||||
#: documents/models.py:654
|
||||
msgid "Auto Task"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:657
|
||||
msgid "Celery ID for the Task that was run"
|
||||
#: documents/models.py:655
|
||||
msgid "Scheduled Task"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:656
|
||||
msgid "Manual Task"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:659
|
||||
msgid "Consume File"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:660
|
||||
msgid "Train Classifier"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:661
|
||||
msgid "Check Sanity"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:662
|
||||
msgid "Index Optimize"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:667
|
||||
msgid "Task ID"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:668
|
||||
msgid "Celery ID for the Task that was run"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:673
|
||||
msgid "Acknowledged"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:663
|
||||
#: documents/models.py:674
|
||||
msgid "If the task is acknowledged via the frontend or API"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:669
|
||||
#: documents/models.py:680
|
||||
msgid "Task Filename"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:670
|
||||
#: documents/models.py:681
|
||||
msgid "Name of the file which the Task was run for"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:676
|
||||
#: documents/models.py:688
|
||||
msgid "Task Name"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:677
|
||||
msgid "Name of the Task which was run"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:684
|
||||
msgid "Task State"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:685
|
||||
msgid "Current state of the task being run"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:690
|
||||
msgid "Created DateTime"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:691
|
||||
msgid "Datetime field when the task result was created in UTC"
|
||||
#: documents/models.py:689
|
||||
msgid "Name of the task that was run"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:696
|
||||
msgid "Started DateTime"
|
||||
msgid "Task State"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:697
|
||||
msgid "Datetime field when the task was started in UTC"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:702
|
||||
msgid "Completed DateTime"
|
||||
msgid "Current state of the task being run"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:703
|
||||
msgid "Datetime field when the task was completed in UTC"
|
||||
msgid "Created DateTime"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:708
|
||||
msgid "Result Data"
|
||||
#: documents/models.py:704
|
||||
msgid "Datetime field when the task result was created in UTC"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:710
|
||||
msgid "Started DateTime"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:711
|
||||
msgid "Datetime field when the task was started in UTC"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:717
|
||||
msgid "Completed DateTime"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:718
|
||||
msgid "Datetime field when the task was completed in UTC"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:724
|
||||
msgid "Result Data"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:726
|
||||
msgid "The data returned by the task"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:722
|
||||
msgid "Note for the document"
|
||||
#: documents/models.py:734
|
||||
msgid "Task Type"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:735
|
||||
msgid "The type of task that was run"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:746
|
||||
msgid "Note for the document"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:770
|
||||
msgid "user"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:751
|
||||
#: documents/models.py:775
|
||||
msgid "note"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:752
|
||||
#: documents/models.py:776
|
||||
msgid "notes"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:760
|
||||
#: documents/models.py:784
|
||||
msgid "Archive"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:761
|
||||
#: documents/models.py:785
|
||||
msgid "Original"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:772 paperless_mail/models.py:75
|
||||
#: documents/models.py:796 paperless_mail/models.py:75
|
||||
msgid "expiration"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:779
|
||||
#: documents/models.py:803
|
||||
msgid "slug"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:811
|
||||
#: documents/models.py:835
|
||||
msgid "share link"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:812
|
||||
#: documents/models.py:836
|
||||
msgid "share links"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:824
|
||||
#: documents/models.py:848
|
||||
msgid "String"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:825
|
||||
#: documents/models.py:849
|
||||
msgid "URL"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:826
|
||||
#: documents/models.py:850
|
||||
msgid "Date"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:827
|
||||
#: documents/models.py:851
|
||||
msgid "Boolean"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:828
|
||||
#: documents/models.py:852
|
||||
msgid "Integer"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:829
|
||||
#: documents/models.py:853
|
||||
msgid "Float"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:830
|
||||
#: documents/models.py:854
|
||||
msgid "Monetary"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:831
|
||||
#: documents/models.py:855
|
||||
msgid "Document Link"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:832
|
||||
#: documents/models.py:856
|
||||
msgid "Select"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:844
|
||||
#: documents/models.py:868
|
||||
msgid "data type"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:851
|
||||
#: documents/models.py:875
|
||||
msgid "extra data"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:855
|
||||
#: documents/models.py:879
|
||||
msgid "Extra data for the custom field, such as select options"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:861
|
||||
#: documents/models.py:885
|
||||
msgid "custom field"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:862
|
||||
#: documents/models.py:886
|
||||
msgid "custom fields"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:959
|
||||
#: documents/models.py:983
|
||||
msgid "custom field instance"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:960
|
||||
#: documents/models.py:984
|
||||
msgid "custom field instances"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:1025
|
||||
#: documents/models.py:1049
|
||||
msgid "Consumption Started"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:1026
|
||||
#: documents/models.py:1050
|
||||
msgid "Document Added"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:1027
|
||||
#: documents/models.py:1051
|
||||
msgid "Document Updated"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:1028
|
||||
#: documents/models.py:1052
|
||||
msgid "Scheduled"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:1031
|
||||
#: documents/models.py:1055
|
||||
msgid "Consume Folder"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:1032
|
||||
#: documents/models.py:1056
|
||||
msgid "Api Upload"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:1033
|
||||
#: documents/models.py:1057
|
||||
msgid "Mail Fetch"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:1038
|
||||
#: documents/models.py:1058
|
||||
msgid "Web UI"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:1063
|
||||
msgid "Modified"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:1039
|
||||
#: documents/models.py:1064
|
||||
msgid "Custom Field"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:1042
|
||||
#: documents/models.py:1067
|
||||
msgid "Workflow Trigger Type"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:1054
|
||||
#: documents/models.py:1079
|
||||
msgid "filter path"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:1059
|
||||
#: documents/models.py:1084
|
||||
msgid ""
|
||||
"Only consume documents with a path that matches this if specified. Wildcards "
|
||||
"specified as * are allowed. Case insensitive."
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:1066
|
||||
#: documents/models.py:1091
|
||||
msgid "filter filename"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:1071 paperless_mail/models.py:200
|
||||
#: documents/models.py:1096 paperless_mail/models.py:200
|
||||
msgid ""
|
||||
"Only consume documents which entirely match this filename if specified. "
|
||||
"Wildcards such as *.pdf or *invoice* are allowed. Case insensitive."
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:1082
|
||||
#: documents/models.py:1107
|
||||
msgid "filter documents from this mail rule"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:1098
|
||||
#: documents/models.py:1123
|
||||
msgid "has these tag(s)"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:1106
|
||||
#: documents/models.py:1131
|
||||
msgid "has this document type"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:1114
|
||||
#: documents/models.py:1139
|
||||
msgid "has this correspondent"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:1118
|
||||
#: documents/models.py:1143
|
||||
msgid "schedule offset days"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:1121
|
||||
#: documents/models.py:1146
|
||||
msgid "The number of days to offset the schedule trigger by."
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:1126
|
||||
#: documents/models.py:1151
|
||||
msgid "schedule is recurring"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:1129
|
||||
#: documents/models.py:1154
|
||||
msgid "If the schedule should be recurring."
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:1134
|
||||
#: documents/models.py:1159
|
||||
msgid "schedule recurring delay in days"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:1138
|
||||
#: documents/models.py:1163
|
||||
msgid "The number of days between recurring schedule triggers."
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:1143
|
||||
#: documents/models.py:1168
|
||||
msgid "schedule date field"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:1148
|
||||
#: documents/models.py:1173
|
||||
msgid "The field to check for a schedule trigger."
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:1157
|
||||
#: documents/models.py:1182
|
||||
msgid "schedule date custom field"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:1161
|
||||
#: documents/models.py:1186
|
||||
msgid "workflow trigger"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:1162
|
||||
#: documents/models.py:1187
|
||||
msgid "workflow triggers"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:1170
|
||||
#: documents/models.py:1195
|
||||
msgid "email subject"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:1174
|
||||
#: documents/models.py:1199
|
||||
msgid ""
|
||||
"The subject of the email, can include some placeholders, see documentation."
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:1180
|
||||
#: documents/models.py:1205
|
||||
msgid "email body"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:1183
|
||||
#: documents/models.py:1208
|
||||
msgid ""
|
||||
"The body (message) of the email, can include some placeholders, see "
|
||||
"documentation."
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:1189
|
||||
#: documents/models.py:1214
|
||||
msgid "emails to"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:1192
|
||||
#: documents/models.py:1217
|
||||
msgid "The destination email addresses, comma separated."
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:1198
|
||||
#: documents/models.py:1223
|
||||
msgid "include document in email"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:1207
|
||||
#: documents/models.py:1234
|
||||
msgid "webhook url"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:1209
|
||||
#: documents/models.py:1237
|
||||
msgid "The destination URL for the notification."
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:1214
|
||||
#: documents/models.py:1242
|
||||
msgid "use parameters"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:1219
|
||||
#: documents/models.py:1247
|
||||
msgid "send as JSON"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:1223
|
||||
#: documents/models.py:1251
|
||||
msgid "webhook parameters"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:1226
|
||||
#: documents/models.py:1254
|
||||
msgid "The parameters to send with the webhook URL if body not used."
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:1230
|
||||
#: documents/models.py:1258
|
||||
msgid "webhook body"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:1233
|
||||
#: documents/models.py:1261
|
||||
msgid "The body to send with the webhook URL if parameters not used."
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:1237
|
||||
#: documents/models.py:1265
|
||||
msgid "webhook headers"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:1240
|
||||
#: documents/models.py:1268
|
||||
msgid "The headers to send with the webhook URL."
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:1245
|
||||
#: documents/models.py:1273
|
||||
msgid "include document in webhook"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:1256
|
||||
#: documents/models.py:1284
|
||||
msgid "Assignment"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:1260
|
||||
#: documents/models.py:1288
|
||||
msgid "Removal"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:1264 documents/templates/account/password_reset.html:15
|
||||
#: documents/models.py:1292 documents/templates/account/password_reset.html:15
|
||||
msgid "Email"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:1268
|
||||
#: documents/models.py:1296
|
||||
msgid "Webhook"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:1272
|
||||
#: documents/models.py:1300
|
||||
msgid "Workflow Action Type"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:1278
|
||||
#: documents/models.py:1306
|
||||
msgid "assign title"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:1283
|
||||
#: documents/models.py:1311
|
||||
msgid ""
|
||||
"Assign a document title, can include some placeholders, see documentation."
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:1292 paperless_mail/models.py:274
|
||||
#: documents/models.py:1320 paperless_mail/models.py:274
|
||||
msgid "assign this tag"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:1301 paperless_mail/models.py:282
|
||||
#: documents/models.py:1329 paperless_mail/models.py:282
|
||||
msgid "assign this document type"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:1310 paperless_mail/models.py:296
|
||||
#: documents/models.py:1338 paperless_mail/models.py:296
|
||||
msgid "assign this correspondent"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:1319
|
||||
#: documents/models.py:1347
|
||||
msgid "assign this storage path"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:1328
|
||||
#: documents/models.py:1356
|
||||
msgid "assign this owner"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:1335
|
||||
#: documents/models.py:1363
|
||||
msgid "grant view permissions to these users"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:1342
|
||||
#: documents/models.py:1370
|
||||
msgid "grant view permissions to these groups"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:1349
|
||||
#: documents/models.py:1377
|
||||
msgid "grant change permissions to these users"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:1356
|
||||
#: documents/models.py:1384
|
||||
msgid "grant change permissions to these groups"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:1363
|
||||
#: documents/models.py:1391
|
||||
msgid "assign these custom fields"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:1370
|
||||
#: documents/models.py:1398
|
||||
msgid "remove these tag(s)"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:1375
|
||||
#: documents/models.py:1403
|
||||
msgid "remove all tags"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:1382
|
||||
#: documents/models.py:1410
|
||||
msgid "remove these document type(s)"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:1387
|
||||
#: documents/models.py:1415
|
||||
msgid "remove all document types"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:1394
|
||||
#: documents/models.py:1422
|
||||
msgid "remove these correspondent(s)"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:1399
|
||||
#: documents/models.py:1427
|
||||
msgid "remove all correspondents"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:1406
|
||||
#: documents/models.py:1434
|
||||
msgid "remove these storage path(s)"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:1411
|
||||
#: documents/models.py:1439
|
||||
msgid "remove all storage paths"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:1418
|
||||
#: documents/models.py:1446
|
||||
msgid "remove these owner(s)"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:1423
|
||||
#: documents/models.py:1451
|
||||
msgid "remove all owners"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:1430
|
||||
#: documents/models.py:1458
|
||||
msgid "remove view permissions for these users"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:1437
|
||||
#: documents/models.py:1465
|
||||
msgid "remove view permissions for these groups"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:1444
|
||||
#: documents/models.py:1472
|
||||
msgid "remove change permissions for these users"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:1451
|
||||
#: documents/models.py:1479
|
||||
msgid "remove change permissions for these groups"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:1456
|
||||
#: documents/models.py:1484
|
||||
msgid "remove all permissions"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:1463
|
||||
#: documents/models.py:1491
|
||||
msgid "remove these custom fields"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:1468
|
||||
#: documents/models.py:1496
|
||||
msgid "remove all custom fields"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:1477
|
||||
#: documents/models.py:1505
|
||||
msgid "email"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:1486
|
||||
#: documents/models.py:1514
|
||||
msgid "webhook"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:1490
|
||||
#: documents/models.py:1518
|
||||
msgid "workflow action"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:1491
|
||||
#: documents/models.py:1519
|
||||
msgid "workflow actions"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:1500 paperless_mail/models.py:145
|
||||
#: documents/models.py:1528 paperless_mail/models.py:145
|
||||
msgid "order"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:1506
|
||||
#: documents/models.py:1534
|
||||
msgid "triggers"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:1513
|
||||
#: documents/models.py:1541
|
||||
msgid "actions"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:1516 paperless_mail/models.py:154
|
||||
#: documents/models.py:1544 paperless_mail/models.py:154
|
||||
msgid "enabled"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:1527
|
||||
#: documents/models.py:1555
|
||||
msgid "workflow"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:1531
|
||||
#: documents/models.py:1559
|
||||
msgid "workflow trigger type"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:1545
|
||||
#: documents/models.py:1573
|
||||
msgid "date run"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:1551
|
||||
#: documents/models.py:1579
|
||||
msgid "workflow run"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:1552
|
||||
#: documents/models.py:1580
|
||||
msgid "workflow runs"
|
||||
msgstr ""
|
||||
|
||||
#: documents/serialisers.py:127
|
||||
#: documents/serialisers.py:128
|
||||
#, python-format
|
||||
msgid "Invalid regular expression: %(error)s"
|
||||
msgstr ""
|
||||
|
||||
#: documents/serialisers.py:553
|
||||
#: documents/serialisers.py:554
|
||||
msgid "Invalid color."
|
||||
msgstr ""
|
||||
|
||||
#: documents/serialisers.py:1554
|
||||
#: documents/serialisers.py:1570
|
||||
#, python-format
|
||||
msgid "File type %(type)s not supported"
|
||||
msgstr ""
|
||||
|
||||
#: documents/serialisers.py:1643
|
||||
#: documents/serialisers.py:1659
|
||||
msgid "Invalid variable detected."
|
||||
msgstr ""
|
||||
|
||||
@@ -1402,17 +1442,23 @@ msgstr ""
|
||||
msgid "As a final step, please complete the following form:"
|
||||
msgstr ""
|
||||
|
||||
#: documents/validators.py:17
|
||||
#: documents/validators.py:24
|
||||
#, python-brace-format
|
||||
msgid "Unable to parse URI {value}, missing scheme"
|
||||
msgstr ""
|
||||
|
||||
#: documents/validators.py:22
|
||||
#: documents/validators.py:29
|
||||
#, python-brace-format
|
||||
msgid "Unable to parse URI {value}, missing net location or path"
|
||||
msgstr ""
|
||||
|
||||
#: documents/validators.py:27
|
||||
#: documents/validators.py:36
|
||||
msgid ""
|
||||
"URI scheme '{parts.scheme}' is not allowed. Allowed schemes: {', '."
|
||||
"join(allowed_schemes)}"
|
||||
msgstr ""
|
||||
|
||||
#: documents/validators.py:45
|
||||
#, python-brace-format
|
||||
msgid "Unable to parse URI {value}"
|
||||
msgstr ""
|
||||
@@ -1701,7 +1747,7 @@ msgstr ""
|
||||
msgid "Chinese Traditional"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/urls.py:364
|
||||
#: paperless/urls.py:369
|
||||
msgid "Paperless-ngx administration"
|
||||
msgstr ""
|
||||
|
||||
|
Reference in New Issue
Block a user