Compare commits

..
19 changed files with 58 additions and 245 deletions
+4
View File
@@ -173,6 +173,10 @@ RUN set -eux \
&& rm --force --verbose *.deb \
&& rm --recursive --force --verbose /var/lib/apt/lists/*
# Ensure interactive shells (docker exec bash) see resolved *_FILE secrets,
# mirroring what with-contenv already does for s6 services.
RUN echo '. /etc/profile.d/contenv.sh' >> /etc/bash.bashrc
WORKDIR /usr/src/paperless/src/
# Python dependencies
+18
View File
@@ -0,0 +1,18 @@
#!/bin/sh
# Source s6 container environment for interactive shells.
# Ensures variables resolved from *_FILE secret injection are visible
# when using 'docker exec bash'. Does not affect s6 services (those
# use with-contenv directly). Has no effect in non-container contexts
# because the directory will not exist.
# Note: sh/dash shells opened via 'docker exec sh' are not covered;
# only bash-based sessions benefit from this file.
_pngx_contenv="/run/s6/container_environment"
if [ -d "${_pngx_contenv}" ]; then
for _pngx_f in "${_pngx_contenv}"/*; do
[ -f "${_pngx_f}" ] || continue
_pngx_name=$(basename "${_pngx_f}")
_pngx_val=$(cat "${_pngx_f}")
export "${_pngx_name}=${_pngx_val}"
done
fi
unset _pngx_contenv _pngx_f _pngx_name _pngx_val
+1 -3
View File
@@ -576,9 +576,7 @@ The following workflow action types are available:
- Tags, correspondent, document type and storage path
- Document owner
- View and / or edit permissions to users or groups
- Custom fields, optionally with a value. If no value is set, the field is only added to the
document and any value it may already have is left untouched. If a value is set, it will
overwrite an existing value of that field on the document.
- Custom fields. Note that no value for the field will be set
##### Removal {#workflow-action-removal}
+1 -1
View File
@@ -66,5 +66,5 @@
"ts-node": "~10.9.1",
"typescript": "^6.0.3"
},
"packageManager": "pnpm@11.15.1"
"packageManager": "pnpm@10.26.0"
}
-1
View File
@@ -5,7 +5,6 @@ trustPolicy: no-downgrade
trustPolicyExclude:
- "chokidar@4.0.3"
- "semver@6.3.1 || 5.7.2"
blockExoticSubdeps: true
allowBuilds:
"@parcel/watcher": true
canvas: true
@@ -52,10 +52,10 @@ describe('CustomFieldsValuesComponent', () => {
})
it('should set selectedFields and map values correctly', () => {
component.value = { 1: 'value1', 3: 0, 4: false }
component.selectedFields = [1, 2, 3, 4]
expect(component.selectedFields).toEqual([1, 2, 3, 4])
expect(component.value).toEqual({ 1: 'value1', 2: null, 3: 0, 4: false })
component.value = { 1: 'value1' }
component.selectedFields = [1, 2]
expect(component.selectedFields).toEqual([1, 2])
expect(component.value).toEqual({ 1: 'value1', 2: null })
})
it('should return the correct custom field by id', () => {
@@ -77,7 +77,7 @@ export class CustomFieldsValuesComponent extends AbstractInputComponent<Object>
this._selectedFields = newFields
// map the selected fields to an object with field_id as key and value as value
this.value = newFields.reduce((acc, fieldId) => {
acc[fieldId] = this.value?.[fieldId] ?? null
acc[fieldId] = this.value?.[fieldId] || null
return acc
}, {})
this.onChange(this.value)
-6
View File
@@ -1047,12 +1047,6 @@ class PermittedObjectsFilter(BaseFilterBackend):
perm_codename: str | None = None
def filter_queryset(self, request, queryset, view):
# Before the superuser and owner-only paths, neither of which consults
# permitted_object_ids. Scoped to authenticated users so anonymous
# access (AnonymousUser.is_active is False) keeps its existing
# unowned-only behaviour.
if request.user.is_authenticated and not request.user.is_active:
return queryset.none()
if request.user.is_superuser:
return queryset
if not self.include_granted:
+3 -18
View File
@@ -54,15 +54,11 @@ class PaperlessObjectPermissions(DjangoObjectPermissions):
class PaperlessAdminPermissions(BasePermission):
def has_permission(self, request, view):
return request.user.is_active and request.user.is_staff
return request.user.is_staff
def has_global_statistics_permission(user: User | None) -> bool:
if (
user is None
or not getattr(user, "is_active", False)
or not getattr(user, "is_authenticated", False)
):
if user is None or not getattr(user, "is_authenticated", False):
return False
return getattr(user, "is_superuser", False) or user.has_perm(
@@ -71,11 +67,7 @@ def has_global_statistics_permission(user: User | None) -> bool:
def has_system_status_permission(user: User | None) -> bool:
if (
user is None
or not getattr(user, "is_active", False)
or not getattr(user, "is_authenticated", False)
):
if user is None or not getattr(user, "is_authenticated", False):
return False
return (
@@ -196,13 +188,6 @@ def permitted_object_ids(
if user is None or not getattr(user, "is_authenticated", False):
return base_qs.filter(owner__isnull=True).values_list("id", flat=True)
# Deactivated users get nothing, deactivated superusers included, so this
# has to come before the superuser shortcut. guardian's
# ObjectPermissionChecker denies inactive users, but get_objects_for_user
# (the pattern this replaces) does not, so it would not be inherited.
if not getattr(user, "is_active", False):
return base_qs.none().values_list("id", flat=True)
if getattr(user, "is_superuser", False):
return base_qs.values_list("id", flat=True)
-7
View File
@@ -3213,13 +3213,6 @@ class WorkflowActionSerializer(serializers.ModelSerializer[WorkflowAction]):
{"assign_title": f'Invalid f-string detected: "{e.args[0]}"'},
)
if attrs.get("assign_custom_fields_values"):
# Empty strings treated as None to avoid unexpected behavior
attrs["assign_custom_fields_values"] = {
field_id: (None if value == "" else value)
for field_id, value in attrs["assign_custom_fields_values"].items()
}
if (
"type" in attrs
and attrs["type"] == WorkflowAction.WorkflowActionType.EMAIL
@@ -422,11 +422,6 @@ class TestApiWorkflows(DirectoriesMixin, APITestCase):
json.dumps(
{
"assign_title": "",
"assign_custom_fields": [self.cf1.id, self.cf2.id],
"assign_custom_fields_values": {
str(self.cf1.id): "",
str(self.cf2.id): 0,
},
},
),
content_type="application/json",
@@ -434,10 +429,6 @@ class TestApiWorkflows(DirectoriesMixin, APITestCase):
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
action = WorkflowAction.objects.get(id=response.data["id"])
self.assertIsNone(action.assign_title)
self.assertEqual(
action.assign_custom_fields_values,
{str(self.cf1.id): None, str(self.cf2.id): 0},
)
response = self.client.post(
self.ENDPOINT_TRIGGERS,
@@ -496,28 +496,6 @@ class TestPermittedObjectIdsGenericModels:
expected_hidden=[strangers.pk],
)
@pytest.mark.parametrize("is_superuser", [False, True])
def test_inactive_user_sees_nothing(self, model, factory, perm, is_superuser):
suffix = f"{model.__name__}_{is_superuser}"
user = User.objects.create_user(
username=f"inactive_{suffix}",
is_active=False,
is_superuser=is_superuser,
)
other = User.objects.create_user(username=f"other_{suffix}")
granted = factory(owner=other)
assign_perm(perm, user, granted)
assert_visible_document_ids(
permitted_object_ids(user, model, perm),
expected_visible=[],
expected_hidden=[
factory(owner=None).pk,
factory(owner=user).pk,
granted.pk,
],
)
def test_unowned_object_visible_to_everyone(self, model, factory, perm):
user = User.objects.create_user(username=f"user_{model.__name__}")
unowned = factory(owner=None)
@@ -68,44 +68,3 @@ class TestPermittedObjectsFilter:
visible_ids = set(result.values_list("id", flat=True))
assert visible_ids == {owned.pk}
assert granted.pk not in visible_ids
@pytest.mark.parametrize(
("username", "is_superuser"),
[("inactive", False), ("inactive_super", True)],
)
def test_inactive_user_sees_nothing(self, username: str, *, is_superuser: bool):
user = User.objects.create_user(
username=username,
is_active=False,
is_superuser=is_superuser,
)
TagFactory(owner=None)
TagFactory(owner=user)
granted = TagFactory(owner=User.objects.create_user(username=f"o_{username}"))
assign_perm("view_tag", user, granted)
request = APIRequestFactory().get("/")
request.user = user
result = PermittedObjectsFilter().filter_queryset(
request,
Tag.objects.all(),
_DummyView(),
)
assert result.count() == 0
def test_inactive_user_sees_nothing_with_include_granted_false(self):
user = User.objects.create_user(username="inactive_owner", is_active=False)
TagFactory(owner=user)
TagFactory(owner=None)
request = APIRequestFactory().get("/")
request.user = user
class _OwnerOnlyFilter(PermittedObjectsFilter):
include_granted = False
result = _OwnerOnlyFilter().filter_queryset(
request,
Tag.objects.all(),
_DummyView(),
)
assert result.count() == 0
-49
View File
@@ -2000,55 +2000,6 @@ class TestWorkflows(
r"Doc added in \w{3,}",
) # Match any 3-letter month name
def test_document_updated_workflow_existing_custom_field_empty_value(self) -> None:
"""
GIVEN:
- Existing workflow with UPDATED trigger and action that assigns a custom field
with an empty value
WHEN:
- Document is updated that already contains the field with a value
THEN:
- The existing value is left untouched, see GH #13627
"""
trigger = WorkflowTrigger.objects.create(
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED,
filter_has_document_type=self.dt,
)
action = WorkflowAction.objects.create()
action.assign_custom_fields.add(self.cf1)
action.assign_custom_fields_values = {self.cf1.pk: ""}
action.save()
w = Workflow.objects.create(
name="Workflow 1",
order=0,
)
w.triggers.add(trigger)
w.actions.add(action)
w.save()
doc = Document.objects.create(
title="sample test",
correspondent=self.c,
original_filename="sample.pdf",
)
CustomFieldInstance.objects.create(
document=doc,
field=self.cf1,
value_text="existing value",
)
superuser = User.objects.create_superuser("superuser")
self.client.force_authenticate(user=superuser)
self.client.patch(
f"/api/documents/{doc.id}/",
{"document_type": self.dt.id},
format="json",
)
doc.refresh_from_db()
self.assertEqual(doc.custom_fields.get(field=self.cf1).value, "existing value")
def test_document_updated_workflow_existing_custom_field(self) -> None:
"""
GIVEN:
+1 -2
View File
@@ -105,8 +105,7 @@ def apply_assignment_to_document(
field=field,
document=document,
).first()
# empty string is indistinguishable from no value in the UI
if instance and args[value_field_name] not in (None, ""):
if instance and args[value_field_name] is not None:
setattr(instance, value_field_name, args[value_field_name])
instance.save()
elif not instance:
+2 -2
View File
@@ -2,7 +2,7 @@ msgid ""
msgstr ""
"Project-Id-Version: paperless-ngx\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-08-10 02:25+0000\n"
"POT-Creation-Date: 2026-08-08 14:28+0000\n"
"PO-Revision-Date: 2022-02-17 04:17\n"
"Last-Translator: \n"
"Language-Team: English\n"
@@ -53,7 +53,7 @@ msgstr ""
msgid "Maximum nesting depth exceeded."
msgstr ""
#: documents/filters.py:1079
#: documents/filters.py:1073
msgid "Custom field not found"
msgstr ""
+1 -4
View File
@@ -19,10 +19,7 @@ class AutoLoginMiddleware(MiddlewareMixin):
if request.path.startswith("/api/token/") and request.method == "POST":
return None
try:
request.user = User.objects.get(
username=settings.AUTO_LOGIN_USERNAME,
is_active=True,
)
request.user = User.objects.get(username=settings.AUTO_LOGIN_USERNAME)
auth.login(
request=request,
user=request.user,
@@ -1,53 +0,0 @@
from django.contrib.auth.models import AnonymousUser
from django.contrib.auth.models import User
from django.test import RequestFactory
from django.test import TestCase
from django.test import override_settings
from paperless.auth import AutoLoginMiddleware
@override_settings(AUTO_LOGIN_USERNAME="autologin")
class TestAutoLoginMiddleware(TestCase):
def setUp(self) -> None:
super().setUp()
self.factory = RequestFactory()
self.middleware = AutoLoginMiddleware(lambda request: None)
def _process(self, request):
# login() needs a session to write to
request.session = self.client.session
self.middleware.process_request(request)
return request
def test_active_user_is_logged_in(self) -> None:
"""
GIVEN:
- AUTO_LOGIN_USERNAME names an active user
WHEN:
- A request is processed by the middleware
THEN:
- That user is attached to the request
"""
user = User.objects.create_user(username="autologin")
request = self._process(self.factory.get("/"))
self.assertEqual(request.user, user)
def test_deactivated_user_is_not_logged_in(self) -> None:
"""
GIVEN:
- AUTO_LOGIN_USERNAME names a user who has been deactivated
WHEN:
- A request is processed by the middleware
THEN:
- The request is left anonymous rather than authenticated as them
"""
User.objects.create_user(username="autologin", is_active=False)
request = self.factory.get("/")
request.user = AnonymousUser()
self._process(request)
self.assertFalse(request.user.is_authenticated)
Generated
+22 -22
View File
@@ -1298,16 +1298,16 @@ wheels = [
[[package]]
name = "fpdf2"
version = "2.8.8"
version = "2.8.7"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "defusedxml", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
{ name = "fonttools", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
{ name = "pillow", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/1e/bc/8fd4321aed40cadadddc8f311c65b6082346b252bca048f7b476d8f35d72/fpdf2-2.8.8.tar.gz", hash = "sha256:9e94e155e85e8053329a9a1fce8b566fd7a7c5bb79e98a1a3952d379b947c5b9", size = 374689, upload-time = "2026-08-09T23:32:45.334Z" }
sdist = { url = "https://files.pythonhosted.org/packages/27/f2/72feae0b2827ed38013e4307b14f95bf0b3d124adfef4d38a7d57533f7be/fpdf2-2.8.7.tar.gz", hash = "sha256:7060ccee5a9c7ab0a271fb765a36a23639f83ef8996c34e3d46af0a17ede57f9", size = 362351, upload-time = "2026-02-28T05:39:16.456Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f5/be/af012eda9507494f28b99b077423806c43a11573eb6225dd46f19ae2d263/fpdf2-2.8.8-py3-none-any.whl", hash = "sha256:3557a478fc577a929c94aace9666aed4dcc432b5ab6764232e6a59f1ccd75f17", size = 337000, upload-time = "2026-08-09T23:32:43.728Z" },
{ url = "https://files.pythonhosted.org/packages/66/0a/cf50ecffa1e3747ed9380a3adfc829259f1f86b3fdbd9e505af789003141/fpdf2-2.8.7-py3-none-any.whl", hash = "sha256:d391fc508a3ce02fc43a577c830cda4fe6f37646f2d143d489839940932fbc19", size = 327056, upload-time = "2026-02-28T05:39:14.619Z" },
]
[[package]]
@@ -2927,8 +2927,8 @@ dependencies = [
{ name = "sqlite-vec", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
{ name = "tantivy", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
{ name = "tika-client", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
{ name = "torch", version = "2.13.0", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "python_full_version < '3.15' and sys_platform == 'darwin'" },
{ name = "torch", version = "2.13.0+cpu", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "(python_full_version >= '3.15' and sys_platform == 'darwin') or sys_platform == 'linux'" },
{ name = "torch", version = "2.13.0", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "sys_platform == 'darwin'" },
{ name = "torch", version = "2.13.0+cpu", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "sys_platform == 'linux'" },
{ name = "watchfiles", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
{ name = "whitenoise", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
{ name = "zxing-cpp", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
@@ -4511,8 +4511,8 @@ dependencies = [
{ name = "numpy", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
{ name = "scikit-learn", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
{ name = "scipy", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
{ name = "torch", version = "2.13.0", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "python_full_version < '3.15' and sys_platform == 'darwin'" },
{ name = "torch", version = "2.13.0+cpu", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "(python_full_version >= '3.15' and sys_platform == 'darwin') or sys_platform == 'linux'" },
{ name = "torch", version = "2.13.0", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "sys_platform == 'darwin'" },
{ name = "torch", version = "2.13.0+cpu", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "sys_platform == 'linux'" },
{ name = "tqdm", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
{ name = "transformers", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
{ name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
@@ -4957,17 +4957,18 @@ name = "torch"
version = "2.13.0"
source = { registry = "https://download.pytorch.org/whl/cpu" }
resolution-markers = [
"python_full_version >= '3.15' and sys_platform == 'darwin'",
"python_full_version >= '3.12' and python_full_version < '3.15' and sys_platform == 'darwin'",
"python_full_version < '3.12' and sys_platform == 'darwin'",
]
dependencies = [
{ name = "filelock", marker = "python_full_version < '3.15' and sys_platform == 'darwin'" },
{ name = "fsspec", marker = "python_full_version < '3.15' and sys_platform == 'darwin'" },
{ name = "jinja2", marker = "python_full_version < '3.15' and sys_platform == 'darwin'" },
{ name = "networkx", marker = "python_full_version < '3.15' and sys_platform == 'darwin'" },
{ name = "setuptools", marker = "python_full_version < '3.15' and sys_platform == 'darwin'" },
{ name = "sympy", marker = "python_full_version < '3.15' and sys_platform == 'darwin'" },
{ name = "typing-extensions", marker = "python_full_version < '3.15' and sys_platform == 'darwin'" },
{ name = "filelock", marker = "sys_platform == 'darwin'" },
{ name = "fsspec", marker = "sys_platform == 'darwin'" },
{ name = "jinja2", marker = "sys_platform == 'darwin'" },
{ name = "networkx", marker = "sys_platform == 'darwin'" },
{ name = "setuptools", marker = "sys_platform == 'darwin'" },
{ name = "sympy", marker = "sys_platform == 'darwin'" },
{ name = "typing-extensions", marker = "sys_platform == 'darwin'" },
]
wheels = [
{ url = "https://download-r2.pytorch.org/whl/cpu/torch-2.13.0-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:e76f9bcecc52b8ff711239a2f7547d5353df95878ab232f0773c1d95928b92f8", upload-time = "2026-07-08T12:26:13Z" },
@@ -4982,7 +4983,6 @@ name = "torch"
version = "2.13.0+cpu"
source = { registry = "https://download.pytorch.org/whl/cpu" }
resolution-markers = [
"python_full_version >= '3.15' and sys_platform == 'darwin'",
"python_full_version == '3.12.*' and platform_machine == 'x86_64' and sys_platform == 'linux'",
"python_full_version == '3.12.*' and platform_machine == 'aarch64' and sys_platform == 'linux'",
"python_full_version >= '3.15' and sys_platform == 'linux'",
@@ -4990,13 +4990,13 @@ resolution-markers = [
"python_full_version < '3.12' and sys_platform == 'linux'",
]
dependencies = [
{ name = "filelock", marker = "(python_full_version >= '3.15' and sys_platform == 'darwin') or sys_platform == 'linux'" },
{ name = "fsspec", marker = "(python_full_version >= '3.15' and sys_platform == 'darwin') or sys_platform == 'linux'" },
{ name = "jinja2", marker = "(python_full_version >= '3.15' and sys_platform == 'darwin') or sys_platform == 'linux'" },
{ name = "networkx", marker = "(python_full_version >= '3.15' and sys_platform == 'darwin') or sys_platform == 'linux'" },
{ name = "setuptools", marker = "(python_full_version >= '3.15' and sys_platform == 'darwin') or sys_platform == 'linux'" },
{ name = "sympy", marker = "(python_full_version >= '3.15' and sys_platform == 'darwin') or sys_platform == 'linux'" },
{ name = "typing-extensions", marker = "(python_full_version >= '3.15' and sys_platform == 'darwin') or sys_platform == 'linux'" },
{ name = "filelock", marker = "sys_platform == 'linux'" },
{ name = "fsspec", marker = "sys_platform == 'linux'" },
{ name = "jinja2", marker = "sys_platform == 'linux'" },
{ name = "networkx", marker = "sys_platform == 'linux'" },
{ name = "setuptools", marker = "sys_platform == 'linux'" },
{ name = "sympy", marker = "sys_platform == 'linux'" },
{ name = "typing-extensions", marker = "sys_platform == 'linux'" },
]
wheels = [
{ url = "https://download-r2.pytorch.org/whl/cpu/torch-2.13.0%2Bcpu-cp311-cp311-linux_s390x.whl", hash = "sha256:6e9817dbdf5ea76789babd46e457eac5bf14ff566cf85f8addbfdff2d56601ce", upload-time = "2026-07-08T19:27:52Z" },