Compare commits

..

11 Commits

Author SHA1 Message Date
shamoon
f6705ae036 Fixhancement: refactor email attachment logic 2025-11-10 11:54:18 -08:00
shamoon
44f0191bfb Fix: only cache remote version for version checking (#11320) 2025-11-09 00:34:46 +00:00
shamoon
e9f846ca24 Fix: include replace none logic in storage path preview, improve jinja conditionals for empty metadata (#11315) 2025-11-08 13:31:57 -08:00
GitHub Actions
2049497b76 Auto translate strings 2025-11-07 19:23:35 +00:00
shamoon
2a9d1fce0d Chore: include password validation on user edit (#11308) 2025-11-07 11:20:27 -08:00
shamoon
808c074f48 Merge branch 'main' into dev 2025-11-06 13:04:32 -08:00
github-actions[bot]
7927e5c436 Changelog v2.19.5 - GHA (#11305) 2025-11-06 13:01:52 -08:00
shamoon
cac48c9855 Bump version to 2.19.5 2025-11-06 11:39:08 -08:00
github-actions[bot]
3fda648f37 New Crowdin translations by GitHub Action (#11288) 2025-11-06 19:36:26 +00:00
dependabot[bot]
95736eebc4 docker(deps): Bump astral-sh/uv (#11283)
Bumps [astral-sh/uv](https://github.com/astral-sh/uv) from 0.9.4-python3.12-bookworm-slim to 0.9.7-python3.12-bookworm-slim.
- [Release notes](https://github.com/astral-sh/uv/releases)
- [Changelog](https://github.com/astral-sh/uv/blob/main/CHANGELOG.md)
- [Commits](https://github.com/astral-sh/uv/compare/0.9.4...0.9.7)

---
updated-dependencies:
- dependency-name: astral-sh/uv
  dependency-version: 0.9.7-python3.12-bookworm-slim
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-11-04 20:12:56 -08:00
shamoon
85027dbffd Fix: ensure custom field query propagation, change detection (#11291) 2025-11-04 12:40:05 -08:00
34 changed files with 518 additions and 160 deletions

View File

@@ -32,7 +32,7 @@ RUN set -eux \
# Purpose: Installs s6-overlay and rootfs
# Comments:
# - Don't leave anything extra in here either
FROM ghcr.io/astral-sh/uv:0.9.4-python3.12-bookworm-slim AS s6-overlay-base
FROM ghcr.io/astral-sh/uv:0.9.7-python3.12-bookworm-slim AS s6-overlay-base
WORKDIR /usr/src/s6

View File

@@ -1,5 +1,19 @@
# Changelog
## paperless-ngx 2.19.5
### Bug Fixes
- Fix: ensure custom field query propagation, change detection [@shamoon](https://github.com/shamoon) ([#11291](https://github.com/paperless-ngx/paperless-ngx/pull/11291))
### Dependencies
- docker(deps): Bump astral-sh/uv from 0.9.4-python3.12-bookworm-slim to 0.9.7-python3.12-bookworm-slim @[dependabot[bot]](https://github.com/apps/dependabot) ([#11283](https://github.com/paperless-ngx/paperless-ngx/pull/11283))
### All App Changes
- Fix: ensure custom field query propagation, change detection [@shamoon](https://github.com/shamoon) ([#11291](https://github.com/paperless-ngx/paperless-ngx/pull/11291))
## paperless-ngx 2.19.4
### Bug Fixes

View File

@@ -1,6 +1,6 @@
[project]
name = "paperless-ngx"
version = "2.19.4"
version = "2.19.5"
description = "A community-supported supercharged document management system: scan, index and archive all your physical documents"
readme = "README.md"
requires-python = ">=3.10"

View File

@@ -2557,7 +2557,7 @@
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.ts</context>
<context context-type="linenumber">195</context>
<context context-type="linenumber">196</context>
</context-group>
</trans-unit>
<trans-unit id="2753185112875184719" datatype="html">
@@ -6044,71 +6044,71 @@
<source>Profile updated successfully</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.ts</context>
<context context-type="linenumber">192</context>
<context context-type="linenumber">193</context>
</context-group>
</trans-unit>
<trans-unit id="3417726855410304962" datatype="html">
<source>Error saving profile</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.ts</context>
<context context-type="linenumber">206</context>
<context context-type="linenumber">207</context>
</context-group>
</trans-unit>
<trans-unit id="154249228726292516" datatype="html">
<source>Error generating auth token</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.ts</context>
<context context-type="linenumber">223</context>
<context context-type="linenumber">225</context>
</context-group>
</trans-unit>
<trans-unit id="4153637646944982460" datatype="html">
<source>Error disconnecting social account</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.ts</context>
<context context-type="linenumber">248</context>
<context context-type="linenumber">250</context>
</context-group>
</trans-unit>
<trans-unit id="5939111172212776886" datatype="html">
<source>Error fetching TOTP settings</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.ts</context>
<context context-type="linenumber">267</context>
<context context-type="linenumber">269</context>
</context-group>
</trans-unit>
<trans-unit id="1030314492414713260" datatype="html">
<source>TOTP activated successfully</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.ts</context>
<context context-type="linenumber">288</context>
<context context-type="linenumber">290</context>
</context-group>
</trans-unit>
<trans-unit id="3755006064892435830" datatype="html">
<source>Error activating TOTP</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.ts</context>
<context context-type="linenumber">290</context>
<context context-type="linenumber">292</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.ts</context>
<context context-type="linenumber">296</context>
<context context-type="linenumber">298</context>
</context-group>
</trans-unit>
<trans-unit id="5919827473541889422" datatype="html">
<source>TOTP deactivated successfully</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.ts</context>
<context context-type="linenumber">312</context>
<context context-type="linenumber">314</context>
</context-group>
</trans-unit>
<trans-unit id="6214722303383624015" datatype="html">
<source>Error deactivating TOTP</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.ts</context>
<context context-type="linenumber">314</context>
<context context-type="linenumber">316</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.ts</context>
<context context-type="linenumber">319</context>
<context context-type="linenumber">321</context>
</context-group>
</trans-unit>
<trans-unit id="6617773613987957957" datatype="html">

View File

@@ -1,6 +1,6 @@
{
"name": "paperless-ngx-ui",
"version": "2.19.4",
"version": "2.19.5",
"scripts": {
"preinstall": "npx only-allow pnpm",
"ng": "ng",

View File

@@ -354,5 +354,13 @@ describe('CustomFieldsQueryDropdownComponent', () => {
model.removeElement(atom)
expect(completeSpy).toHaveBeenCalled()
})
it('should subscribe to existing elements when queries are assigned', () => {
const expression = new CustomFieldQueryExpression()
const nextSpy = jest.spyOn(model.changed, 'next')
model.queries = [expression]
expression.changed.next(expression)
expect(nextSpy).toHaveBeenCalledWith(model)
})
})
})

View File

@@ -17,7 +17,7 @@ import {
} from '@ng-bootstrap/ng-bootstrap'
import { NgSelectComponent, NgSelectModule } from '@ng-select/ng-select'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { first, Subject, takeUntil } from 'rxjs'
import { first, Subject, Subscription, takeUntil } from 'rxjs'
import { CustomField, CustomFieldDataType } from 'src/app/data/custom-field'
import {
CUSTOM_FIELD_QUERY_MAX_ATOMS,
@@ -41,10 +41,27 @@ import { ClearableBadgeComponent } from '../clearable-badge/clearable-badge.comp
import { DocumentLinkComponent } from '../input/document-link/document-link.component'
export class CustomFieldQueriesModel {
public queries: CustomFieldQueryElement[] = []
private _queries: CustomFieldQueryElement[] = []
private rootSubscriptions: Subscription[] = []
public readonly changed = new Subject<CustomFieldQueriesModel>()
public get queries(): CustomFieldQueryElement[] {
return this._queries
}
public set queries(value: CustomFieldQueryElement[]) {
this.teardownRootSubscriptions()
this._queries = value ?? []
for (const element of this._queries) {
this.rootSubscriptions.push(
element.changed.subscribe(() => {
this.changed.next(this)
})
)
}
}
public clear(fireEvent = true) {
this.queries = []
if (fireEvent) {
@@ -107,14 +124,14 @@ export class CustomFieldQueriesModel {
public addExpression(
expression: CustomFieldQueryExpression = new CustomFieldQueryExpression()
) {
if (this.queries.length > 0) {
;(
(this.queries[0] as CustomFieldQueryExpression)
.value as CustomFieldQueryElement[]
).push(expression)
} else {
this.queries.push(expression)
if (this.queries.length === 0) {
this.queries = [expression]
return
}
;(
(this.queries[0] as CustomFieldQueryExpression)
.value as CustomFieldQueryElement[]
).push(expression)
expression.changed.subscribe(() => {
this.changed.next(this)
})
@@ -166,6 +183,13 @@ export class CustomFieldQueriesModel {
this.changed.next(this)
}
}
private teardownRootSubscriptions() {
for (const subscription of this.rootSubscriptions) {
subscription.unsubscribe()
}
this.rootSubscriptions = []
}
}
@Component({

View File

@@ -210,7 +210,6 @@
<pngx-input-tags
[allowCreate]="false"
[title]="null"
[autoHeirarchy]="false"
formControlName="values"
></pngx-input-tags>
} @else if (

View File

@@ -103,9 +103,6 @@ export class TagsComponent implements OnInit, ControlValueAccessor {
@Input()
multiple: boolean = true
@Input()
autoHeirarchy: boolean = true
@Output()
filterDocuments = new EventEmitter<Tag[]>()
@@ -137,7 +134,7 @@ export class TagsComponent implements OnInit, ControlValueAccessor {
oldValue.splice(index, 1)
// remove children
if (this.autoHeirarchy) oldValue = this.removeChildren(oldValue, tag)
oldValue = this.removeChildren(oldValue, tag)
this.value = [...oldValue]
this.onChange(this.value)
@@ -156,7 +153,7 @@ export class TagsComponent implements OnInit, ControlValueAccessor {
}
public onAdd(tag: Tag) {
if (this.autoHeirarchy && tag.parent) {
if (tag.parent) {
// add all parents recursively
const parent = this.getTag(tag.parent)
this.value = [...this.value, parent.id]

View File

@@ -183,6 +183,7 @@ export class ProfileEditDialogComponent
this.newPassword && this.currentPassword !== this.newPassword
const profile = Object.assign({}, this.form.value)
delete profile.totp_code
this.error = null
this.networkActive = true
this.profileService
.update(profile)
@@ -204,6 +205,7 @@ export class ProfileEditDialogComponent
},
error: (error) => {
this.toastService.showError($localize`Error saving profile`, error)
this.error = error?.error
this.networkActive = false
},
})

View File

@@ -1,4 +1,3 @@
import { fakeAsync, tick } from '@angular/core/testing'
import {
CustomFieldQueryElementType,
CustomFieldQueryLogicalOperator,
@@ -111,13 +110,38 @@ describe('CustomFieldQueryAtom', () => {
expect(atom.serialize()).toEqual([1, 'operator', 'value'])
})
it('should emit changed on value change after debounce', fakeAsync(() => {
it('should emit changed on value change immediately', () => {
const atom = new CustomFieldQueryAtom()
const changeSpy = jest.spyOn(atom.changed, 'next')
atom.value = 'new value'
tick(1000)
expect(changeSpy).toHaveBeenCalled()
}))
})
it('should ignore duplicate array emissions', () => {
const atom = new CustomFieldQueryAtom()
atom.operator = CustomFieldQueryOperator.In
const changeSpy = jest.fn()
atom.changed.subscribe(changeSpy)
atom.value = [1, 2]
expect(changeSpy).toHaveBeenCalledTimes(1)
changeSpy.mockClear()
atom.value = [1, 2]
expect(changeSpy).not.toHaveBeenCalled()
})
it('should emit when array values differ while length matches', () => {
const atom = new CustomFieldQueryAtom()
atom.operator = CustomFieldQueryOperator.In
const changeSpy = jest.fn()
atom.changed.subscribe(changeSpy)
atom.value = [1, 2]
changeSpy.mockClear()
atom.value = [1, 3]
expect(changeSpy).toHaveBeenCalledTimes(1)
})
})
describe('CustomFieldQueryExpression', () => {

View File

@@ -1,4 +1,4 @@
import { Subject, debounceTime, distinctUntilChanged } from 'rxjs'
import { Subject, distinctUntilChanged } from 'rxjs'
import { v4 as uuidv4 } from 'uuid'
import {
CUSTOM_FIELD_QUERY_VALUE_TYPES_BY_OPERATOR,
@@ -110,7 +110,22 @@ export class CustomFieldQueryAtom extends CustomFieldQueryElement {
protected override connectValueModelChanged(): void {
this.valueModelChanged
.pipe(debounceTime(1000), distinctUntilChanged())
.pipe(
distinctUntilChanged((previous, current) => {
if (Array.isArray(previous) && Array.isArray(current)) {
if (previous.length !== current.length) {
return false
}
for (let i = 0; i < previous.length; i++) {
if (previous[i] !== current[i]) {
return false
}
}
return true
}
return previous === current
})
)
.subscribe(() => {
this.changed.next(this)
})

View File

@@ -6,7 +6,7 @@ export const environment = {
apiVersion: '9', // match src/paperless/settings.py
appTitle: 'Paperless-ngx',
tag: 'prod',
version: '2.19.4',
version: '2.19.5',
webSocketHost: window.location.host,
webSocketProtocol: window.location.protocol == 'https:' ? 'wss:' : 'ws:',
webSocketBaseUrl: base_url.pathname + 'ws/',

View File

@@ -764,7 +764,7 @@
<context context-type="sourcefile">src/app/components/admin/logs/logs.component.html</context>
<context context-type="linenumber">17</context>
</context-group>
<target state="needs-translation">lines</target>
<target state="translated">línies</target>
</trans-unit>
<trans-unit id="8838884664569764142" datatype="html">
<source>Auto refresh</source>
@@ -8700,7 +8700,7 @@
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
<context context-type="linenumber">18</context>
</context-group>
<target state="needs-translation">Select:</target>
<target state="translated">Selecciona:</target>
</trans-unit>
<trans-unit id="6252070156626006029" datatype="html">
<source>None</source>

View File

@@ -764,7 +764,7 @@
<context context-type="sourcefile">src/app/components/admin/logs/logs.component.html</context>
<context context-type="linenumber">17</context>
</context-group>
<target state="needs-translation">lines</target>
<target state="translated">lignes</target>
</trans-unit>
<trans-unit id="8838884664569764142" datatype="html" approved="yes">
<source>Auto refresh</source>
@@ -8699,7 +8699,7 @@
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
<context context-type="linenumber">18</context>
</context-group>
<target state="needs-translation">Select:</target>
<target state="translated">Sélectionner :</target>
</trans-unit>
<trans-unit id="6252070156626006029" datatype="html" approved="yes">
<source>None</source>

View File

@@ -8700,7 +8700,7 @@
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
<context context-type="linenumber">18</context>
</context-group>
<target state="needs-translation">Select:</target>
<target state="translated">選取:</target>
</trans-unit>
<trans-unit id="6252070156626006029" datatype="html">
<source>None</source>

View File

@@ -99,6 +99,29 @@ def generate_unique_filename(doc, *, archive_filename=False) -> Path:
return new_filename
def format_filename(document: Document, template_str: str) -> str | None:
rendered_filename = validate_filepath_template_and_render(
template_str,
document,
)
if rendered_filename is None:
return None
# Apply this setting. It could become a filter in the future (or users could use |default)
if settings.FILENAME_FORMAT_REMOVE_NONE:
rendered_filename = rendered_filename.replace("/-none-/", "/")
rendered_filename = rendered_filename.replace(" -none-", "")
rendered_filename = rendered_filename.replace("-none-", "")
rendered_filename = rendered_filename.strip(os.sep)
rendered_filename = rendered_filename.replace(
"-none-",
"none",
) # backward compatibility
return rendered_filename
def generate_filename(
doc: Document,
*,
@@ -108,28 +131,6 @@ def generate_filename(
) -> Path:
base_path: Path | None = None
def format_filename(document: Document, template_str: str) -> str | None:
rendered_filename = validate_filepath_template_and_render(
template_str,
document,
)
if rendered_filename is None:
return None
# Apply this setting. It could become a filter in the future (or users could use |default)
if settings.FILENAME_FORMAT_REMOVE_NONE:
rendered_filename = rendered_filename.replace("/-none-/", "/")
rendered_filename = rendered_filename.replace(" -none-", "")
rendered_filename = rendered_filename.replace("-none-", "")
rendered_filename = rendered_filename.strip(os.sep)
rendered_filename = rendered_filename.replace(
"-none-",
"none",
) # backward compatibility
return rendered_filename
# Determine the source of the format string
if doc.storage_path is not None:
filename_format = doc.storage_path.path

View File

@@ -1,25 +1,26 @@
from __future__ import annotations
from dataclasses import dataclass
from email import message_from_bytes
from typing import TYPE_CHECKING
from pathlib import Path
from django.conf import settings
from django.core.mail import EmailMessage
from filelock import FileLock
from documents.data_models import ConsumableDocument
if TYPE_CHECKING:
from documents.models import Document
@dataclass(frozen=True)
class EmailAttachment:
path: Path
mime_type: str
friendly_name: str
def send_email(
subject: str,
body: str,
to: list[str],
attachments: list[Document | ConsumableDocument],
*,
use_archive: bool,
attachments: list[EmailAttachment],
) -> int:
"""
Send an email with attachments.
@@ -28,8 +29,7 @@ def send_email(
subject: Email subject
body: Email body text
to: List of recipient email addresses
attachments: List of documents to attach (the list may be empty)
use_archive: Whether to attach archive versions when available
attachments: List of attachments
Returns:
Number of emails sent
@@ -46,47 +46,41 @@ def send_email(
# Something could be renaming the file concurrently so it can't be attached
with FileLock(settings.MEDIA_LOCK):
for document in attachments:
if isinstance(document, ConsumableDocument):
attachment_path = document.original_file
friendly_filename = document.original_file.name
else:
attachment_path = (
document.archive_path
if use_archive and document.has_archive_version
else document.source_path
)
friendly_filename = _get_unique_filename(
document,
used_filenames,
archive=use_archive and document.has_archive_version,
)
used_filenames.add(friendly_filename)
for attachment in attachments:
filename = _get_unique_filename(
attachment.friendly_name,
used_filenames,
)
used_filenames.add(filename)
with attachment_path.open("rb") as f:
with attachment.path.open("rb") as f:
content = f.read()
if document.mime_type == "message/rfc822":
if attachment.mime_type == "message/rfc822":
# See https://forum.djangoproject.com/t/using-emailmessage-with-an-attached-email-file-crashes-due-to-non-ascii/37981
content = message_from_bytes(content)
email.attach(
filename=friendly_filename,
filename=filename,
content=content,
mimetype=document.mime_type,
mimetype=attachment.mime_type,
)
return email.send()
def _get_unique_filename(doc: Document, used_names: set[str], *, archive: bool) -> str:
def _get_unique_filename(friendly_name: str, used_names: set[str]) -> str:
"""
Constructs a unique friendly filename for the given document.
Constructs a unique friendly filename for the given document, append a counter if needed.
"""
if friendly_name not in used_names:
return friendly_name
The filename might not be unique enough, so a counter is appended if needed.
"""
counter = 0
stem = Path(friendly_name).stem
suffix = "".join(Path(friendly_name).suffixes)
counter = 1
while True:
filename = doc.get_public_filename(archive=archive, counter=counter)
filename = f"{stem}_{counter:02}{suffix}"
if filename not in used_names:
return filename
counter += 1

View File

@@ -31,11 +31,10 @@ from guardian.shortcuts import remove_perm
from documents import matching
from documents.caching import clear_document_caches
from documents.data_models import ConsumableDocument
from documents.data_models import DocumentSource
from documents.file_handling import create_source_path_directory
from documents.file_handling import delete_empty_directories
from documents.file_handling import generate_unique_filename
from documents.mail import EmailAttachment
from documents.mail import send_email
from documents.models import Correspondent
from documents.models import CustomField
@@ -57,6 +56,7 @@ from documents.templating.workflows import parse_w_workflow_placeholders
if TYPE_CHECKING:
from documents.classifier import DocumentClassifier
from documents.data_models import ConsumableDocument
from documents.data_models import DocumentMetadataOverrides
logger = logging.getLogger("paperless.handlers")
@@ -1163,28 +1163,41 @@ def run_workflows(
else ""
)
try:
attachments = []
attachments: list[EmailAttachment] = []
if action.email.include_document:
attachment: EmailAttachment | None = None
if trigger_type in [
WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED,
WorkflowTrigger.WorkflowTriggerType.SCHEDULED,
]:
# Updated and scheduled can pass the document directly
attachments = [document]
] and isinstance(document, Document):
friendly_name = (
Path(current_filename).name
if current_filename
else document.source_path.name
)
attachment = EmailAttachment(
path=document.source_path,
mime_type=document.mime_type,
friendly_name=friendly_name,
)
elif original_file:
# For consumed and added document is not yet saved, so pass the original file
attachments = [
ConsumableDocument(
source=DocumentSource.ApiUpload,
original_file=original_file,
),
]
friendly_name = (
Path(current_filename).name
if current_filename
else original_file.name
)
attachment = EmailAttachment(
path=original_file,
mime_type=document.mime_type,
friendly_name=friendly_name,
)
if attachment:
attachments = [attachment]
n_messages = send_email(
subject=subject,
body=body,
to=action.email.to.split(","),
attachments=attachments,
use_archive=False,
)
logger.debug(
f"Sent {n_messages} notification email(s) to {action.email.to}",

View File

@@ -52,6 +52,33 @@ class FilePathTemplate(Template):
return clean_filepath(original_render)
class PlaceholderString(str):
"""
String subclass used as a sentinel for empty metadata values inside templates.
- Renders as \"-none-\" to preserve existing filename cleaning logic.
- Compares equal to either \"-none-\" or \"none\" so templates can check for either.
- Evaluates to False so {% if correspondent %} behaves intuitively.
"""
def __new__(cls, value: str = "-none-"):
return super().__new__(cls, value)
def __bool__(self) -> bool:
return False
def __eq__(self, other) -> bool:
if isinstance(other, str) and other == "none":
other = "-none-"
return super().__eq__(other)
def __ne__(self, other) -> bool:
return not self.__eq__(other)
NO_VALUE_PLACEHOLDER = PlaceholderString("-none-")
_template_environment.undefined = _LogStrictUndefined
_template_environment.filters["get_cf_value"] = get_cf_value
@@ -128,7 +155,7 @@ def get_added_date_context(document: Document) -> dict[str, str]:
def get_basic_metadata_context(
document: Document,
*,
no_value_default: str,
no_value_default: str = NO_VALUE_PLACEHOLDER,
) -> dict[str, str]:
"""
Given a Document, constructs some basic information about it. If certain values are not set,
@@ -266,7 +293,7 @@ def validate_filepath_template_and_render(
# Build the context dictionary
context = (
{"document": document}
| get_basic_metadata_context(document, no_value_default="-none-")
| get_basic_metadata_context(document, no_value_default=NO_VALUE_PLACEHOLDER)
| get_creation_date_context(document)
| get_added_date_context(document)
| get_tags_context(tags_list)

View File

@@ -4,6 +4,7 @@ from unittest import mock
from django.contrib.auth.models import Permission
from django.contrib.auth.models import User
from django.test import override_settings
from rest_framework import status
from rest_framework.test import APITestCase
@@ -334,6 +335,45 @@ class TestApiStoragePaths(DirectoriesMixin, APITestCase):
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data, "path/Something")
def test_test_storage_path_respects_none_placeholder_setting(self):
"""
GIVEN:
- A storage path template referencing an empty field
WHEN:
- Testing the template before and after enabling remove-none
THEN:
- The preview shows "none" by default and drops the placeholder when configured
"""
document = Document.objects.create(
mime_type="application/pdf",
storage_path=self.sp1,
title="Something",
checksum="123",
)
payload = json.dumps(
{
"document": document.id,
"path": "folder/{{ correspondent }}/{{ title }}",
},
)
response = self.client.post(
f"{self.ENDPOINT}test/",
payload,
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data, "folder/none/Something")
with override_settings(FILENAME_FORMAT_REMOVE_NONE=True):
response = self.client.post(
f"{self.ENDPOINT}test/",
payload,
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data, "folder/Something")
class TestBulkEditObjects(APITestCase):
# See test_api_permissions.py for bulk tests on permissions

View File

@@ -648,7 +648,7 @@ class TestApiUser(DirectoriesMixin, APITestCase):
user1 = {
"username": "testuser",
"password": "test",
"password": "areallysupersecretpassword235",
"first_name": "Test",
"last_name": "User",
}
@@ -730,7 +730,7 @@ class TestApiUser(DirectoriesMixin, APITestCase):
f"{self.ENDPOINT}{user1.pk}/",
data={
"first_name": "Updated Name 2",
"password": "123xyz",
"password": "newreallystrongpassword456",
},
)

View File

@@ -192,6 +192,65 @@ class TestApiProfile(DirectoriesMixin, APITestCase):
self.assertEqual(user.first_name, user_data["first_name"])
self.assertEqual(user.last_name, user_data["last_name"])
def test_update_profile_invalid_password_returns_field_error(self):
"""
GIVEN:
- Configured user
WHEN:
- API call is made to update profile with weak password
THEN:
- Profile update fails with password field error
"""
user_data = {
"email": "new@email.com",
"password": "short", # shorter than default validator threshold
"first_name": "new first name",
"last_name": "new last name",
}
response = self.client.patch(self.ENDPOINT, user_data)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertIn("password", response.data)
self.assertIsInstance(response.data["password"], list)
self.assertTrue(
any(
"too short" in message.lower() for message in response.data["password"]
),
)
def test_update_profile_placeholder_password_skips_validation(self):
"""
GIVEN:
- Configured user with existing password
WHEN:
- API call is made with the obfuscated placeholder password value
THEN:
- Profile is updated without changing the password or running validators
"""
original_password = "orig-pass-12345"
self.user.set_password(original_password)
self.user.save()
user_data = {
"email": "new@email.com",
"password": "*" * 12, # matches obfuscated value from serializer
"first_name": "new first name",
"last_name": "new last name",
}
response = self.client.patch(self.ENDPOINT, user_data)
self.assertEqual(response.status_code, status.HTTP_200_OK)
user = User.objects.get(username=self.user.username)
self.assertTrue(user.check_password(original_password))
self.assertEqual(user.email, user_data["email"])
self.assertEqual(user.first_name, user_data["first_name"])
self.assertEqual(user.last_name, user_data["last_name"])
def test_update_auth_token(self):
"""
GIVEN:

View File

@@ -1078,6 +1078,47 @@ class TestFilenameGeneration(DirectoriesMixin, TestCase):
Path("SomeImportantNone/2020-07-25.pdf"),
)
@override_settings(
FILENAME_FORMAT=(
"{% if correspondent == 'none' %}none/{% endif %}"
"{% if correspondent == '-none-' %}dash/{% endif %}"
"{% if not correspondent %}false/{% endif %}"
"{% if correspondent != 'none' %}notnoneyes/{% else %}notnoneno/{% endif %}"
"{{ correspondent or 'missing' }}/{{ title }}"
),
)
def test_placeholder_matches_none_variants_and_false(self):
"""
GIVEN:
- Templates that compare against 'none', '-none-' and rely on truthiness
WHEN:
- A document has or lacks a correspondent
THEN:
- Empty placeholders behave like both strings and evaluate False
"""
doc_without_correspondent = Document.objects.create(
title="does not matter",
mime_type="application/pdf",
checksum="abc",
)
doc_with_correspondent = Document.objects.create(
title="does not matter",
mime_type="application/pdf",
checksum="def",
correspondent=Correspondent.objects.create(name="Acme"),
)
self.assertEqual(
generate_filename(doc_without_correspondent),
Path(
"none/dash/false/notnoneno/missing/does not matter.pdf",
),
)
self.assertEqual(
generate_filename(doc_with_correspondent),
Path("notnoneyes/Acme/does not matter.pdf"),
)
@override_settings(
FILENAME_FORMAT="{created_year_short}/{created_month_name_short}/{created_month_name}/{title}",
)

View File

@@ -8,8 +8,10 @@ from typing import TYPE_CHECKING
from unittest import mock
import pytest
from django.conf import settings
from django.contrib.auth.models import Group
from django.contrib.auth.models import User
from django.core import mail
from django.test import override_settings
from django.utils import timezone
from guardian.shortcuts import assign_perm
@@ -21,6 +23,8 @@ from pytest_httpx import HTTPXMock
from rest_framework.test import APIClient
from rest_framework.test import APITestCase
from documents.file_handling import create_source_path_directory
from documents.file_handling import generate_unique_filename
from documents.signals.handlers import run_workflows
from documents.signals.handlers import send_webhook
@@ -2989,6 +2993,70 @@ class TestWorkflows(
mock_email_send.assert_called_once()
@override_settings(
PAPERLESS_EMAIL_HOST="localhost",
EMAIL_ENABLED=True,
PAPERLESS_URL="http://localhost:8000",
EMAIL_BACKEND="django.core.mail.backends.locmem.EmailBackend",
)
def test_workflow_email_attachment_uses_storage_filename(self):
"""
GIVEN:
- Document updated workflow with include document action
- Document stored with formatted storage-path filename
WHEN:
- Workflow sends an email
THEN:
- Attachment filename matches the stored filename
"""
trigger = WorkflowTrigger.objects.create(
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED,
)
email_action = WorkflowActionEmail.objects.create(
subject="Test Notification: {doc_title}",
body="Test message: {doc_url}",
to="me@example.com",
include_document=True,
)
action = WorkflowAction.objects.create(
type=WorkflowAction.WorkflowActionType.EMAIL,
email=email_action,
)
workflow = Workflow.objects.create(
name="Workflow attachment filename",
order=0,
)
workflow.triggers.add(trigger)
workflow.actions.add(action)
workflow.save()
storage_path = StoragePath.objects.create(
name="Fancy Path",
path="formatted/{{ document.pk }}/{{ title }}",
)
doc = Document.objects.create(
title="workflow doc",
correspondent=self.c,
checksum="workflow-email-attachment",
mime_type="application/pdf",
storage_path=storage_path,
original_filename="workflow-orig.pdf",
)
# eg what happens in update_filename_and_move_files
generated = generate_unique_filename(doc)
destination = (settings.ORIGINALS_DIR / generated).resolve()
create_source_path_directory(destination)
shutil.copy(self.SAMPLE_DIR / "simple.pdf", destination)
Document.objects.filter(pk=doc.pk).update(filename=generated.as_posix())
run_workflows(WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED, doc)
self.assertEqual(len(mail.outbox), 1)
attachment_names = [att[0] for att in mail.outbox[0].attachments]
self.assertEqual(attachment_names, [Path(generated).name])
@override_settings(
EMAIL_ENABLED=False,
)

View File

@@ -23,6 +23,7 @@ from django.conf import settings
from django.contrib.auth.models import Group
from django.contrib.auth.models import User
from django.contrib.contenttypes.models import ContentType
from django.core.cache import cache
from django.db import connections
from django.db.migrations.loader import MigrationLoader
from django.db.migrations.recorder import MigrationRecorder
@@ -51,7 +52,6 @@ from django.utils.timezone import make_aware
from django.utils.translation import get_language
from django.views import View
from django.views.decorators.cache import cache_control
from django.views.decorators.cache import cache_page
from django.views.decorators.http import condition
from django.views.decorators.http import last_modified
from django.views.generic import TemplateView
@@ -108,6 +108,7 @@ from documents.conditionals import thumbnail_last_modified
from documents.data_models import ConsumableDocument
from documents.data_models import DocumentMetadataOverrides
from documents.data_models import DocumentSource
from documents.file_handling import format_filename
from documents.filters import CorrespondentFilterSet
from documents.filters import CustomFieldFilterSet
from documents.filters import DocumentFilterSet
@@ -119,6 +120,7 @@ from documents.filters import PaperlessTaskFilterSet
from documents.filters import ShareLinkFilterSet
from documents.filters import StoragePathFilterSet
from documents.filters import TagFilterSet
from documents.mail import EmailAttachment
from documents.mail import send_email
from documents.matching import match_correspondents
from documents.matching import match_document_types
@@ -183,7 +185,6 @@ from documents.tasks import index_optimize
from documents.tasks import sanity_check
from documents.tasks import train_classifier
from documents.tasks import update_document_parent_tags
from documents.templating.filepath import validate_filepath_template_and_render
from documents.utils import get_boolean
from paperless import version
from paperless.celery import app as celery_app
@@ -1216,12 +1217,28 @@ class DocumentViewSet(
return HttpResponseForbidden("Insufficient permissions")
try:
attachments: list[EmailAttachment] = []
for doc in documents:
attachment_path = (
doc.archive_path
if use_archive_version and doc.has_archive_version
else doc.source_path
)
attachments.append(
EmailAttachment(
path=attachment_path,
mime_type=doc.mime_type,
friendly_name=doc.get_public_filename(
archive=use_archive_version and doc.has_archive_version,
),
),
)
send_email(
subject=subject,
body=message,
to=addresses,
attachments=documents,
use_archive=use_archive_version,
attachments=attachments,
)
logger.debug(
@@ -2336,7 +2353,7 @@ class StoragePathViewSet(ModelViewSet, PermissionsAwareDocumentCountMixin):
document = serializer.validated_data.get("document")
path = serializer.validated_data.get("path")
result = validate_filepath_template_and_render(path, document)
result = format_filename(document, path)
return Response(result)
@@ -2426,7 +2443,6 @@ class UiSettingsView(GenericAPIView):
)
@method_decorator(cache_page(60 * 15), name="dispatch")
@extend_schema_view(
get=extend_schema(
description="Get the current version of the Paperless-NGX server",
@@ -2436,31 +2452,34 @@ class UiSettingsView(GenericAPIView):
),
)
class RemoteVersionView(GenericAPIView):
cache_key = "remote_version_view_latest_release"
def get(self, request, format=None):
remote_version = "0.0.0"
is_greater_than_current = False
current_version = packaging_version.parse(version.__full_version_str__)
try:
resp = httpx.get(
"https://api.github.com/repos/paperless-ngx/paperless-ngx/releases/latest",
headers={"Accept": "application/json"},
)
resp.raise_for_status()
remote_version = cache.get(self.cache_key)
if remote_version is None:
try:
resp = httpx.get(
"https://api.github.com/repos/paperless-ngx/paperless-ngx/releases/latest",
headers={"Accept": "application/json"},
)
resp.raise_for_status()
data = resp.json()
remote_version = data["tag_name"]
# Some early tags used ngx-x.y.z
remote_version = remote_version.removeprefix("ngx-")
except ValueError as e:
logger.debug(f"An error occurred parsing remote version json: {e}")
except httpx.HTTPError as e:
logger.debug(f"An error occurred checking for available updates: {e}")
except httpx.HTTPError as e:
logger.debug(f"An error occurred checking for available updates: {e}")
if remote_version:
cache.set(self.cache_key, remote_version, 60 * 15)
else:
remote_version = "0.0.0"
is_greater_than_current = (
packaging_version.parse(
remote_version,
)
> current_version
packaging_version.parse(remote_version) > current_version
)
return Response(

View File

@@ -3,7 +3,7 @@ msgstr ""
"Project-Id-Version: paperless-ngx\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-10-28 18:06+0000\n"
"PO-Revision-Date: 2025-10-28 18:07\n"
"PO-Revision-Date: 2025-11-04 12:15\n"
"Last-Translator: \n"
"Language-Team: Catalan\n"
"Language: ca_ES\n"

View File

@@ -3,7 +3,7 @@ msgstr ""
"Project-Id-Version: paperless-ngx\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-10-28 18:06+0000\n"
"PO-Revision-Date: 2025-11-03 17:49\n"
"PO-Revision-Date: 2025-11-06 00:35\n"
"Last-Translator: \n"
"Language-Team: German\n"
"Language: de_DE\n"

View File

@@ -3,7 +3,7 @@ msgstr ""
"Project-Id-Version: paperless-ngx\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-10-28 18:06+0000\n"
"PO-Revision-Date: 2025-10-29 12:14\n"
"PO-Revision-Date: 2025-11-06 12:14\n"
"Last-Translator: \n"
"Language-Team: French\n"
"Language: fr_FR\n"

View File

@@ -3,7 +3,7 @@ msgstr ""
"Project-Id-Version: paperless-ngx\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-10-28 18:06+0000\n"
"PO-Revision-Date: 2025-10-28 18:07\n"
"PO-Revision-Date: 2025-11-06 00:35\n"
"Last-Translator: \n"
"Language-Team: Chinese Traditional\n"
"Language: zh_TW\n"

View File

@@ -9,6 +9,7 @@ from allauth.socialaccount.models import SocialApp
from django.contrib.auth.models import Group
from django.contrib.auth.models import Permission
from django.contrib.auth.models import User
from django.contrib.auth.password_validation import validate_password
from rest_framework import serializers
from rest_framework.authtoken.serializers import AuthTokenSerializer
@@ -19,6 +20,23 @@ from paperless_mail.serialisers import ObfuscatedPasswordField
logger = logging.getLogger("paperless.settings")
class PasswordValidationMixin:
def _has_real_password(self, value: str | None) -> bool:
return bool(value) and value.replace("*", "") != ""
def validate_password(self, value: str) -> str:
if not self._has_real_password(value):
return value
request = self.context.get("request") if hasattr(self, "context") else None
user = self.instance or (
request.user if request and hasattr(request, "user") else None
)
validate_password(value, user) # raise ValidationError if invalid
return value
class PaperlessAuthTokenSerializer(AuthTokenSerializer):
code = serializers.CharField(
label="MFA Code",
@@ -49,7 +67,7 @@ class PaperlessAuthTokenSerializer(AuthTokenSerializer):
return attrs
class UserSerializer(serializers.ModelSerializer):
class UserSerializer(PasswordValidationMixin, serializers.ModelSerializer):
password = ObfuscatedPasswordField(required=False)
user_permissions = serializers.SlugRelatedField(
many=True,
@@ -87,11 +105,11 @@ class UserSerializer(serializers.ModelSerializer):
return obj.get_group_permissions()
def update(self, instance, validated_data):
if "password" in validated_data:
if len(validated_data.get("password").replace("*", "")) > 0:
instance.set_password(validated_data.get("password"))
instance.save()
validated_data.pop("password")
password = validated_data.pop("password", None)
if self._has_real_password(password):
instance.set_password(password)
instance.save()
super().update(instance, validated_data)
return instance
@@ -102,12 +120,7 @@ class UserSerializer(serializers.ModelSerializer):
user_permissions = None
if "user_permissions" in validated_data:
user_permissions = validated_data.pop("user_permissions")
password = None
if (
"password" in validated_data
and len(validated_data.get("password").replace("*", "")) > 0
):
password = validated_data.pop("password")
password = validated_data.pop("password", None)
user = User.objects.create(**validated_data)
# set groups
if groups:
@@ -116,7 +129,7 @@ class UserSerializer(serializers.ModelSerializer):
if user_permissions:
user.user_permissions.set(user_permissions)
# set password
if password:
if self._has_real_password(password):
user.set_password(password)
user.save()
return user
@@ -156,7 +169,7 @@ class SocialAccountSerializer(serializers.ModelSerializer):
return "Unknown App"
class ProfileSerializer(serializers.ModelSerializer):
class ProfileSerializer(PasswordValidationMixin, serializers.ModelSerializer):
email = serializers.EmailField(allow_blank=True, required=False)
password = ObfuscatedPasswordField(required=False, allow_null=False)
auth_token = serializers.SlugRelatedField(read_only=True, slug_field="key")

View File

@@ -1,6 +1,6 @@
from typing import Final
__version__: Final[tuple[int, int, int]] = (2, 19, 4)
__version__: Final[tuple[int, int, int]] = (2, 19, 5)
# Version string like X.Y.Z
__full_version_str__: Final[str] = ".".join(map(str, __version__))
# Version string like X.Y

View File

@@ -197,10 +197,10 @@ class ProfileView(GenericAPIView):
serializer.is_valid(raise_exception=True)
user = self.request.user if hasattr(self.request, "user") else None
if len(serializer.validated_data.get("password").replace("*", "")) > 0:
user.set_password(serializer.validated_data.get("password"))
password = serializer.validated_data.pop("password", None)
if password and password.replace("*", ""):
user.set_password(password)
user.save()
serializer.validated_data.pop("password")
for key, value in serializer.validated_data.items():
setattr(user, key, value)

2
uv.lock generated
View File

@@ -2115,7 +2115,7 @@ wheels = [
[[package]]
name = "paperless-ngx"
version = "2.19.4"
version = "2.19.5"
source = { virtual = "." }
dependencies = [
{ name = "babel", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },