mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-01-12 21:44:21 -06:00
Compare commits
3 Commits
feature-pw
...
dependabot
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d004c39bad | ||
|
|
4347ba1f9c | ||
|
|
7b666e7569 |
@@ -46,14 +46,14 @@ dependencies = [
|
|||||||
"drf-writable-nested~=0.7.1",
|
"drf-writable-nested~=0.7.1",
|
||||||
"filelock~=3.20.0",
|
"filelock~=3.20.0",
|
||||||
"flower~=2.0.1",
|
"flower~=2.0.1",
|
||||||
"gotenberg-client~=0.12.0",
|
"gotenberg-client~=0.13.1",
|
||||||
"httpx-oauth~=0.16",
|
"httpx-oauth~=0.16",
|
||||||
"imap-tools~=1.11.0",
|
"imap-tools~=1.11.0",
|
||||||
"inotifyrecursive~=0.3",
|
"inotifyrecursive~=0.3",
|
||||||
"jinja2~=3.1.5",
|
"jinja2~=3.1.5",
|
||||||
"langdetect~=1.0.9",
|
"langdetect~=1.0.9",
|
||||||
"nltk~=3.9.1",
|
"nltk~=3.9.1",
|
||||||
"ocrmypdf~=16.12.0",
|
"ocrmypdf~=16.13.0",
|
||||||
"pathvalidate~=3.3.1",
|
"pathvalidate~=3.3.1",
|
||||||
"pdf2image~=1.17.0",
|
"pdf2image~=1.17.0",
|
||||||
"python-dateutil~=2.9.0",
|
"python-dateutil~=2.9.0",
|
||||||
|
|||||||
@@ -430,24 +430,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@case (WorkflowActionType.PasswordRemoval) {
|
|
||||||
<div class="row">
|
|
||||||
<div class="col">
|
|
||||||
<p class="small" i18n>
|
|
||||||
One or more passwords separated by commas or new lines. The workflow will try them in order until one succeeds.
|
|
||||||
</p>
|
|
||||||
<pngx-input-textarea
|
|
||||||
i18n-title
|
|
||||||
title="Passwords"
|
|
||||||
formControlName="passwords"
|
|
||||||
rows="4"
|
|
||||||
[error]="error?.actions?.[i]?.passwords"
|
|
||||||
hint="Passwords are stored in plain text. Use with caution."
|
|
||||||
i18n-hint
|
|
||||||
></pngx-input-textarea>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
|
|||||||
@@ -139,10 +139,6 @@ export const WORKFLOW_ACTION_OPTIONS = [
|
|||||||
id: WorkflowActionType.Webhook,
|
id: WorkflowActionType.Webhook,
|
||||||
name: $localize`Webhook`,
|
name: $localize`Webhook`,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
id: WorkflowActionType.PasswordRemoval,
|
|
||||||
name: $localize`Password removal`,
|
|
||||||
},
|
|
||||||
]
|
]
|
||||||
|
|
||||||
export enum TriggerFilterType {
|
export enum TriggerFilterType {
|
||||||
@@ -1137,7 +1133,6 @@ export class WorkflowEditDialogComponent
|
|||||||
headers: new FormControl(action.webhook?.headers),
|
headers: new FormControl(action.webhook?.headers),
|
||||||
include_document: new FormControl(!!action.webhook?.include_document),
|
include_document: new FormControl(!!action.webhook?.include_document),
|
||||||
}),
|
}),
|
||||||
passwords: new FormControl(action.passwords),
|
|
||||||
}),
|
}),
|
||||||
{ emitEvent }
|
{ emitEvent }
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -176,7 +176,6 @@ export enum ZoomSetting {
|
|||||||
NgxBootstrapIconsModule,
|
NgxBootstrapIconsModule,
|
||||||
PdfViewerModule,
|
PdfViewerModule,
|
||||||
TextAreaComponent,
|
TextAreaComponent,
|
||||||
PasswordRemovalConfirmDialogComponent,
|
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class DocumentDetailComponent
|
export class DocumentDetailComponent
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ export enum WorkflowActionType {
|
|||||||
Removal = 2,
|
Removal = 2,
|
||||||
Email = 3,
|
Email = 3,
|
||||||
Webhook = 4,
|
Webhook = 4,
|
||||||
PasswordRemoval = 5,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WorkflowActionEmail extends ObjectWithId {
|
export interface WorkflowActionEmail extends ObjectWithId {
|
||||||
@@ -98,6 +97,4 @@ export interface WorkflowAction extends ObjectWithId {
|
|||||||
email?: WorkflowActionEmail
|
email?: WorkflowActionEmail
|
||||||
|
|
||||||
webhook?: WorkflowActionWebhook
|
webhook?: WorkflowActionWebhook
|
||||||
|
|
||||||
passwords?: string
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,38 +0,0 @@
|
|||||||
# Generated by Django 5.2.7 on 2025-12-29 03:56
|
|
||||||
|
|
||||||
from django.db import migrations
|
|
||||||
from django.db import models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
dependencies = [
|
|
||||||
("documents", "1074_workflowrun_deleted_at_workflowrun_restored_at_and_more"),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.AddField(
|
|
||||||
model_name="workflowaction",
|
|
||||||
name="passwords",
|
|
||||||
field=models.TextField(
|
|
||||||
blank=True,
|
|
||||||
help_text="Passwords to try when removing PDF protection. Separate with commas or new lines.",
|
|
||||||
null=True,
|
|
||||||
verbose_name="passwords",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name="workflowaction",
|
|
||||||
name="type",
|
|
||||||
field=models.PositiveIntegerField(
|
|
||||||
choices=[
|
|
||||||
(1, "Assignment"),
|
|
||||||
(2, "Removal"),
|
|
||||||
(3, "Email"),
|
|
||||||
(4, "Webhook"),
|
|
||||||
(5, "Password removal"),
|
|
||||||
],
|
|
||||||
default=1,
|
|
||||||
verbose_name="Workflow Action Type",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@@ -1287,10 +1287,6 @@ class WorkflowAction(models.Model):
|
|||||||
4,
|
4,
|
||||||
_("Webhook"),
|
_("Webhook"),
|
||||||
)
|
)
|
||||||
PASSWORD_REMOVAL = (
|
|
||||||
5,
|
|
||||||
_("Password removal"),
|
|
||||||
)
|
|
||||||
|
|
||||||
type = models.PositiveIntegerField(
|
type = models.PositiveIntegerField(
|
||||||
_("Workflow Action Type"),
|
_("Workflow Action Type"),
|
||||||
@@ -1518,15 +1514,6 @@ class WorkflowAction(models.Model):
|
|||||||
verbose_name=_("webhook"),
|
verbose_name=_("webhook"),
|
||||||
)
|
)
|
||||||
|
|
||||||
passwords = models.TextField(
|
|
||||||
_("passwords"),
|
|
||||||
null=True,
|
|
||||||
blank=True,
|
|
||||||
help_text=_(
|
|
||||||
"Passwords to try when removing PDF protection. Separate with commas or new lines.",
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
verbose_name = _("workflow action")
|
verbose_name = _("workflow action")
|
||||||
verbose_name_plural = _("workflow actions")
|
verbose_name_plural = _("workflow actions")
|
||||||
|
|||||||
@@ -580,30 +580,34 @@ class TagSerializer(MatchingModelSerializer, OwnedObjectSerializer):
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
def get_children(self, obj):
|
def get_children(self, obj):
|
||||||
filter_q = self.context.get("document_count_filter")
|
children_map = self.context.get("children_map")
|
||||||
request = self.context.get("request")
|
if children_map is not None:
|
||||||
if filter_q is None:
|
children = children_map.get(obj.pk, [])
|
||||||
user = getattr(request, "user", None) if request else None
|
else:
|
||||||
filter_q = get_document_count_filter_for_user(user)
|
filter_q = self.context.get("document_count_filter")
|
||||||
self.context["document_count_filter"] = filter_q
|
request = self.context.get("request")
|
||||||
|
if filter_q is None:
|
||||||
|
user = getattr(request, "user", None) if request else None
|
||||||
|
filter_q = get_document_count_filter_for_user(user)
|
||||||
|
self.context["document_count_filter"] = filter_q
|
||||||
|
|
||||||
children_queryset = (
|
children = (
|
||||||
obj.get_children_queryset()
|
obj.get_children_queryset()
|
||||||
.select_related("owner")
|
.select_related("owner")
|
||||||
.annotate(document_count=Count("documents", filter=filter_q))
|
.annotate(document_count=Count("documents", filter=filter_q))
|
||||||
)
|
)
|
||||||
|
|
||||||
view = self.context.get("view")
|
view = self.context.get("view")
|
||||||
ordering = (
|
ordering = (
|
||||||
OrderingFilter().get_ordering(request, children_queryset, view)
|
OrderingFilter().get_ordering(request, children, view)
|
||||||
if request and view
|
if request and view
|
||||||
else None
|
else None
|
||||||
)
|
)
|
||||||
ordering = ordering or (Lower("name"),)
|
ordering = ordering or (Lower("name"),)
|
||||||
children_queryset = children_queryset.order_by(*ordering)
|
children = children.order_by(*ordering)
|
||||||
|
|
||||||
serializer = TagSerializer(
|
serializer = TagSerializer(
|
||||||
children_queryset,
|
children,
|
||||||
many=True,
|
many=True,
|
||||||
user=self.user,
|
user=self.user,
|
||||||
full_perms=self.full_perms,
|
full_perms=self.full_perms,
|
||||||
@@ -2449,7 +2453,6 @@ class WorkflowActionSerializer(serializers.ModelSerializer):
|
|||||||
"remove_change_groups",
|
"remove_change_groups",
|
||||||
"email",
|
"email",
|
||||||
"webhook",
|
"webhook",
|
||||||
"passwords",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
def validate(self, attrs):
|
def validate(self, attrs):
|
||||||
@@ -2506,20 +2509,6 @@ class WorkflowActionSerializer(serializers.ModelSerializer):
|
|||||||
"Webhook data is required for webhook actions",
|
"Webhook data is required for webhook actions",
|
||||||
)
|
)
|
||||||
|
|
||||||
if (
|
|
||||||
"type" in attrs
|
|
||||||
and attrs["type"] == WorkflowAction.WorkflowActionType.PASSWORD_REMOVAL
|
|
||||||
):
|
|
||||||
passwords = attrs.get("passwords")
|
|
||||||
if passwords is None or not isinstance(passwords, str):
|
|
||||||
raise serializers.ValidationError(
|
|
||||||
"Passwords are required for password removal actions",
|
|
||||||
)
|
|
||||||
if not passwords.strip():
|
|
||||||
raise serializers.ValidationError(
|
|
||||||
"Passwords are required for password removal actions",
|
|
||||||
)
|
|
||||||
|
|
||||||
return attrs
|
return attrs
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -46,7 +46,6 @@ from documents.permissions import get_objects_for_user_owner_aware
|
|||||||
from documents.templating.utils import convert_format_str_to_template_format
|
from documents.templating.utils import convert_format_str_to_template_format
|
||||||
from documents.workflows.actions import build_workflow_action_context
|
from documents.workflows.actions import build_workflow_action_context
|
||||||
from documents.workflows.actions import execute_email_action
|
from documents.workflows.actions import execute_email_action
|
||||||
from documents.workflows.actions import execute_password_removal_action
|
|
||||||
from documents.workflows.actions import execute_webhook_action
|
from documents.workflows.actions import execute_webhook_action
|
||||||
from documents.workflows.mutations import apply_assignment_to_document
|
from documents.workflows.mutations import apply_assignment_to_document
|
||||||
from documents.workflows.mutations import apply_assignment_to_overrides
|
from documents.workflows.mutations import apply_assignment_to_overrides
|
||||||
@@ -793,8 +792,6 @@ def run_workflows(
|
|||||||
logging_group,
|
logging_group,
|
||||||
original_file,
|
original_file,
|
||||||
)
|
)
|
||||||
elif action.type == WorkflowAction.WorkflowActionType.PASSWORD_REMOVAL:
|
|
||||||
execute_password_removal_action(action, document, logging_group)
|
|
||||||
|
|
||||||
if not use_overrides:
|
if not use_overrides:
|
||||||
# limit title to 128 characters
|
# limit title to 128 characters
|
||||||
|
|||||||
@@ -808,57 +808,3 @@ class TestApiWorkflows(DirectoriesMixin, APITestCase):
|
|||||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
self.action.refresh_from_db()
|
self.action.refresh_from_db()
|
||||||
self.assertEqual(self.action.assign_title, "Patched Title")
|
self.assertEqual(self.action.assign_title, "Patched Title")
|
||||||
|
|
||||||
def test_password_action_passwords_field(self):
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- Nothing
|
|
||||||
WHEN:
|
|
||||||
- A workflow password removal action is created with passwords set
|
|
||||||
THEN:
|
|
||||||
- The passwords field is correctly stored and retrieved
|
|
||||||
"""
|
|
||||||
passwords = "password1,password2\npassword3"
|
|
||||||
response = self.client.post(
|
|
||||||
"/api/workflow_actions/",
|
|
||||||
{
|
|
||||||
"type": WorkflowAction.WorkflowActionType.PASSWORD_REMOVAL,
|
|
||||||
"passwords": passwords,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
|
|
||||||
self.assertEqual(response.data["passwords"], passwords)
|
|
||||||
|
|
||||||
def test_password_action_no_passwords_field(self):
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- Nothing
|
|
||||||
WHEN:
|
|
||||||
- A workflow password removal action is created with no passwords set
|
|
||||||
- A workflow password removal action is created with passwords set to empty string
|
|
||||||
THEN:
|
|
||||||
- The required validation error is raised
|
|
||||||
"""
|
|
||||||
response = self.client.post(
|
|
||||||
"/api/workflow_actions/",
|
|
||||||
{
|
|
||||||
"type": WorkflowAction.WorkflowActionType.PASSWORD_REMOVAL,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
|
||||||
self.assertIn(
|
|
||||||
"Passwords are required",
|
|
||||||
str(response.data["non_field_errors"][0]),
|
|
||||||
)
|
|
||||||
response = self.client.post(
|
|
||||||
"/api/workflow_actions/",
|
|
||||||
{
|
|
||||||
"type": WorkflowAction.WorkflowActionType.PASSWORD_REMOVAL,
|
|
||||||
"passwords": "",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
|
||||||
self.assertIn(
|
|
||||||
"Passwords are required",
|
|
||||||
str(response.data["non_field_errors"][0]),
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import datetime
|
|||||||
import json
|
import json
|
||||||
import shutil
|
import shutil
|
||||||
import socket
|
import socket
|
||||||
import tempfile
|
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
@@ -61,7 +60,6 @@ from documents.tests.utils import DirectoriesMixin
|
|||||||
from documents.tests.utils import DummyProgressManager
|
from documents.tests.utils import DummyProgressManager
|
||||||
from documents.tests.utils import FileSystemAssertsMixin
|
from documents.tests.utils import FileSystemAssertsMixin
|
||||||
from documents.tests.utils import SampleDirMixin
|
from documents.tests.utils import SampleDirMixin
|
||||||
from documents.workflows.actions import execute_password_removal_action
|
|
||||||
from paperless_mail.models import MailAccount
|
from paperless_mail.models import MailAccount
|
||||||
from paperless_mail.models import MailRule
|
from paperless_mail.models import MailRule
|
||||||
|
|
||||||
@@ -3612,196 +3610,6 @@ class TestWorkflows(
|
|||||||
|
|
||||||
mock_post.assert_called_once()
|
mock_post.assert_called_once()
|
||||||
|
|
||||||
@mock.patch("documents.bulk_edit.remove_password")
|
|
||||||
def test_password_removal_action_attempts_multiple_passwords(
|
|
||||||
self,
|
|
||||||
mock_remove_password,
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- Workflow password removal action
|
|
||||||
- Multiple passwords provided
|
|
||||||
WHEN:
|
|
||||||
- Document updated triggering the workflow
|
|
||||||
THEN:
|
|
||||||
- Password removal is attempted until one succeeds
|
|
||||||
"""
|
|
||||||
doc = Document.objects.create(
|
|
||||||
title="Protected",
|
|
||||||
checksum="pw-checksum",
|
|
||||||
)
|
|
||||||
trigger = WorkflowTrigger.objects.create(
|
|
||||||
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED,
|
|
||||||
)
|
|
||||||
action = WorkflowAction.objects.create(
|
|
||||||
type=WorkflowAction.WorkflowActionType.PASSWORD_REMOVAL,
|
|
||||||
passwords="wrong, right\n extra ",
|
|
||||||
)
|
|
||||||
workflow = Workflow.objects.create(name="Password workflow")
|
|
||||||
workflow.triggers.add(trigger)
|
|
||||||
workflow.actions.add(action)
|
|
||||||
|
|
||||||
mock_remove_password.side_effect = [
|
|
||||||
ValueError("wrong password"),
|
|
||||||
"OK",
|
|
||||||
]
|
|
||||||
|
|
||||||
run_workflows(trigger.type, doc)
|
|
||||||
|
|
||||||
assert mock_remove_password.call_count == 2
|
|
||||||
mock_remove_password.assert_has_calls(
|
|
||||||
[
|
|
||||||
mock.call(
|
|
||||||
[doc.id],
|
|
||||||
password="wrong",
|
|
||||||
update_document=True,
|
|
||||||
user=doc.owner,
|
|
||||||
),
|
|
||||||
mock.call(
|
|
||||||
[doc.id],
|
|
||||||
password="right",
|
|
||||||
update_document=True,
|
|
||||||
user=doc.owner,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
|
|
||||||
@mock.patch("documents.bulk_edit.remove_password")
|
|
||||||
def test_password_removal_action_fails_without_correct_password(
|
|
||||||
self,
|
|
||||||
mock_remove_password,
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- Workflow password removal action
|
|
||||||
- No correct password provided
|
|
||||||
WHEN:
|
|
||||||
- Document updated triggering the workflow
|
|
||||||
THEN:
|
|
||||||
- Password removal is attempted for all passwords and fails
|
|
||||||
"""
|
|
||||||
doc = Document.objects.create(
|
|
||||||
title="Protected",
|
|
||||||
checksum="pw-checksum-2",
|
|
||||||
)
|
|
||||||
trigger = WorkflowTrigger.objects.create(
|
|
||||||
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED,
|
|
||||||
)
|
|
||||||
action = WorkflowAction.objects.create(
|
|
||||||
type=WorkflowAction.WorkflowActionType.PASSWORD_REMOVAL,
|
|
||||||
passwords=" \n , ",
|
|
||||||
)
|
|
||||||
workflow = Workflow.objects.create(name="Password workflow missing passwords")
|
|
||||||
workflow.triggers.add(trigger)
|
|
||||||
workflow.actions.add(action)
|
|
||||||
|
|
||||||
run_workflows(trigger.type, doc)
|
|
||||||
|
|
||||||
mock_remove_password.assert_not_called()
|
|
||||||
|
|
||||||
@mock.patch("documents.bulk_edit.remove_password")
|
|
||||||
def test_password_removal_action_skips_without_passwords(
|
|
||||||
self,
|
|
||||||
mock_remove_password,
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- Workflow password removal action with no passwords
|
|
||||||
WHEN:
|
|
||||||
- Workflow is run
|
|
||||||
THEN:
|
|
||||||
- Password removal is not attempted
|
|
||||||
"""
|
|
||||||
doc = Document.objects.create(
|
|
||||||
title="Protected",
|
|
||||||
checksum="pw-checksum-2",
|
|
||||||
)
|
|
||||||
trigger = WorkflowTrigger.objects.create(
|
|
||||||
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED,
|
|
||||||
)
|
|
||||||
action = WorkflowAction.objects.create(
|
|
||||||
type=WorkflowAction.WorkflowActionType.PASSWORD_REMOVAL,
|
|
||||||
passwords="",
|
|
||||||
)
|
|
||||||
workflow = Workflow.objects.create(name="Password workflow missing passwords")
|
|
||||||
workflow.triggers.add(trigger)
|
|
||||||
workflow.actions.add(action)
|
|
||||||
|
|
||||||
run_workflows(trigger.type, doc)
|
|
||||||
|
|
||||||
mock_remove_password.assert_not_called()
|
|
||||||
|
|
||||||
@mock.patch("documents.bulk_edit.remove_password")
|
|
||||||
def test_password_removal_consumable_document_deferred(
|
|
||||||
self,
|
|
||||||
mock_remove_password,
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- Workflow password removal action
|
|
||||||
- Simulated consumption trigger (a ConsumableDocument is used)
|
|
||||||
WHEN:
|
|
||||||
- Document consumption is finished
|
|
||||||
THEN:
|
|
||||||
- Password removal is attempted
|
|
||||||
"""
|
|
||||||
action = WorkflowAction.objects.create(
|
|
||||||
type=WorkflowAction.WorkflowActionType.PASSWORD_REMOVAL,
|
|
||||||
passwords="first, second",
|
|
||||||
)
|
|
||||||
|
|
||||||
temp_dir = Path(tempfile.mkdtemp())
|
|
||||||
original_file = temp_dir / "file.pdf"
|
|
||||||
original_file.write_bytes(b"pdf content")
|
|
||||||
consumable = ConsumableDocument(
|
|
||||||
source=DocumentSource.ApiUpload,
|
|
||||||
original_file=original_file,
|
|
||||||
)
|
|
||||||
|
|
||||||
execute_password_removal_action(action, consumable, logging_group=None)
|
|
||||||
|
|
||||||
mock_remove_password.assert_not_called()
|
|
||||||
|
|
||||||
mock_remove_password.side_effect = [
|
|
||||||
ValueError("bad password"),
|
|
||||||
"OK",
|
|
||||||
]
|
|
||||||
|
|
||||||
doc = Document.objects.create(
|
|
||||||
checksum="pw-checksum-consumed",
|
|
||||||
title="Protected",
|
|
||||||
)
|
|
||||||
|
|
||||||
document_consumption_finished.send(
|
|
||||||
sender=self.__class__,
|
|
||||||
document=doc,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert mock_remove_password.call_count == 2
|
|
||||||
mock_remove_password.assert_has_calls(
|
|
||||||
[
|
|
||||||
mock.call(
|
|
||||||
[doc.id],
|
|
||||||
password="first",
|
|
||||||
update_document=True,
|
|
||||||
user=doc.owner,
|
|
||||||
),
|
|
||||||
mock.call(
|
|
||||||
[doc.id],
|
|
||||||
password="second",
|
|
||||||
update_document=True,
|
|
||||||
user=doc.owner,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
|
|
||||||
# ensure handler disconnected after first run
|
|
||||||
document_consumption_finished.send(
|
|
||||||
sender=self.__class__,
|
|
||||||
document=doc,
|
|
||||||
)
|
|
||||||
assert mock_remove_password.call_count == 2
|
|
||||||
|
|
||||||
|
|
||||||
class TestWebhookSend:
|
class TestWebhookSend:
|
||||||
def test_send_webhook_data_or_json(
|
def test_send_webhook_data_or_json(
|
||||||
|
|||||||
@@ -448,8 +448,43 @@ class TagViewSet(ModelViewSet, PermissionsAwareDocumentCountMixin):
|
|||||||
def get_serializer_context(self):
|
def get_serializer_context(self):
|
||||||
context = super().get_serializer_context()
|
context = super().get_serializer_context()
|
||||||
context["document_count_filter"] = self.get_document_count_filter()
|
context["document_count_filter"] = self.get_document_count_filter()
|
||||||
|
if hasattr(self, "_children_map"):
|
||||||
|
context["children_map"] = self._children_map
|
||||||
return context
|
return context
|
||||||
|
|
||||||
|
def list(self, request, *args, **kwargs):
|
||||||
|
"""
|
||||||
|
Build a children map once to avoid per-parent queries in the serializer.
|
||||||
|
"""
|
||||||
|
queryset = self.filter_queryset(self.get_queryset())
|
||||||
|
ordering = OrderingFilter().get_ordering(request, queryset, self) or (
|
||||||
|
Lower("name"),
|
||||||
|
)
|
||||||
|
queryset = queryset.order_by(*ordering)
|
||||||
|
|
||||||
|
all_tags = list(queryset)
|
||||||
|
descendant_pks = {pk for tag in all_tags for pk in tag.get_descendants_pks()}
|
||||||
|
|
||||||
|
if descendant_pks:
|
||||||
|
filter_q = self.get_document_count_filter()
|
||||||
|
children_source = (
|
||||||
|
Tag.objects.filter(pk__in=descendant_pks | {t.pk for t in all_tags})
|
||||||
|
.select_related("owner")
|
||||||
|
.annotate(document_count=Count("documents", filter=filter_q))
|
||||||
|
.order_by(*ordering)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
children_source = all_tags
|
||||||
|
|
||||||
|
children_map = {}
|
||||||
|
for tag in children_source:
|
||||||
|
children_map.setdefault(tag.tn_parent_id, []).append(tag)
|
||||||
|
self._children_map = children_map
|
||||||
|
|
||||||
|
page = self.paginate_queryset(queryset)
|
||||||
|
serializer = self.get_serializer(page, many=True)
|
||||||
|
return self.get_paginated_response(serializer.data)
|
||||||
|
|
||||||
def perform_update(self, serializer):
|
def perform_update(self, serializer):
|
||||||
old_parent = self.get_object().get_parent()
|
old_parent = self.get_object().get_parent()
|
||||||
tag = serializer.save()
|
tag = serializer.save()
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import logging
|
import logging
|
||||||
import re
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
@@ -15,7 +14,6 @@ from documents.models import Document
|
|||||||
from documents.models import DocumentType
|
from documents.models import DocumentType
|
||||||
from documents.models import WorkflowAction
|
from documents.models import WorkflowAction
|
||||||
from documents.models import WorkflowTrigger
|
from documents.models import WorkflowTrigger
|
||||||
from documents.signals import document_consumption_finished
|
|
||||||
from documents.templating.workflows import parse_w_workflow_placeholders
|
from documents.templating.workflows import parse_w_workflow_placeholders
|
||||||
from documents.workflows.webhooks import send_webhook
|
from documents.workflows.webhooks import send_webhook
|
||||||
|
|
||||||
@@ -261,74 +259,3 @@ def execute_webhook_action(
|
|||||||
f"Error occurred sending webhook: {e}",
|
f"Error occurred sending webhook: {e}",
|
||||||
extra={"group": logging_group},
|
extra={"group": logging_group},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def execute_password_removal_action(
|
|
||||||
action: WorkflowAction,
|
|
||||||
document: Document | ConsumableDocument,
|
|
||||||
logging_group,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
Try to remove a password from a document using the configured list.
|
|
||||||
"""
|
|
||||||
passwords = action.passwords
|
|
||||||
if not passwords:
|
|
||||||
logger.warning(
|
|
||||||
"Password removal action %s has no passwords configured",
|
|
||||||
action.pk,
|
|
||||||
extra={"group": logging_group},
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
passwords = [
|
|
||||||
password.strip()
|
|
||||||
for password in re.split(r"[,\n]", passwords)
|
|
||||||
if password.strip()
|
|
||||||
]
|
|
||||||
|
|
||||||
if isinstance(document, ConsumableDocument):
|
|
||||||
# hook the consumption-finished signal to attempt password removal later
|
|
||||||
def handler(sender, **kwargs):
|
|
||||||
consumed_document: Document = kwargs.get("document")
|
|
||||||
if consumed_document is not None:
|
|
||||||
execute_password_removal_action(
|
|
||||||
action,
|
|
||||||
consumed_document,
|
|
||||||
logging_group,
|
|
||||||
)
|
|
||||||
document_consumption_finished.disconnect(handler)
|
|
||||||
|
|
||||||
document_consumption_finished.connect(handler, weak=False)
|
|
||||||
return
|
|
||||||
|
|
||||||
# import here to avoid circular dependency
|
|
||||||
from documents.bulk_edit import remove_password
|
|
||||||
|
|
||||||
for password in passwords:
|
|
||||||
try:
|
|
||||||
remove_password(
|
|
||||||
[document.id],
|
|
||||||
password=password,
|
|
||||||
update_document=True,
|
|
||||||
user=document.owner,
|
|
||||||
)
|
|
||||||
logger.info(
|
|
||||||
"Removed password from document %s using workflow action %s",
|
|
||||||
document.pk,
|
|
||||||
action.pk,
|
|
||||||
extra={"group": logging_group},
|
|
||||||
)
|
|
||||||
return
|
|
||||||
except ValueError as e:
|
|
||||||
logger.warning(
|
|
||||||
"Password removal failed for document %s with supplied password: %s",
|
|
||||||
document.pk,
|
|
||||||
e,
|
|
||||||
extra={"group": logging_group},
|
|
||||||
)
|
|
||||||
|
|
||||||
logger.error(
|
|
||||||
"Password removal failed for document %s after trying all provided passwords",
|
|
||||||
document.pk,
|
|
||||||
extra={"group": logging_group},
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ msgid ""
|
|||||||
msgstr ""
|
msgstr ""
|
||||||
"Project-Id-Version: paperless-ngx\n"
|
"Project-Id-Version: paperless-ngx\n"
|
||||||
"Report-Msgid-Bugs-To: \n"
|
"Report-Msgid-Bugs-To: \n"
|
||||||
"POT-Creation-Date: 2026-01-08 21:50+0000\n"
|
"POT-Creation-Date: 2026-01-12 21:04+0000\n"
|
||||||
"PO-Revision-Date: 2022-02-17 04:17\n"
|
"PO-Revision-Date: 2022-02-17 04:17\n"
|
||||||
"Last-Translator: \n"
|
"Last-Translator: \n"
|
||||||
"Language-Team: English\n"
|
"Language-Team: English\n"
|
||||||
@@ -1219,35 +1219,35 @@ msgstr ""
|
|||||||
msgid "workflow runs"
|
msgid "workflow runs"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/serialisers.py:642
|
#: documents/serialisers.py:646
|
||||||
msgid "Invalid color."
|
msgid "Invalid color."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/serialisers.py:1846
|
#: documents/serialisers.py:1850
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "File type %(type)s not supported"
|
msgid "File type %(type)s not supported"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/serialisers.py:1890
|
#: documents/serialisers.py:1894
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "Custom field id must be an integer: %(id)s"
|
msgid "Custom field id must be an integer: %(id)s"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/serialisers.py:1897
|
#: documents/serialisers.py:1901
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "Custom field with id %(id)s does not exist"
|
msgid "Custom field with id %(id)s does not exist"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/serialisers.py:1914 documents/serialisers.py:1924
|
#: documents/serialisers.py:1918 documents/serialisers.py:1928
|
||||||
msgid ""
|
msgid ""
|
||||||
"Custom fields must be a list of integers or an object mapping ids to values."
|
"Custom fields must be a list of integers or an object mapping ids to values."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/serialisers.py:1919
|
#: documents/serialisers.py:1923
|
||||||
msgid "Some custom fields don't exist or were specified twice."
|
msgid "Some custom fields don't exist or were specified twice."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/serialisers.py:2034
|
#: documents/serialisers.py:2038
|
||||||
msgid "Invalid variable detected."
|
msgid "Invalid variable detected."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
|||||||
34
uv.lock
generated
34
uv.lock
generated
@@ -104,9 +104,9 @@ dependencies = [
|
|||||||
{ name = "isodate", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
|
{ name = "isodate", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
|
||||||
{ name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
|
{ name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/44/7b/8115cd713e2caa5e44def85f2b7ebd02a74ae74d7113ba20bdd41fd6dd80/azure_ai_documentintelligence-1.0.2.tar.gz", hash = "sha256:4d75a2513f2839365ebabc0e0e1772f5601b3a8c9a71e75da12440da13b63484", size = 170940 }
|
sdist = { url = "https://files.pythonhosted.org/packages/44/7b/8115cd713e2caa5e44def85f2b7ebd02a74ae74d7113ba20bdd41fd6dd80/azure_ai_documentintelligence-1.0.2.tar.gz", hash = "sha256:4d75a2513f2839365ebabc0e0e1772f5601b3a8c9a71e75da12440da13b63484", size = 170940, upload-time = "2025-03-27T02:46:20.606Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/d9/75/c9ec040f23082f54ffb1977ff8f364c2d21c79a640a13d1c1809e7fd6b1a/azure_ai_documentintelligence-1.0.2-py3-none-any.whl", hash = "sha256:e1fb446abbdeccc9759d897898a0fe13141ed29f9ad11fc705f951925822ed59", size = 106005 },
|
{ url = "https://files.pythonhosted.org/packages/d9/75/c9ec040f23082f54ffb1977ff8f364c2d21c79a640a13d1c1809e7fd6b1a/azure_ai_documentintelligence-1.0.2-py3-none-any.whl", hash = "sha256:e1fb446abbdeccc9759d897898a0fe13141ed29f9ad11fc705f951925822ed59", size = 106005, upload-time = "2025-03-27T02:46:22.356Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -118,9 +118,9 @@ dependencies = [
|
|||||||
{ name = "six", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
|
{ name = "six", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
|
||||||
{ name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
|
{ name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/75/aa/7c9db8edd626f1a7d99d09ef7926f6f4fb34d5f9fa00dc394afdfe8e2a80/azure_core-1.33.0.tar.gz", hash = "sha256:f367aa07b5e3005fec2c1e184b882b0b039910733907d001c20fb08ebb8c0eb9", size = 295633 }
|
sdist = { url = "https://files.pythonhosted.org/packages/75/aa/7c9db8edd626f1a7d99d09ef7926f6f4fb34d5f9fa00dc394afdfe8e2a80/azure_core-1.33.0.tar.gz", hash = "sha256:f367aa07b5e3005fec2c1e184b882b0b039910733907d001c20fb08ebb8c0eb9", size = 295633, upload-time = "2025-04-03T23:51:02.058Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/07/b7/76b7e144aa53bd206bf1ce34fa75350472c3f69bf30e5c8c18bc9881035d/azure_core-1.33.0-py3-none-any.whl", hash = "sha256:9b5b6d0223a1d38c37500e6971118c1e0f13f54951e6893968b38910bc9cda8f", size = 207071 },
|
{ url = "https://files.pythonhosted.org/packages/07/b7/76b7e144aa53bd206bf1ce34fa75350472c3f69bf30e5c8c18bc9881035d/azure_core-1.33.0-py3-none-any.whl", hash = "sha256:9b5b6d0223a1d38c37500e6971118c1e0f13f54951e6893968b38910bc9cda8f", size = 207071, upload-time = "2025-04-03T23:51:03.806Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1101,15 +1101,15 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "gotenberg-client"
|
name = "gotenberg-client"
|
||||||
version = "0.12.0"
|
version = "0.13.1"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "httpx", extra = ["http2"], marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
|
{ name = "httpx", extra = ["http2"], marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
|
||||||
{ name = "typing-extensions", marker = "(python_full_version < '3.11' and sys_platform == 'darwin') or (python_full_version < '3.11' and sys_platform == 'linux')" },
|
{ name = "typing-extensions", marker = "(python_full_version < '3.11' and sys_platform == 'darwin') or (python_full_version < '3.11' and sys_platform == 'linux')" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/61/6d/07ea213c146bbe91dffebff2d8f4dc61e7076d3dd34d4fd1467f9163e752/gotenberg_client-0.12.0.tar.gz", hash = "sha256:1ab50878024469fc003c414ee9810ceeb00d4d7d7c36bd2fb75318fbff139e9b", size = 1210884, upload-time = "2025-10-15T15:32:37.669Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/e4/6c/aaadd6657ca42fbd148b1c00604b98c1ead5a22552f4e5365ce5f0632430/gotenberg_client-0.13.1.tar.gz", hash = "sha256:cdd6bbb535cd739b87446cd1b4f6347ed7f9af6a0d4b19baf7c064b75528ee54", size = 1211143, upload-time = "2025-12-04T20:45:24.151Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/12/39/fcb24ff053b1be7e5124f56c3d358706a23a328f685c6db33bc9dbc5472d/gotenberg_client-0.12.0-py3-none-any.whl", hash = "sha256:a540b35ac518e902c2860a88fbe448c15fe5a56fe8ec8604e6a2c8c2228fd0cb", size = 51051, upload-time = "2025-10-15T15:32:36.32Z" },
|
{ url = "https://files.pythonhosted.org/packages/79/f6/7a6e6785295332d2538f729ae19516cef712273a5ab8b90d015f08e37a45/gotenberg_client-0.13.1-py3-none-any.whl", hash = "sha256:613f7083a5e8a81699dd8d715c97e5806a424ac48920aad25d7c11b600cdfaf3", size = 51058, upload-time = "2025-12-04T20:45:22.603Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1483,9 +1483,9 @@ wheels = [
|
|||||||
name = "isodate"
|
name = "isodate"
|
||||||
version = "0.7.2"
|
version = "0.7.2"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/54/4d/e940025e2ce31a8ce1202635910747e5a87cc3a6a6bb2d00973375014749/isodate-0.7.2.tar.gz", hash = "sha256:4cd1aa0f43ca76f4a6c6c0292a85f40b35ec2e43e315b59f06e6d32171a953e6", size = 29705 }
|
sdist = { url = "https://files.pythonhosted.org/packages/54/4d/e940025e2ce31a8ce1202635910747e5a87cc3a6a6bb2d00973375014749/isodate-0.7.2.tar.gz", hash = "sha256:4cd1aa0f43ca76f4a6c6c0292a85f40b35ec2e43e315b59f06e6d32171a953e6", size = 29705, upload-time = "2024-10-08T23:04:11.5Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/15/aa/0aca39a37d3c7eb941ba736ede56d689e7be91cab5d9ca846bde3999eba6/isodate-0.7.2-py3-none-any.whl", hash = "sha256:28009937d8031054830160fce6d409ed342816b543597cece116d966c6d99e15", size = 22320 },
|
{ url = "https://files.pythonhosted.org/packages/15/aa/0aca39a37d3c7eb941ba736ede56d689e7be91cab5d9ca846bde3999eba6/isodate-0.7.2-py3-none-any.whl", hash = "sha256:28009937d8031054830160fce6d409ed342816b543597cece116d966c6d99e15", size = 22320, upload-time = "2024-10-08T23:04:09.501Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2114,7 +2114,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ocrmypdf"
|
name = "ocrmypdf"
|
||||||
version = "16.12.0"
|
version = "16.13.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "deprecation", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
|
{ name = "deprecation", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
|
||||||
@@ -2127,9 +2127,9 @@ dependencies = [
|
|||||||
{ name = "pluggy", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
|
{ name = "pluggy", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
|
||||||
{ name = "rich", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
|
{ name = "rich", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/2b/ed/dacc0f189e4fcefc52d709e9961929e3f622a85efa5ae47c9d9663d75cab/ocrmypdf-16.12.0.tar.gz", hash = "sha256:a0f6509e7780b286391f8847fae1811d2b157b14283ad74a2431d6755c5c0ed0", size = 7037326, upload-time = "2025-11-11T22:30:14.223Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/8c/52/be1aaece0703a736757d8957c0d4f19c37561054169b501eb0e7132f15e5/ocrmypdf-16.13.0.tar.gz", hash = "sha256:29d37e915234ce717374863a9cc5dd32d29e063dfe60c51380dda71254c88248", size = 7042247, upload-time = "2025-12-24T07:58:35.86Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/ce/34/d9d04420e6f7a71e2135b41599dae273e4ef36e2ce79b065b65fb2471636/ocrmypdf-16.12.0-py3-none-any.whl", hash = "sha256:0ea5c42027db9cf3bd12b0d0b4190689027ef813fdad3377106ea66bba0012c3", size = 163415, upload-time = "2025-11-11T22:30:11.56Z" },
|
{ url = "https://files.pythonhosted.org/packages/41/b1/e2e7ad98de0d3ee05b44dbc3f78ccb158a620f3add82d00c85490120e7f2/ocrmypdf-16.13.0-py3-none-any.whl", hash = "sha256:fad8a6f7cc52cdc6225095c401a1766c778c47efe9f1e854ae4dc64a550a3d37", size = 165377, upload-time = "2025-12-24T07:58:33.925Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2321,7 +2321,7 @@ requires-dist = [
|
|||||||
{ name = "drf-writable-nested", specifier = "~=0.7.1" },
|
{ name = "drf-writable-nested", specifier = "~=0.7.1" },
|
||||||
{ name = "filelock", specifier = "~=3.20.0" },
|
{ name = "filelock", specifier = "~=3.20.0" },
|
||||||
{ name = "flower", specifier = "~=2.0.1" },
|
{ name = "flower", specifier = "~=2.0.1" },
|
||||||
{ name = "gotenberg-client", specifier = "~=0.12.0" },
|
{ name = "gotenberg-client", specifier = "~=0.13.1" },
|
||||||
{ name = "granian", extras = ["uvloop"], marker = "extra == 'webserver'", specifier = "~=2.5.1" },
|
{ name = "granian", extras = ["uvloop"], marker = "extra == 'webserver'", specifier = "~=2.5.1" },
|
||||||
{ name = "httpx-oauth", specifier = "~=0.16" },
|
{ name = "httpx-oauth", specifier = "~=0.16" },
|
||||||
{ name = "imap-tools", specifier = "~=1.11.0" },
|
{ name = "imap-tools", specifier = "~=1.11.0" },
|
||||||
@@ -2330,7 +2330,7 @@ requires-dist = [
|
|||||||
{ name = "langdetect", specifier = "~=1.0.9" },
|
{ name = "langdetect", specifier = "~=1.0.9" },
|
||||||
{ name = "mysqlclient", marker = "extra == 'mariadb'", specifier = "~=2.2.7" },
|
{ name = "mysqlclient", marker = "extra == 'mariadb'", specifier = "~=2.2.7" },
|
||||||
{ name = "nltk", specifier = "~=3.9.1" },
|
{ name = "nltk", specifier = "~=3.9.1" },
|
||||||
{ name = "ocrmypdf", specifier = "~=16.12.0" },
|
{ name = "ocrmypdf", specifier = "~=16.13.0" },
|
||||||
{ name = "pathvalidate", specifier = "~=3.3.1" },
|
{ name = "pathvalidate", specifier = "~=3.3.1" },
|
||||||
{ name = "pdf2image", specifier = "~=1.17.0" },
|
{ name = "pdf2image", specifier = "~=1.17.0" },
|
||||||
{ name = "psycopg", extras = ["c", "pool"], marker = "extra == 'postgres'", specifier = "==3.2.12" },
|
{ name = "psycopg", extras = ["c", "pool"], marker = "extra == 'postgres'", specifier = "==3.2.12" },
|
||||||
@@ -3004,11 +3004,11 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "python-gnupg"
|
name = "python-gnupg"
|
||||||
version = "0.5.5"
|
version = "0.5.6"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/42/d0/72a14a79f26c6119b281f6ccc475a787432ef155560278e60df97ce68a86/python-gnupg-0.5.5.tar.gz", hash = "sha256:3fdcaf76f60a1b948ff8e37dc398d03cf9ce7427065d583082b92da7a4ff5a63", size = 66467, upload-time = "2025-08-04T19:26:55.778Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/98/2c/6cd2c7cff4bdbb434be5429ef6b8e96ee6b50155551361f30a1bb2ea3c1d/python_gnupg-0.5.6.tar.gz", hash = "sha256:5743e96212d38923fc19083812dc127907e44dbd3bcf0db4d657e291d3c21eac", size = 66825, upload-time = "2025-12-31T17:19:33.19Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/aa/19/c147f78cc18c8788f54d4a16a22f6c05deba85ead5672d3ddf6dcba5a5fe/python_gnupg-0.5.5-py2.py3-none-any.whl", hash = "sha256:51fa7b8831ff0914bc73d74c59b99c613de7247b91294323c39733bb85ac3fc1", size = 21916, upload-time = "2025-08-04T19:26:54.307Z" },
|
{ url = "https://files.pythonhosted.org/packages/d2/ab/0ea9de971caf3cd2e268d2b05dfe9883b21cfe686a59249bd2dccb4bae33/python_gnupg-0.5.6-py2.py3-none-any.whl", hash = "sha256:b5050a55663d8ab9fcc8d97556d229af337a87a3ebebd7054cbd8b7e2043394a", size = 22082, upload-time = "2025-12-31T17:16:22.743Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
Reference in New Issue
Block a user