Compare commits

...
Author SHA1 Message Date
stumpylogandClaude Sonnet 5 8e558a2c30 test: add matching.py permission coverage for correspondents, document types, storage paths
Completes the parametrized coverage started for tags -- proves all 4
matching.py lookups migrated to permitted_object_ids respect
per-object view permissions, not just the tag case.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-07 13:16:04 -07:00
stumpylog 20be62a30e perf: migrate matching.py's 4 permission-filtered lookups to permitted_object_ids 2026-08-07 13:16:04 -07:00
stumpylogandClaude Sonnet 5 2676a70166 refactor: remove redundant deleted_at filter in permitted_object_ids
SoftDeleteManager's own get_queryset() already excludes soft-deleted
rows, so the extra deleted_at__isnull=True filter was dead code.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-07 13:16:03 -07:00
stumpylogandClaude Sonnet 5 5779fe4ade refactor: add type hints to permitted_object_ids and permitted_document_ids
Add missing type annotations to match the established conventions in this file
(see get_objects_for_user_owner_aware). Also added Model import from django.db.models.

- permitted_object_ids: user: User | None, model: type[Model], return -> QuerySet[int]
- permitted_document_ids: user: User | None, return -> QuerySet[int]

All 38 permission filtering security tests pass; this is a type-annotation-only change.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UmMBGW9FKyDgmKRJ5H9rif
2026-08-07 13:16:03 -07:00
stumpylogandClaude Sonnet 5 dc4f3f5029 feat: generalize permitted_document_ids into permitted_object_ids for any model
Implements Task 14 of the permission-filtering consolidation plan:
- Add generic permitted_object_ids(user, model, perm, include_deleted=False)
- Refactor permitted_document_ids to delegate to permitted_object_ids
- Add comprehensive tests for Tag/Correspondent/DocumentType/StoragePath
- Preserve exact public behavior of permitted_document_ids (100% regression-free)

