mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-02-26 01:09:34 -06:00
Compare commits
6 Commits
feature-82
...
feature-te
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
13e07844fe | ||
|
|
98298e37cd | ||
|
|
35be0850ec | ||
|
|
1bb4b9b473 | ||
|
|
f85094dc2b | ||
|
|
65ca78e9e7 |
@@ -1,5 +1,7 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## paperless-ngx 2.20.8
|
||||||
|
|
||||||
## paperless-ngx 2.20.7
|
## paperless-ngx 2.20.7
|
||||||
|
|
||||||
### Bug Fixes
|
### Bug Fixes
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "paperless-ngx"
|
name = "paperless-ngx"
|
||||||
version = "2.20.7"
|
version = "2.20.8"
|
||||||
description = "A community-supported supercharged document management system: scan, index and archive all your physical documents"
|
description = "A community-supported supercharged document management system: scan, index and archive all your physical documents"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.10"
|
requires-python = ">=3.10"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "paperless-ngx-ui",
|
"name": "paperless-ngx-ui",
|
||||||
"version": "2.20.7",
|
"version": "2.20.8",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"preinstall": "npx only-allow pnpm",
|
"preinstall": "npx only-allow pnpm",
|
||||||
"ng": "ng",
|
"ng": "ng",
|
||||||
|
|||||||
@@ -62,9 +62,9 @@
|
|||||||
|
|
||||||
@if (!loading) {
|
@if (!loading) {
|
||||||
<div class="d-flex mb-2">
|
<div class="d-flex mb-2">
|
||||||
@if (collectionSize > 0) {
|
@if (displayCollectionSize > 0) {
|
||||||
<div>
|
<div>
|
||||||
<ng-container i18n>{collectionSize, plural, =1 {One {{typeName}}} other {{{collectionSize || 0}} total {{typeNamePlural}}}}</ng-container>
|
<ng-container i18n>{displayCollectionSize, plural, =1 {One {{typeName}}} other {{{displayCollectionSize || 0}} total {{typeNamePlural}}}}</ng-container>
|
||||||
@if (selectedObjects.size > 0) {
|
@if (selectedObjects.size > 0) {
|
||||||
({{selectedObjects.size}} selected)
|
({{selectedObjects.size}} selected)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -229,7 +229,7 @@ describe('ManagementListComponent', () => {
|
|||||||
expect(reloadSpy).toHaveBeenCalled()
|
expect(reloadSpy).toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should use the all list length for collection size when provided', fakeAsync(() => {
|
it('should use API count for pagination and all ids for displayed total', fakeAsync(() => {
|
||||||
jest.spyOn(tagService, 'listFiltered').mockReturnValueOnce(
|
jest.spyOn(tagService, 'listFiltered').mockReturnValueOnce(
|
||||||
of({
|
of({
|
||||||
count: 1,
|
count: 1,
|
||||||
@@ -241,7 +241,8 @@ describe('ManagementListComponent', () => {
|
|||||||
component.reloadData()
|
component.reloadData()
|
||||||
tick(100)
|
tick(100)
|
||||||
|
|
||||||
expect(component.collectionSize).toBe(3)
|
expect(component.collectionSize).toBe(1)
|
||||||
|
expect(component.displayCollectionSize).toBe(3)
|
||||||
}))
|
}))
|
||||||
|
|
||||||
it('should support quick filter for objects', () => {
|
it('should support quick filter for objects', () => {
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
MatchingModel,
|
MatchingModel,
|
||||||
} from 'src/app/data/matching-model'
|
} from 'src/app/data/matching-model'
|
||||||
import { ObjectWithPermissions } from 'src/app/data/object-with-permissions'
|
import { ObjectWithPermissions } from 'src/app/data/object-with-permissions'
|
||||||
|
import { Results } from 'src/app/data/results'
|
||||||
import {
|
import {
|
||||||
SortableDirective,
|
SortableDirective,
|
||||||
SortEvent,
|
SortEvent,
|
||||||
@@ -88,6 +89,7 @@ export abstract class ManagementListComponent<T extends MatchingModel>
|
|||||||
public page = 1
|
public page = 1
|
||||||
|
|
||||||
public collectionSize = 0
|
public collectionSize = 0
|
||||||
|
public displayCollectionSize = 0
|
||||||
|
|
||||||
public sortField: string
|
public sortField: string
|
||||||
public sortReverse: boolean
|
public sortReverse: boolean
|
||||||
@@ -141,6 +143,14 @@ export abstract class ManagementListComponent<T extends MatchingModel>
|
|||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected getCollectionSize(results: Results<T>): number {
|
||||||
|
return results.all?.length ?? results.count
|
||||||
|
}
|
||||||
|
|
||||||
|
protected getDisplayCollectionSize(results: Results<T>): number {
|
||||||
|
return this.getCollectionSize(results)
|
||||||
|
}
|
||||||
|
|
||||||
getDocumentCount(object: MatchingModel): number {
|
getDocumentCount(object: MatchingModel): number {
|
||||||
return (
|
return (
|
||||||
object.document_count ??
|
object.document_count ??
|
||||||
@@ -171,7 +181,8 @@ export abstract class ManagementListComponent<T extends MatchingModel>
|
|||||||
tap((c) => {
|
tap((c) => {
|
||||||
this.unfilteredData = c.results
|
this.unfilteredData = c.results
|
||||||
this.data = this.filterData(c.results)
|
this.data = this.filterData(c.results)
|
||||||
this.collectionSize = c.all?.length ?? c.count
|
this.collectionSize = this.getCollectionSize(c)
|
||||||
|
this.displayCollectionSize = this.getDisplayCollectionSize(c)
|
||||||
}),
|
}),
|
||||||
delay(100)
|
delay(100)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
} from '@ng-bootstrap/ng-bootstrap'
|
} from '@ng-bootstrap/ng-bootstrap'
|
||||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||||
import { FILTER_HAS_TAGS_ALL } from 'src/app/data/filter-rule-type'
|
import { FILTER_HAS_TAGS_ALL } from 'src/app/data/filter-rule-type'
|
||||||
|
import { Results } from 'src/app/data/results'
|
||||||
import { Tag } from 'src/app/data/tag'
|
import { Tag } from 'src/app/data/tag'
|
||||||
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
||||||
import { SortableDirective } from 'src/app/directives/sortable.directive'
|
import { SortableDirective } from 'src/app/directives/sortable.directive'
|
||||||
@@ -77,6 +78,16 @@ export class TagListComponent extends ManagementListComponent<Tag> {
|
|||||||
return data.filter((tag) => !tag.parent || !availableIds.has(tag.parent))
|
return data.filter((tag) => !tag.parent || !availableIds.has(tag.parent))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected override getCollectionSize(results: Results<Tag>): number {
|
||||||
|
// Tag list pages are requested with is_root=true (when unfiltered), so
|
||||||
|
// pagination must follow root count even though `all` includes descendants
|
||||||
|
return results.count
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override getDisplayCollectionSize(results: Results<Tag>): number {
|
||||||
|
return super.getCollectionSize(results)
|
||||||
|
}
|
||||||
|
|
||||||
protected override getSelectableIDs(tags: Tag[]): number[] {
|
protected override getSelectableIDs(tags: Tag[]): number[] {
|
||||||
const ids: number[] = []
|
const ids: number[] = []
|
||||||
for (const tag of tags.filter(Boolean)) {
|
for (const tag of tags.filter(Boolean)) {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ export const environment = {
|
|||||||
apiVersion: '9', // match src/paperless/settings.py
|
apiVersion: '9', // match src/paperless/settings.py
|
||||||
appTitle: 'Paperless-ngx',
|
appTitle: 'Paperless-ngx',
|
||||||
tag: 'prod',
|
tag: 'prod',
|
||||||
version: '2.20.7',
|
version: '2.20.8',
|
||||||
webSocketHost: window.location.host,
|
webSocketHost: window.location.host,
|
||||||
webSocketProtocol: window.location.protocol == 'https:' ? 'wss:' : 'ws:',
|
webSocketProtocol: window.location.protocol == 'https:' ? 'wss:' : 'ws:',
|
||||||
webSocketBaseUrl: base_url.pathname + 'ws/',
|
webSocketBaseUrl: base_url.pathname + 'ws/',
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
from typing import Final
|
from typing import Final
|
||||||
|
|
||||||
__version__: Final[tuple[int, int, int]] = (2, 20, 7)
|
__version__: Final[tuple[int, int, int]] = (2, 20, 8)
|
||||||
# Version string like X.Y.Z
|
# Version string like X.Y.Z
|
||||||
__full_version_str__: Final[str] = ".".join(map(str, __version__))
|
__full_version_str__: Final[str] = ".".join(map(str, __version__))
|
||||||
# Version string like X.Y
|
# Version string like X.Y
|
||||||
|
|||||||
@@ -272,6 +272,24 @@ class TestAPIMailAccounts(DirectoriesMixin, APITestCase):
|
|||||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
self.assertEqual(response.data["success"], True)
|
self.assertEqual(response.data["success"], True)
|
||||||
|
|
||||||
|
def test_mail_account_test_existing_nonexistent_id_forbidden(self):
|
||||||
|
response = self.client.post(
|
||||||
|
f"{self.ENDPOINT}test/",
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"id": 999999,
|
||||||
|
"imap_server": "server.example.com",
|
||||||
|
"imap_port": 443,
|
||||||
|
"imap_security": MailAccount.ImapSecurity.SSL,
|
||||||
|
"username": "admin",
|
||||||
|
"password": "******",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
||||||
|
self.assertEqual(response.content.decode(), "Insufficient permissions")
|
||||||
|
|
||||||
def test_get_mail_accounts_owner_aware(self):
|
def test_get_mail_accounts_owner_aware(self):
|
||||||
"""
|
"""
|
||||||
GIVEN:
|
GIVEN:
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from datetime import timedelta
|
|||||||
from unittest import mock
|
from unittest import mock
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from django.contrib.auth.models import Permission
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
from django.core.management import call_command
|
from django.core.management import call_command
|
||||||
from django.db import DatabaseError
|
from django.db import DatabaseError
|
||||||
@@ -1699,6 +1700,10 @@ class TestMailAccountTestView(APITestCase):
|
|||||||
username="testuser",
|
username="testuser",
|
||||||
password="testpassword",
|
password="testpassword",
|
||||||
)
|
)
|
||||||
|
self.user.user_permissions.add(
|
||||||
|
*Permission.objects.filter(codename__in=["add_mailaccount"]),
|
||||||
|
)
|
||||||
|
self.user.save()
|
||||||
self.client.force_authenticate(user=self.user)
|
self.client.force_authenticate(user=self.user)
|
||||||
self.url = "/api/mail_accounts/test/"
|
self.url = "/api/mail_accounts/test/"
|
||||||
|
|
||||||
@@ -1815,6 +1820,54 @@ class TestMailAccountTestView(APITestCase):
|
|||||||
expected_str = "Unable to refresh oauth token"
|
expected_str = "Unable to refresh oauth token"
|
||||||
self.assertIn(expected_str, error_str)
|
self.assertIn(expected_str, error_str)
|
||||||
|
|
||||||
|
def test_mail_account_test_view_existing_forbidden_for_other_owner(self):
|
||||||
|
other_user = User.objects.create_user(
|
||||||
|
username="otheruser",
|
||||||
|
password="testpassword",
|
||||||
|
)
|
||||||
|
existing_account = MailAccount.objects.create(
|
||||||
|
name="Owned account",
|
||||||
|
imap_server="imap.example.com",
|
||||||
|
imap_port=993,
|
||||||
|
imap_security=MailAccount.ImapSecurity.SSL,
|
||||||
|
username="admin",
|
||||||
|
password="secret",
|
||||||
|
owner=other_user,
|
||||||
|
)
|
||||||
|
data = {
|
||||||
|
"id": existing_account.id,
|
||||||
|
"imap_server": "imap.example.com",
|
||||||
|
"imap_port": 993,
|
||||||
|
"imap_security": MailAccount.ImapSecurity.SSL,
|
||||||
|
"username": "admin",
|
||||||
|
"password": "****",
|
||||||
|
"is_token": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
response = self.client.post(self.url, data, format="json")
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
||||||
|
self.assertEqual(response.content.decode(), "Insufficient permissions")
|
||||||
|
|
||||||
|
def test_mail_account_test_view_requires_add_permission_without_account_id(self):
|
||||||
|
self.user.user_permissions.remove(
|
||||||
|
*Permission.objects.filter(codename__in=["add_mailaccount"]),
|
||||||
|
)
|
||||||
|
self.user.save()
|
||||||
|
data = {
|
||||||
|
"imap_server": "imap.example.com",
|
||||||
|
"imap_port": 993,
|
||||||
|
"imap_security": MailAccount.ImapSecurity.SSL,
|
||||||
|
"username": "admin",
|
||||||
|
"password": "secret",
|
||||||
|
"is_token": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
response = self.client.post(self.url, data, format="json")
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
||||||
|
self.assertEqual(response.content.decode(), "Insufficient permissions")
|
||||||
|
|
||||||
|
|
||||||
class TestMailAccountProcess(APITestCase):
|
class TestMailAccountProcess(APITestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
|
|||||||
@@ -86,13 +86,34 @@ class MailAccountViewSet(ModelViewSet, PassUserMixin):
|
|||||||
request.data["name"] = datetime.datetime.now().isoformat()
|
request.data["name"] = datetime.datetime.now().isoformat()
|
||||||
serializer = self.get_serializer(data=request.data)
|
serializer = self.get_serializer(data=request.data)
|
||||||
serializer.is_valid(raise_exception=True)
|
serializer.is_valid(raise_exception=True)
|
||||||
|
existing_account = None
|
||||||
|
account_id = request.data.get("id")
|
||||||
|
|
||||||
# account exists, use the password from there instead of *** and refresh_token / expiration
|
# testing a new connection requires add permission
|
||||||
|
if account_id is None and not request.user.has_perms(
|
||||||
|
["paperless_mail.add_mailaccount"],
|
||||||
|
):
|
||||||
|
return HttpResponseForbidden("Insufficient permissions")
|
||||||
|
|
||||||
|
# testing an existing account requires change permission on that account
|
||||||
|
if account_id is not None:
|
||||||
|
try:
|
||||||
|
existing_account = MailAccount.objects.get(pk=account_id)
|
||||||
|
except (TypeError, ValueError, MailAccount.DoesNotExist):
|
||||||
|
return HttpResponseForbidden("Insufficient permissions")
|
||||||
|
|
||||||
|
if not has_perms_owner_aware(
|
||||||
|
request.user,
|
||||||
|
"change_mailaccount",
|
||||||
|
existing_account,
|
||||||
|
):
|
||||||
|
return HttpResponseForbidden("Insufficient permissions")
|
||||||
|
|
||||||
|
# account exists, use the password from there instead of ***
|
||||||
if (
|
if (
|
||||||
len(serializer.validated_data.get("password").replace("*", "")) == 0
|
len(serializer.validated_data.get("password").replace("*", "")) == 0
|
||||||
and request.data["id"] is not None
|
and existing_account is not None
|
||||||
):
|
):
|
||||||
existing_account = MailAccount.objects.get(pk=request.data["id"])
|
|
||||||
serializer.validated_data["password"] = existing_account.password
|
serializer.validated_data["password"] = existing_account.password
|
||||||
serializer.validated_data["account_type"] = existing_account.account_type
|
serializer.validated_data["account_type"] = existing_account.account_type
|
||||||
serializer.validated_data["refresh_token"] = existing_account.refresh_token
|
serializer.validated_data["refresh_token"] = existing_account.refresh_token
|
||||||
@@ -106,7 +127,8 @@ class MailAccountViewSet(ModelViewSet, PassUserMixin):
|
|||||||
) as M:
|
) as M:
|
||||||
try:
|
try:
|
||||||
if (
|
if (
|
||||||
account.is_token
|
existing_account is not None
|
||||||
|
and account.is_token
|
||||||
and account.expiration is not None
|
and account.expiration is not None
|
||||||
and account.expiration < timezone.now()
|
and account.expiration < timezone.now()
|
||||||
):
|
):
|
||||||
@@ -248,6 +270,7 @@ class OauthCallbackView(GenericAPIView):
|
|||||||
imap_server=imap_server,
|
imap_server=imap_server,
|
||||||
refresh_token=refresh_token,
|
refresh_token=refresh_token,
|
||||||
expiration=timezone.now() + timedelta(seconds=expires_in),
|
expiration=timezone.now() + timedelta(seconds=expires_in),
|
||||||
|
owner=request.user,
|
||||||
defaults=defaults,
|
defaults=defaults,
|
||||||
)
|
)
|
||||||
return HttpResponseRedirect(
|
return HttpResponseRedirect(
|
||||||
|
|||||||
2
uv.lock
generated
2
uv.lock
generated
@@ -1991,7 +1991,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "paperless-ngx"
|
name = "paperless-ngx"
|
||||||
version = "2.20.7"
|
version = "2.20.8"
|
||||||
source = { virtual = "." }
|
source = { virtual = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "babel", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
|
{ name = "babel", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
|
||||||
|
|||||||
Reference in New Issue
Block a user