mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2025-11-11 03:56:07 -06:00
Compare commits
16 Commits
v2.19.4
...
feature-pw
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d1cf7b1164 | ||
|
|
a5bc2e7632 | ||
|
|
b56185fa31 | ||
|
|
4e67f13187 | ||
|
|
6dfb18bc0b | ||
|
|
44f0191bfb | ||
|
|
e9f846ca24 | ||
|
|
2049497b76 | ||
|
|
2a9d1fce0d | ||
|
|
808c074f48 | ||
|
|
7927e5c436 | ||
|
|
cac48c9855 | ||
|
|
3fda648f37 | ||
|
|
95736eebc4 | ||
|
|
85027dbffd | ||
|
|
74f72e417d |
@@ -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
|
||||
|
||||
|
||||
@@ -294,6 +294,9 @@ The following methods are supported:
|
||||
- `"delete_original": true` to delete the original documents after editing.
|
||||
- `"update_document": true` to update the existing document with the edited PDF.
|
||||
- `"include_metadata": true` to copy metadata from the original document to the edited document.
|
||||
- `remove_password`
|
||||
- Requires `parameters`:
|
||||
- `"password": "PASSWORD_STRING"` The password to remove from the PDF documents.
|
||||
- `merge`
|
||||
- No additional `parameters` required.
|
||||
- The ordering of the merged document is determined by the list of IDs.
|
||||
|
||||
@@ -1,5 +1,55 @@
|
||||
# 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
|
||||
|
||||
- Fix: use original_file when attaching docs to workflow emails with added trigger [@shamoon](https://github.com/shamoon) ([#11266](https://github.com/paperless-ngx/paperless-ngx/pull/11266))
|
||||
- Fix: mark 'Select' button in doc list for translation [@shamoon](https://github.com/shamoon) ([#11278](https://github.com/paperless-ngx/paperless-ngx/pull/11278))
|
||||
- Fix: respect fields parameter for created field [@shamoon](https://github.com/shamoon) ([#11251](https://github.com/paperless-ngx/paperless-ngx/pull/11251))
|
||||
- Fix: improve legibility of processed mail error popover in light mode [@shamoon](https://github.com/shamoon) ([#11258](https://github.com/paperless-ngx/paperless-ngx/pull/11258))
|
||||
- Fixhancement: truncate large logs, improve auto-scroll [@shamoon](https://github.com/shamoon) ([#11239](https://github.com/paperless-ngx/paperless-ngx/pull/11239))
|
||||
- Chore: add max-height and overflow to processedmail error popover [@shamoon](https://github.com/shamoon) ([#11252](https://github.com/paperless-ngx/paperless-ngx/pull/11252))
|
||||
- Fix: delay iframe DOM removal, handle onafterprint error for print in FF [@shamoon](https://github.com/shamoon) ([#11237](https://github.com/paperless-ngx/paperless-ngx/pull/11237))
|
||||
- Fix: de-deduplicate children in tag list when filtering [@shamoon](https://github.com/shamoon) ([#11229](https://github.com/paperless-ngx/paperless-ngx/pull/11229))
|
||||
|
||||
### Performance
|
||||
|
||||
- Performance: re-enable virtual scroll, bump ng-select [@shamoon](https://github.com/shamoon) ([#11279](https://github.com/paperless-ngx/paperless-ngx/pull/11279))
|
||||
- Performance: use virtual scroll container and log level parsing for logs view [@MickLesk](https://github.com/MickLesk) ([#11233](https://github.com/paperless-ngx/paperless-ngx/pull/11233))
|
||||
|
||||
### All App Changes
|
||||
|
||||
<details>
|
||||
<summary>11 changes</summary>
|
||||
|
||||
- Performance: re-enable virtual scroll, bump ng-select [@shamoon](https://github.com/shamoon) ([#11279](https://github.com/paperless-ngx/paperless-ngx/pull/11279))
|
||||
- Fix: use original_file when attaching docs to workflow emails with added trigger [@shamoon](https://github.com/shamoon) ([#11266](https://github.com/paperless-ngx/paperless-ngx/pull/11266))
|
||||
- Fix: mark 'Select' button in doc list for translation [@shamoon](https://github.com/shamoon) ([#11278](https://github.com/paperless-ngx/paperless-ngx/pull/11278))
|
||||
- Fix: respect fields parameter for created field [@shamoon](https://github.com/shamoon) ([#11251](https://github.com/paperless-ngx/paperless-ngx/pull/11251))
|
||||
- Fix: improve legibility of processed mail error popover in light mode [@shamoon](https://github.com/shamoon) ([#11258](https://github.com/paperless-ngx/paperless-ngx/pull/11258))
|
||||
- Fixhancement: truncate large logs, improve auto-scroll [@shamoon](https://github.com/shamoon) ([#11239](https://github.com/paperless-ngx/paperless-ngx/pull/11239))
|
||||
- Chore: add max-height and overflow to processedmail error popover [@shamoon](https://github.com/shamoon) ([#11252](https://github.com/paperless-ngx/paperless-ngx/pull/11252))
|
||||
- Fix: delay iframe DOM removal, handle onafterprint error for print in FF [@shamoon](https://github.com/shamoon) ([#11237](https://github.com/paperless-ngx/paperless-ngx/pull/11237))
|
||||
- Performance: use virtual scroll container and log level parsing for logs view [@MickLesk](https://github.com/MickLesk) ([#11233](https://github.com/paperless-ngx/paperless-ngx/pull/11233))
|
||||
- Chore: cache Github version check for 15 minutes [@shamoon](https://github.com/shamoon) ([#11235](https://github.com/paperless-ngx/paperless-ngx/pull/11235))
|
||||
- Fix: de-deduplicate children in tag list when filtering [@shamoon](https://github.com/shamoon) ([#11229](https://github.com/paperless-ngx/paperless-ngx/pull/11229))
|
||||
</details>
|
||||
|
||||
## paperless-ngx 2.19.3
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paperless-ngx-ui",
|
||||
"version": "2.19.4",
|
||||
"version": "2.19.5",
|
||||
"scripts": {
|
||||
"preinstall": "npx only-allow pnpm",
|
||||
"ng": "ng",
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
},
|
||||
})
|
||||
|
||||
@@ -65,6 +65,12 @@
|
||||
<button ngbDropdownItem (click)="editPdf()" [disabled]="!userIsOwner || !userCanEdit || originalContentRenderType !== ContentRenderType.PDF">
|
||||
<i-bs name="pencil"></i-bs> <ng-container i18n>PDF Editor</ng-container>
|
||||
</button>
|
||||
|
||||
@if (userIsOwner && (requiresPassword || password)) {
|
||||
<button ngbDropdownItem (click)="removePassword()" [disabled]="!password">
|
||||
<i-bs name="unlock"></i-bs> <ng-container i18n>Remove Password</ng-container>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1209,6 +1209,24 @@ describe('DocumentDetailComponent', () => {
|
||||
expect(closeSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should support removing password protection from pdfs', () => {
|
||||
initNormally()
|
||||
component.password = 'secret'
|
||||
component.removePassword()
|
||||
const req = httpTestingController.expectOne(
|
||||
`${environment.apiBaseUrl}documents/bulk_edit/`
|
||||
)
|
||||
expect(req.request.body).toEqual({
|
||||
documents: [doc.id],
|
||||
method: 'remove_password',
|
||||
parameters: {
|
||||
password: 'secret',
|
||||
update_document: true,
|
||||
},
|
||||
})
|
||||
req.flush(true)
|
||||
})
|
||||
|
||||
it('should support keyboard shortcuts', () => {
|
||||
initNormally()
|
||||
|
||||
|
||||
@@ -1428,6 +1428,38 @@ export class DocumentDetailComponent
|
||||
})
|
||||
}
|
||||
|
||||
removePassword() {
|
||||
if (this.requiresPassword || !this.password) {
|
||||
this.toastService.showError(
|
||||
$localize`Please enter the current password before attempting to remove it.`
|
||||
)
|
||||
return
|
||||
}
|
||||
this.networkActive = true
|
||||
this.documentsService
|
||||
.bulkEdit([this.document.id], 'remove_password', {
|
||||
password: this.password,
|
||||
update_document: true,
|
||||
})
|
||||
.pipe(first(), takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe({
|
||||
next: () => {
|
||||
this.toastService.showInfo(
|
||||
$localize`Password removal operation for "${this.document.title}" will begin in the background.`
|
||||
)
|
||||
this.networkActive = false
|
||||
this.openDocumentService.refreshDocument(this.documentId)
|
||||
},
|
||||
error: (error) => {
|
||||
this.networkActive = false
|
||||
this.toastService.showError(
|
||||
$localize`Error executing password removal operation`,
|
||||
error
|
||||
)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
printDocument() {
|
||||
const printUrl = this.documentsService.getDownloadUrl(
|
||||
this.document.id,
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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/',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -132,6 +132,7 @@ import {
|
||||
threeDotsVertical,
|
||||
trash,
|
||||
uiRadios,
|
||||
unlock,
|
||||
upcScan,
|
||||
windowStack,
|
||||
x,
|
||||
@@ -346,6 +347,7 @@ const icons = {
|
||||
threeDotsVertical,
|
||||
trash,
|
||||
uiRadios,
|
||||
unlock,
|
||||
upcScan,
|
||||
windowStack,
|
||||
x,
|
||||
|
||||
@@ -644,6 +644,77 @@ def edit_pdf(
|
||||
return "OK"
|
||||
|
||||
|
||||
def remove_password(
|
||||
doc_ids: list[int],
|
||||
password: str,
|
||||
*,
|
||||
delete_original: bool = False,
|
||||
update_document: bool = False,
|
||||
include_metadata: bool = True,
|
||||
user: User | None = None,
|
||||
) -> Literal["OK"]:
|
||||
"""
|
||||
Remove password protection from PDF documents.
|
||||
"""
|
||||
import pikepdf
|
||||
|
||||
for doc_id in doc_ids:
|
||||
doc = Document.objects.get(id=doc_id)
|
||||
try:
|
||||
logger.info(
|
||||
f"Attempting password removal from document {doc_ids[0]}",
|
||||
)
|
||||
with pikepdf.open(doc.source_path, password=password) as pdf:
|
||||
temp_path = doc.source_path.with_suffix(".tmp.pdf")
|
||||
pdf.remove_unreferenced_resources()
|
||||
pdf.save(temp_path)
|
||||
|
||||
if update_document:
|
||||
# replace the original document with the unprotected one
|
||||
temp_path.replace(doc.source_path)
|
||||
doc.checksum = hashlib.md5(doc.source_path.read_bytes()).hexdigest()
|
||||
doc.page_count = len(pdf.pages)
|
||||
doc.save()
|
||||
update_document_content_maybe_archive_file.delay(document_id=doc.id)
|
||||
else:
|
||||
consume_tasks = []
|
||||
overrides = (
|
||||
DocumentMetadataOverrides().from_document(doc)
|
||||
if include_metadata
|
||||
else DocumentMetadataOverrides()
|
||||
)
|
||||
if user is not None:
|
||||
overrides.owner_id = user.id
|
||||
|
||||
filepath: Path = (
|
||||
Path(tempfile.mkdtemp(dir=settings.SCRATCH_DIR))
|
||||
/ f"{doc.id}_unprotected.pdf"
|
||||
)
|
||||
temp_path.replace(filepath)
|
||||
consume_tasks.append(
|
||||
consume_file.s(
|
||||
ConsumableDocument(
|
||||
source=DocumentSource.ConsumeFolder,
|
||||
original_file=filepath,
|
||||
),
|
||||
overrides,
|
||||
),
|
||||
)
|
||||
|
||||
if delete_original:
|
||||
chord(header=consume_tasks, body=delete.si([doc.id])).delay()
|
||||
else:
|
||||
group(consume_tasks).delay()
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"Error removing password from document {doc.id}: {e}")
|
||||
raise ValueError(
|
||||
f"An error occurred while removing the password: {e}",
|
||||
) from e
|
||||
|
||||
return "OK"
|
||||
|
||||
|
||||
def reflect_doclinks(
|
||||
document: Document,
|
||||
field: CustomField,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1400,6 +1400,7 @@ class BulkEditSerializer(
|
||||
"split",
|
||||
"delete_pages",
|
||||
"edit_pdf",
|
||||
"remove_password",
|
||||
],
|
||||
label="Method",
|
||||
write_only=True,
|
||||
@@ -1475,6 +1476,8 @@ class BulkEditSerializer(
|
||||
return bulk_edit.delete_pages
|
||||
elif method == "edit_pdf":
|
||||
return bulk_edit.edit_pdf
|
||||
elif method == "remove_password":
|
||||
return bulk_edit.remove_password
|
||||
else: # pragma: no cover
|
||||
# This will never happen as it is handled by the ChoiceField
|
||||
raise serializers.ValidationError("Unsupported method.")
|
||||
@@ -1671,6 +1674,12 @@ class BulkEditSerializer(
|
||||
f"Page {op['page']} is out of bounds for document with {doc.page_count} pages.",
|
||||
)
|
||||
|
||||
def validate_parameters_remove_password(self, parameters):
|
||||
if "password" not in parameters:
|
||||
raise serializers.ValidationError("password not specified")
|
||||
if not isinstance(parameters["password"], str):
|
||||
raise serializers.ValidationError("password must be a string")
|
||||
|
||||
def validate(self, attrs):
|
||||
method = attrs["method"]
|
||||
parameters = attrs["parameters"]
|
||||
@@ -1711,6 +1720,8 @@ class BulkEditSerializer(
|
||||
"Edit PDF method only supports one document",
|
||||
)
|
||||
self._validate_parameters_edit_pdf(parameters, attrs["documents"][0])
|
||||
elif method == bulk_edit.remove_password:
|
||||
self.validate_parameters_remove_password(parameters)
|
||||
|
||||
return attrs
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1582,6 +1582,58 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
self.assertIn(b"out of bounds", response.content)
|
||||
|
||||
@mock.patch("documents.serialisers.bulk_edit.remove_password")
|
||||
def test_remove_password(self, m):
|
||||
self.setup_mock(m, "remove_password")
|
||||
response = self.client.post(
|
||||
"/api/documents/bulk_edit/",
|
||||
json.dumps(
|
||||
{
|
||||
"documents": [self.doc2.id],
|
||||
"method": "remove_password",
|
||||
"parameters": {"password": "secret", "update_document": True},
|
||||
},
|
||||
),
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
m.assert_called_once()
|
||||
args, kwargs = m.call_args
|
||||
self.assertCountEqual(args[0], [self.doc2.id])
|
||||
self.assertEqual(kwargs["password"], "secret")
|
||||
self.assertTrue(kwargs["update_document"])
|
||||
self.assertEqual(kwargs["user"], self.user)
|
||||
|
||||
def test_remove_password_invalid_params(self):
|
||||
response = self.client.post(
|
||||
"/api/documents/bulk_edit/",
|
||||
json.dumps(
|
||||
{
|
||||
"documents": [self.doc2.id],
|
||||
"method": "remove_password",
|
||||
"parameters": {},
|
||||
},
|
||||
),
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
self.assertIn(b"password not specified", response.content)
|
||||
|
||||
response = self.client.post(
|
||||
"/api/documents/bulk_edit/",
|
||||
json.dumps(
|
||||
{
|
||||
"documents": [self.doc2.id],
|
||||
"method": "remove_password",
|
||||
"parameters": {"password": 123},
|
||||
},
|
||||
),
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
self.assertIn(b"password must be a string", response.content)
|
||||
|
||||
@override_settings(AUDIT_LOG_ENABLED=True)
|
||||
def test_bulk_edit_audit_log_enabled_simple_field(self):
|
||||
"""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import hashlib
|
||||
import shutil
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
@@ -1062,3 +1063,101 @@ class TestPDFActions(DirectoriesMixin, TestCase):
|
||||
bulk_edit.edit_pdf(doc_ids, operations, update_document=True)
|
||||
mock_group.assert_not_called()
|
||||
mock_consume_file.assert_not_called()
|
||||
|
||||
@mock.patch("documents.bulk_edit.update_document_content_maybe_archive_file.delay")
|
||||
@mock.patch("pikepdf.open")
|
||||
def test_remove_password_update_document(self, mock_open, mock_update_document):
|
||||
doc = self.doc1
|
||||
original_checksum = doc.checksum
|
||||
|
||||
fake_pdf = mock.MagicMock()
|
||||
fake_pdf.pages = [mock.Mock(), mock.Mock(), mock.Mock()]
|
||||
|
||||
def save_side_effect(target_path):
|
||||
Path(target_path).write_bytes(b"new pdf content")
|
||||
|
||||
fake_pdf.save.side_effect = save_side_effect
|
||||
mock_open.return_value.__enter__.return_value = fake_pdf
|
||||
|
||||
result = bulk_edit.remove_password(
|
||||
[doc.id],
|
||||
password="secret",
|
||||
update_document=True,
|
||||
)
|
||||
|
||||
self.assertEqual(result, "OK")
|
||||
mock_open.assert_called_once_with(doc.source_path, password="secret")
|
||||
fake_pdf.remove_unreferenced_resources.assert_called_once()
|
||||
doc.refresh_from_db()
|
||||
self.assertNotEqual(doc.checksum, original_checksum)
|
||||
expected_checksum = hashlib.md5(doc.source_path.read_bytes()).hexdigest()
|
||||
self.assertEqual(doc.checksum, expected_checksum)
|
||||
self.assertEqual(doc.page_count, len(fake_pdf.pages))
|
||||
mock_update_document.assert_called_once_with(document_id=doc.id)
|
||||
|
||||
@mock.patch("documents.bulk_edit.chord")
|
||||
@mock.patch("documents.bulk_edit.group")
|
||||
@mock.patch("documents.tasks.consume_file.s")
|
||||
@mock.patch("documents.bulk_edit.tempfile.mkdtemp")
|
||||
@mock.patch("pikepdf.open")
|
||||
def test_remove_password_creates_consumable_document(
|
||||
self,
|
||||
mock_open,
|
||||
mock_mkdtemp,
|
||||
mock_consume_file,
|
||||
mock_group,
|
||||
mock_chord,
|
||||
):
|
||||
doc = self.doc2
|
||||
temp_dir = self.dirs.scratch_dir / "remove-password"
|
||||
temp_dir.mkdir(parents=True, exist_ok=True)
|
||||
mock_mkdtemp.return_value = str(temp_dir)
|
||||
|
||||
fake_pdf = mock.MagicMock()
|
||||
fake_pdf.pages = [mock.Mock(), mock.Mock()]
|
||||
|
||||
def save_side_effect(target_path):
|
||||
Path(target_path).write_bytes(b"password removed")
|
||||
|
||||
fake_pdf.save.side_effect = save_side_effect
|
||||
mock_open.return_value.__enter__.return_value = fake_pdf
|
||||
mock_group.return_value.delay.return_value = None
|
||||
|
||||
user = User.objects.create(username="owner")
|
||||
|
||||
result = bulk_edit.remove_password(
|
||||
[doc.id],
|
||||
password="secret",
|
||||
include_metadata=False,
|
||||
update_document=False,
|
||||
delete_original=False,
|
||||
user=user,
|
||||
)
|
||||
|
||||
self.assertEqual(result, "OK")
|
||||
mock_open.assert_called_once_with(doc.source_path, password="secret")
|
||||
mock_consume_file.assert_called_once()
|
||||
consume_args, _ = mock_consume_file.call_args
|
||||
consumable_document = consume_args[0]
|
||||
overrides = consume_args[1]
|
||||
expected_path = temp_dir / f"{doc.id}_unprotected.pdf"
|
||||
self.assertTrue(expected_path.exists())
|
||||
self.assertEqual(
|
||||
Path(consumable_document.original_file).resolve(),
|
||||
expected_path.resolve(),
|
||||
)
|
||||
self.assertEqual(overrides.owner_id, user.id)
|
||||
mock_group.assert_called_once_with([mock_consume_file.return_value])
|
||||
mock_group.return_value.delay.assert_called_once()
|
||||
mock_chord.assert_not_called()
|
||||
|
||||
@mock.patch("pikepdf.open")
|
||||
def test_remove_password_open_failure(self, mock_open):
|
||||
mock_open.side_effect = RuntimeError("wrong password")
|
||||
|
||||
with self.assertLogs("paperless.bulk_edit", level="ERROR") as cm:
|
||||
with self.assertRaises(ValueError) as exc:
|
||||
bulk_edit.remove_password([self.doc1.id], password="secret")
|
||||
|
||||
self.assertIn("wrong password", str(exc.exception))
|
||||
self.assertIn("Error removing password from document", cm.output[0])
|
||||
|
||||
@@ -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}",
|
||||
)
|
||||
|
||||
@@ -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
|
||||
@@ -183,7 +184,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
|
||||
@@ -1486,6 +1486,7 @@ class BulkEditView(PassUserMixin):
|
||||
"merge": None,
|
||||
"edit_pdf": "checksum",
|
||||
"reprocess": "checksum",
|
||||
"remove_password": "checksum",
|
||||
}
|
||||
|
||||
permission_classes = (IsAuthenticated,)
|
||||
@@ -1504,6 +1505,7 @@ class BulkEditView(PassUserMixin):
|
||||
bulk_edit.split,
|
||||
bulk_edit.merge,
|
||||
bulk_edit.edit_pdf,
|
||||
bulk_edit.remove_password,
|
||||
]:
|
||||
parameters["user"] = user
|
||||
|
||||
@@ -1532,6 +1534,7 @@ class BulkEditView(PassUserMixin):
|
||||
bulk_edit.rotate,
|
||||
bulk_edit.delete_pages,
|
||||
bulk_edit.edit_pdf,
|
||||
bulk_edit.remove_password,
|
||||
]
|
||||
)
|
||||
or (
|
||||
@@ -1548,7 +1551,7 @@ class BulkEditView(PassUserMixin):
|
||||
and (
|
||||
method in [bulk_edit.split, bulk_edit.merge]
|
||||
or (
|
||||
method == bulk_edit.edit_pdf
|
||||
method in [bulk_edit.edit_pdf, bulk_edit.remove_password]
|
||||
and not parameters["update_document"]
|
||||
)
|
||||
)
|
||||
@@ -2336,7 +2339,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 +2429,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 +2438,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(
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user