All 38 tests pass (18 existing + 20 new). The include_deleted parameter
correctly handles soft-delete patterns (effective only for Document).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-07 13:16:03 -07:00
GitHub Actions 3eb784b34d Auto translate strings 2026-08-07 20:01:16 +00:00
shamoonandGitHub cb03a0b33e Fix: fix broken docker frontend from esbuild migration (#13603) 2026-08-07 12:59:46 -07:00
GitHub Actions a96d0b15b8 Auto translate strings 2026-08-07 18:53:58 +00:00
shamoonandGitHub 376b61938f Fix: prevent debounce overwrites in advanced search field, also improve Esc behavior (#13602) 2026-08-07 11:52:24 -07:00
Trenton HandGitHub d1f5eb0335 Performance: reduce memory and I/O overhead of the document exporter during zip exports (#13490) 2026-08-07 18:50:22 +00:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
d283205f57 Chore(deps): Bump h2 in the uv group across 1 directory (#13593)
Bumps the uv group with 1 update in the / directory: [h2](https://github.com/python-hyper/h2).


Updates `h2` from 4.3.0 to 4.4.1
- [Changelog](https://github.com/python-hyper/h2/blob/master/CHANGELOG.rst)
- [Commits](https://github.com/python-hyper/h2/compare/v4.3.0...v4.4.1)

---
updated-dependencies:
- dependency-name: h2
  dependency-version: 4.4.1
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-07 16:54:38 +00:00
shamoonandGitHub 42908ad2b9 Chore: remove codecov/webpack-plugin (#13595) 2026-08-06 23:48:07 -07:00
GitHub Actions db6842e710 Auto translate strings 2026-08-07 05:38:12 +00:00
shamoonandGitHub d5f8cd59fb Chore: migrate frontend to esbuild from webpack (#13488) 2026-08-06 22:36:17 -07:00
dependabot[bot]andGitHub 91d6741b4f Chore(deps): Bump pdfjs-dist from 6.1.200 to 6.2.108 in /src-ui in the npm_and_yarn group across 1 directory (#13594)
Signed-off-by: dependabot[bot] <support@github.com>
2026-08-07 04:40:22 +00:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
972758fc72 Chore(deps): Bump cryptography in the uv group across 1 directory (#13588)
Bumps the uv group with 1 update in the / directory: [cryptography](https://github.com/pyca/cryptography).


Updates `cryptography` from 48.0.1 to 50.0.0
- [Changelog](https://github.com/pyca/cryptography/blob/main/CHANGELOG.rst)
- [Commits](https://github.com/pyca/cryptography/compare/48.0.1...50.0.0)

---
updated-dependencies:
- dependency-name: cryptography
  dependency-version: 50.0.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-06 21:12:13 -07:00
shamoonandGitHub 15d9829b6d QoL: make name button text on attribute pages selectable (#13592) 2026-08-06 18:56:04 -07:00
Trenton HandGitHub 5ad34dfe03 Fix: Set the document modified dates to hopefully prevent this flake. Good across 30 runs at least (#13584) 2026-08-07 01:06:02 +00:00
25 changed files with 3742 additions and 3027 deletions
+14 -19
View File
@@ -129,8 +129,8 @@ jobs:
~/.pnpm-store
~/.cache
key: ${{ runner.os }}-frontend-${{ hashFiles('src-ui/pnpm-lock.yaml') }}
- name: Re-link Angular CLI
run: cd src-ui && pnpm link @angular/cli
- name: Install dependencies
run: cd src-ui && pnpm install --frozen-lockfile
- name: Run lint
run: cd src-ui && pnpm run lint
unit-tests:
@@ -168,8 +168,8 @@ jobs:
~/.pnpm-store
~/.cache
key: ${{ runner.os }}-frontend-${{ hashFiles('src-ui/pnpm-lock.yaml') }}
- name: Re-link Angular CLI
run: cd src-ui && pnpm link @angular/cli
- name: Install dependencies
run: cd src-ui && pnpm install --frozen-lockfile
- name: Run Jest unit tests
run: cd src-ui && pnpm run test --max-workers=2 --shard=${{ matrix.shard-index }}/${{ matrix.shard-count }}
- name: Upload test results to Codecov
@@ -223,18 +223,15 @@ jobs:
~/.pnpm-store
~/.cache
key: ${{ runner.os }}-frontend-${{ hashFiles('src-ui/pnpm-lock.yaml') }}
- name: Re-link Angular CLI
run: cd src-ui && pnpm link @angular/cli
- name: Install dependencies
run: cd src-ui && pnpm install --no-frozen-lockfile
run: cd src-ui && pnpm install --frozen-lockfile
- name: Run Playwright E2E tests
run: cd src-ui && pnpm exec playwright test --shard ${{ matrix.shard-index }}/${{ matrix.shard-count }}
bundle-analysis:
name: Bundle Analysis
frontend-build:
name: Frontend Build
needs: [changes, unit-tests, e2e-tests]
if: needs.changes.outputs.frontend_changed == 'true'
runs-on: ubuntu-24.04
environment: bundle-analysis
permissions:
contents: read
steps:
@@ -260,21 +257,19 @@ jobs:
~/.pnpm-store
~/.cache
key: ${{ runner.os }}-frontend-${{ hashFiles('src-ui/pnpm-lock.yaml') }}
- name: Re-link Angular CLI
run: cd src-ui && pnpm link @angular/cli
- name: Build and analyze
env:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
- name: Install dependencies
run: cd src-ui && pnpm install --frozen-lockfile
- name: Build
run: cd src-ui && pnpm run build --configuration=production
gate:
name: Frontend CI Gate
needs: [changes, install-dependencies, lint, unit-tests, e2e-tests, bundle-analysis]
needs: [changes, install-dependencies, lint, unit-tests, e2e-tests, frontend-build]
if: always()
runs-on: ubuntu-slim
steps:
- name: Check gate
env:
BUNDLE_ANALYSIS_RESULT: ${{ needs['bundle-analysis'].result }}
BUILD_RESULT: ${{ needs['frontend-build'].result }}
E2E_RESULT: ${{ needs['e2e-tests'].result }}
FRONTEND_CHANGED: ${{ needs.changes.outputs.frontend_changed }}
INSTALL_RESULT: ${{ needs['install-dependencies'].result }}
@@ -306,8 +301,8 @@ jobs:
exit 1
fi
if [[ "${BUNDLE_ANALYSIS_RESULT}" != "success" ]]; then
echo "::error::Frontend bundle-analysis job result: ${BUNDLE_ANALYSIS_RESULT}"
if [[ "${BUILD_RESULT}" != "success" ]]; then
echo "::error::Frontend build job result: ${BUILD_RESULT}"
exit 1
fi
+1 -4
View File
@@ -61,10 +61,7 @@ jobs:
~/.cache
key: ${{ runner.os }}-frontenddeps-${{ hashFiles('src-ui/pnpm-lock.yaml') }}
- name: Install frontend dependencies
if: steps.cache-frontend-deps.outputs.cache-hit != 'true'
run: cd src-ui && pnpm install
- name: Re-link Angular cli
run: cd src-ui && pnpm link @angular/cli
run: cd src-ui && pnpm install --frozen-lockfile
- name: Generate frontend translation strings
run: |
cd src-ui
+12 -9
View File
@@ -56,13 +56,13 @@
},
"architect": {
"build": {
"builder": "@angular-builders/custom-webpack:browser",
"builder": "@angular/build:application",
"options": {
"customWebpackConfig": {
"path": "./extra-webpack.config.ts"
"outputPath": {
"base": "dist/paperless-ui",
"browser": ""
},
"outputPath": "dist/paperless-ui",
"main": "src/main.ts",
"browser": "src/main.ts",
"outputHashing": "none",
"index": "src/index.html",
"polyfills": [
@@ -97,6 +97,7 @@
"scripts": [],
"allowedCommonJsDependencies": [
"file-saver",
"mime-names",
"utif"
],
"extractLicenses": false,
@@ -117,11 +118,13 @@
"with": "src/environments/environment.prod.ts"
}
],
"outputPath": "../src/documents/static/frontend/",
"outputPath": {
"base": "../src/documents/static/frontend/",
"browser": ""
},
"optimization": true,
"outputHashing": "none",
"sourceMap": false,
"namedChunks": false,
"extractLicenses": true,
"budgets": [
{
@@ -145,7 +148,7 @@
"defaultConfiguration": ""
},
"serve": {
"builder": "@angular-builders/custom-webpack:dev-server",
"builder": "@angular/build:dev-server",
"options": {
"buildTarget": "paperless-ui:build:en-US"
},
@@ -156,7 +159,7 @@
}
},
"extract-i18n": {
"builder": "@angular-builders/custom-webpack:extract-i18n",
"builder": "@angular/build:extract-i18n",
"options": {
"buildTarget": "paperless-ui:build"
}
-24
View File
@@ -1,24 +0,0 @@
import {
CustomWebpackBrowserSchema,
TargetOptions,
} from '@angular-builders/custom-webpack'
import * as webpack from 'webpack'
const { codecovWebpackPlugin } = require('@codecov/webpack-plugin')
export default (
config: webpack.Configuration,
options: CustomWebpackBrowserSchema,
targetOptions: TargetOptions
) => {
if (config.plugins) {
config.plugins.push(
codecovWebpackPlugin({
enableBundleAnalysis: process.env.CODECOV_TOKEN !== undefined,
bundleName: 'paperless-ngx',
uploadToken: process.env.CODECOV_TOKEN,
})
)
}
return config
}
+708 -709
View File
File diff suppressed because it is too large Load Diff
+14 -17
View File
@@ -12,13 +12,13 @@
"private": true,
"dependencies": {
"@angular/cdk": "^22.0.6",
"@angular/common": "~22.0.8",
"@angular/compiler": "~22.0.8",
"@angular/core": "~22.0.8",
"@angular/forms": "~22.0.8",
"@angular/localize": "~22.0.8",
"@angular/platform-browser": "~22.0.8",
"@angular/router": "~22.0.8",
"@angular/common": "~22.1.0",
"@angular/compiler": "~22.1.0",
"@angular/core": "~22.1.0",
"@angular/forms": "~22.1.0",
"@angular/localize": "~22.1.0",
"@angular/platform-browser": "~22.1.0",
"@angular/router": "~22.1.0",
"@ng-bootstrap/ng-bootstrap": "^21.0.0",
"@ng-select/ng-select": "^23.5.0",
"@ngneat/dirty-check-forms": "^3.0.3",
@@ -32,26 +32,24 @@
"ngx-device-detector": "^12.0.0",
"ngx-ui-tour-ng-bootstrap": "^19.0.0",
"normalize-diacritics": "^5.0.0",
"pdfjs-dist": "^6.0.227",
"pdfjs-dist": "^6.2.108",
"rxjs": "^7.8.2",
"tslib": "^2.8.1",
"utif": "^3.1.0",
"uuid": "^14.0.1"
},
"devDependencies": {
"@angular-builders/custom-webpack": "^22.0.1",
"@angular-builders/jest": "^22.0.1",
"@angular-devkit/core": "^22.0.8",
"@angular-devkit/schematics": "^22.0.8",
"@angular-devkit/core": "^22.1.2",
"@angular-devkit/schematics": "^22.1.2",
"@angular-eslint/builder": "22.1.0",
"@angular-eslint/eslint-plugin": "22.1.0",
"@angular-eslint/eslint-plugin-template": "22.1.0",
"@angular-eslint/schematics": "22.1.0",
"@angular-eslint/template-parser": "22.1.0",
"@angular/build": "^22.0.8",
"@angular/cli": "~22.0.5",
"@angular/compiler-cli": "~22.0.8",
"@codecov/webpack-plugin": "^2.0.1",
"@angular/build": "22.1.2",
"@angular/cli": "22.1.2",
"@angular/compiler-cli": "~22.1.0",
"@playwright/test": "^1.62.0",
"@types/jest": "^30.0.0",
"@types/node": "^26.1.1",
@@ -66,8 +64,7 @@
"jest-websocket-mock": "^2.5.0",
"prettier-plugin-organize-imports": "^4.3.0",
"ts-node": "~10.9.1",
"typescript": "^6.0.3",
"webpack": "^5.107.2"
"typescript": "^6.0.3"
},
"packageManager": "pnpm@10.26.0"
}
+1810 -1797
View File
File diff suppressed because it is too large Load Diff
@@ -2213,6 +2213,20 @@ describe('FilterEditorComponent', () => {
expect(blurSpy).toHaveBeenCalled()
})
it('should only dismiss open autocomplete suggestions on Escape, keeping the query', () => {
component.textFilter = 'foo bar'
component.textFilterInput.nativeElement.value = 'foo bar'
jest.spyOn(component.searchTypeahead, 'isPopupOpen').mockReturnValue(true)
const dismissSpy = jest
.spyOn(component.searchTypeahead, 'dismissPopup')
.mockImplementation(() => {})
component.textFilterInput.nativeElement.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Escape' })
)
expect(dismissSpy).toHaveBeenCalled()
expect(component.textFilter).toEqual('foo bar')
})
it('should adjust text filter targets if more like search', () => {
const TEXT_FILTER_TARGET_FULLTEXT_MORELIKE = 'fulltext-morelike' // private const
component.textFilterTarget = TEXT_FILTER_TARGET_FULLTEXT_MORELIKE
@@ -15,6 +15,7 @@ import {
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import {
NgbDropdownModule,
NgbTypeahead,
NgbTypeaheadModule,
} from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
@@ -351,6 +352,9 @@ export class FilterEditorComponent
@ViewChild('textFilterInput')
textFilterInput: ElementRef
@ViewChild(NgbTypeahead)
searchTypeahead: NgbTypeahead
readonly customFields = signal<CustomField[]>([])
tagDocumentCounts: SelectionDataItem[]
@@ -1150,6 +1154,7 @@ export class FilterEditorComponent
}
set textFilter(value) {
this._textFilter = value // set immediately to prevent loss of keystrokes
this.textFilterDebounce.next(value)
}
@@ -1242,9 +1247,9 @@ export class FilterEditorComponent
distinctUntilChanged(),
filter((query) => !query.length || query.length > 2)
)
.subscribe((text) =>
.subscribe(() =>
this.updateTextFilter(
text,
this._textFilter, // use the current value, not the debounced (possibly stale) one
this.textFilterTarget !== TEXT_FILTER_TARGET_FULLTEXT_QUERY
)
)
@@ -1320,6 +1325,11 @@ export class FilterEditorComponent
this.updateTextFilter(filterString)
}
} else if (event.key === 'Escape') {
if (this.searchTypeahead?.isPopupOpen()) {
// only dismiss the suggestions, so longer query can use Enter
this.searchTypeahead.dismissPopup()
return
}
if (this._textFilter?.length) {
this.resetTextField()
} else {
@@ -88,7 +88,7 @@
@if (depth > 0) {
<div class="indicator"></div>
}
<button class="btn btn-link ms-0 ps-0 text-start" (click)="userCanEdit(object) ? openEditDialog(object) : null; $event.stopPropagation()">{{ object.name }}</button>
<button class="btn btn-link ms-0 ps-0 text-start" style="user-select: text;" (click)="userCanEdit(object) ? openEditDialog(object) : null; $event.stopPropagation()">{{ object.name }}</button>
</td>
<td class="d-none d-sm-table-cell">{{ getMatching(object) }}</td>
<td>{{ getDocumentCount(object) }}</td>
View File
+346
View File
@@ -0,0 +1,346 @@
from __future__ import annotations
import abc
import hashlib
import json
import os
import shutil
import tempfile
import zipfile
from contextlib import AbstractContextManager
from contextlib import contextmanager
from pathlib import Path
from pathlib import PurePosixPath
from typing import TYPE_CHECKING
from django.conf import settings
from django.core.serializers.json import DjangoJSONEncoder
from documents.file_handling import delete_empty_directories
from documents.utils import compute_checksum
from documents.utils import copy_file_with_basic_stats
if TYPE_CHECKING:
from collections.abc import Iterator
from typing import TextIO
def _dumps(content: list | dict) -> str:
"""Serialize export JSON consistently across all sinks."""
return json.dumps(content, cls=DjangoJSONEncoder, indent=2, ensure_ascii=False)
class StreamingManifestWriter:
"""Incrementally writes a JSON array to a text handle, one record at a time.
Knows nothing about folders or zips: it writes the array framing and records
to whatever handle the sink's ``stream()`` yields. The sink owns the handle's
lifecycle (atomic rename, compare, spooling).
"""
def __init__(self, handle: TextIO) -> None:
self._file = handle
self._first = True
self._file.write("[")
def write_record(self, record: dict) -> None:
if not self._first:
self._file.write(",\n")
else:
self._first = False
self._file.write(_dumps(record))
def write_batch(self, records: list[dict]) -> None:
for record in records:
self.write_record(record)
def close(self) -> None:
"""Write the closing bracket. Does NOT close the handle (the sink owns it)."""
self._file.write("\n]")
class ExportSink(AbstractContextManager, abc.ABC):
"""Destination for a document export.
The command declares export contents via three verbs; the sink decides how to
persist each. ``arcname`` is always a relative POSIX path
(e.g. ``"manifest.json"``, ``"originals/foo.pdf"``).
Contract:
* At most one ``stream()`` open at a time (it is the manifest);
``add_file``/``add_json`` may be called while it is open.
* Context-manager: normal exit finalizes, an exception aborts. No partial or
failed run leaves a complete-looking artifact.
"""
@abc.abstractmethod
def add_file(
self,
source: Path,
arcname: str,
*,
checksum: str | None = None,
) -> None: ...
@abc.abstractmethod
def add_json(self, content: list | dict, arcname: str) -> None: ...
@abc.abstractmethod
def stream(self, arcname: str) -> AbstractContextManager[TextIO]: ...
def _open(self) -> None:
"""Hook called on context entry. Override as needed."""
@abc.abstractmethod
def _finalize(self) -> None:
"""Commit on clean exit."""
@abc.abstractmethod
def _abort(self) -> None:
"""Roll back on exception."""
def __enter__(self) -> ExportSink:
self._open()
return self
def __exit__(self, exc_type, exc_val, exc_tb) -> None:
if exc_type is not None:
self._abort()
else:
self._finalize()
class DirectoryExportSink(ExportSink):
"""Writes loose files into a target directory, with incremental sync.
Owns the snapshot/skip/compare/prune machinery that used to live in the
command (``files_in_export_dir``, ``check_and_copy``, ``check_and_write_json``,
and the ``--delete`` pass).
"""
def __init__(
self,
target: Path,
*,
compare_checksums: bool,
compare_json: bool,
delete: bool,
) -> None:
self._target = target.resolve()
self._compare_checksums = compare_checksums
self._compare_json = compare_json
self._delete = delete
self._snapshot: set[Path] = set()
self._stream_open = False
def _open(self) -> None:
for x in self._target.glob("**/*"):
if x.is_file():
self._snapshot.add(x.resolve())
def add_file(
self,
source: Path,
arcname: str,
*,
checksum: str | None = None,
) -> None:
target = (self._target / arcname).resolve()
self._snapshot.discard(target)
perform_copy = False
if target.exists():
source_stat = source.stat()
target_stat = target.stat()
if self._compare_checksums and checksum:
perform_copy = compute_checksum(target) != checksum
elif (
source_stat.st_mtime != target_stat.st_mtime
or source_stat.st_size != target_stat.st_size
):
perform_copy = True
else:
perform_copy = True
if perform_copy:
target.parent.mkdir(parents=True, exist_ok=True)
copy_file_with_basic_stats(source, target)
@staticmethod
def _content_unchanged(target: Path, new_bytes: bytes) -> bool:
"""True if ``target`` already holds byte-identical content (BLAKE2b)."""
return (
hashlib.blake2b(target.read_bytes()).hexdigest()
== hashlib.blake2b(new_bytes).hexdigest()
)
def add_json(self, content: list | dict, arcname: str) -> None:
target = (self._target / arcname).resolve()
json_str = _dumps(content)
perform_write = True
if target in self._snapshot:
self._snapshot.discard(target)
if self._compare_json and self._content_unchanged(
target,
json_str.encode("utf-8"),
):
perform_write = False
if perform_write:
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(json_str, encoding="utf-8")
@contextmanager
def stream(self, arcname: str) -> Iterator[TextIO]:
if self._stream_open:
raise RuntimeError("A stream is already open on this sink")
target = (self._target / arcname).resolve()
tmp = target.with_suffix(target.suffix + ".tmp")
target.parent.mkdir(parents=True, exist_ok=True)
handle = tmp.open("w", encoding="utf-8")
self._stream_open = True
try:
yield handle
except BaseException:
handle.close()
tmp.unlink(missing_ok=True)
raise
else:
handle.close()
self._commit_streamed_file(target, tmp)
finally:
self._stream_open = False
def _commit_streamed_file(self, target: Path, tmp: Path) -> None:
if target in self._snapshot:
self._snapshot.discard(target)
if self._compare_json and self._content_unchanged(
target,
tmp.read_bytes(),
):
tmp.unlink()
return
tmp.rename(target)
def _finalize(self) -> None:
if self._delete:
for f in self._snapshot:
if not f.is_relative_to(self._target): # pragma: no cover
# Defense in depth: a symlink inside the export dir can
# resolve outside of it; never delete outside the target.
continue
f.unlink()
delete_empty_directories(f.parent, self._target)
def _abort(self) -> None:
# Folder mode is in-place/incremental: streamed .tmp files are already
# cleaned in stream(); leave everything else intact and skip the prune.
return None
class ZipExportSink(ExportSink):
"""Writes a single zip archive, produced atomically only on success.
Builds into ``<target>/<zip_name>.zip.tmp`` and renames to ``.zip`` on clean
finalize. The manifest stream is spooled to a temp file in SCRATCH_DIR and
added as an entry at finalize (a zip entry cannot be interleaved with others).
"""
def __init__(self, target: Path, zip_name: str, *, delete: bool = False) -> None:
self._target = target.resolve()
self._zip_path = (self._target / zip_name).with_suffix(".zip")
self._tmp_path = self._zip_path.with_name(self._zip_path.name + ".tmp")
self._delete = delete
self._zip: zipfile.ZipFile | None = None
self._dirs: set[str] = set()
self._pending_manifest: tuple[Path, str] | None = None
self._stream_open = False
def _open(self) -> None:
settings.SCRATCH_DIR.mkdir(parents=True, exist_ok=True)
self._zip = zipfile.ZipFile(
self._tmp_path,
"w",
compression=zipfile.ZIP_DEFLATED,
allowZip64=True,
)
def _ensure_dirs(self, arcname: str) -> None:
assert self._zip is not None
dir_arc = ""
for part in PurePosixPath(arcname).parts[:-1]:
dir_arc += f"{part}/"
if dir_arc not in self._dirs:
self._dirs.add(dir_arc)
self._zip.mkdir(dir_arc)
def add_file(
self,
source: Path,
arcname: str,
*,
checksum: str | None = None,
) -> None:
assert self._zip is not None
self._ensure_dirs(arcname)
self._zip.write(source, arcname=arcname)
def add_json(self, content: list | dict, arcname: str) -> None:
assert self._zip is not None
self._ensure_dirs(arcname)
self._zip.writestr(arcname, _dumps(content))
@contextmanager
def stream(self, arcname: str) -> Iterator[TextIO]:
if self._stream_open:
raise RuntimeError("A stream is already open on this sink")
settings.SCRATCH_DIR.mkdir(parents=True, exist_ok=True)
fd, tmp_name = tempfile.mkstemp(
dir=settings.SCRATCH_DIR,
prefix="export-manifest-",
suffix=".json",
)
tmp = Path(tmp_name)
handle = os.fdopen(fd, "w", encoding="utf-8")
self._stream_open = True
try:
yield handle
except BaseException:
handle.close()
tmp.unlink(missing_ok=True)
raise
else:
handle.close()
self._pending_manifest = (tmp, arcname)
finally:
self._stream_open = False
def _finalize(self) -> None:
assert self._zip is not None
if self._pending_manifest is not None:
tmp, arcname = self._pending_manifest
self._ensure_dirs(arcname)
self._zip.write(tmp, arcname=arcname)
tmp.unlink(missing_ok=True)
self._pending_manifest = None
self._zip.close()
self._zip = None
if self._delete:
self._wipe_destination()
self._tmp_path.replace(self._zip_path)
def _wipe_destination(self) -> None:
skip = {self._zip_path.resolve(), self._tmp_path.resolve()}
for item in self._target.glob("*"):
if item.resolve() in skip:
continue
if item.is_dir():
shutil.rmtree(item)
else:
item.unlink()
def _abort(self) -> None:
if self._zip is not None:
self._zip.close()
self._zip = None
self._tmp_path.unlink(missing_ok=True)
if self._pending_manifest is not None:
self._pending_manifest[0].unlink(missing_ok=True)
self._pending_manifest = None
@@ -1,8 +1,4 @@
import hashlib
import json
import os
import shutil
import tempfile
from itertools import islice
from pathlib import Path
from typing import TYPE_CHECKING
@@ -19,7 +15,6 @@ from django.contrib.auth.models import User
from django.contrib.contenttypes.models import ContentType
from django.core import serializers
from django.core.management.base import CommandError
from django.core.serializers.json import DjangoJSONEncoder
from django.db import transaction
from django.utils import timezone
from filelock import FileLock
@@ -34,7 +29,10 @@ if TYPE_CHECKING:
if settings.AUDIT_LOG_ENABLED:
from auditlog.models import LogEntry
from documents.file_handling import delete_empty_directories
from documents.export.sinks import DirectoryExportSink
from documents.export.sinks import ExportSink
from documents.export.sinks import StreamingManifestWriter
from documents.export.sinks import ZipExportSink
from documents.file_handling import generate_filename
from documents.management.commands.base import PaperlessCommand
from documents.management.commands.mixins import CryptMixin
@@ -60,8 +58,7 @@ from documents.settings import EXPORTER_ARCHIVE_NAME
from documents.settings import EXPORTER_FILE_NAME
from documents.settings import EXPORTER_SHARE_LINK_BUNDLE_NAME
from documents.settings import EXPORTER_THUMBNAIL_NAME
from documents.utils import compute_checksum
from documents.utils import copy_file_with_basic_stats
from documents.utils import QuerySetStream
from paperless import version
from paperless.models import ApplicationConfiguration
from paperless_mail.models import MailAccount
@@ -84,87 +81,6 @@ def serialize_queryset_batched(
yield serializers.serialize("python", chunk)
class StreamingManifestWriter:
"""Incrementally writes a JSON array to a file, one record at a time.
Writes to <target>.tmp first; on close(), optionally BLAKE2b-compares
with the existing file (--compare-json) and renames or discards accordingly.
On exception, discard() deletes the tmp file and leaves the original intact.
"""
def __init__(
self,
path: Path,
*,
compare_json: bool = False,
files_in_export_dir: "set[Path] | None" = None,
) -> None:
self._path = path.resolve()
self._tmp_path = self._path.with_suffix(self._path.suffix + ".tmp")
self._compare_json = compare_json
self._files_in_export_dir: set[Path] = (
files_in_export_dir if files_in_export_dir is not None else set()
)
self._file = None
self._first = True
def open(self) -> None:
self._path.parent.mkdir(parents=True, exist_ok=True)
self._file = self._tmp_path.open("w", encoding="utf-8")
self._file.write("[")
self._first = True
def write_record(self, record: dict) -> None:
if not self._first:
self._file.write(",\n")
else:
self._first = False
self._file.write(
json.dumps(record, cls=DjangoJSONEncoder, indent=2, ensure_ascii=False),
)
def write_batch(self, records: list[dict]) -> None:
for record in records:
self.write_record(record)
def close(self) -> None:
if self._file is None:
return
self._file.write("\n]")
self._file.close()
self._file = None
self._finalize()
def discard(self) -> None:
if self._file is not None:
self._file.close()
self._file = None
if self._tmp_path.exists():
self._tmp_path.unlink()
def _finalize(self) -> None:
"""Compare with existing file (if --compare-json) then rename or discard tmp."""
if self._path in self._files_in_export_dir:
self._files_in_export_dir.remove(self._path)
if self._compare_json:
existing_hash = hashlib.blake2b(self._path.read_bytes()).hexdigest()
new_hash = hashlib.blake2b(self._tmp_path.read_bytes()).hexdigest()
if existing_hash == new_hash:
self._tmp_path.unlink()
return
self._tmp_path.rename(self._path)
def __enter__(self) -> "StreamingManifestWriter":
self.open()
return self
def __exit__(self, exc_type, exc_val, exc_tb) -> None:
if exc_type is not None:
self.discard()
else:
self.close()
class Command(CryptMixin, PaperlessCommand):
help = (
"Decrypt and rename all files in our collection into a given target "
@@ -314,20 +230,13 @@ class Command(CryptMixin, PaperlessCommand):
self.passphrase: str | None = options.get("passphrase")
self.batch_size: int = options["batch_size"]
self.files_in_export_dir: set[Path] = set()
self.exported_files: set[str] = set()
# If zipping, save the original target for later and
# get a temporary directory for the target instead
temp_dir = None
self.original_target = self.target
if self.zip_export:
settings.SCRATCH_DIR.mkdir(parents=True, exist_ok=True)
temp_dir = tempfile.TemporaryDirectory(
dir=settings.SCRATCH_DIR,
prefix="paperless-export",
if self.zip_export and (self.compare_checksums or self.compare_json):
raise CommandError(
"--compare-checksums and --compare-json have no effect when "
"used with --zip",
)
self.target = Path(temp_dir.name).resolve()
if not self.target.exists():
raise CommandError("That path doesn't exist")
@@ -338,33 +247,28 @@ class Command(CryptMixin, PaperlessCommand):
if not os.access(self.target, os.W_OK):
raise CommandError("That path doesn't appear to be writable")
try:
# Prevent any ongoing changes in the documents
with FileLock(settings.MEDIA_LOCK):
self.dump()
sink: ExportSink
if self.zip_export:
sink = ZipExportSink(
self.target,
options["zip_name"],
delete=self.delete,
)
else:
sink = DirectoryExportSink(
self.target,
compare_checksums=self.compare_checksums,
compare_json=self.compare_json,
delete=self.delete,
)
# We've written everything to the temporary directory in this case,
# now make an archive in the original target, with all files stored
if self.zip_export and temp_dir is not None:
shutil.make_archive(
self.original_target / options["zip_name"],
format="zip",
root_dir=temp_dir.name,
)
# Prevent any ongoing changes in the documents while exporting
with FileLock(settings.MEDIA_LOCK), sink:
self.dump(sink)
finally:
# Always cleanup the temporary directory, if one was created
if self.zip_export and temp_dir is not None:
temp_dir.cleanup()
def dump(self) -> None:
# 1. Take a snapshot of what files exist in the current export folder
for x in self.target.glob("**/*"):
if x.is_file():
self.files_in_export_dir.add(x.resolve())
# 2. Create manifest, containing all correspondents, types, tags, storage paths
# note, documents and ui_settings
def dump(self, sink: ExportSink) -> None:
# 1. Create manifest, containing all correspondents, types, tags, storage
# paths, note, documents and ui_settings
_excluded_usernames = ["consumer", "AnonymousUser"]
manifest_key_to_object_query: dict[str, QuerySet[Any]] = {
"correspondents": Correspondent.objects.all(),
@@ -427,13 +331,9 @@ class Command(CryptMixin, PaperlessCommand):
document_manifest: list[dict] = []
share_link_bundle_manifest: list[dict] = []
manifest_path = (self.target / "manifest.json").resolve()
with StreamingManifestWriter(
manifest_path,
compare_json=self.compare_json,
files_in_export_dir=self.files_in_export_dir,
) as writer:
with sink.stream("manifest.json") as handle:
writer = StreamingManifestWriter(handle)
with transaction.atomic():
for key, qs in manifest_key_to_object_query.items():
if key == "documents":
@@ -469,9 +369,6 @@ class Command(CryptMixin, PaperlessCommand):
self._encrypt_record_inline(record)
writer.write_batch(batch)
document_map: dict[int, Document] = {
d.pk: d for d in Document.global_objects.order_by("id")
}
share_link_bundle_map: dict[int, ShareLinkBundle] = {
b.pk: b
for b in ShareLinkBundle.objects.order_by("id").prefetch_related(
@@ -479,84 +376,72 @@ class Command(CryptMixin, PaperlessCommand):
)
}
# 3. Export files from each document
for index, document_dict in enumerate(
self.track(
document_manifest,
description="Exporting documents...",
total=len(document_manifest),
),
# 2. Export files from each document
# document_manifest and this stream are both ordered by id from the
# same underlying rows, so zip them in lockstep instead of building
# a dict of every Document instance up front (QuerySetStream keeps
# only one batch of documents resident at a time).
documents_stream = QuerySetStream(
Document.global_objects.order_by("id"),
chunk_size=self.batch_size,
)
for document_dict, document in self.track(
zip(document_manifest, documents_stream, strict=True),
description="Exporting documents...",
total=len(document_manifest),
):
document = document_map[document_dict["pk"]]
# Both document_manifest and documents_stream come from the same
# Document.global_objects.order_by("id") query, taken while
# MEDIA_LOCK is held, so this should be unreachable -- it guards
# against silent data corruption if that invariant ever breaks.
if document.pk != document_dict["pk"]: # pragma: no cover
raise CommandError(
"Document export ordering mismatch: expected "
f"pk={document_dict['pk']}, got pk={document.pk}. "
"Documents may have changed during export.",
)
# 3.1. generate a unique filename
# generate a unique filename, then the arcnames for its files
base_name = self.generate_base_name(document)
# 3.2. write filenames into manifest
original_target, thumbnail_target, archive_target = (
original_arc, thumbnail_arc, archive_arc = (
self.generate_document_targets(document, base_name, document_dict)
)
# 3.3. write files to target folder
if not self.data_only:
self.copy_document_files(
document,
original_target,
thumbnail_target,
archive_target,
sink,
original_arc,
thumbnail_arc,
archive_arc,
)
if self.split_manifest:
self._write_split_manifest(document_dict, document, base_name)
self._write_split_manifest(sink, document_dict, document, base_name)
else:
writer.write_record(document_dict)
for bundle_dict in share_link_bundle_manifest:
bundle = share_link_bundle_map[bundle_dict["pk"]]
bundle_target = self.generate_share_link_bundle_target(
bundle_arc = self.generate_share_link_bundle_target(
bundle,
bundle_dict,
)
if not self.data_only and bundle_target is not None:
self.copy_share_link_bundle_file(bundle, bundle_target)
if not self.data_only and bundle_arc is not None:
self.copy_share_link_bundle_file(bundle, sink, bundle_arc)
writer.write_record(bundle_dict)
# 4.2 write version information to target folder
extra_metadata_path = (self.target / "metadata.json").resolve()
writer.close()
# 3. Write version (and crypto params) to metadata.json
# Django stores most crypto values in the field itself; we store
# them once here for the whole export
metadata: dict[str, str | int | dict[str, str | int]] = {
"version": version.__full_version_str__,
}
# 4.2.1 If needed, write the crypto values into the metadata
# Django stores most of these in the field itself, we store them once here
if self.passphrase:
metadata.update(self.get_crypt_params())
self.check_and_write_json(
metadata,
extra_metadata_path,
)
if self.delete:
# 5. Remove files which we did not explicitly export in this run
if not self.zip_export:
for f in self.files_in_export_dir:
f.unlink()
delete_empty_directories(
f.parent,
self.target,
)
else:
# 5. Remove anything in the original location (before moving the zip)
for item in self.original_target.glob("*"):
if item.is_dir():
shutil.rmtree(item)
else:
item.unlink()
sink.add_json(metadata, "metadata.json")
def generate_base_name(self, document: Document) -> Path:
"""
@@ -584,73 +469,69 @@ class Command(CryptMixin, PaperlessCommand):
document: Document,
base_name: Path,
document_dict: dict,
) -> tuple[Path, Path | None, Path | None]:
) -> tuple[str, str | None, str | None]:
"""
Generates the targets for a given document, including the original file, archive file and thumbnail (depending on settings).
Generates the relative POSIX arcnames for a document's original, thumbnail
and archive files (depending on settings), and records them in the manifest.
"""
original_name = base_name
if self.use_folder_prefix:
original_name = Path("originals") / original_name
original_target = (self.target / original_name).resolve()
document_dict[EXPORTER_FILE_NAME] = str(original_name)
original_arc = original_name.as_posix()
document_dict[EXPORTER_FILE_NAME] = original_arc
if not self.no_thumbnail:
thumbnail_name = base_name.parent / (base_name.stem + "-thumbnail.webp")
if self.use_folder_prefix:
thumbnail_name = Path("thumbnails") / thumbnail_name
thumbnail_target = (self.target / thumbnail_name).resolve()
document_dict[EXPORTER_THUMBNAIL_NAME] = str(thumbnail_name)
thumbnail_arc = thumbnail_name.as_posix()
document_dict[EXPORTER_THUMBNAIL_NAME] = thumbnail_arc
else:
thumbnail_target = None
thumbnail_arc = None
if not self.no_archive and document.has_archive_version:
archive_name = base_name.parent / (base_name.stem + "-archive.pdf")
if self.use_folder_prefix:
archive_name = Path("archive") / archive_name
archive_target = (self.target / archive_name).resolve()
document_dict[EXPORTER_ARCHIVE_NAME] = str(archive_name)
archive_arc = archive_name.as_posix()
document_dict[EXPORTER_ARCHIVE_NAME] = archive_arc
else:
archive_target = None
archive_arc = None
return original_target, thumbnail_target, archive_target
return original_arc, thumbnail_arc, archive_arc
def copy_document_files(
self,
document: Document,
original_target: Path,
thumbnail_target: Path | None,
archive_target: Path | None,
sink: ExportSink,
original_arc: str,
thumbnail_arc: str | None,
archive_arc: str | None,
) -> None:
"""
Copies files from the document storage location to the specified target location.
If the document is encrypted, the files are decrypted before copying them to the target location.
Hands the document's files to the sink (original, thumbnail, archive).
"""
self.check_and_copy(
document.source_path,
document.checksum,
original_target,
)
sink.add_file(document.source_path, original_arc, checksum=document.checksum)
if thumbnail_target:
self.check_and_copy(document.thumbnail_path, None, thumbnail_target)
if thumbnail_arc:
sink.add_file(document.thumbnail_path, thumbnail_arc)
if archive_target:
if archive_arc:
if TYPE_CHECKING:
assert isinstance(document.archive_path, Path)
self.check_and_copy(
sink.add_file(
document.archive_path,
document.archive_checksum,
archive_target,
archive_arc,
checksum=document.archive_checksum,
)
def generate_share_link_bundle_target(
self,
bundle: ShareLinkBundle,
bundle_dict: dict,
) -> Path | None:
) -> str | None:
"""
Generates the export target for a share link bundle file, when present.
Generates the relative POSIX arcname for a share link bundle file, if any.
"""
if not bundle.file_path:
return None
@@ -666,25 +547,22 @@ class Command(CryptMixin, PaperlessCommand):
bundle_dict["fields"]["file_path"] = portable_bundle_path.as_posix()
bundle_dict[EXPORTER_SHARE_LINK_BUNDLE_NAME] = export_bundle_path.as_posix()
return (self.target / export_bundle_path).resolve()
return export_bundle_path.as_posix()
def copy_share_link_bundle_file(
self,
bundle: ShareLinkBundle,
bundle_target: Path,
sink: ExportSink,
bundle_arc: str,
) -> None:
"""
Copies a share link bundle ZIP into the export directory.
Hands a share link bundle ZIP to the sink.
"""
bundle_source_path = bundle.absolute_file_path
if bundle_source_path is None:
raise FileNotFoundError(f"Share link bundle {bundle.pk} has no file path")
self.check_and_copy(
bundle_source_path,
None,
bundle_target,
)
sink.add_file(bundle_source_path, bundle_arc)
def _encrypt_record_inline(self, record: dict) -> None:
"""Encrypt sensitive fields in a single record, if passphrase is set."""
@@ -700,6 +578,7 @@ class Command(CryptMixin, PaperlessCommand):
def _write_split_manifest(
self,
sink: ExportSink,
document_dict: dict,
document: Document,
base_name: Path,
@@ -721,81 +600,4 @@ class Command(CryptMixin, PaperlessCommand):
manifest_name = base_name.with_name(f"{base_name.stem}-manifest.json")
if self.use_folder_prefix:
manifest_name = Path("json") / manifest_name
manifest_name = (self.target / manifest_name).resolve()
manifest_name.parent.mkdir(parents=True, exist_ok=True)
self.check_and_write_json(content, manifest_name)
def check_and_write_json(
self,
content: list[dict] | dict,
target: Path,
) -> None:
"""
Writes the source content to the target json file.
If --compare-json arg was used, don't write to target file if
the file exists and checksum is identical to content checksum.
This preserves the file timestamps when no changes are made.
"""
target = target.resolve()
perform_write = True
if target in self.files_in_export_dir:
self.files_in_export_dir.remove(target)
if self.compare_json:
target_checksum = hashlib.blake2b(target.read_bytes()).hexdigest()
src_str = json.dumps(
content,
cls=DjangoJSONEncoder,
indent=2,
ensure_ascii=False,
)
src_checksum = hashlib.blake2b(src_str.encode("utf-8")).hexdigest()
if src_checksum == target_checksum:
perform_write = False
if perform_write:
target.write_text(
json.dumps(
content,
cls=DjangoJSONEncoder,
indent=2,
ensure_ascii=False,
),
encoding="utf-8",
)
def check_and_copy(
self,
source: Path,
source_checksum: str | None,
target: Path,
) -> None:
"""
Copies the source to the target, if target doesn't exist or the target doesn't seem to match
the source attributes
"""
target = target.resolve()
if target in self.files_in_export_dir:
self.files_in_export_dir.remove(target)
perform_copy = False
if target.exists():
source_stat = source.stat()
target_stat = target.stat()
if self.compare_checksums and source_checksum:
target_checksum = compute_checksum(target)
perform_copy = target_checksum != source_checksum
elif (
source_stat.st_mtime != target_stat.st_mtime
or source_stat.st_size != target_stat.st_size
):
perform_copy = True
else:
# Copy if it does not exist
perform_copy = True
if perform_copy:
target.parent.mkdir(parents=True, exist_ok=True)
copy_file_with_basic_stats(source, target)
sink.add_json(content, manifest_name.as_posix())
+10 -14
View File
@@ -19,7 +19,7 @@ from documents.models import StoragePath
from documents.models import Tag
from documents.models import Workflow
from documents.models import WorkflowTrigger
from documents.permissions import get_objects_for_user_owner_aware
from documents.permissions import permitted_object_ids
from documents.regex import safe_regex_search
if TYPE_CHECKING:
@@ -55,10 +55,8 @@ def match_correspondents(document: Document, classifier: DocumentClassifier, use
user = document.owner
if user is not None:
correspondents = get_objects_for_user_owner_aware(
user,
"documents.view_correspondent",
Correspondent,
correspondents = Correspondent.objects.filter(
id__in=permitted_object_ids(user, Correspondent, "view_correspondent"),
)
else:
correspondents = Correspondent.objects.all()
@@ -86,10 +84,8 @@ def match_document_types(document: Document, classifier: DocumentClassifier, use
user = document.owner
if user is not None:
document_types = get_objects_for_user_owner_aware(
user,
"documents.view_documenttype",
DocumentType,
document_types = DocumentType.objects.filter(
id__in=permitted_object_ids(user, DocumentType, "view_documenttype"),
)
else:
document_types = DocumentType.objects.all()
@@ -116,7 +112,9 @@ def match_tags(document: Document, classifier: DocumentClassifier, user=None):
user = document.owner
if user is not None:
tags = get_objects_for_user_owner_aware(user, "documents.view_tag", Tag)
tags = Tag.objects.filter(
id__in=permitted_object_ids(user, Tag, "view_tag"),
)
else:
tags = Tag.objects.all()
@@ -145,10 +143,8 @@ def match_storage_paths(document: Document, classifier: DocumentClassifier, user
user = document.owner
if user is not None:
storage_paths = get_objects_for_user_owner_aware(
user,
"documents.view_storagepath",
StoragePath,
storage_paths = StoragePath.objects.filter(
id__in=permitted_object_ids(user, StoragePath, "view_storagepath"),
)
else:
storage_paths = StoragePath.objects.all()
+43 -25
View File
@@ -7,6 +7,7 @@ from django.contrib.contenttypes.models import ContentType
from django.db.models import Case
from django.db.models import Count
from django.db.models import IntegerField
from django.db.models import Model
from django.db.models import Q
from django.db.models import QuerySet
from django.db.models import Value
@@ -163,30 +164,32 @@ def set_permissions_for_object(
)
def permitted_document_ids(
user,
def permitted_object_ids(
user: User | None,
model: type[Model],
perm: str,
*,
perm: str = "view_document",
include_deleted: bool = False,
):
) -> QuerySet[int]:
"""
Return a queryset of document IDs the user has ``perm`` on (default
``"view_document"``). By default limited to non-deleted documents; pass
``include_deleted=True`` for callers that need to check permission on
soft-deleted documents (e.g. trash restore). This intentionally avoids
``get_objects_for_user`` to keep the subquery small and index-friendly.
Generic version of ``permitted_document_ids`` for any model with an
``owner`` field and guardian object-level permissions. ``include_deleted``
only has an effect for models exposing a ``global_objects``/``deleted_at``
soft-delete pattern (currently only ``Document``); for every other model
it is accepted but has no effect, since those models have no soft-delete
concept.
"""
manager = Document.global_objects if include_deleted else Document.objects
base_docs = manager.all()
base_docs = base_docs.only("id", "owner")
has_soft_delete = hasattr(model, "global_objects")
manager = (
model.global_objects if include_deleted and has_soft_delete else model.objects
)
base_qs = manager.all().only("id", "owner")
if user is None or not getattr(user, "is_authenticated", False):
# Just Anonymous user e.g. for drf-spectacular
return base_docs.filter(owner__isnull=True).values_list("id", flat=True)
return base_qs.filter(owner__isnull=True).values_list("id", flat=True)
if getattr(user, "is_superuser", False):
return base_docs.values_list("id", flat=True)
return base_qs.values_list("id", flat=True)
# Guardian's UserObjectPermission/GroupObjectPermission always store a bare
# codename, but has_perm()-style callers commonly pass the qualified
@@ -194,31 +197,46 @@ def permitted_document_ids(
# codename, so just drop any prefix rather than silently under-permitting.
perm = perm.rsplit(".", 1)[-1]
document_ct = ContentType.objects.get_for_model(Document)
content_type = ContentType.objects.get_for_model(model)
perm_filter = {
"permission__codename": perm,
"permission__content_type": document_ct,
"permission__content_type": content_type,
}
user_perm_docs = (
user_perm_ids = (
UserObjectPermission.objects.filter(user=user, **perm_filter)
.annotate(object_pk_int=Cast("object_pk", IntegerField()))
.values_list("object_pk_int", flat=True)
)
group_perm_docs = (
group_perm_ids = (
GroupObjectPermission.objects.filter(group__user=user, **perm_filter)
.annotate(object_pk_int=Cast("object_pk", IntegerField()))
.values_list("object_pk_int", flat=True)
)
permitted_ids = user_perm_ids.union(group_perm_ids)
permitted_documents = user_perm_docs.union(group_perm_docs)
return base_docs.filter(
Q(owner=user) | Q(owner__isnull=True) | Q(id__in=permitted_documents),
return base_qs.filter(
Q(owner=user) | Q(owner__isnull=True) | Q(id__in=permitted_ids),
).values_list("id", flat=True)
def permitted_document_ids(
user: User | None,
*,
perm: str = "view_document",
include_deleted: bool = False,
) -> QuerySet[int]:
"""
Document-specific convenience wrapper around ``permitted_object_ids``.
Return a queryset of document IDs the user has ``perm`` on (default
``"view_document"``). By default limited to non-deleted documents; pass
``include_deleted=True`` for callers that need to check permission on
soft-deleted documents (e.g. trash restore). This intentionally avoids
``get_objects_for_user`` to keep the subquery small and index-friendly.
"""
return permitted_object_ids(user, Document, perm, include_deleted=include_deleted)
def get_document_count_filter_for_user(user, related_name: str = "documents"):
"""
Return the Q object used to filter document counts for the given user.
+2 -3
View File
@@ -70,8 +70,7 @@
]
</script>
</pngx-root>
<script src="{% static runtime_js %}" defer></script>
<script src="{% static polyfills_js %}" defer></script>
<script src="{% static main_js %}" defer></script>
<script src="{% static polyfills_js %}" type="module"></script>
<script src="{% static main_js %}" type="module"></script>
</body>
</html>
+327
View File
@@ -0,0 +1,327 @@
import io
import json
import os
import zipfile
from pathlib import Path
import pytest
from pytest_django.fixtures import SettingsWrapper
from documents.export.sinks import DirectoryExportSink
from documents.export.sinks import ExportSink
from documents.export.sinks import StreamingManifestWriter
from documents.export.sinks import ZipExportSink
from documents.export.sinks import _dumps
@pytest.fixture()
def source_file(tmp_path: Path) -> Path:
src: Path = tmp_path / "src" / "doc.pdf"
src.parent.mkdir(parents=True)
src.write_bytes(b"PDF-CONTENT")
return src
class TestDumps:
def test_dumps_is_indented_unicode_json(self) -> None:
result: str = _dumps({"a": "é", "b": 1})
assert '"é"' in result # ensure_ascii=False keeps unicode literal
assert "\n" in result # indent=2 produces newlines
assert json.loads(result) == {"a": "é", "b": 1}
class TestStreamingManifestWriter:
def test_writes_json_array_of_records(self) -> None:
handle: io.StringIO = io.StringIO()
writer: StreamingManifestWriter = StreamingManifestWriter(handle)
writer.write_batch([{"pk": 1}, {"pk": 2}])
writer.write_record({"pk": 3})
writer.close()
assert json.loads(handle.getvalue()) == [{"pk": 1}, {"pk": 2}, {"pk": 3}]
def test_empty_manifest_is_valid_empty_array(self) -> None:
handle: io.StringIO = io.StringIO()
writer: StreamingManifestWriter = StreamingManifestWriter(handle)
writer.close()
assert json.loads(handle.getvalue()) == []
class TestDirectoryExportSink:
def test_add_file_copies_to_relative_arcname(
self,
tmp_path: Path,
source_file: Path,
) -> None:
target: Path = tmp_path / "out"
target.mkdir()
with DirectoryExportSink(
target,
compare_checksums=False,
compare_json=False,
delete=False,
) as sink:
sink.add_file(source_file, "originals/doc.pdf")
assert (target / "originals" / "doc.pdf").read_bytes() == b"PDF-CONTENT"
def test_add_json_writes_file(self, tmp_path: Path) -> None:
target: Path = tmp_path / "out"
target.mkdir()
with DirectoryExportSink(
target,
compare_checksums=False,
compare_json=False,
delete=False,
) as sink:
sink.add_json({"version": "x"}, "metadata.json")
assert json.loads((target / "metadata.json").read_text()) == {"version": "x"}
def test_stream_writes_manifest(self, tmp_path: Path) -> None:
target: Path = tmp_path / "out"
target.mkdir()
with DirectoryExportSink(
target,
compare_checksums=False,
compare_json=False,
delete=False,
) as sink:
with sink.stream("manifest.json") as handle:
writer: StreamingManifestWriter = StreamingManifestWriter(handle)
writer.write_record({"pk": 1})
writer.close()
assert json.loads((target / "manifest.json").read_text()) == [{"pk": 1}]
def test_add_file_skips_when_size_and_mtime_match(
self,
tmp_path: Path,
source_file: Path,
) -> None:
# Pre-existing target with identical size+mtime but DIFFERENT content:
# if add_file skips (no compare-checksums), the old content survives.
target: Path = tmp_path / "out"
target.mkdir()
existing: Path = target / "originals" / "doc.pdf"
existing.parent.mkdir(parents=True)
# Same byte length as the source but different content + matching mtime,
# so a size/mtime comparison treats it as unchanged and skips the copy.
existing.write_bytes(b"X" * len(b"PDF-CONTENT"))
stat = source_file.stat()
os.utime(existing, (stat.st_atime, stat.st_mtime))
with DirectoryExportSink(
target,
compare_checksums=False,
compare_json=False,
delete=False,
) as sink:
sink.add_file(source_file, "originals/doc.pdf", checksum="abc")
assert existing.read_bytes() == b"X" * len(b"PDF-CONTENT") # skipped
def test_add_file_recopies_when_compare_checksums_differ(
self,
tmp_path: Path,
source_file: Path,
) -> None:
target: Path = tmp_path / "out"
target.mkdir()
existing: Path = target / "originals" / "doc.pdf"
existing.parent.mkdir(parents=True)
existing.write_bytes(b"X" * len(b"PDF-CONTENT"))
stat = source_file.stat()
os.utime(existing, (stat.st_atime, stat.st_mtime))
with DirectoryExportSink(
target,
compare_checksums=True,
compare_json=False,
delete=False,
) as sink:
# wrong checksum forces recopy despite matching size/mtime
sink.add_file(source_file, "originals/doc.pdf", checksum="not-the-real-sum")
assert existing.read_bytes() == b"PDF-CONTENT" # recopied
def test_delete_prunes_unwritten_snapshot_files(
self,
tmp_path: Path,
source_file: Path,
) -> None:
target: Path = tmp_path / "out"
target.mkdir()
stale: Path = target / "stale.pdf"
stale.write_bytes(b"STALE")
with DirectoryExportSink(
target,
compare_checksums=False,
compare_json=False,
delete=True,
) as sink:
sink.add_file(source_file, "originals/doc.pdf")
assert not stale.exists()
assert (target / "originals" / "doc.pdf").exists()
def test_no_delete_keeps_unwritten_files(
self,
tmp_path: Path,
source_file: Path,
) -> None:
target: Path = tmp_path / "out"
target.mkdir()
stale: Path = target / "stale.pdf"
stale.write_bytes(b"STALE")
with DirectoryExportSink(
target,
compare_checksums=False,
compare_json=False,
delete=False,
) as sink:
sink.add_file(source_file, "originals/doc.pdf")
assert stale.exists()
class TestZipExportSink:
def test_round_trip_files_json_and_stream(
self,
tmp_path: Path,
source_file: Path,
) -> None:
target: Path = tmp_path / "out"
target.mkdir()
with ZipExportSink(target, "export", delete=False) as sink:
sink.add_file(source_file, "originals/doc.pdf")
sink.add_json({"version": "x"}, "metadata.json")
with sink.stream("manifest.json") as handle:
writer = StreamingManifestWriter(handle)
writer.write_record({"pk": 1})
writer.close()
zip_path: Path = target / "export.zip"
assert zip_path.exists()
assert not (target / "export.zip.tmp").exists()
with zipfile.ZipFile(zip_path) as zf:
names = set(zf.namelist())
assert {"originals/doc.pdf", "metadata.json", "manifest.json"} <= names
assert zf.read("originals/doc.pdf") == b"PDF-CONTENT"
assert json.loads(zf.read("manifest.json")) == [{"pk": 1}]
def test_nested_arcname_emits_directory_marker(
self,
tmp_path: Path,
source_file: Path,
) -> None:
target: Path = tmp_path / "out"
target.mkdir()
with ZipExportSink(target, "export", delete=False) as sink:
sink.add_file(source_file, "originals/doc.pdf")
with zipfile.ZipFile(target / "export.zip") as zf:
assert "originals/" in zf.namelist()
def test_flat_arcname_has_no_directory_markers(
self,
tmp_path: Path,
source_file: Path,
) -> None:
target: Path = tmp_path / "out"
target.mkdir()
with ZipExportSink(target, "export", delete=False) as sink:
sink.add_file(source_file, "doc.pdf")
with zipfile.ZipFile(target / "export.zip") as zf:
assert all(not n.endswith("/") for n in zf.namelist())
def test_exception_leaves_no_zip_and_no_tmp(
self,
tmp_path: Path,
source_file: Path,
) -> None:
target: Path = tmp_path / "out"
target.mkdir()
with pytest.raises(RuntimeError):
with ZipExportSink(target, "export", delete=False) as sink:
sink.add_file(source_file, "doc.pdf")
raise RuntimeError("boom")
assert not (target / "export.zip").exists()
assert not (target / "export.zip.tmp").exists()
def test_exception_inside_stream_cleans_up_manifest_tmp(
self,
tmp_path: Path,
source_file: Path,
settings: SettingsWrapper,
) -> None:
scratch_dir = tmp_path / "scratch"
settings.SCRATCH_DIR = scratch_dir
target: Path = tmp_path / "out"
target.mkdir()
with pytest.raises(RuntimeError):
with ZipExportSink(target, "export", delete=False) as sink:
sink.add_file(source_file, "doc.pdf")
with sink.stream("manifest.json") as handle:
handle.write("[")
raise RuntimeError("boom")
assert list(scratch_dir.glob("export-manifest-*")) == []
assert not (target / "export.zip").exists()
assert not (target / "export.zip.tmp").exists()
def test_abort_after_manifest_written_cleans_up_pending_tmp(
self,
tmp_path: Path,
settings: SettingsWrapper,
) -> None:
scratch_dir = tmp_path / "scratch"
settings.SCRATCH_DIR = scratch_dir
target: Path = tmp_path / "out"
target.mkdir()
with pytest.raises(RuntimeError):
with ZipExportSink(target, "export", delete=False) as sink:
with sink.stream("manifest.json") as handle:
handle.write("[]")
raise RuntimeError("boom")
assert list(scratch_dir.glob("export-manifest-*")) == []
assert not (target / "export.zip").exists()
def test_delete_wipes_destination_on_success(
self,
tmp_path: Path,
source_file: Path,
) -> None:
target: Path = tmp_path / "out"
target.mkdir()
(target / "preexisting.txt").write_text("old")
(target / "olddir").mkdir()
with ZipExportSink(target, "export", delete=True) as sink:
sink.add_file(source_file, "doc.pdf")
assert (target / "export.zip").exists()
assert not (target / "preexisting.txt").exists()
assert not (target / "olddir").exists()
def test_abort_with_delete_does_not_wipe_destination(
self,
tmp_path: Path,
source_file: Path,
) -> None:
target: Path = tmp_path / "out"
target.mkdir()
(target / "preexisting.txt").write_text("old")
with pytest.raises(RuntimeError):
with ZipExportSink(target, "export", delete=True) as sink:
sink.add_file(source_file, "doc.pdf")
raise RuntimeError("boom")
assert (target / "preexisting.txt").exists()
assert not (target / "export.zip").exists()
class TestStreamContract:
@pytest.fixture(params=["dir", "zip"])
def sink(self, request: pytest.FixtureRequest, tmp_path: Path) -> ExportSink:
target: Path = tmp_path / "out"
target.mkdir()
if request.param == "dir":
return DirectoryExportSink(
target,
compare_checksums=False,
compare_json=False,
delete=False,
)
return ZipExportSink(target, "export", delete=False)
def test_second_concurrent_stream_is_rejected(self, sink: ExportSink) -> None:
with sink:
with sink.stream("manifest.json"):
with pytest.raises(RuntimeError, match="already open"):
with sink.stream("other.json"):
pass
+46 -27
View File
@@ -1057,33 +1057,52 @@ class TestDocumentSearchApi(DirectoriesMixin, APITestCase):
THEN:
- The similar documents are returned from the API request
"""
# Distinct created/added dates: documents created at the same instant
# share a timestamp term, and more_like_this (which cannot be scoped to
# content fields) would then match on it, surfacing unrelated documents.
d1 = DocumentFactory(
title="invoice",
content="the thing i bought at a shop and paid with bank account",
created=datetime.date(2018, 1, 1),
added=timezone.make_aware(datetime.datetime(2018, 1, 1)),
)
d2 = DocumentFactory(
title="bank statement 1",
content="things i paid for in august",
created=datetime.date(2019, 3, 4),
added=timezone.make_aware(datetime.datetime(2019, 3, 4)),
)
d3 = DocumentFactory(
title="bank statement 3",
content="things i paid for in september",
created=datetime.date(2020, 7, 9),
added=timezone.make_aware(datetime.datetime(2020, 7, 9)),
)
d4 = DocumentFactory(
title="Quarterly Report",
content="quarterly revenue profit margin earnings growth",
created=datetime.date(2021, 11, 30),
added=timezone.make_aware(datetime.datetime(2021, 11, 30)),
)
# Distinct created/added/modified dates: documents sharing a timestamp
# term (down to the second) would be matched on it by more_like_this
# (which cannot be scoped to content fields), surfacing unrelated
# documents. `modified` is auto_now, so it can't be set via factory
# kwargs like created/added - freeze time per document instead so all
# three date fields land on distinct seconds.
with time_machine.travel(
timezone.make_aware(datetime.datetime(2018, 1, 1)),
tick=False,
):
d1 = DocumentFactory(
title="invoice",
content="the thing i bought at a shop and paid with bank account",
created=datetime.date(2018, 1, 1),
added=timezone.make_aware(datetime.datetime(2018, 1, 1)),
)
with time_machine.travel(
timezone.make_aware(datetime.datetime(2019, 3, 4)),
tick=False,
):
d2 = DocumentFactory(
title="bank statement 1",
content="things i paid for in august",
created=datetime.date(2019, 3, 4),
added=timezone.make_aware(datetime.datetime(2019, 3, 4)),
)
with time_machine.travel(
timezone.make_aware(datetime.datetime(2020, 7, 9)),
tick=False,
):
d3 = DocumentFactory(
title="bank statement 3",
content="things i paid for in september",
created=datetime.date(2020, 7, 9),
added=timezone.make_aware(datetime.datetime(2020, 7, 9)),
)
with time_machine.travel(
timezone.make_aware(datetime.datetime(2021, 11, 30)),
tick=False,
):
d4 = DocumentFactory(
title="Quarterly Report",
content="quarterly revenue profit margin earnings growth",
created=datetime.date(2021, 11, 30),
added=timezone.make_aware(datetime.datetime(2021, 11, 30)),
)
backend = get_backend()
backend.add_or_update(d1)
backend.add_or_update(d2)
@@ -426,7 +426,7 @@ class TestExportImport(
st_mtime_1 = (self.target / "manifest.json").stat().st_mtime
with mock.patch(
"documents.management.commands.document_exporter.copy_file_with_basic_stats",
"documents.export.sinks.copy_file_with_basic_stats",
) as m:
self._do_export()
m.assert_not_called()
@@ -437,7 +437,7 @@ class TestExportImport(
Path(self.d1.source_path).touch()
with mock.patch(
"documents.management.commands.document_exporter.copy_file_with_basic_stats",
"documents.export.sinks.copy_file_with_basic_stats",
) as m:
self._do_export()
self.assertEqual(m.call_count, 1)
@@ -464,7 +464,7 @@ class TestExportImport(
self.assertIsFile(self.target / "manifest.json")
with mock.patch(
"documents.management.commands.document_exporter.copy_file_with_basic_stats",
"documents.export.sinks.copy_file_with_basic_stats",
) as m:
self._do_export()
m.assert_not_called()
@@ -475,7 +475,7 @@ class TestExportImport(
self.d2.save()
with mock.patch(
"documents.management.commands.document_exporter.copy_file_with_basic_stats",
"documents.export.sinks.copy_file_with_basic_stats",
) as m:
self._do_export(compare_checksums=True)
self.assertEqual(m.call_count, 1)
@@ -1058,6 +1058,26 @@ class TestExportImport(
self.assertEqual(Document.objects.all().count(), 4)
def test_zip_with_compare_flags_raises(self) -> None:
"""
GIVEN:
- A request to export to a zip file
WHEN:
- --compare-checksums or --compare-json is also passed
THEN:
- A CommandError is raised (the flags are no-ops in zip mode)
"""
for flag in ("--compare-checksums", "--compare-json"):
with self.subTest(flag=flag):
with self.assertRaises(CommandError):
call_command(
"document_exporter",
self.target,
"--zip",
flag,
skip_checks=True,
)
@pytest.mark.management
class TestCryptExportImport(
@@ -12,9 +12,22 @@ from django.test import override_settings
from guardian.shortcuts import assign_perm
from rest_framework.test import APIClient
from documents.matching import match_correspondents
from documents.matching import match_document_types
from documents.matching import match_storage_paths
from documents.matching import match_tags
from documents.models import Correspondent
from documents.models import DocumentType
from documents.models import StoragePath
from documents.models import Tag
from documents.permissions import permitted_document_ids
from documents.permissions import permitted_object_ids
from documents.serialisers import _get_viewable_duplicates
from documents.tests.factories import CorrespondentFactory
from documents.tests.factories import DocumentFactory
from documents.tests.factories import DocumentTypeFactory
from documents.tests.factories import StoragePathFactory
from documents.tests.factories import TagFactory
def assert_visible_document_ids(actual_ids, *, expected_visible, expected_hidden):
@@ -431,3 +444,179 @@ class TestTrashRestorePermissionBoundary:
format="json",
)
assert response.status_code == HTTPStatus.OK
@pytest.mark.django_db
@pytest.mark.parametrize(
("model", "factory", "perm"),
[
(Tag, TagFactory, "view_tag"),
(Correspondent, CorrespondentFactory, "view_correspondent"),
(DocumentType, DocumentTypeFactory, "view_documenttype"),
(StoragePath, StoragePathFactory, "view_storagepath"),
],
)
class TestPermittedObjectIdsGenericModels:
def test_owner_sees_own_object(self, model, factory, perm):
owner = User.objects.create_user(username=f"owner_{model.__name__}")
stranger = User.objects.create_user(username=f"stranger_{model.__name__}")
owned = factory(owner=owner)
strangers = factory(owner=stranger)
assert_visible_document_ids(
permitted_object_ids(owner, model, perm),
expected_visible=[owned.pk],
expected_hidden=[strangers.pk],
)
def test_unowned_object_visible_to_everyone(self, model, factory, perm):
user = User.objects.create_user(username=f"user_{model.__name__}")
unowned = factory(owner=None)
assert_visible_document_ids(
permitted_object_ids(user, model, perm),
expected_visible=[unowned.pk],
expected_hidden=[],
)
def test_explicit_permission_grants_visibility(self, model, factory, perm):
owner = User.objects.create_user(username=f"owner2_{model.__name__}")
grantee = User.objects.create_user(username=f"grantee_{model.__name__}")
stranger = User.objects.create_user(username=f"stranger2_{model.__name__}")
shared = factory(owner=owner)
not_shared = factory(owner=owner)
assign_perm(perm, grantee, shared)
assert_visible_document_ids(
permitted_object_ids(grantee, model, perm),
expected_visible=[shared.pk],
expected_hidden=[not_shared.pk],
)
assert_visible_document_ids(
permitted_object_ids(stranger, model, perm),
expected_visible=[],
expected_hidden=[shared.pk, not_shared.pk],
)
def test_group_permission_grants_visibility_to_members_only(
self,
model,
factory,
perm,
):
owner = User.objects.create_user(username=f"owner3_{model.__name__}")
member = User.objects.create_user(username=f"member_{model.__name__}")
non_member = User.objects.create_user(username=f"nonmember_{model.__name__}")
group = Group.objects.create(name=f"group_{model.__name__}")
member.groups.add(group)
shared = factory(owner=owner)
assign_perm(perm, group, shared)
assert_visible_document_ids(
permitted_object_ids(member, model, perm),
expected_visible=[shared.pk],
expected_hidden=[],
)
assert_visible_document_ids(
permitted_object_ids(non_member, model, perm),
expected_visible=[],
expected_hidden=[shared.pk],
)
def test_superuser_sees_everything(self, model, factory, perm):
superuser = User.objects.create_superuser(username=f"root_{model.__name__}")
owner = User.objects.create_user(username=f"owner4_{model.__name__}")
obj = factory(owner=owner)
assert_visible_document_ids(
permitted_object_ids(superuser, model, perm),
expected_visible=[obj.pk],
expected_hidden=[],
)
@pytest.mark.django_db
class TestMatchingRespectsObjectPermissions:
def test_match_tags_only_considers_tags_visible_to_user(self):
owner = User.objects.create_user(username="tag_owner")
classifying_user = User.objects.create_user(username="classifier_user")
visible_tag = TagFactory(
owner=owner,
match="invoice",
matching_algorithm=Tag.MATCH_LITERAL,
)
hidden_tag = TagFactory(
owner=owner,
match="invoice",
matching_algorithm=Tag.MATCH_LITERAL,
)
assign_perm("view_tag", classifying_user, visible_tag)
doc = DocumentFactory(owner=classifying_user, content="an invoice document")
matched = match_tags(doc, classifier=None, user=classifying_user)
matched_ids = {t.pk for t in matched}
assert visible_tag.pk in matched_ids
assert hidden_tag.pk not in matched_ids
def test_match_correspondents_only_considers_correspondents_visible_to_user(self):
owner = User.objects.create_user(username="correspondent_owner")
classifying_user = User.objects.create_user(username="classifier_user2")
visible_correspondent = CorrespondentFactory(
owner=owner,
match="invoice",
matching_algorithm=Correspondent.MATCH_LITERAL,
)
hidden_correspondent = CorrespondentFactory(
owner=owner,
match="invoice",
matching_algorithm=Correspondent.MATCH_LITERAL,
)
assign_perm("view_correspondent", classifying_user, visible_correspondent)
doc = DocumentFactory(owner=classifying_user, content="an invoice document")
matched = match_correspondents(doc, classifier=None, user=classifying_user)
matched_ids = {c.pk for c in matched}
assert visible_correspondent.pk in matched_ids
assert hidden_correspondent.pk not in matched_ids
def test_match_document_types_only_considers_document_types_visible_to_user(self):
owner = User.objects.create_user(username="document_type_owner")
classifying_user = User.objects.create_user(username="classifier_user3")
visible_document_type = DocumentTypeFactory(
owner=owner,
match="invoice",
matching_algorithm=DocumentType.MATCH_LITERAL,
)
hidden_document_type = DocumentTypeFactory(
owner=owner,
match="invoice",
matching_algorithm=DocumentType.MATCH_LITERAL,
)
assign_perm("view_documenttype", classifying_user, visible_document_type)
doc = DocumentFactory(owner=classifying_user, content="an invoice document")
matched = match_document_types(doc, classifier=None, user=classifying_user)
matched_ids = {dt.pk for dt in matched}
assert visible_document_type.pk in matched_ids
assert hidden_document_type.pk not in matched_ids
def test_match_storage_paths_only_considers_storage_paths_visible_to_user(self):
owner = User.objects.create_user(username="storage_path_owner")
classifying_user = User.objects.create_user(username="classifier_user4")
visible_storage_path = StoragePathFactory(
owner=owner,
match="invoice",
matching_algorithm=StoragePath.MATCH_LITERAL,
)
hidden_storage_path = StoragePathFactory(
owner=owner,
match="invoice",
matching_algorithm=StoragePath.MATCH_LITERAL,
)
assign_perm("view_storagepath", classifying_user, visible_storage_path)
doc = DocumentFactory(owner=classifying_user, content="an invoice document")
matched = match_storage_paths(doc, classifier=None, user=classifying_user)
matched_ids = {sp.pk for sp in matched}
assert visible_storage_path.pk in matched_ids
assert hidden_storage_path.pk not in matched_ids
-4
View File
@@ -78,10 +78,6 @@ class TestViews(DirectoriesMixin, TestCase):
response.context_data["styles_css"],
f"frontend/{language_actual}/styles.css",
)
self.assertEqual(
response.context_data["runtime_js"],
f"frontend/{language_actual}/runtime.js",
)
self.assertEqual(
response.context_data["polyfills_js"],
f"frontend/{language_actual}/polyfills.js",
-1
View File
@@ -348,7 +348,6 @@ class IndexView(TemplateView):
context["username"] = self.request.user.username
context["full_name"] = self.request.user.get_full_name()
context["styles_css"] = f"frontend/{self.get_frontend_language()}/styles.css"
context["runtime_js"] = f"frontend/{self.get_frontend_language()}/runtime.js"
context["polyfills_js"] = (
f"frontend/{self.get_frontend_language()}/polyfills.js"
)
+11 -11
View File
@@ -2,7 +2,7 @@ msgid ""
msgstr ""
"Project-Id-Version: paperless-ngx\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-08-05 14:50+0000\n"
"POT-Creation-Date: 2026-08-07 20:00+0000\n"
"PO-Revision-Date: 2022-02-17 04:17\n"
"Last-Translator: \n"
"Language-Team: English\n"
@@ -1352,7 +1352,7 @@ msgid "workflow runs"
msgstr ""
#: documents/serialisers.py:521 documents/serialisers.py:873
#: documents/serialisers.py:2767 documents/views.py:300 documents/views.py:2557
#: documents/serialisers.py:2767 documents/views.py:300 documents/views.py:2556
#: paperless_mail/serialisers.py:155
msgid "Insufficient permissions."
msgstr ""
@@ -1393,7 +1393,7 @@ msgstr ""
msgid "Duplicate document identifiers are not allowed."
msgstr ""
#: documents/serialisers.py:2853 documents/views.py:4511
#: documents/serialisers.py:2853 documents/views.py:4510
#, python-format
msgid "Documents not found: %(ids)s"
msgstr ""
@@ -1661,36 +1661,36 @@ msgstr ""
msgid "Unable to parse URI {value}"
msgstr ""
#: documents/views.py:293 documents/views.py:2554
#: documents/views.py:293 documents/views.py:2553
msgid "Invalid more_like_id"
msgstr ""
#: documents/views.py:1568
#: documents/views.py:1567
msgid "Invalid AI configuration."
msgstr ""
#: documents/views.py:1577
#: documents/views.py:1576
msgid "AI backend request timed out."
msgstr ""
#: documents/views.py:2379 documents/views.py:2700
#: documents/views.py:2378 documents/views.py:2699
msgid "Specify only one of text, title_search, query, or more_like_id."
msgstr ""
#: documents/views.py:4524
#: documents/views.py:4523
#, python-format
msgid "Insufficient permissions to share document %(id)s."
msgstr ""
#: documents/views.py:4570
#: documents/views.py:4569
msgid "Bundle is already being processed."
msgstr ""
#: documents/views.py:4631
#: documents/views.py:4630
msgid "The share link bundle is still being prepared. Please try again later."
msgstr ""
#: documents/views.py:4641
#: documents/views.py:4640
msgid "The share link bundle is unavailable."
msgstr ""
Generated
+55 -55
View File
@@ -740,54 +740,54 @@ toml = [
[[package]]
name = "cryptography"
version = "48.0.1"
version = "50.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/12/45/870e7f4bef50e5f53b9f51d4428aee5290eedf58ba443f16b1ebb7ab8e66/cryptography-48.0.1.tar.gz", hash = "sha256:266f4ee051abb2f725b74ef8072b521ce1feacf685a3364fa6a6b45548db791a", size = 832989, upload-time = "2026-06-09T22:32:31.8Z" }
sdist = { url = "https://files.pythonhosted.org/packages/de/41/6cbdcf9142d00fe82836fbb51e503e58088575cf7a0fe1dbff6695bf0840/cryptography-50.0.0.tar.gz", hash = "sha256:eeac2acb5a20ed25e0ad6d1df9891a520b78b404266b6d11778f25d5d691a6c9", size = 880201, upload-time = "2026-07-31T14:25:10.11Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1b/bc/ee4137cbbe105652c0ee4252792b78fc8e7afa4b8e61d9d5dc05a7f45731/cryptography-48.0.1-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:3e4a1a3232eef2e6c732827d5722db29a0cc8b27af2a4d865b094cf954be9ca1", size = 8008324, upload-time = "2026-06-09T22:31:00.702Z" },
{ url = "https://files.pythonhosted.org/packages/d5/85/6379d42181bfc713094f081360fc5784d6c816b599d45e7f082502d173ce/cryptography-48.0.1-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:32143b24adb918f078134e1e230f1eb8cc04886b92c28b5f0041aaf3e5699225", size = 4696243, upload-time = "2026-06-09T22:32:33.446Z" },
{ url = "https://files.pythonhosted.org/packages/9c/87/c85d147b53323c7eb4d850920c8901377323c2a0ff8d79c262d4fee89aa2/cryptography-48.0.1-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f0d27a5696721ef7a672b8c810f6aded391058e0b9486e63e6d93baf765da691", size = 4713235, upload-time = "2026-06-09T22:31:40.141Z" },
{ url = "https://files.pythonhosted.org/packages/79/58/67cbf8cf1ee7c54b439ca07bbecf8362c07afc11a3724fea70f745784add/cryptography-48.0.1-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:eb86ce1af36fe65041b6db9a8bb064ee621a7e5fded0f80d475ec243477cd242", size = 4702323, upload-time = "2026-06-09T22:31:42.191Z" },
{ url = "https://files.pythonhosted.org/packages/89/c6/24266ac10c47f6cd2a865f4446062b466da1d1f10b27189eac00e61bf0c9/cryptography-48.0.1-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:b024e784ad6c077ee0147b35ea9cbfc1e34e1fd4c1dcca214c2794d73a12df08", size = 5300085, upload-time = "2026-06-09T22:31:58.703Z" },
{ url = "https://files.pythonhosted.org/packages/d2/bb/cc4b78784f97efc8c5874c2a9743708d172be6663024b34a0467885ae0c8/cryptography-48.0.1-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3752f2dbc8f07a30aad2932c986cea495b03bb554887828225da104f732852b6", size = 4746137, upload-time = "2026-06-09T22:31:31.01Z" },
{ url = "https://files.pythonhosted.org/packages/1f/52/0c44de3f5267f8fbe8e835138017522a333436166e406f0db9b9e6e3033f/cryptography-48.0.1-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:bd81490cd5801d755cf97bb68ac191f14b708470b1c7cf4580f669b9c9264cd8", size = 4333867, upload-time = "2026-06-09T22:32:28.096Z" },
{ url = "https://files.pythonhosted.org/packages/9a/2e/772d7adbfa931537bc401640b7cac9976bff689bda187833e5d63b428e49/cryptography-48.0.1-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:66fd0771e7b9c6dcd44cf1120690d2338d16d72795cf40cae2786a39eba65429", size = 4701805, upload-time = "2026-06-09T22:31:38.284Z" },
{ url = "https://files.pythonhosted.org/packages/f8/a3/b06844f303873493c963caf581c04df31c7035e0c1b0f02c4814d319ec80/cryptography-48.0.1-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:3fd2ca57062b241c856670b073487d2e86c4637937ca5601e48f97bf8e11fc8f", size = 5258461, upload-time = "2026-06-09T22:31:04.187Z" },
{ url = "https://files.pythonhosted.org/packages/9f/13/8b765e2e12b07c74941caadb9d1c8fdc006c4dfbf2b8f2d610519758954d/cryptography-48.0.1-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:0ee6ea481db1ab889cba043ec1eda17bb9c1ea79db6722f779c3667f9f70322f", size = 4745488, upload-time = "2026-06-09T22:32:30.07Z" },
{ url = "https://files.pythonhosted.org/packages/2e/aa/48972bce55049b32a94f4907eda4d75fa385aad8a39506cc2fc72196ecf0/cryptography-48.0.1-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:f2ceef93cb096aa3c4cc4b5c94ca6131f9196d28c64d6111533402a9b2054d41", size = 4830256, upload-time = "2026-06-09T22:31:43.868Z" },
{ url = "https://files.pythonhosted.org/packages/47/a2/e5079a032fb85cf6005046ca92bbd78b0c82dad2b5751ab8c311659da06f/cryptography-48.0.1-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9bd3f92d76217892b15df84ca256c2c113d386fdda7a7d8691aeeced976507c6", size = 4979117, upload-time = "2026-06-09T22:31:05.845Z" },
{ url = "https://files.pythonhosted.org/packages/42/06/3e768b4c3bc78201583fa35a0e18f640dd782ff41afba88f8545481a8874/cryptography-48.0.1-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:f817adc181390bd54f2f700107a7419040fb7c1bdf2fc26f36551a06a68c3345", size = 7989830, upload-time = "2026-06-09T22:31:07.8Z" },
{ url = "https://files.pythonhosted.org/packages/8a/13/6476736484b94041110c8340a3eb63962fea4975baea8cb4a512adb44d4d/cryptography-48.0.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d5d30989c6917b478b5817902e85fddaea2261efa8648383d965381ccb9e1ac4", size = 4689201, upload-time = "2026-06-09T22:31:09.745Z" },
{ url = "https://files.pythonhosted.org/packages/79/62/65a87f34d2a431546e2509b85d55e8c90df86d668f6731da64d538512ac2/cryptography-48.0.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:df637c05205ea7c1d7fbcbe54bbfea648a52951155f997af13d895d0ecc96991", size = 4702822, upload-time = "2026-06-09T22:32:24.409Z" },
{ url = "https://files.pythonhosted.org/packages/7f/59/810b5204b0a9b10f4b6bc06bd551a8b609803cd931806bc3b71884b225e5/cryptography-48.0.1-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:869c3b8a53bfe27147832df48b32adadf558249d50e76cb3769d40e986b13265", size = 4694875, upload-time = "2026-06-09T22:32:08.737Z" },
{ url = "https://files.pythonhosted.org/packages/24/dc/d8ca05ffea724eec6d232ea6f18e74c269eb6bdfdcc9bfba689790d1325f/cryptography-48.0.1-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:e361afba8918070d376df76f408a4f67fec0ee9cff81a99e48fe9a233ef59e17", size = 5290385, upload-time = "2026-06-09T22:31:15.212Z" },
{ url = "https://files.pythonhosted.org/packages/03/8c/3be6cb4da181f5bb6c19cf560c2359d60644a6b5fc5b57854e528f47b296/cryptography-48.0.1-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:d069066deead00ac7f090be101be875a06855908f7ec004c27b8fefb4acfb411", size = 4737082, upload-time = "2026-06-09T22:32:22.66Z" },
{ url = "https://files.pythonhosted.org/packages/aa/f6/d5f60a5a1434dbfd949e227fd0065d194c7e6b6ac526b17f5c06152b8231/cryptography-48.0.1-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:09f73a725d582cef64b91281a322cd798d14a33b2b6f2b7ad9531dc336d84c02", size = 4325328, upload-time = "2026-06-09T22:32:10.777Z" },
{ url = "https://files.pythonhosted.org/packages/17/b7/ba75dd947a14b6ad907b01ae8f6b5b348cdd1b48142f0063dee9e20c1d9d/cryptography-48.0.1-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:15254441469dd6bf027039453288e2072124f8b6603563f5d759e1c9b69273fa", size = 4694530, upload-time = "2026-06-09T22:31:53.105Z" },
{ url = "https://files.pythonhosted.org/packages/62/29/50d6b9e8aff12d8b67afaeb3569335e32dc83a5723e3bbded24fdac9f809/cryptography-48.0.1-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:8ace4507d1e6533c125f4fac754f8bb8b6a74c08e92179dabd7e16571a3efbf3", size = 5245046, upload-time = "2026-06-09T22:31:25.774Z" },
{ url = "https://files.pythonhosted.org/packages/9f/04/618f4115cfc0add0838c82507aa18a346089428da8653ad38b3ff36f5cb3/cryptography-48.0.1-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:b4e391975f038e66432328639620a4aff2d307513b004f1ca06d6225bced815c", size = 4736660, upload-time = "2026-06-09T22:32:12.676Z" },
{ url = "https://files.pythonhosted.org/packages/24/9c/06e062462a0de28a3b3911322eded4c16deb9f441b1b7575d3dc59488ab5/cryptography-48.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:42fcd8e26fe555d9b3577a135f5091fefa0aa4e99129c23fb56787a1bd4ada72", size = 4822229, upload-time = "2026-06-09T22:31:17.062Z" },
{ url = "https://files.pythonhosted.org/packages/f4/be/0561971eaaee4b8a0e7d5113c536921063ab91aaf23278ac374eaf881e11/cryptography-48.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c1400da5e32a43253392277eac7490a60e497d810a63dd5608d71bbd7af507c9", size = 4966364, upload-time = "2026-06-09T22:31:32.842Z" },
{ url = "https://files.pythonhosted.org/packages/ca/6c/00fa2a95997164c8b2072ce327c23d4ab20809ccc323ea5fab91e53a4bba/cryptography-48.0.1-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:4fdc69f8e4316bcf0c8c8ec1f26f285d12e8142d88d96c876a59a03be3f6ae67", size = 7987408, upload-time = "2026-06-09T22:32:20.777Z" },
{ url = "https://files.pythonhosted.org/packages/b0/d9/45f309a7e4e5f3f8f121d6d3be9e94024a7726ec598d6e08ae04edb2f04d/cryptography-48.0.1-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48fe40804d4caa2288f24e70ca8c64c42dd826da0ad7e4f1b41b2128d679e6c8", size = 4690196, upload-time = "2026-06-09T22:31:54.74Z" },
{ url = "https://files.pythonhosted.org/packages/5f/9f/a1bc8bcc798811b8527eb374bbccf30a3f3e806829d967118222bf1125eb/cryptography-48.0.1-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:86be3b1b0b6bf09482fb50a979c508d2950ed95f5621ec77f4e385962006b83a", size = 4696782, upload-time = "2026-06-09T22:31:45.615Z" },
{ url = "https://files.pythonhosted.org/packages/66/c2/81a4fb4e4373c500bb526bc337ac5719dd31dd15b970b84a238168c6aa08/cryptography-48.0.1-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:4ab0a343c807bbcd90c971cd1ecf072937cd01847a9e002bef88fb47ac6be577", size = 4696618, upload-time = "2026-06-09T22:31:11.564Z" },
{ url = "https://files.pythonhosted.org/packages/e5/0b/aa68b221dde92d09cb29a024ede17550ee21e77a404e59fc093c82bb51e1/cryptography-48.0.1-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9621de99d2da096006b629979efd8ae7eb2d8b822488d0c89ee4000c306c59b1", size = 5289970, upload-time = "2026-06-09T22:31:20.368Z" },
{ url = "https://files.pythonhosted.org/packages/78/13/fba657f958d2af66ea959a4ba01212632089249d34af1ae48054136344d7/cryptography-48.0.1-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:88c852a0ae366e262e5a1744b685e6a433dc8788dd2a277e418bf4904203609d", size = 4731873, upload-time = "2026-06-09T22:31:22.253Z" },
{ url = "https://files.pythonhosted.org/packages/4c/4c/9a964756d24a26b3e34dfcb16f961b89838786e6700b635b0d1e3adff4b6/cryptography-48.0.1-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:43c5835e2cb98c8733d86f57d6fc879b613f5c3478607281c3e36daffc6dd8a6", size = 4330804, upload-time = "2026-06-09T22:31:36.56Z" },
{ url = "https://files.pythonhosted.org/packages/4b/0f/a10f3a6eb12950a10e3a874070283aa2dd5875b2bfd15fad8a3e17b3f13e/cryptography-48.0.1-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:fe0180af5bf9236518a087e35bf2d9a347d5f5f51e63c579d683ddff424e3d46", size = 4696217, upload-time = "2026-06-09T22:31:13.351Z" },
{ url = "https://files.pythonhosted.org/packages/f3/6f/5cd12f951165ea73ef85266775d97e4c763b2474ccfd816dd69d3a18d6f8/cryptography-48.0.1-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:b7a2d1a937a738a881737cec135a38bb61470589b17515b9f73f571d0ae10401", size = 5245252, upload-time = "2026-06-09T22:32:02.193Z" },
{ url = "https://files.pythonhosted.org/packages/68/ab/8aaa12e4516ec4464033ab79b6f3b592bd5a92102467c4ace8a0d970203f/cryptography-48.0.1-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:b74ca3b8e5ecdd833bf6a002ca41b4793bb27fb8f1c06ffaf2643c9e9140e31b", size = 4731388, upload-time = "2026-06-09T22:32:04.019Z" },
{ url = "https://files.pythonhosted.org/packages/1b/24/50027ea4dca85ec1f40688f3c24fb32ccacd520583c9592c3cc95628e6fb/cryptography-48.0.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:2c37f2461406063b417837f5f3daab668652acd82423efcd7f0a9f04be972de1", size = 4824186, upload-time = "2026-06-09T22:32:18.707Z" },
{ url = "https://files.pythonhosted.org/packages/52/41/04cb5eb17085ade6f50cc611fb657df6a0f5885350de8764ece89c050197/cryptography-48.0.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:86fe77abb1bd87afb251d4d02ada7ecf53a32cee9b67d976abb2e45a13297475", size = 4964539, upload-time = "2026-06-09T22:31:18.793Z" },
{ url = "https://files.pythonhosted.org/packages/a9/d3/eb4e394e587341fdad09a09101fa76478ead3a78b0ad63e55c22f0d75c02/cryptography-48.0.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:08a597acce1ff37f347400087776599e2348a3a8bc53b44120e463cd274efe4a", size = 3951747, upload-time = "2026-06-09T22:31:23.871Z" },
{ url = "https://files.pythonhosted.org/packages/e0/4a/3f43451b4f858bfceaaaffc649e6e787e8d4fb332a1d443af39ab02cc8f1/cryptography-48.0.1-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:735824ec41b7f74a7c45fb1591349333e4c696cb6c044e5f46356e560143e4cd", size = 4641226, upload-time = "2026-06-09T22:31:02.532Z" },
{ url = "https://files.pythonhosted.org/packages/73/4e/855584c2c23b09e4ce2d3b9c30e983e679cd60b068c513c6bbdb91e11782/cryptography-48.0.1-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:92a46e1d638daa264ba2971c0b0489c9409787943efae4d60ffda3d091ef832c", size = 4668958, upload-time = "2026-06-09T22:32:06.213Z" },
{ url = "https://files.pythonhosted.org/packages/42/3b/d35750e41d803d1e516fd6d6011f065424924da7af1748cef4cc9cb3ede1/cryptography-48.0.1-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:7e234ac052af99f2700826a5c29ea99d9c1b1f80341cde62d11c8154dc8e0bd9", size = 4640793, upload-time = "2026-06-09T22:32:26.331Z" },
{ url = "https://files.pythonhosted.org/packages/ca/aa/cdb7181fe865285e87e96825aaab239400f1de0c3bfba9bd9769b79f1a92/cryptography-48.0.1-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:33842cf0888951cef5bc7ac724ab844a42044c1727b967b7f8997289a0464f92", size = 4668505, upload-time = "2026-06-09T22:31:27.534Z" },
{ url = "https://files.pythonhosted.org/packages/c5/5c/59086b4aac5e879d38ddbcf74e4be7ade89cebc3eb199a55da998c3bb46a/cryptography-50.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:031e2d5dd4bb9caa3ca9c82e5a197fd8ae680232cee62603d1a813f3f07e3d03", size = 4001252, upload-time = "2026-07-31T14:23:33.331Z" },
{ url = "https://files.pythonhosted.org/packages/57/ef/8f2df13c7216bcad3e1c74e07f6e193d93e998e114f524a53877c9af27ad/cryptography-50.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fd9192b7b70c573d7f214eb1ae35e00d359f6f5e4b27c7e21e30de1fc6204645", size = 4719554, upload-time = "2026-07-31T14:23:35.611Z" },
{ url = "https://files.pythonhosted.org/packages/d9/41/029086c34d91052fc3b88bcc8056f709a7c915c7a23b235a54eb800b1c97/cryptography-50.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:06a32a980526a6ab9a4b9bf8f7385800791e2bb960903cb6b530e4817509a3b7", size = 4702130, upload-time = "2026-07-31T14:23:37.635Z" },
{ url = "https://files.pythonhosted.org/packages/7d/ff/b6ce0954962e7f7b969f850a883744197bb3910bdfd7b6da162eab7d9f68/cryptography-50.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:a1b30560f2acc95aa8b2e06e716a13dbfc97314747b80d9707e307f77b40d6b3", size = 4725244, upload-time = "2026-07-31T14:23:39.471Z" },
{ url = "https://files.pythonhosted.org/packages/06/1e/63a1027cb7fec360a182208e1b7767d5aa1fe57be3d6aa856e69a321edc0/cryptography-50.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:8d89f3976b10b4ce31118de72329025f70d2c6ead14a8217c5514dd2c6d5a78f", size = 5342265, upload-time = "2026-07-31T14:23:41.286Z" },
{ url = "https://files.pythonhosted.org/packages/6b/72/a1116d683a6d7ece94590013882515de087edf9ef0e6292aae615a44df73/cryptography-50.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:b42a28c1844fd9de8f3f7d540e36b66f3a9c83fceac7170ebc7a6a19edd9dcae", size = 4734609, upload-time = "2026-07-31T14:23:43.139Z" },
{ url = "https://files.pythonhosted.org/packages/15/37/36a9c479bbe49acea2636c7fd3360d20f7b7e079c300352011c44850b181/cryptography-50.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:900131fafd8aead39ac7dd3a7e833be754c17a95cfd91221636949fe4eb0aa8a", size = 4356517, upload-time = "2026-07-31T14:23:44.939Z" },
{ url = "https://files.pythonhosted.org/packages/32/98/8a151d64367204cbc63ec65d37502f1d9c53cf4bfc6ec3c532614dbec60d/cryptography-50.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:07949c449a1abcf60d1ee6e88956d89404c7df3c8258f46589e912988e551987", size = 4724529, upload-time = "2026-07-31T14:23:46.93Z" },
{ url = "https://files.pythonhosted.org/packages/22/f6/ec13b470172126464a86bf54d2294a46d29837fc51ba3e45d4047946fb5e/cryptography-50.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:f89831ef99dd7dd169ab06d63a831adb9e20a87aac6d380266bbda5823349169", size = 5299852, upload-time = "2026-07-31T14:23:48.851Z" },
{ url = "https://files.pythonhosted.org/packages/da/3a/f05e32c99d440c9bb891ea0e36c9091891e36be5a9a87ab2ee6ea20729f6/cryptography-50.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:82148ec5bddac30b51a5b3c1945075f896fa022cb93f8e4a01e9f6ee95292c5f", size = 4734462, upload-time = "2026-07-31T14:23:50.861Z" },
{ url = "https://files.pythonhosted.org/packages/ca/dc/bd72b26be8953f80625f63151efd38eee71c76ca6cf591c08ff34615a79e/cryptography-50.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1489e263a8048bb8b6a8bac662eb2d402ea5d2b7b4699b72f385f1e2772db105", size = 4852708, upload-time = "2026-07-31T14:23:52.715Z" },
{ url = "https://files.pythonhosted.org/packages/27/20/c930314a2ab476d15dec966ec87e2e9637bb02b06106b12c0396c57bb603/cryptography-50.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:7cec5b856506da6defb290f30c9ee687d5f5e8cb0bd3f6459dde43b0b4fa40ef", size = 5004179, upload-time = "2026-07-31T14:23:54.887Z" },
{ url = "https://files.pythonhosted.org/packages/c3/fb/951032a3bf22a5697c83183fb6294a4843772947a70e616c57b3ff5f522e/cryptography-50.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:49e7d93abdbd2990caced757e5fade25302f719c3c8fb6e6fff2dde98999fc41", size = 3989258, upload-time = "2026-07-31T14:23:58.881Z" },
{ url = "https://files.pythonhosted.org/packages/d4/67/91eb047e69c5e845f2f14b8a2e4a1aab0f283cb885531e9e22c8adb176bc/cryptography-50.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:19736989797678c6af1e55cd49055cdbcb55d8f6b5583ac5335f933aba9101dc", size = 4700648, upload-time = "2026-07-31T14:24:00.702Z" },
{ url = "https://files.pythonhosted.org/packages/30/82/85f0f7425c856b9f96459411eb12e74ef72df9caf6f8f15bf23a33ff131f/cryptography-50.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:80b63928fa35083b33966ce1efb70e5b9607181e49dcd1c22c8c005e319f667f", size = 4682442, upload-time = "2026-07-31T14:24:02.538Z" },
{ url = "https://files.pythonhosted.org/packages/1a/28/b555a365adff1cca2fbe7b9e487d68a40de6bc67ff2cb587473eb43de0e7/cryptography-50.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:d58c3db7cd6eed54e6c06744db55456b65ebd7492ddeae9c1e93cfca7aa857d3", size = 4707596, upload-time = "2026-07-31T14:24:04.394Z" },
{ url = "https://files.pythonhosted.org/packages/72/d8/f52538140cc719df62a01cf87d1c7142318d235817109d6f4054d7c352d6/cryptography-50.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:df2a58a472f332225671c35b0a830208b86d004f82baa8530fa3782c85646533", size = 5314552, upload-time = "2026-07-31T14:24:06.31Z" },
{ url = "https://files.pythonhosted.org/packages/38/14/6120e5bd7c5aa022ad15424ba4d5c5269d0d9448ed4d55e492ea91e3c1c4/cryptography-50.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:11b74db56cdbe3cdee6e3f6982ecb70334fa10dce99ed58bf7894aaaa3b2a037", size = 4717113, upload-time = "2026-07-31T14:24:08.349Z" },
{ url = "https://files.pythonhosted.org/packages/fa/71/190bf38c3ee2e0f8efc9860ae100c9df4169742eef274b91e7aa1cb133b9/cryptography-50.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f59e38625469987d7ef6d495323c55e7db6c212eaf6112267e0d3b565a2e9c9f", size = 4338580, upload-time = "2026-07-31T14:24:10.227Z" },
{ url = "https://files.pythonhosted.org/packages/3a/63/504ccfbbe61fd8aa983f7f146399cdf034c72c2fc55f5b2dfdcdcdb20c99/cryptography-50.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:ecfed7367f965a0328cfbdd70da860f15441f002f613185668c6e6ebf5a0ac11", size = 4707038, upload-time = "2026-07-31T14:24:12.169Z" },
{ url = "https://files.pythonhosted.org/packages/01/77/2cf79bbfc4d12ca106437a6e170d6aaa01a373e93093118aaaef0e801bd4/cryptography-50.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:9aa87839c383bdbab6ef865787a1fb877af8dd03464c4400322726feaaadfc6d", size = 5273110, upload-time = "2026-07-31T14:24:14.38Z" },
{ url = "https://files.pythonhosted.org/packages/e5/45/8aae2972c520145377ea3559a605a899bebe227bf070b33cdb445929a9b9/cryptography-50.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:6ba6a53445bd3cfa809ef3ef5f1589aa6ba08784a1d962bf47d0940e871dab1c", size = 4716439, upload-time = "2026-07-31T14:24:16.415Z" },
{ url = "https://files.pythonhosted.org/packages/7b/20/4fe50b619a48c2525cc46e2dbc1ac490708d704be5d467bdaac6dc955682/cryptography-50.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3f5735ffe4996d28b809371756219f5354864902a3b9e7c0b9ee87041209fc9c", size = 4837383, upload-time = "2026-07-31T14:24:18.553Z" },
{ url = "https://files.pythonhosted.org/packages/92/91/3a31366e183343d3703f8995c095f5734676bd6938118047e50fcf279eb4/cryptography-50.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:1b4a266766514614f8aa60416e71f2fc6e575d36e7bdc90f644fadb2f4b75b95", size = 4985772, upload-time = "2026-07-31T14:24:20.385Z" },
{ url = "https://files.pythonhosted.org/packages/03/37/73d005be173aff344af30e9fd2a576575cb2391a7101d9cd3842e1fa8cce/cryptography-50.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ccdc4a71a4dabae05de219404f9f4abc38e3b58422177ff93d0da05967dafa07", size = 4036009, upload-time = "2026-07-31T14:24:24.122Z" },
{ url = "https://files.pythonhosted.org/packages/ff/c6/7a6202a534e32103a285b7834a120869557fe198d51d7cfe59754c8bda9c/cryptography-50.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:910e1d2668e7de9648f2bcee30e180db2a6b15c30f887d7c4c93ddf96e3992e3", size = 4745252, upload-time = "2026-07-31T14:24:26.118Z" },
{ url = "https://files.pythonhosted.org/packages/85/4f/0fa8c2f4428198f15d9ff8d63400e27afbf94ce833f6108da1eb3753f945/cryptography-50.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a91296cb61e8df6f86d0c19cc4068228da256bf59bf86049fbd821084565327f", size = 4728939, upload-time = "2026-07-31T14:24:27.994Z" },
{ url = "https://files.pythonhosted.org/packages/d1/63/54dd723490ba2dc09b299682c10b38db38f159728bcaae8c591b8af2f22d/cryptography-50.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:e722f16708d854fe924790e051061f6704a472c3bac347b6fd88033ea8dd0dc5", size = 4748483, upload-time = "2026-07-31T14:24:30.254Z" },
{ url = "https://files.pythonhosted.org/packages/1d/dd/7c77d26285cc7f6991efce64a0f5b4f9383bfa5dd8c5033003eaf7db4cdb/cryptography-50.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:d764dcf130c428ef66786f866dd750f53182bc608813489915e9fc106bb0c82f", size = 5367599, upload-time = "2026-07-31T14:24:32.457Z" },
{ url = "https://files.pythonhosted.org/packages/46/c9/f60aed34c013f317f92817b6c171c2d22a78270fa41109bd4b08af26b194/cryptography-50.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:105110f43a471dbd0060b9c9516cb8a6a79233631a04cc2ba16f28323ac6e025", size = 4762647, upload-time = "2026-07-31T14:24:34.599Z" },
{ url = "https://files.pythonhosted.org/packages/be/f3/f9a0173b139372c3a48ed98154b45cc6b9de17c789d5ab552e621c293609/cryptography-50.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:828743d939e9629bc267b8e2d08d8bb67cd4319c771a33d4b18b22dd8fb7440a", size = 4385197, upload-time = "2026-07-31T14:24:36.647Z" },
{ url = "https://files.pythonhosted.org/packages/d8/36/83bb81f6e569bc38e1e4a7bc80f29b46bb9601920bc455fc8e888f5d5742/cryptography-50.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:2a8183b489dc1f7f80f135780fadc1108f14b31b8a40411c7a5b17425f65f28b", size = 4748095, upload-time = "2026-07-31T14:24:39.493Z" },
{ url = "https://files.pythonhosted.org/packages/6b/16/d3008eff98c764979865834c3d386d4fd041b5f52e7f34fc29ac1a5eb515/cryptography-50.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6e7d61120573a7f2cd94cc095f9e81f6967c61ccdf194285aa143ecec8e0b708", size = 5325948, upload-time = "2026-07-31T14:24:41.556Z" },
{ url = "https://files.pythonhosted.org/packages/9c/f8/d97f9603efda3888187bfdb893f26c41be4735c10631d05d284ee6b047c4/cryptography-50.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:37fdb0d0111f1e2ff07139dfb79f1b49531f8e213c46f1163dd7642979b58c47", size = 4762400, upload-time = "2026-07-31T14:24:43.636Z" },
{ url = "https://files.pythonhosted.org/packages/64/a2/4615c8f7d81a00b1d6e6afe19f694e1543582349fb5f4076f6cb5dc36485/cryptography-50.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c87f62a3d3b9888ed0fdde100ec06aa61ca9cd44bad9057d1dff9a516b5f5bb9", size = 4878208, upload-time = "2026-07-31T14:24:45.522Z" },
{ url = "https://files.pythonhosted.org/packages/d2/1a/efcfb02f91407149a0dacffffab791f7e19bf6385f63b3666dc8b5e5c9c8/cryptography-50.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:65c2c3add92b45fd0709db8594536aea39c2a67af0e27ffcf049c498501140b7", size = 5037050, upload-time = "2026-07-31T14:24:47.697Z" },
{ url = "https://files.pythonhosted.org/packages/9d/3e/e54cde8c01631a5a8226ccd617eab9e57fd5cfdad90f1a9e6bb570794631/cryptography-50.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:5e34edd123674534acd70147f0ca331eaa2c74e6325fb2028c886aa26ba0b68c", size = 3963170, upload-time = "2026-07-31T14:24:51.968Z" },
{ url = "https://files.pythonhosted.org/packages/01/b6/0b9e125e90f3d2dcf599a218a899cda7326a3158cfa258723f0b398b08f6/cryptography-50.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:8eb5e1172eb569ea8a872796576e6a67c276351728b6455d5beb01242b027c6a", size = 4692441, upload-time = "2026-07-31T14:24:53.743Z" },
{ url = "https://files.pythonhosted.org/packages/53/c9/a5151588710785a96d7bc4de27d4cd62f263bbbcb203cfe29df537eb6505/cryptography-50.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:910d11e1a385c654bf738bf3e6b8e6ed5de0f5610fcae2be9e5b398d8081d20e", size = 4699810, upload-time = "2026-07-31T14:24:55.746Z" },
{ url = "https://files.pythonhosted.org/packages/c7/1a/15b92b25eb6ce3089cd49377ae990a0f3ad485a510f968aed1f19dbdcdf2/cryptography-50.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:62598a8a57f815db4c6259a4e97d857dab56697e7de8e8ab02352ab74da1995d", size = 4691924, upload-time = "2026-07-31T14:24:58.082Z" },
{ url = "https://files.pythonhosted.org/packages/62/15/219075012ab13e8905f3cd572204f4acb4b111df787104346b9bc0cea789/cryptography-50.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:07479a1cb08219ab719147e742e76090c9c773321959bb94946fffdd397a6437", size = 4699593, upload-time = "2026-07-31T14:24:59.951Z" },
]
[[package]]
@@ -1227,14 +1227,14 @@ wheels = [
[[package]]
name = "fido2"
version = "2.1.1"
version = "2.2.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cryptography" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e7/3c/c65377e48c144afca6b02c69f10c0fe936db556096a4e2c9798e2aa72db6/fido2-2.1.1.tar.gz", hash = "sha256:f1379f845870cc7fc64c7f07323c3ce41e8c96c37054e79e0acd5630b3fec5ac", size = 4455940, upload-time = "2026-01-19T11:08:34.683Z" }
sdist = { url = "https://files.pythonhosted.org/packages/ba/ea/6f08c354b7aeb8019249d46a86c2153f8218499cced4d21bf16b6d49fc16/fido2-2.2.1.tar.gz", hash = "sha256:85787428a94c3f8eaf72f0ff30afba983b559a1b1b795c93318c81b4ad4062c4", size = 327147, upload-time = "2026-06-29T17:41:11.927Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e2/ab/d0fa89cc4b982800dd88daa799612f11642bf9393851715d9eaeba3cfcac/fido2-2.1.1-py3-none-any.whl", hash = "sha256:f85c16c8084abf6530b6c6ec3a0cf8575943321842e06916686943a8b784182c", size = 226945, upload-time = "2026-01-19T11:08:29.675Z" },
{ url = "https://files.pythonhosted.org/packages/71/84/198d99c3312557ef6121cf78c38281efe9b3bc88cba0e2c05446f38a024d/fido2-2.2.1-py3-none-any.whl", hash = "sha256:ed397da981b9ab133da6ead7309e41f924b566b749956129efe286fae097749f", size = 238354, upload-time = "2026-06-29T17:41:09.921Z" },
]
[[package]]
@@ -1575,15 +1575,15 @@ wheels = [
[[package]]
name = "h2"
version = "4.3.0"
version = "4.4.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "hpack" },
{ name = "hyperframe" },
]
sdist = { url = "https://files.pythonhosted.org/packages/1d/17/afa56379f94ad0fe8defd37d6eb3f89a25404ffc71d4d848893d270325fc/h2-4.3.0.tar.gz", hash = "sha256:6c59efe4323fa18b47a632221a1888bd7fde6249819beda254aeca909f221bf1", size = 2152026, upload-time = "2025-08-23T18:12:19.778Z" }
sdist = { url = "https://files.pythonhosted.org/packages/e7/85/7c366e69d84c17bb778fe41419e1fbcce3033d5b7ce29bbffff0a98b859f/h2-4.4.1.tar.gz", hash = "sha256:4e866ffb1a869ae14dd9b5e6beb5c24a13da0495ad72b65925ded182521c1516", size = 2157281, upload-time = "2026-08-03T11:45:09.509Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/69/b2/119f6e6dcbd96f9069ce9a2665e0146588dc9f88f29549711853645e736a/h2-4.3.0-py3-none-any.whl", hash = "sha256:c438f029a25f7945c69e0ccf0fb951dc3f73a5f6412981daee861431b70e2bdd", size = 61779, upload-time = "2025-08-23T18:12:17.779Z" },
{ url = "https://files.pythonhosted.org/packages/7e/22/e85faf23bd72a92d1921e37d674ca56eb298a3c8be31fdecef0ff2b3aaac/h2-4.4.1-py3-none-any.whl", hash = "sha256:0e25f1462b23c9cb82d9eb02e28bc706dac2a68cb457c6a0d74d63c8a2a5d0e6", size = 62636, upload-time = "2026-08-03T11:44:59.164Z" },
]
[[package]]
@@ -1677,11 +1677,11 @@ wheels = [
[[package]]
name = "hpack"
version = "4.1.0"
version = "4.2.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/2c/48/71de9ed269fdae9c8057e5a4c0aa7402e8bb16f2c6e90b3aa53327b113f8/hpack-4.1.0.tar.gz", hash = "sha256:ec5eca154f7056aa06f196a557655c5b009b382873ac8d1e66e79e87535f1dca", size = 51276, upload-time = "2025-01-22T21:44:58.347Z" }
sdist = { url = "https://files.pythonhosted.org/packages/26/5b/fcabf6028144a8723726318b07a32c2f3314acdff6265743cf08a344b18e/hpack-4.2.0.tar.gz", hash = "sha256:0895cfa3b5531fc65fe439c05eb65144f123bf7a394fcaa56aa423548d8e45c0", size = 51300, upload-time = "2026-06-23T18:34:46.667Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/07/c6/80c95b1b2b94682a72cbdbfb85b81ae2daffa4291fbfa1b1464502ede10d/hpack-4.1.0-py3-none-any.whl", hash = "sha256:157ac792668d995c657d93111f46b4535ed114f0c9c8d672271bbec7eae1b496", size = 34357, upload-time = "2025-01-22T21:44:56.92Z" },
{ url = "https://files.pythonhosted.org/packages/71/b4/4a9fcfb2aef6ba44d9073ecd301443aa00b3dac95de5619f2a7de7ec8a91/hpack-4.2.0-py3-none-any.whl", hash = "sha256:858ac0b02280fa582b5080d68db0899c62a80375e0e5413a74970c5e518b6986", size = 34246, upload-time = "2026-06-23T18:34:45.472Z" },
]
[[package]]
@@ -3748,15 +3748,15 @@ wheels = [
[[package]]
name = "pyopenssl"
version = "26.2.0"
version = "26.4.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cryptography" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/1a/51/27a5ad5f939d08f690a326ef9582cda7140555180db71695f6fb747d6a36/pyopenssl-26.2.0.tar.gz", hash = "sha256:8c6fcecd1183a7fc897548dfe388b0cdb7f37e018200d8409cf33959dbe35387", size = 182195, upload-time = "2026-05-04T23:06:09.72Z" }
sdist = { url = "https://files.pythonhosted.org/packages/3f/e8/7325d258199b159eb2c03fe32107533e2832e70e63f4fb88a6aa00023201/pyopenssl-26.4.0.tar.gz", hash = "sha256:28dfcce0162b9211413e26dfbfdf1d24317fbeba18fc93c12400a1856b2a0bc7", size = 182046, upload-time = "2026-08-01T19:50:50.512Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/73/b8/a0e2790ae249d6f38c9f66de7a211621a7ab2650217bcd04e1262f578a56/pyopenssl-26.2.0-py3-none-any.whl", hash = "sha256:4f9d971bc5298b8bc1fab282803da04bf000c755d4ad9d99b52de2569ca19a70", size = 55823, upload-time = "2026-05-04T23:06:08.395Z" },
{ url = "https://files.pythonhosted.org/packages/51/ad/2cf6d3fa2fae5c79e1ed9960c0d42badd0f94d81dd12b50604cdc839e648/pyopenssl-26.4.0-py3-none-any.whl", hash = "sha256:f0eb0cb2d581d3ad2b9c489468485e7f2ab6727d08401bcf9d824c3caddf3c1c", size = 56026, upload-time = "2026-08-01T19:50:48.94Z" },
]
[[package]]