mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-10-22 23:58:55 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d28713f28b | ||
|
|
ea4307e3e5 | ||
|
|
f6153e962e |
No files matched your search
@@ -1,62 +1,53 @@
|
||||
name: Install apt packages
|
||||
description: >
|
||||
Caches apt packages and their dependencies for an exact hosted runner image. Cache hits install offline; cache misses download from Ubuntu mirrors with bounded retries.
|
||||
Installs apt packages with a per-attempt timeout and retries. The hosted runner's Azure apt mirror intermittently serves archives at a crawl, which apt's own timeout does not catch. A stuck attempt is killed and retried, which usually lands on a healthy mirror node.
|
||||
|
||||
inputs:
|
||||
packages:
|
||||
description: Space separated list of package names to install
|
||||
description: Space separated list of packages to install
|
||||
required: true
|
||||
attempts:
|
||||
description: Maximum number of download attempts
|
||||
description: Maximum number of attempts
|
||||
default: "3"
|
||||
attempt-timeout:
|
||||
description: Seconds before a single download attempt is killed
|
||||
description: Seconds before a single attempt is killed
|
||||
default: "180"
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Identify apt cache
|
||||
id: cache-key
|
||||
shell: bash
|
||||
env:
|
||||
PACKAGES: ${{ inputs.packages }}
|
||||
run: |
|
||||
# The bundle contains only dependencies missing from this runner. Never
|
||||
# restore it onto a different image or initial package installation.
|
||||
# Without an image version (e.g. self-hosted), download normally.
|
||||
if [ -n "${ImageVersion:-}" ]; then
|
||||
fingerprint=$({
|
||||
printf '%s\n' "$PACKAGES"
|
||||
dpkg --print-architecture
|
||||
dpkg-query -W -f='${binary:Package}=${Version} ${db:Status-Abbrev}\n'
|
||||
sha256sum "$GITHUB_ACTION_PATH/action.yml" "$GITHUB_ACTION_PATH/install.sh" | cut -d ' ' -f 1
|
||||
} | sha256sum | cut -d ' ' -f 1)
|
||||
echo "key=apt-v1-${ImageOS}-${ImageVersion}-${fingerprint}" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
- name: Restore apt packages
|
||||
id: cache
|
||||
if: steps.cache-key.outputs.key != ''
|
||||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: |
|
||||
${{ runner.temp }}/apt-packages/*.deb
|
||||
${{ runner.temp }}/apt-packages/complete
|
||||
key: ${{ steps.cache-key.outputs.key }}
|
||||
- name: Install ${{ inputs.packages }}
|
||||
shell: bash
|
||||
env:
|
||||
PACKAGES: ${{ inputs.packages }}
|
||||
ATTEMPTS: ${{ inputs.attempts }}
|
||||
ATTEMPT_TIMEOUT: ${{ inputs.attempt-timeout }}
|
||||
CACHE_DIR: ${{ runner.temp }}/apt-packages
|
||||
CACHE_HIT: ${{ steps.cache.outputs.cache-hit }}
|
||||
run: bash "$GITHUB_ACTION_PATH/install.sh"
|
||||
# Save immediately: a later test failure must not discard a good apt bundle.
|
||||
- name: Save apt packages
|
||||
if: steps.cache-key.outputs.key != '' && steps.cache.outputs.cache-hit != 'true'
|
||||
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: |
|
||||
${{ runner.temp }}/apt-packages/*.deb
|
||||
${{ runner.temp }}/apt-packages/complete
|
||||
key: ${{ steps.cache-key.outputs.key }}
|
||||
run: |
|
||||
SUDO=""
|
||||
if [ "$(id -u)" -ne 0 ]; then
|
||||
SUDO="sudo"
|
||||
fi
|
||||
|
||||
attempt_install() {
|
||||
$SUDO apt-get update -q
|
||||
# Intentionally unquoted, this is a list of package names
|
||||
$SUDO apt-get install -q --yes --no-install-recommends $PACKAGES
|
||||
}
|
||||
export -f attempt_install
|
||||
export SUDO PACKAGES
|
||||
|
||||
for attempt in $(seq 1 "${ATTEMPTS}"); do
|
||||
echo "::group::apt install attempt ${attempt}/${ATTEMPTS}"
|
||||
start=$(date +%s)
|
||||
if timeout --kill-after=10 "${ATTEMPT_TIMEOUT}" bash -c attempt_install; then
|
||||
echo "::endgroup::"
|
||||
echo "Installed in $(( $(date +%s) - start ))s"
|
||||
exit 0
|
||||
fi
|
||||
echo "::endgroup::"
|
||||
echo "::warning::apt install attempt ${attempt}/${ATTEMPTS} failed or exceeded ${ATTEMPT_TIMEOUT}s"
|
||||
# A killed attempt can leave dpkg half configured
|
||||
$SUDO dpkg --configure -a || true
|
||||
done
|
||||
|
||||
echo "::error::apt install failed after ${ATTEMPTS} attempts"
|
||||
exit 1
|
||||
@@ -1,67 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SUDO=()
|
||||
if [ "$(id -u)" -ne 0 ]; then
|
||||
SUDO=(sudo)
|
||||
fi
|
||||
|
||||
# Inputs are package names, separated by whitespace (not shell expressions).
|
||||
read -r -a requested_packages <<< "$PACKAGES"
|
||||
mkdir -p "$CACHE_DIR"
|
||||
|
||||
install_local() {
|
||||
local archives=("$CACHE_DIR"/*.deb)
|
||||
if [ -e "${archives[0]}" ]; then
|
||||
# Local .deb arguments pin the downloaded versions. No package lists or
|
||||
# network access are needed, and apt still runs all package maintainer scripts.
|
||||
"${SUDO[@]}" chmod a+r "${archives[@]}"
|
||||
"${SUDO[@]}" apt-get install -q --yes --no-install-recommends \
|
||||
--no-download -o "Dir::Cache::archives=$CACHE_DIR" "${archives[@]}"
|
||||
fi
|
||||
# An empty bundle is valid when the runner already includes every package.
|
||||
local package
|
||||
for package in "${requested_packages[@]}"; do
|
||||
[ "$(dpkg-query -W -f='${Status}' "$package")" = "install ok installed" ]
|
||||
done
|
||||
}
|
||||
|
||||
if [ "$CACHE_HIT" = true ]; then
|
||||
echo "Installing cached apt packages without contacting package repositories"
|
||||
install_local
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# The hosted runner gives Azure priority 1. A slow transfer never triggers its
|
||||
# fallback mirrors, so retrying alone keeps selecting the same unhealthy mirror.
|
||||
if [ -f /etc/apt/apt-mirrors.txt ]; then
|
||||
"${SUDO[@]}" sed -i '\|azure\.archive\.ubuntu\.com|d' /etc/apt/apt-mirrors.txt
|
||||
fi
|
||||
|
||||
# Download first so the bundle contains every missing transitive dependency.
|
||||
# Keep downloads outside apt's default cache, which Docker cleanup hooks may wipe.
|
||||
download_packages() {
|
||||
"${SUDO[@]}" apt-get update -q --error-on=any
|
||||
"${SUDO[@]}" apt-get install -q --yes --no-install-recommends \
|
||||
--download-only -o "Dir::Cache::archives=$CACHE_DIR" "${requested_packages[@]}"
|
||||
}
|
||||
# Recreate the sudo and package arrays in the timed child process.
|
||||
if [ "${1:-}" = download ]; then
|
||||
download_packages
|
||||
exit 0
|
||||
fi
|
||||
|
||||
for attempt in $(seq 1 "$ATTEMPTS"); do
|
||||
echo "::group::apt download attempt ${attempt}/${ATTEMPTS}"
|
||||
if timeout --kill-after=10 "$ATTEMPT_TIMEOUT" bash "$0" download; then
|
||||
echo "::endgroup::"
|
||||
install_local
|
||||
touch "$CACHE_DIR/complete"
|
||||
exit 0
|
||||
fi
|
||||
echo "::endgroup::"
|
||||
echo "::warning::apt download attempt ${attempt}/${ATTEMPTS} failed or exceeded ${ATTEMPT_TIMEOUT}s"
|
||||
done
|
||||
|
||||
echo "::error::apt download failed after ${ATTEMPTS} attempts"
|
||||
exit 1
|
||||
@@ -11,7 +11,7 @@ concurrency:
|
||||
group: backend-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
env:
|
||||
DEFAULT_UV_VERSION: "0.13.x"
|
||||
DEFAULT_UV_VERSION: "0.12.x"
|
||||
# Match the Docker image: nltk refuses to read hardlinked data files, such as
|
||||
# the copy bundled with llama-index when uv links packages from its cache
|
||||
UV_LINK_MODE: copy
|
||||
@@ -76,7 +76,6 @@ jobs:
|
||||
- 'uv.lock'
|
||||
- 'docker/compose/docker-compose.ci-test.yml'
|
||||
- '.github/workflows/ci-backend.yml'
|
||||
- '.github/actions/apt-install/**'
|
||||
test:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.backend_changed == 'true'
|
||||
@@ -103,7 +102,7 @@ jobs:
|
||||
with:
|
||||
python-version: "${{ matrix.python-version }}"
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2
|
||||
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
|
||||
with:
|
||||
version: ${{ env.DEFAULT_UV_VERSION }}
|
||||
enable-cache: true
|
||||
@@ -173,7 +172,7 @@ jobs:
|
||||
with:
|
||||
python-version: "${{ env.DEFAULT_PYTHON }}"
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2
|
||||
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
|
||||
with:
|
||||
version: ${{ env.DEFAULT_UV_VERSION }}
|
||||
enable-cache: true
|
||||
|
||||
@@ -11,7 +11,7 @@ concurrency:
|
||||
permissions:
|
||||
contents: read
|
||||
env:
|
||||
DEFAULT_UV_VERSION: "0.13.x"
|
||||
DEFAULT_UV_VERSION: "0.12.x"
|
||||
DEFAULT_PYTHON_VERSION: "3.12"
|
||||
jobs:
|
||||
changes:
|
||||
@@ -78,7 +78,7 @@ jobs:
|
||||
with:
|
||||
python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2
|
||||
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
|
||||
with:
|
||||
version: ${{ env.DEFAULT_UV_VERSION }}
|
||||
enable-cache: true
|
||||
|
||||
@@ -68,7 +68,6 @@ jobs:
|
||||
frontend:
|
||||
- 'src-ui/**'
|
||||
- '.github/workflows/ci-frontend.yml'
|
||||
- '.github/actions/apt-install/**'
|
||||
install-dependencies:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.frontend_changed == 'true'
|
||||
@@ -217,9 +216,9 @@ jobs:
|
||||
with:
|
||||
python-version: '3.12'
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2
|
||||
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
|
||||
with:
|
||||
version: '0.13.x'
|
||||
version: '0.12.x'
|
||||
enable-cache: false
|
||||
python-version: ${{ steps.setup-python.outputs.python-version }}
|
||||
- name: Cache frontend dependencies
|
||||
|
||||
@@ -8,7 +8,7 @@ concurrency:
|
||||
group: release-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
env:
|
||||
DEFAULT_UV_VERSION: "0.13.x"
|
||||
DEFAULT_UV_VERSION: "0.12.x"
|
||||
DEFAULT_PYTHON_VERSION: "3.12"
|
||||
permissions: {}
|
||||
jobs:
|
||||
@@ -59,7 +59,7 @@ jobs:
|
||||
with:
|
||||
python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2
|
||||
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
|
||||
with:
|
||||
version: ${{ env.DEFAULT_UV_VERSION }}
|
||||
enable-cache: false
|
||||
@@ -213,7 +213,7 @@ jobs:
|
||||
with:
|
||||
python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2
|
||||
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
|
||||
with:
|
||||
version: ${{ env.DEFAULT_UV_VERSION }}
|
||||
enable-cache: false
|
||||
|
||||
@@ -4,7 +4,7 @@ on:
|
||||
branches:
|
||||
- dev
|
||||
env:
|
||||
DEFAULT_UV_VERSION: "0.13.x"
|
||||
DEFAULT_UV_VERSION: "0.12.x"
|
||||
jobs:
|
||||
generate-translate-strings:
|
||||
name: Generate Translation Strings
|
||||
@@ -30,7 +30,7 @@ jobs:
|
||||
with:
|
||||
packages: gettext
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2
|
||||
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
|
||||
with:
|
||||
version: ${{ env.DEFAULT_UV_VERSION }}
|
||||
enable-cache: true
|
||||
|
||||
+1
-1
@@ -30,7 +30,7 @@ RUN set -eux \
|
||||
# Purpose: Installs s6-overlay and rootfs
|
||||
# Comments:
|
||||
# - Don't leave anything extra in here either
|
||||
FROM ghcr.io/astral-sh/uv:0.13.5-python3.14-trixie-slim@sha256:d8e732839dfc4225a1ff698803b478b2223c05636c055d3add41ab9dbec0f315 AS s6-overlay-base
|
||||
FROM ghcr.io/astral-sh/uv:0.12.23-python3.14-trixie-slim AS s6-overlay-base
|
||||
|
||||
WORKDIR /usr/src/s6
|
||||
|
||||
|
||||
@@ -1,12 +1,5 @@
|
||||
# Changelog
|
||||
|
||||
## paperless-ngx 3.3.4
|
||||
|
||||
### All App Changes
|
||||
|
||||
- Chore: Update certifi for eMudhra CA incident [@shamoon](https://github.com/shamoon) ([#14533](https://github.com/paperless-ngx/paperless-ngx/pull/14533))
|
||||
- Chore: Update uv to 0.13 for the eMudhra CA fixes [@stumpylog](https://github.com/stumpylog) ([#14532](https://github.com/paperless-ngx/paperless-ngx/pull/14532))
|
||||
|
||||
## paperless-ngx 3.3.3
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
@@ -370,15 +370,6 @@ instead of being removed from the filesystem. Only the original version is kept.
|
||||
|
||||
This setting was previously named PAPERLESS_TRASH_DIR.
|
||||
|
||||
#### [`PAPERLESS_SCRATCH_DIR=<path>`](#PAPERLESS_SCRATCH_DIR) {#PAPERLESS_SCRATCH_DIR}
|
||||
|
||||
: This folder is used as a temporary workspace for multiple paperless operations, including
|
||||
consuming documents, editing, and the document exporter.
|
||||
|
||||
This must be writeable by the user running paperless.
|
||||
|
||||
Defaults to "/tmp/paperless".
|
||||
|
||||
#### [`PAPERLESS_MEDIA_ROOT=<path>`](#PAPERLESS_MEDIA_ROOT) {#PAPERLESS_MEDIA_ROOT}
|
||||
|
||||
: This is where your documents and thumbnails are stored.
|
||||
|
||||
@@ -16,7 +16,6 @@
|
||||
#PAPERLESS_CONSUMPTION_DIR=../consume
|
||||
#PAPERLESS_DATA_DIR=../data
|
||||
#PAPERLESS_EMPTY_TRASH_DIR=
|
||||
#PAPERLESS_SCRATCH_DIR=/tmp/paperless
|
||||
#PAPERLESS_MEDIA_ROOT=../media
|
||||
#PAPERLESS_STATICDIR=../static
|
||||
#PAPERLESS_FILENAME_FORMAT=
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "paperless-ngx"
|
||||
version = "3.3.4"
|
||||
version = "3.3.3"
|
||||
description = """\
|
||||
A community-supported supercharged document management system: scan, index and archive all your physical documents\
|
||||
"""
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paperless-ngx-ui",
|
||||
"version": "3.3.4",
|
||||
"version": "3.3.3",
|
||||
"scripts": {
|
||||
"preinstall": "npx only-allow pnpm",
|
||||
"ng": "ng",
|
||||
|
||||
@@ -8,7 +8,7 @@ export const environment = {
|
||||
apiVersion: '10', // match src/paperless/settings.py
|
||||
appTitle: DEFAULT_APP_TITLE,
|
||||
tag: 'prod',
|
||||
version: '3.3.4',
|
||||
version: '3.3.3',
|
||||
webSocketHost: window.location.host,
|
||||
webSocketProtocol: window.location.protocol == 'https:' ? 'wss:' : 'ws:',
|
||||
webSocketBaseUrl: base_url.pathname + 'ws/',
|
||||
|
||||
@@ -36,6 +36,7 @@ from documents.models import DocumentType
|
||||
from documents.models import PaperlessTask
|
||||
from documents.models import StoragePath
|
||||
from documents.models import Tag
|
||||
from documents.permissions import PermissionsSpec
|
||||
from documents.permissions import set_permissions_for_objects
|
||||
from documents.plugins.helpers import DocumentsStatusManager
|
||||
from documents.tasks import bulk_update_documents
|
||||
@@ -452,7 +453,7 @@ def reprocess(doc_ids: list[int], *, remote_ocr: bool = False) -> Literal["OK"]:
|
||||
|
||||
def set_permissions(
|
||||
doc_ids: list[int],
|
||||
set_permissions: dict,
|
||||
set_permissions: PermissionsSpec,
|
||||
*,
|
||||
owner: User | None = None,
|
||||
merge: bool = False,
|
||||
|
||||
@@ -38,7 +38,8 @@ from documents.models import StoragePath
|
||||
from documents.models import Tag
|
||||
from documents.models import WorkflowTrigger
|
||||
from documents.parsers import ParseError
|
||||
from documents.permissions import set_permissions_for_object
|
||||
from documents.permissions import PermissionsSpec
|
||||
from documents.permissions import set_permissions_for_objects
|
||||
from documents.plugins.base import AlwaysRunPluginMixin
|
||||
from documents.plugins.base import ConsumeTaskPlugin
|
||||
from documents.plugins.base import NoCleanupPluginMixin
|
||||
@@ -948,7 +949,7 @@ class ConsumerPlugin(
|
||||
or self.metadata.change_users is not None
|
||||
or self.metadata.change_groups is not None
|
||||
):
|
||||
permissions = {
|
||||
permissions: PermissionsSpec = {
|
||||
"view": {
|
||||
"users": self.metadata.view_users or [],
|
||||
"groups": self.metadata.view_groups or [],
|
||||
@@ -958,7 +959,7 @@ class ConsumerPlugin(
|
||||
"groups": self.metadata.change_groups or [],
|
||||
},
|
||||
}
|
||||
set_permissions_for_object(permissions=permissions, object=document)
|
||||
set_permissions_for_objects(permissions, Document, [document.pk])
|
||||
|
||||
if self.metadata.custom_fields:
|
||||
for field in CustomField.objects.filter(
|
||||
|
||||
+35
-134
@@ -1,8 +1,12 @@
|
||||
from collections.abc import Iterable
|
||||
from collections.abc import Mapping
|
||||
from collections.abc import Sequence
|
||||
from typing import Any
|
||||
from typing import ClassVar
|
||||
from typing import Final
|
||||
from typing import Literal
|
||||
from typing import TypeAlias
|
||||
from typing import TypedDict
|
||||
from typing import TypeGuard
|
||||
from typing import TypeVar
|
||||
from typing import cast
|
||||
@@ -26,15 +30,28 @@ from django.db.models.functions import Coalesce
|
||||
from guardian.core import ObjectPermissionChecker
|
||||
from guardian.models import GroupObjectPermission
|
||||
from guardian.models import UserObjectPermission
|
||||
from guardian.shortcuts import assign_perm
|
||||
from guardian.shortcuts import get_users_with_perms
|
||||
from guardian.shortcuts import remove_perm
|
||||
from rest_framework.permissions import BasePermission
|
||||
from rest_framework.permissions import DjangoObjectPermissions
|
||||
|
||||
from documents.models import Document
|
||||
from documents.versioning import get_root_document
|
||||
|
||||
PermissionAction = Literal["view", "change"]
|
||||
PERMISSION_ACTIONS: Final[tuple[PermissionAction, ...]] = ("view", "change")
|
||||
|
||||
|
||||
class PermissionGrantees(TypedDict, total=False):
|
||||
"""
|
||||
The user and group ids to grant one permission action to. A missing key
|
||||
leaves that kind of grant untouched.
|
||||
"""
|
||||
|
||||
users: Iterable[int]
|
||||
groups: Iterable[int]
|
||||
|
||||
|
||||
PermissionsSpec: TypeAlias = Mapping[PermissionAction, PermissionGrantees]
|
||||
|
||||
|
||||
class PaperlessObjectPermissions(DjangoObjectPermissions):
|
||||
"""
|
||||
@@ -90,101 +107,10 @@ def has_system_status_permission(user: User | None) -> bool:
|
||||
)
|
||||
|
||||
|
||||
def get_groups_with_only_permission(obj, codename):
|
||||
ctype = ContentType.objects.get_for_model(obj)
|
||||
permission = Permission.objects.get(content_type=ctype, codename=codename)
|
||||
group_object_perm_group_ids = (
|
||||
GroupObjectPermission.objects.filter(
|
||||
object_pk=obj.pk,
|
||||
content_type=ctype,
|
||||
)
|
||||
.filter(permission=permission)
|
||||
.values_list("group_id")
|
||||
)
|
||||
return Group.objects.filter(id__in=group_object_perm_group_ids).distinct()
|
||||
|
||||
|
||||
def set_permissions_for_object(
|
||||
permissions: dict,
|
||||
object,
|
||||
*,
|
||||
merge: bool = False,
|
||||
) -> None:
|
||||
"""
|
||||
Set permissions for an object. The permissions are given as a mapping of actions
|
||||
to a dict of user / group id lists, e.g.
|
||||
{"view": {"users": [1], "groups": [2]}, "change": {"users": [], "groups": []}}.
|
||||
|
||||
If merge is True, the permissions are merged with the existing permissions and
|
||||
no users or groups are removed. If False, the permissions are set to exactly
|
||||
the given list of users and groups.
|
||||
"""
|
||||
|
||||
for action, entry in permissions.items():
|
||||
permission = f"{action}_{object.__class__.__name__.lower()}"
|
||||
if "users" in entry:
|
||||
# users
|
||||
users_to_add = User.objects.filter(id__in=entry["users"])
|
||||
users_to_remove = (
|
||||
get_users_with_perms(
|
||||
object,
|
||||
only_with_perms_in=[permission],
|
||||
with_group_users=False,
|
||||
)
|
||||
if not merge
|
||||
else User.objects.none()
|
||||
)
|
||||
if users_to_add.exists() and users_to_remove.exists():
|
||||
users_to_remove = users_to_remove.exclude(id__in=users_to_add)
|
||||
if users_to_remove.exists():
|
||||
for user in users_to_remove:
|
||||
remove_perm(permission, user, object)
|
||||
if users_to_add.exists():
|
||||
for user in users_to_add:
|
||||
assign_perm(permission, user, object)
|
||||
if action == "change":
|
||||
# change gives view too
|
||||
assign_perm(
|
||||
f"view_{object.__class__.__name__.lower()}",
|
||||
user,
|
||||
object,
|
||||
)
|
||||
if "groups" in entry:
|
||||
# groups
|
||||
groups_to_add = Group.objects.filter(id__in=entry["groups"])
|
||||
groups_to_remove = (
|
||||
get_groups_with_only_permission(
|
||||
object,
|
||||
permission,
|
||||
)
|
||||
if not merge
|
||||
else Group.objects.none()
|
||||
)
|
||||
if groups_to_add.exists() and groups_to_remove.exists():
|
||||
groups_to_remove = groups_to_remove.exclude(id__in=groups_to_add)
|
||||
if groups_to_remove.exists():
|
||||
for group in groups_to_remove:
|
||||
remove_perm(permission, group, object)
|
||||
if groups_to_add.exists():
|
||||
for group in groups_to_add:
|
||||
assign_perm(permission, group, object)
|
||||
if action == "change":
|
||||
# change gives view too
|
||||
assign_perm(
|
||||
f"view_{object.__class__.__name__.lower()}",
|
||||
group,
|
||||
object,
|
||||
)
|
||||
|
||||
|
||||
def _resolve_permissions(codenames: set[str], ctype: ContentType) -> list[Permission]:
|
||||
"""
|
||||
Resolves `codenames` to Permission rows, raising like the single-object
|
||||
assign_perm() this bulk path replaces does (via a `.get()` internally)
|
||||
if any codename doesn't exist. SetPermissionsSerializer rejects unknown
|
||||
action names at the API, but a caller passing one directly would
|
||||
otherwise get a plain `.filter()` that silently builds zero rows and
|
||||
no-ops instead of reporting the bad input.
|
||||
Resolve ``codenames`` to Permission rows, raising for any that don't exist
|
||||
so a bad action name fails loudly instead of granting nothing.
|
||||
"""
|
||||
permission_objs = list(
|
||||
Permission.objects.filter(content_type=ctype, codename__in=codenames),
|
||||
@@ -203,16 +129,13 @@ def _apply_bulk_permission_entry(
|
||||
perm_model: type[UserObjectPermission] | type[GroupObjectPermission],
|
||||
identity_model: type[User] | type[Group],
|
||||
identity_field: str,
|
||||
ids: list[int],
|
||||
ids: Iterable[int],
|
||||
codename: str,
|
||||
permission_objs: list[Permission],
|
||||
ctype: ContentType,
|
||||
object_pks: list[str],
|
||||
merge: bool,
|
||||
) -> None:
|
||||
# Only the ids are needed to build permission rows (via `<field>_id=`),
|
||||
# so avoid fetching full User/Group rows for identities that may not
|
||||
# even end up being granted anything new.
|
||||
add_ids = set(
|
||||
identity_model.objects.filter(id__in=ids).values_list("id", flat=True),
|
||||
)
|
||||
@@ -250,45 +173,26 @@ def _apply_bulk_permission_entry(
|
||||
for pk in object_pks
|
||||
for identity_id in add_ids
|
||||
]
|
||||
# ignore_conflicts skips only rows that already exist as an exact
|
||||
# (identity, permission, object) match -- the same de-dup the
|
||||
# underlying (user|group, permission, object_pk) unique constraint
|
||||
# already enforces for the single-object assign_perm() this replaces,
|
||||
# so it doesn't change what counts as "already granted". batch_size
|
||||
# caps how many rows go into a single INSERT statement.
|
||||
# ignore_conflicts skips grants that already exist (unique constraint).
|
||||
perm_model.objects.bulk_create(rows, ignore_conflicts=True, batch_size=1000)
|
||||
|
||||
|
||||
def set_permissions_for_objects(
|
||||
permissions: dict,
|
||||
permissions: PermissionsSpec,
|
||||
model: type[Model],
|
||||
pks: QuerySet | list,
|
||||
*,
|
||||
merge: bool = False,
|
||||
) -> None:
|
||||
"""
|
||||
Bulk equivalent of set_permissions_for_object: applies the same
|
||||
permission changes to every object identified by `pks` at once.
|
||||
Set the view/change grants for every object in ``pks``. With ``merge``,
|
||||
the given users and groups are added; without it, each given action's
|
||||
direct grants are replaced. Granting change also grants view, and an
|
||||
action entry with neither key is left untouched.
|
||||
|
||||
Takes a model + pks (rather than model instances) deliberately -- the
|
||||
permission rows built below only ever need `pk`, `content_type`, and
|
||||
identity ids, so callers shouldn't have to fetch full rows (with every
|
||||
other field) just to hand them to this function.
|
||||
|
||||
Deliberately does not use guardian's queryset/list-aware assign_perm:
|
||||
passing a list as the object routes to bulk_assign_perm, which skips
|
||||
creating a direct permission row for anyone who already has the
|
||||
permission via ANY group membership (it checks
|
||||
ObjectPermissionChecker.has_perm, which is group-inheritance-aware) --
|
||||
unlike the single-object assign_perm this replaces, which always
|
||||
ensures a direct row via get_or_create regardless of group-derived
|
||||
access. Losing that guarantee would mean a later revocation of the
|
||||
group's grant silently strips access an admin explicitly asked to be
|
||||
direct. Bulk-creating rows straight against the permission models
|
||||
instead (see _apply_bulk_permission_entry) preserves the original
|
||||
always-create-a-direct-row semantics while still batching every object
|
||||
and every identity into one query per action, rather than one query per
|
||||
(object, user) pair.
|
||||
guardian's bulk assign_perm is avoided because it skips the direct row
|
||||
for anyone who already has access through a group, so revoking the group
|
||||
later would silently remove access that was granted explicitly.
|
||||
"""
|
||||
object_pks = [str(pk) for pk in pks]
|
||||
if not object_pks: # pragma: no cover
|
||||
@@ -297,12 +201,9 @@ def set_permissions_for_objects(
|
||||
model_name = model.__name__.lower()
|
||||
ctype = ContentType.objects.get_for_model(model)
|
||||
|
||||
# Every action is resolved up front, before anything is written, so an
|
||||
# unrecognized action name (see _resolve_permissions) aborts the whole
|
||||
# call instead of leaving the actions ahead of it already applied.
|
||||
# SetPermissionsSerializer rejects unknown actions at the API, so this
|
||||
# guards any other caller.
|
||||
permissions_by_action: dict[str, list[Permission]] = {}
|
||||
# Resolve every action before writing, so a bad one can't leave the
|
||||
# actions ahead of it applied.
|
||||
permissions_by_action: dict[PermissionAction, list[Permission]] = {}
|
||||
for action, entry in permissions.items():
|
||||
if "users" not in entry and "groups" not in entry:
|
||||
continue
|
||||
|
||||
@@ -39,6 +39,7 @@ from drf_spectacular.utils import extend_schema_field
|
||||
from drf_spectacular.utils import extend_schema_serializer
|
||||
from drf_writable_nested.serializers import NestedUpdateMixin
|
||||
from guardian.core import ObjectPermissionChecker
|
||||
from guardian.shortcuts import get_groups_with_perms
|
||||
from guardian.shortcuts import get_users_with_perms
|
||||
from guardian.utils import get_group_obj_perms_model
|
||||
from guardian.utils import get_user_obj_perms_model
|
||||
@@ -78,12 +79,15 @@ from documents.models import WorkflowActionEmail
|
||||
from documents.models import WorkflowActionWebhook
|
||||
from documents.models import WorkflowTrigger
|
||||
from documents.parsers import is_mime_type_supported
|
||||
from documents.permissions import PERMISSION_ACTIONS
|
||||
from documents.permissions import PermissionAction
|
||||
from documents.permissions import PermissionGrantees
|
||||
from documents.permissions import PermissionsSpec
|
||||
from documents.permissions import get_document_count_filter_for_user
|
||||
from documents.permissions import get_groups_with_only_permission
|
||||
from documents.permissions import has_perms_owner_aware
|
||||
from documents.permissions import permitted_document_ids
|
||||
from documents.permissions import restrict_queryset_to_visible
|
||||
from documents.permissions import set_permissions_for_object
|
||||
from documents.permissions import set_permissions_for_objects
|
||||
from documents.regex import validate_regex_pattern
|
||||
from documents.templating.filepath import validate_filepath_template_and_render
|
||||
from documents.templating.utils import convert_format_str_to_template_format
|
||||
@@ -211,9 +215,6 @@ class MatchingModelSerializer(serializers.ModelSerializer[Any]):
|
||||
return match
|
||||
|
||||
|
||||
PERMISSION_ACTIONS = ("view", "change")
|
||||
|
||||
|
||||
class SetPermissionsMixin:
|
||||
def _validate_user_ids(self, user_ids):
|
||||
users = User.objects.none()
|
||||
@@ -235,8 +236,10 @@ class SetPermissionsMixin:
|
||||
)
|
||||
return groups
|
||||
|
||||
def validate_set_permissions(self, set_permissions=None):
|
||||
permissions_dict = {action: {} for action in PERMISSION_ACTIONS}
|
||||
def validate_set_permissions(self, set_permissions=None) -> PermissionsSpec:
|
||||
permissions_dict: dict[PermissionAction, PermissionGrantees] = {
|
||||
action: {} for action in PERMISSION_ACTIONS
|
||||
}
|
||||
if set_permissions is not None:
|
||||
for action in PERMISSION_ACTIONS:
|
||||
if action in set_permissions:
|
||||
@@ -244,18 +247,18 @@ class SetPermissionsMixin:
|
||||
users = set_permissions[action]["users"]
|
||||
permissions_dict[action]["users"] = self._validate_user_ids(
|
||||
users,
|
||||
)
|
||||
).values_list("id", flat=True)
|
||||
if "groups" in set_permissions[action]:
|
||||
groups = set_permissions[action]["groups"]
|
||||
permissions_dict[action]["groups"] = self._validate_group_ids(
|
||||
groups,
|
||||
)
|
||||
).values_list("id", flat=True)
|
||||
else:
|
||||
del permissions_dict[action]
|
||||
return permissions_dict
|
||||
|
||||
def _set_permissions(self, permissions, object) -> None:
|
||||
set_permissions_for_object(permissions, object)
|
||||
def _set_permissions(self, permissions: PermissionsSpec, object) -> None:
|
||||
set_permissions_for_objects(permissions, type(object), [object.pk])
|
||||
|
||||
|
||||
class SerializerWithPerms(serializers.Serializer[dict[str, Any]]):
|
||||
@@ -334,9 +337,9 @@ class OwnedObjectSerializer(
|
||||
)
|
||||
else: # groups
|
||||
return list(
|
||||
get_groups_with_only_permission(
|
||||
get_groups_with_perms(
|
||||
obj,
|
||||
codename=f"{codename}_{obj.__class__.__name__.lower()}",
|
||||
only_with_perms_in=[f"{codename}_{obj.__class__.__name__.lower()}"],
|
||||
).values_list("id", flat=True),
|
||||
)
|
||||
|
||||
@@ -3088,7 +3091,7 @@ class BulkEditObjectsSerializer(SerializerWithPerms, SetPermissionsMixin):
|
||||
)
|
||||
return objects
|
||||
|
||||
def _validate_permissions(self, permissions) -> dict:
|
||||
def _validate_permissions(self, permissions) -> PermissionsSpec:
|
||||
return self.validate_set_permissions(
|
||||
permissions,
|
||||
)
|
||||
|
||||
@@ -24,6 +24,7 @@ from documents.models import DocumentType
|
||||
from documents.models import StoragePath
|
||||
from documents.models import Tag
|
||||
from documents.pdf_ops import PageSpec
|
||||
from documents.permissions import PermissionsSpec
|
||||
from documents.permissions import set_permissions_for_objects
|
||||
from paperless_testing.dirs import DirectoriesMixin
|
||||
from paperless_testing.factories import DocumentFactory
|
||||
@@ -446,7 +447,7 @@ class TestBulkEdit(DirectoriesMixin, TestCase):
|
||||
|
||||
grant_object(self.group1, self.doc1, "view_document")
|
||||
|
||||
permissions = {
|
||||
permissions: PermissionsSpec = {
|
||||
"view": {
|
||||
"users": [self.user1.id, self.user2.id],
|
||||
"groups": [self.group2.id],
|
||||
@@ -489,7 +490,7 @@ class TestBulkEdit(DirectoriesMixin, TestCase):
|
||||
grant_object(self.user1, self.doc1, "view_document")
|
||||
grant_object(self.group1, self.doc1, "view_document")
|
||||
|
||||
permissions = {
|
||||
permissions: PermissionsSpec = {
|
||||
"view": {
|
||||
"users": [self.user2.id],
|
||||
"groups": [self.group2.id],
|
||||
@@ -540,7 +541,7 @@ class TestBulkEdit(DirectoriesMixin, TestCase):
|
||||
batched call rather than one call per (document, identity)
|
||||
pair
|
||||
"""
|
||||
permissions = {
|
||||
permissions: PermissionsSpec = {
|
||||
"view": {
|
||||
"users": [self.user1.id, self.user2.id],
|
||||
"groups": [self.group2.id],
|
||||
@@ -647,7 +648,7 @@ class TestBulkEdit(DirectoriesMixin, TestCase):
|
||||
"""
|
||||
with self.assertRaises(Permission.DoesNotExist):
|
||||
set_permissions_for_objects(
|
||||
{"not_a_real_action": {"users": [self.user1.id], "groups": []}},
|
||||
{"not_a_real_action": {"users": [self.user1.id], "groups": []}}, # type: ignore[bad-assignment]
|
||||
Document,
|
||||
[self.doc1.pk],
|
||||
)
|
||||
@@ -675,7 +676,7 @@ class TestBulkEdit(DirectoriesMixin, TestCase):
|
||||
set_permissions_for_objects(
|
||||
{
|
||||
"view": {"users": [self.user1.id], "groups": []},
|
||||
"not_a_real_action": {"users": [self.user1.id], "groups": []},
|
||||
"not_a_real_action": {"users": [self.user1.id], "groups": []}, # type: ignore[bad-assignment]
|
||||
},
|
||||
Document,
|
||||
[self.doc1.pk],
|
||||
|
||||
@@ -292,6 +292,33 @@ class TestViews(DirectoriesMixin, TestCase):
|
||||
else:
|
||||
assert False, f"Unexpected tag found: {tag['name']}"
|
||||
|
||||
def test_retrieve_with_full_permissions(self) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- A tag with view granted to two groups and change granted to only
|
||||
one of them
|
||||
WHEN:
|
||||
- The single tag is requested with full permissions
|
||||
THEN:
|
||||
- Each action lists only the groups granted that action
|
||||
"""
|
||||
view_only = Group.objects.create(name="view_only")
|
||||
view_and_change = Group.objects.create(name="view_and_change")
|
||||
tag = Tag.objects.create(name="invoice")
|
||||
grant_object(view_only, tag, "view_tag")
|
||||
grant_object(view_and_change, tag, "view_tag", "change_tag")
|
||||
grant_global(self.user, "view_tag")
|
||||
|
||||
self.client.force_login(self.user)
|
||||
response = self.client.get(f"/api/tags/{tag.pk}/?full_perms=true")
|
||||
|
||||
assert response.status_code == 200
|
||||
permissions = response.json()["permissions"]
|
||||
assert sorted(permissions["view"]["groups"]) == sorted(
|
||||
[view_only.pk, view_and_change.pk],
|
||||
)
|
||||
assert permissions["change"]["groups"] == [view_and_change.pk]
|
||||
|
||||
def test_list_no_n_plus_1_queries(self) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
|
||||
@@ -7,7 +7,8 @@ from documents.data_models import DocumentMetadataOverrides
|
||||
from documents.models import CustomFieldInstance
|
||||
from documents.models import Document
|
||||
from documents.models import WorkflowAction
|
||||
from documents.permissions import set_permissions_for_object
|
||||
from documents.permissions import PermissionsSpec
|
||||
from documents.permissions import set_permissions_for_objects
|
||||
from documents.templating.workflows import parse_w_workflow_placeholders
|
||||
|
||||
logger = logging.getLogger("paperless.workflows.mutations")
|
||||
@@ -73,7 +74,7 @@ def apply_assignment_to_document(
|
||||
action.has_assign_change_groups,
|
||||
],
|
||||
):
|
||||
permissions = {
|
||||
permissions: PermissionsSpec = {
|
||||
"view": {
|
||||
"users": action.assign_view_users.values_list("id", flat=True),
|
||||
"groups": action.assign_view_groups.values_list("id", flat=True),
|
||||
@@ -83,9 +84,10 @@ def apply_assignment_to_document(
|
||||
"groups": action.assign_change_groups.values_list("id", flat=True),
|
||||
},
|
||||
}
|
||||
set_permissions_for_object(
|
||||
permissions=permissions,
|
||||
object=document,
|
||||
set_permissions_for_objects(
|
||||
permissions,
|
||||
Document,
|
||||
[document.pk],
|
||||
merge=True,
|
||||
)
|
||||
|
||||
@@ -243,13 +245,14 @@ def apply_removal_to_document(
|
||||
document.owner = None
|
||||
|
||||
if action.remove_all_permissions:
|
||||
permissions = {
|
||||
permissions: PermissionsSpec = {
|
||||
"view": {"users": [], "groups": []},
|
||||
"change": {"users": [], "groups": []},
|
||||
}
|
||||
set_permissions_for_object(
|
||||
permissions=permissions,
|
||||
object=document,
|
||||
set_permissions_for_objects(
|
||||
permissions,
|
||||
Document,
|
||||
[document.pk],
|
||||
merge=False,
|
||||
)
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from typing import Final
|
||||
|
||||
__version__: Final[tuple[int, int, int]] = (3, 3, 4)
|
||||
__version__: Final[tuple[int, int, int]] = (3, 3, 3)
|
||||
# Version string like X.Y.Z
|
||||
__full_version_str__: Final[str] = ".".join(map(str, __version__))
|
||||
# Version string like X.Y
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
version = 1
|
||||
revision = 5
|
||||
revision = 3
|
||||
requires-python = ">=3.11"
|
||||
resolution-markers = [
|
||||
"python_full_version >= '3.15' and sys_platform == 'darwin'",
|
||||
@@ -9,7 +9,7 @@ resolution-markers = [
|
||||
"python_full_version >= '3.12' and python_full_version < '3.15' and sys_platform == 'darwin'",
|
||||
"python_full_version == '3.12.*' and platform_machine == 'x86_64' and sys_platform == 'linux'",
|
||||
"python_full_version == '3.12.*' and platform_machine == 'aarch64' and sys_platform == 'linux'",
|
||||
"(python_full_version == '3.12.*' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version == '3.14.*' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version == '3.13.*' and sys_platform == 'linux')",
|
||||
"(python_full_version >= '3.12' and python_full_version < '3.15' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version == '3.13.*' and platform_machine == 'aarch64' and sys_platform == 'linux') or (python_full_version == '3.13.*' and platform_machine == 'x86_64' and sys_platform == 'linux')",
|
||||
"python_full_version < '3.12' and sys_platform == 'darwin'",
|
||||
"python_full_version < '3.12' and sys_platform == 'linux'",
|
||||
]
|
||||
@@ -3040,7 +3040,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "paperless-ngx"
|
||||
version = "3.3.4"
|
||||
version = "3.3.3"
|
||||
source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "azure-ai-documentintelligence" },
|
||||
@@ -3114,7 +3114,7 @@ mariadb = [
|
||||
]
|
||||
postgres = [
|
||||
{ name = "psycopg", extra = ["c", "pool"] },
|
||||
{ name = "psycopg-c", version = "3.3.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12' or python_full_version == '3.13.*' or python_full_version >= '3.15' or (platform_machine != 'aarch64' and platform_machine != 'x86_64') or sys_platform != 'linux'" },
|
||||
{ name = "psycopg-c", version = "3.3.4", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version < '3.12' and platform_machine == 'aarch64') or (python_full_version == '3.13.*' and platform_machine == 'aarch64') or (python_full_version >= '3.15' and platform_machine == 'aarch64') or (python_full_version < '3.12' and platform_machine == 'x86_64') or (python_full_version == '3.13.*' and platform_machine == 'x86_64') or (python_full_version >= '3.15' and platform_machine == 'x86_64') or (platform_machine != 'aarch64' and platform_machine != 'x86_64') or sys_platform != 'linux'" },
|
||||
{ name = "psycopg-c", version = "3.3.4", source = { url = "https://github.com/paperless-ngx/builder/releases/download/psycopg-trixie-3.3.4/psycopg_c-3.3.4-cp312-cp312-linux_aarch64.whl" }, marker = "python_full_version == '3.12.*' and platform_machine == 'aarch64' and sys_platform == 'linux'" },
|
||||
{ name = "psycopg-c", version = "3.3.4", source = { url = "https://github.com/paperless-ngx/builder/releases/download/psycopg-trixie-3.3.4/psycopg_c-3.3.4-cp312-cp312-linux_x86_64.whl" }, marker = "python_full_version == '3.12.*' and platform_machine == 'x86_64' and sys_platform == 'linux'" },
|
||||
{ name = "psycopg-c", version = "3.3.4", source = { url = "https://github.com/paperless-ngx/builder/releases/download/psycopg-trixie-3.3.4/psycopg_c-3.3.4-cp314-cp314-linux_aarch64.whl" }, marker = "python_full_version == '3.14.*' and platform_machine == 'aarch64' and sys_platform == 'linux'" },
|
||||
@@ -3243,7 +3243,7 @@ requires-dist = [
|
||||
{ name = "psycopg-c", marker = "python_full_version == '3.14.*' and platform_machine == 'aarch64' and sys_platform == 'linux' and extra == 'postgres'", url = "https://github.com/paperless-ngx/builder/releases/download/psycopg-trixie-3.3.4/psycopg_c-3.3.4-cp314-cp314-linux_aarch64.whl" },
|
||||
{ name = "psycopg-c", marker = "python_full_version == '3.12.*' and platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'postgres'", url = "https://github.com/paperless-ngx/builder/releases/download/psycopg-trixie-3.3.4/psycopg_c-3.3.4-cp312-cp312-linux_x86_64.whl" },
|
||||
{ name = "psycopg-c", marker = "python_full_version == '3.14.*' and platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'postgres'", url = "https://github.com/paperless-ngx/builder/releases/download/psycopg-trixie-3.3.4/psycopg_c-3.3.4-cp314-cp314-linux_x86_64.whl" },
|
||||
{ name = "psycopg-c", marker = "(python_full_version < '3.12' and extra == 'postgres') or (python_full_version == '3.13.*' and extra == 'postgres') or (python_full_version >= '3.15' and extra == 'postgres') or (platform_machine != 'aarch64' and platform_machine != 'x86_64' and extra == 'postgres') or (sys_platform != 'linux' and extra == 'postgres')", specifier = "==3.3.4" },
|
||||
{ name = "psycopg-c", marker = "(python_full_version < '3.12' and platform_machine == 'aarch64' and extra == 'postgres') or (python_full_version == '3.13.*' and platform_machine == 'aarch64' and extra == 'postgres') or (python_full_version >= '3.15' and platform_machine == 'aarch64' and extra == 'postgres') or (python_full_version < '3.12' and platform_machine == 'x86_64' and extra == 'postgres') or (python_full_version == '3.13.*' and platform_machine == 'x86_64' and extra == 'postgres') or (python_full_version >= '3.15' and platform_machine == 'x86_64' and extra == 'postgres') or (platform_machine != 'aarch64' and platform_machine != 'x86_64' and extra == 'postgres') or (sys_platform != 'linux' and extra == 'postgres')", specifier = "==3.3.4" },
|
||||
{ name = "psycopg-pool", marker = "extra == 'postgres'", specifier = "==3.3.3" },
|
||||
{ name = "python-dateutil", specifier = "~=2.9.0" },
|
||||
{ name = "python-dotenv", specifier = "~=1.2.1" },
|
||||
@@ -3700,7 +3700,7 @@ wheels = [
|
||||
|
||||
[package.optional-dependencies]
|
||||
c = [
|
||||
{ name = "psycopg-c", version = "3.3.4", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version < '3.12' and implementation_name != 'pypy') or (python_full_version == '3.13.*' and implementation_name != 'pypy') or (python_full_version >= '3.15' and implementation_name != 'pypy') or (implementation_name != 'pypy' and platform_machine != 'aarch64' and platform_machine != 'x86_64') or (implementation_name != 'pypy' and sys_platform != 'linux')" },
|
||||
{ name = "psycopg-c", version = "3.3.4", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version < '3.12' and implementation_name != 'pypy' and platform_machine == 'aarch64') or (python_full_version == '3.13.*' and implementation_name != 'pypy' and platform_machine == 'aarch64') or (python_full_version >= '3.15' and implementation_name != 'pypy' and platform_machine == 'aarch64') or (python_full_version < '3.12' and implementation_name != 'pypy' and platform_machine == 'x86_64') or (python_full_version == '3.13.*' and implementation_name != 'pypy' and platform_machine == 'x86_64') or (python_full_version >= '3.15' and implementation_name != 'pypy' and platform_machine == 'x86_64') or (implementation_name != 'pypy' and platform_machine != 'aarch64' and platform_machine != 'x86_64') or (implementation_name != 'pypy' and sys_platform != 'linux')" },
|
||||
{ name = "psycopg-c", version = "3.3.4", source = { url = "https://github.com/paperless-ngx/builder/releases/download/psycopg-trixie-3.3.4/psycopg_c-3.3.4-cp312-cp312-linux_aarch64.whl" }, marker = "python_full_version == '3.12.*' and implementation_name != 'pypy' and platform_machine == 'aarch64' and sys_platform == 'linux'" },
|
||||
{ name = "psycopg-c", version = "3.3.4", source = { url = "https://github.com/paperless-ngx/builder/releases/download/psycopg-trixie-3.3.4/psycopg_c-3.3.4-cp312-cp312-linux_x86_64.whl" }, marker = "python_full_version == '3.12.*' and implementation_name != 'pypy' and platform_machine == 'x86_64' and sys_platform == 'linux'" },
|
||||
{ name = "psycopg-c", version = "3.3.4", source = { url = "https://github.com/paperless-ngx/builder/releases/download/psycopg-trixie-3.3.4/psycopg_c-3.3.4-cp314-cp314-linux_aarch64.whl" }, marker = "python_full_version == '3.14.*' and implementation_name != 'pypy' and platform_machine == 'aarch64' and sys_platform == 'linux'" },
|
||||
@@ -3718,7 +3718,7 @@ resolution-markers = [
|
||||
"python_full_version >= '3.15' and sys_platform == 'darwin'",
|
||||
"python_full_version >= '3.15' and sys_platform == 'linux'",
|
||||
"python_full_version >= '3.12' and python_full_version < '3.15' and sys_platform == 'darwin'",
|
||||
"(python_full_version == '3.12.*' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version == '3.14.*' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version == '3.13.*' and sys_platform == 'linux')",
|
||||
"(python_full_version >= '3.12' and python_full_version < '3.15' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version == '3.13.*' and platform_machine == 'aarch64' and sys_platform == 'linux') or (python_full_version == '3.13.*' and platform_machine == 'x86_64' and sys_platform == 'linux')",
|
||||
"python_full_version < '3.12' and sys_platform == 'darwin'",
|
||||
"python_full_version < '3.12' and sys_platform == 'linux'",
|
||||
]
|
||||
@@ -5260,7 +5260,7 @@ resolution-markers = [
|
||||
"python_full_version == '3.14.*' and platform_machine == 'aarch64' and sys_platform == 'linux'",
|
||||
"python_full_version == '3.12.*' and platform_machine == 'x86_64' and sys_platform == 'linux'",
|
||||
"python_full_version == '3.12.*' and platform_machine == 'aarch64' and sys_platform == 'linux'",
|
||||
"(python_full_version == '3.12.*' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version == '3.14.*' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version == '3.13.*' and sys_platform == 'linux')",
|
||||
"(python_full_version >= '3.12' and python_full_version < '3.15' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version == '3.13.*' and platform_machine == 'aarch64' and sys_platform == 'linux') or (python_full_version == '3.13.*' and platform_machine == 'x86_64' and sys_platform == 'linux')",
|
||||
"python_full_version < '3.12' and sys_platform == 'linux'",
|
||||
]
|
||||
dependencies = [
|
||||
|
||||
Reference in new issue
Block a user