mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-03-04 16:26:24 +00:00
Compare commits
8 Commits
v2.20.8
...
fix-12227-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
05beea9215 | ||
|
|
5b809122b5 | ||
|
|
8b8307571a | ||
|
|
c7f83212a3 | ||
|
|
b010f65ae7 | ||
|
|
9601b3d597 | ||
|
|
13e07844fe | ||
|
|
98298e37cd |
@@ -1,5 +1,7 @@
|
||||
# Changelog
|
||||
|
||||
## paperless-ngx 2.20.8
|
||||
|
||||
## paperless-ngx 2.20.7
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
@@ -19,13 +19,18 @@
|
||||
<div class="col">
|
||||
<div class="card bg-light">
|
||||
<div class="card-body">
|
||||
<div class="card-title">
|
||||
<h6>
|
||||
{{option.title}}
|
||||
<a class="btn btn-sm btn-link" title="Read the documentation about this setting" i18n-title [href]="getDocsUrl(option.config_key)" target="_blank" referrerpolicy="no-referrer">
|
||||
<i-bs name="info-circle"></i-bs>
|
||||
</a>
|
||||
<div class="card-title d-flex align-items-center">
|
||||
<h6 class="mb-0">
|
||||
{{option.title}}
|
||||
</h6>
|
||||
<a class="btn btn-sm btn-link" title="Read the documentation about this setting" i18n-title [href]="getDocsUrl(option.config_key)" target="_blank" referrerpolicy="no-referrer">
|
||||
<i-bs name="info-circle"></i-bs>
|
||||
</a>
|
||||
@if (isSet(option.key)) {
|
||||
<button type="button" class="btn btn-sm btn-link text-danger ms-auto pe-0" title="Reset" i18n-title (click)="resetOption(option.key)">
|
||||
<i-bs class="me-1" name="x"></i-bs><ng-container i18n>Reset</ng-container>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
<div class="mb-n3">
|
||||
@switch (option.type) {
|
||||
|
||||
@@ -144,4 +144,18 @@ describe('ConfigComponent', () => {
|
||||
component.uploadFile(new File([], 'test.png'), 'app_logo')
|
||||
expect(initSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should reset option to null', () => {
|
||||
component.configForm.patchValue({ output_type: OutputTypeConfig.PDF_A })
|
||||
expect(component.isSet('output_type')).toBeTruthy()
|
||||
component.resetOption('output_type')
|
||||
expect(component.configForm.get('output_type').value).toBeNull()
|
||||
expect(component.isSet('output_type')).toBeFalsy()
|
||||
component.configForm.patchValue({ app_title: 'Test Title' })
|
||||
component.resetOption('app_title')
|
||||
expect(component.configForm.get('app_title').value).toBeNull()
|
||||
component.configForm.patchValue({ barcodes_enabled: true })
|
||||
component.resetOption('barcodes_enabled')
|
||||
expect(component.configForm.get('barcodes_enabled').value).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -208,4 +208,12 @@ export class ConfigComponent
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
public isSet(key: string): boolean {
|
||||
return this.configForm.get(key).value != null
|
||||
}
|
||||
|
||||
public resetOption(key: string) {
|
||||
this.configForm.get(key).setValue(null)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,9 +62,9 @@
|
||||
|
||||
@if (!loading) {
|
||||
<div class="d-flex mb-2">
|
||||
@if (collectionSize > 0) {
|
||||
@if (displayCollectionSize > 0) {
|
||||
<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) {
|
||||
({{selectedObjects.size}} selected)
|
||||
}
|
||||
|
||||
@@ -229,7 +229,7 @@ describe('ManagementListComponent', () => {
|
||||
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(
|
||||
of({
|
||||
count: 1,
|
||||
@@ -241,7 +241,8 @@ describe('ManagementListComponent', () => {
|
||||
component.reloadData()
|
||||
tick(100)
|
||||
|
||||
expect(component.collectionSize).toBe(3)
|
||||
expect(component.collectionSize).toBe(1)
|
||||
expect(component.displayCollectionSize).toBe(3)
|
||||
}))
|
||||
|
||||
it('should support quick filter for objects', () => {
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
MatchingModel,
|
||||
} from 'src/app/data/matching-model'
|
||||
import { ObjectWithPermissions } from 'src/app/data/object-with-permissions'
|
||||
import { Results } from 'src/app/data/results'
|
||||
import {
|
||||
SortableDirective,
|
||||
SortEvent,
|
||||
@@ -88,6 +89,7 @@ export abstract class ManagementListComponent<T extends MatchingModel>
|
||||
public page = 1
|
||||
|
||||
public collectionSize = 0
|
||||
public displayCollectionSize = 0
|
||||
|
||||
public sortField: string
|
||||
public sortReverse: boolean
|
||||
@@ -141,6 +143,14 @@ export abstract class ManagementListComponent<T extends MatchingModel>
|
||||
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 {
|
||||
return (
|
||||
object.document_count ??
|
||||
@@ -171,7 +181,8 @@ export abstract class ManagementListComponent<T extends MatchingModel>
|
||||
tap((c) => {
|
||||
this.unfilteredData = 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)
|
||||
)
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
} from '@ng-bootstrap/ng-bootstrap'
|
||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||
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 { IfPermissionsDirective } from 'src/app/directives/if-permissions.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))
|
||||
}
|
||||
|
||||
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[] {
|
||||
const ids: number[] = []
|
||||
for (const tag of tags.filter(Boolean)) {
|
||||
|
||||
@@ -19,6 +19,7 @@ from documents.classifier import load_classifier
|
||||
from documents.data_models import ConsumableDocument
|
||||
from documents.data_models import DocumentMetadataOverrides
|
||||
from documents.file_handling import create_source_path_directory
|
||||
from documents.file_handling import generate_filename
|
||||
from documents.file_handling import generate_unique_filename
|
||||
from documents.loggers import LoggingMixin
|
||||
from documents.models import Correspondent
|
||||
@@ -493,7 +494,19 @@ class ConsumerPlugin(
|
||||
# After everything is in the database, copy the files into
|
||||
# place. If this fails, we'll also rollback the transaction.
|
||||
with FileLock(settings.MEDIA_LOCK):
|
||||
document.filename = generate_unique_filename(document)
|
||||
generated_filename = generate_unique_filename(document)
|
||||
if (
|
||||
len(str(generated_filename))
|
||||
> Document.MAX_STORED_FILENAME_LENGTH
|
||||
):
|
||||
self.log.warning(
|
||||
"Generated source filename exceeds db path limit, falling back to default naming",
|
||||
)
|
||||
generated_filename = generate_filename(
|
||||
document,
|
||||
use_format=False,
|
||||
)
|
||||
document.filename = generated_filename
|
||||
create_source_path_directory(document.source_path)
|
||||
|
||||
self._write(
|
||||
@@ -511,10 +524,23 @@ class ConsumerPlugin(
|
||||
)
|
||||
|
||||
if archive_path and Path(archive_path).is_file():
|
||||
document.archive_filename = generate_unique_filename(
|
||||
generated_archive_filename = generate_unique_filename(
|
||||
document,
|
||||
archive_filename=True,
|
||||
)
|
||||
if (
|
||||
len(str(generated_archive_filename))
|
||||
> Document.MAX_STORED_FILENAME_LENGTH
|
||||
):
|
||||
self.log.warning(
|
||||
"Generated archive filename exceeds db path limit, falling back to default naming",
|
||||
)
|
||||
generated_archive_filename = generate_filename(
|
||||
document,
|
||||
archive_filename=True,
|
||||
use_format=False,
|
||||
)
|
||||
document.archive_filename = generated_archive_filename
|
||||
create_source_path_directory(document.archive_path)
|
||||
self._write(
|
||||
document.storage_type,
|
||||
|
||||
@@ -128,17 +128,21 @@ def generate_filename(
|
||||
counter=0,
|
||||
append_gpg=True,
|
||||
archive_filename=False,
|
||||
use_format=True,
|
||||
) -> Path:
|
||||
base_path: Path | None = None
|
||||
|
||||
# Determine the source of the format string
|
||||
if doc.storage_path is not None:
|
||||
filename_format = doc.storage_path.path
|
||||
elif settings.FILENAME_FORMAT is not None:
|
||||
# Maybe convert old to new style
|
||||
filename_format = convert_format_str_to_template_format(
|
||||
settings.FILENAME_FORMAT,
|
||||
)
|
||||
if use_format:
|
||||
if doc.storage_path is not None:
|
||||
filename_format = doc.storage_path.path
|
||||
elif settings.FILENAME_FORMAT is not None:
|
||||
# Maybe convert old to new style
|
||||
filename_format = convert_format_str_to_template_format(
|
||||
settings.FILENAME_FORMAT,
|
||||
)
|
||||
else:
|
||||
filename_format = None
|
||||
else:
|
||||
filename_format = None
|
||||
|
||||
|
||||
@@ -160,6 +160,7 @@ class Document(SoftDeleteModel, ModelWithOwner):
|
||||
(STORAGE_TYPE_UNENCRYPTED, _("Unencrypted")),
|
||||
(STORAGE_TYPE_GPG, _("Encrypted with GNU Privacy Guard")),
|
||||
)
|
||||
MAX_STORED_FILENAME_LENGTH: Final[int] = 1024
|
||||
|
||||
correspondent = models.ForeignKey(
|
||||
Correspondent,
|
||||
@@ -267,7 +268,7 @@ class Document(SoftDeleteModel, ModelWithOwner):
|
||||
|
||||
filename = models.FilePathField(
|
||||
_("filename"),
|
||||
max_length=1024,
|
||||
max_length=MAX_STORED_FILENAME_LENGTH,
|
||||
editable=False,
|
||||
default=None,
|
||||
unique=True,
|
||||
@@ -277,7 +278,7 @@ class Document(SoftDeleteModel, ModelWithOwner):
|
||||
|
||||
archive_filename = models.FilePathField(
|
||||
_("archive filename"),
|
||||
max_length=1024,
|
||||
max_length=MAX_STORED_FILENAME_LENGTH,
|
||||
editable=False,
|
||||
default=None,
|
||||
unique=True,
|
||||
@@ -287,7 +288,7 @@ class Document(SoftDeleteModel, ModelWithOwner):
|
||||
|
||||
original_filename = models.CharField(
|
||||
_("original filename"),
|
||||
max_length=1024,
|
||||
max_length=MAX_STORED_FILENAME_LENGTH,
|
||||
editable=False,
|
||||
default=None,
|
||||
unique=False,
|
||||
|
||||
@@ -75,6 +75,7 @@ from documents.parsers import is_mime_type_supported
|
||||
from documents.permissions import get_document_count_filter_for_user
|
||||
from documents.permissions import get_groups_with_only_permission
|
||||
from documents.permissions import get_objects_for_user_owner_aware
|
||||
from documents.permissions import has_perms_owner_aware
|
||||
from documents.permissions import set_permissions_for_object
|
||||
from documents.regex import validate_regex_pattern
|
||||
from documents.templating.filepath import validate_filepath_template_and_render
|
||||
@@ -2179,6 +2180,17 @@ class ShareLinkSerializer(OwnedObjectSerializer):
|
||||
validated_data["slug"] = get_random_string(50)
|
||||
return super().create(validated_data)
|
||||
|
||||
def validate_document(self, document):
|
||||
if self.user is not None and has_perms_owner_aware(
|
||||
self.user,
|
||||
"view_document",
|
||||
document,
|
||||
):
|
||||
return document
|
||||
raise PermissionDenied(
|
||||
_("Insufficient permissions."),
|
||||
)
|
||||
|
||||
|
||||
class BulkEditObjectsSerializer(SerializerWithPerms, SetPermissionsMixin):
|
||||
objects = serializers.ListField(
|
||||
|
||||
@@ -460,8 +460,22 @@ def update_filename_and_move_files(
|
||||
|
||||
old_filename = instance.filename
|
||||
old_source_path = instance.source_path
|
||||
move_original = False
|
||||
|
||||
old_archive_filename = instance.archive_filename
|
||||
old_archive_path = instance.archive_path
|
||||
move_archive = False
|
||||
|
||||
candidate_filename = generate_filename(instance)
|
||||
if len(str(candidate_filename)) > Document.MAX_STORED_FILENAME_LENGTH:
|
||||
msg = (
|
||||
f"Document {instance!s}: Generated filename exceeds db path "
|
||||
f"limit ({len(str(candidate_filename))} > "
|
||||
f"{Document.MAX_STORED_FILENAME_LENGTH}): {candidate_filename!s}"
|
||||
)
|
||||
logger.warning(msg)
|
||||
raise CannotMoveFilesException(msg)
|
||||
|
||||
candidate_source_path = (
|
||||
settings.ORIGINALS_DIR / candidate_filename
|
||||
).resolve()
|
||||
@@ -480,11 +494,16 @@ def update_filename_and_move_files(
|
||||
instance.filename = str(new_filename)
|
||||
move_original = old_filename != instance.filename
|
||||
|
||||
old_archive_filename = instance.archive_filename
|
||||
old_archive_path = instance.archive_path
|
||||
|
||||
if instance.has_archive_version:
|
||||
archive_candidate = generate_filename(instance, archive_filename=True)
|
||||
if len(str(archive_candidate)) > Document.MAX_STORED_FILENAME_LENGTH:
|
||||
msg = (
|
||||
f"Document {instance!s}: Generated archive filename exceeds "
|
||||
f"db path limit ({len(str(archive_candidate))} > "
|
||||
f"{Document.MAX_STORED_FILENAME_LENGTH}): {archive_candidate!s}"
|
||||
)
|
||||
logger.warning(msg)
|
||||
raise CannotMoveFilesException(msg)
|
||||
archive_candidate_path = (
|
||||
settings.ARCHIVE_DIR / archive_candidate
|
||||
).resolve()
|
||||
|
||||
@@ -79,6 +79,23 @@ class PlaceholderString(str):
|
||||
NO_VALUE_PLACEHOLDER = PlaceholderString("-none-")
|
||||
|
||||
|
||||
class MatchingModelContext:
|
||||
"""
|
||||
Safe template context for related objects.
|
||||
|
||||
Keeps legacy behavior where including the object ina template yields the related object's
|
||||
name as a string, while still exposing limited attributes.
|
||||
"""
|
||||
|
||||
def __init__(self, *, id: int, name: str, path: str | None = None):
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.path = path
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.name
|
||||
|
||||
|
||||
_template_environment.undefined = _LogStrictUndefined
|
||||
|
||||
_template_environment.filters["get_cf_value"] = get_cf_value
|
||||
@@ -221,19 +238,26 @@ def get_safe_document_context(
|
||||
else None,
|
||||
"tags": [{"name": tag.name, "id": tag.id} for tag in tags],
|
||||
"correspondent": (
|
||||
{"name": document.correspondent.name, "id": document.correspondent.id}
|
||||
MatchingModelContext(
|
||||
name=document.correspondent.name,
|
||||
id=document.correspondent.id,
|
||||
)
|
||||
if document.correspondent
|
||||
else None
|
||||
),
|
||||
"document_type": (
|
||||
{"name": document.document_type.name, "id": document.document_type.id}
|
||||
MatchingModelContext(
|
||||
name=document.document_type.name,
|
||||
id=document.document_type.id,
|
||||
)
|
||||
if document.document_type
|
||||
else None
|
||||
),
|
||||
"storage_path": {
|
||||
"path": document.storage_path.path,
|
||||
"id": document.storage_path.id,
|
||||
}
|
||||
"storage_path": MatchingModelContext(
|
||||
name=document.storage_path.name,
|
||||
path=document.storage_path.path,
|
||||
id=document.storage_path.id,
|
||||
)
|
||||
if document.storage_path
|
||||
else None,
|
||||
}
|
||||
|
||||
@@ -773,6 +773,22 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
||||
],
|
||||
)
|
||||
|
||||
def test_api_selection_data_requires_view_permission(self):
|
||||
self.doc2.owner = self.user
|
||||
self.doc2.save()
|
||||
|
||||
user1 = User.objects.create(username="user1")
|
||||
self.client.force_authenticate(user=user1)
|
||||
|
||||
response = self.client.post(
|
||||
"/api/documents/selection_data/",
|
||||
json.dumps({"documents": [self.doc2.id]}),
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
||||
self.assertEqual(response.content, b"Insufficient permissions")
|
||||
|
||||
@mock.patch("documents.serialisers.bulk_edit.set_permissions")
|
||||
def test_set_permissions(self, m):
|
||||
self.setup_mock(m, "set_permissions")
|
||||
|
||||
@@ -2905,6 +2905,54 @@ class TestDocumentApi(DirectoriesMixin, DocumentConsumeDelayMixin, APITestCase):
|
||||
)
|
||||
self.assertEqual(resp.status_code, status.HTTP_200_OK)
|
||||
|
||||
def test_create_share_link_requires_view_permission_for_document(self):
|
||||
"""
|
||||
GIVEN:
|
||||
- A user with add_sharelink but without view permission on a document
|
||||
WHEN:
|
||||
- API request is made to create a share link for that document
|
||||
THEN:
|
||||
- Share link creation is denied until view permission is granted
|
||||
"""
|
||||
user1 = User.objects.create_user(username="test1")
|
||||
user1.user_permissions.add(*Permission.objects.filter(codename="add_sharelink"))
|
||||
user1.save()
|
||||
|
||||
user2 = User.objects.create_user(username="test2")
|
||||
user2.save()
|
||||
|
||||
doc = Document.objects.create(
|
||||
title="test",
|
||||
mime_type="application/pdf",
|
||||
content="this is a document which will be protected",
|
||||
owner=user2,
|
||||
)
|
||||
|
||||
self.client.force_authenticate(user1)
|
||||
|
||||
create_resp = self.client.post(
|
||||
"/api/share_links/",
|
||||
data={
|
||||
"document": doc.pk,
|
||||
"file_version": "original",
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(create_resp.status_code, status.HTTP_403_FORBIDDEN)
|
||||
|
||||
assign_perm("view_document", user1, doc)
|
||||
|
||||
create_resp = self.client.post(
|
||||
"/api/share_links/",
|
||||
data={
|
||||
"document": doc.pk,
|
||||
"file_version": "original",
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(create_resp.status_code, status.HTTP_201_CREATED)
|
||||
self.assertEqual(create_resp.data["document"], doc.pk)
|
||||
|
||||
def test_next_asn(self):
|
||||
"""
|
||||
GIVEN:
|
||||
|
||||
@@ -633,6 +633,33 @@ class TestConsumer(
|
||||
|
||||
self._assert_first_last_send_progress()
|
||||
|
||||
@mock.patch("documents.consumer.generate_unique_filename")
|
||||
def testFilenameHandlingFallsBackWhenGeneratedPathExceedsDbLimit(self, m):
|
||||
m.side_effect = lambda doc, archive_filename=False: Path(
|
||||
("a" * 1100 + ".pdf") if not archive_filename else ("b" * 1100 + ".pdf"),
|
||||
)
|
||||
|
||||
with self.get_consumer(
|
||||
self.get_test_file(),
|
||||
DocumentMetadataOverrides(title="new docs"),
|
||||
) as consumer:
|
||||
consumer.run()
|
||||
|
||||
document = Document.objects.first()
|
||||
self.assertIsNotNone(document)
|
||||
assert document is not None
|
||||
|
||||
self.assertEqual(document.filename, f"{document.pk:07d}.pdf")
|
||||
self.assertLessEqual(len(document.filename), 1024)
|
||||
self.assertLessEqual(
|
||||
len(document.archive_filename),
|
||||
1024,
|
||||
)
|
||||
self.assertIsFile(document.source_path)
|
||||
self.assertIsFile(document.archive_path)
|
||||
|
||||
self._assert_first_last_send_progress()
|
||||
|
||||
@override_settings(FILENAME_FORMAT="{correspondent}/{title}")
|
||||
@mock.patch("documents.signals.handlers.generate_unique_filename")
|
||||
def testFilenameHandlingUnstableFormat(self, m):
|
||||
|
||||
@@ -1341,6 +1341,41 @@ class TestFilenameGeneration(DirectoriesMixin, TestCase):
|
||||
Path("somepath/asn-201-400/asn-3xx/Does Matter.pdf"),
|
||||
)
|
||||
|
||||
def test_template_related_context_keeps_legacy_string_coercion(self):
|
||||
"""
|
||||
GIVEN:
|
||||
- A storage path template that uses related objects directly as strings
|
||||
WHEN:
|
||||
- Filepath for a document with this format is called
|
||||
THEN:
|
||||
- Related objects coerce to their names (legacy behavior)
|
||||
- Explicit attribute access remains available for new templates
|
||||
"""
|
||||
sp = StoragePath.objects.create(
|
||||
name="PARTNER",
|
||||
path=(
|
||||
"{{ document.storage_path|lower }} / "
|
||||
"{{ document.correspondent|lower|replace('mi:', 'mieter/') }} / "
|
||||
"{{ document_type|lower }} / "
|
||||
"{{ title|lower }}"
|
||||
),
|
||||
)
|
||||
doc = Document.objects.create(
|
||||
title="scan_017562",
|
||||
created=datetime.date(2025, 7, 2),
|
||||
added=timezone.make_aware(datetime.datetime(2026, 3, 3, 11, 53, 16)),
|
||||
mime_type="application/pdf",
|
||||
checksum="test-checksum",
|
||||
storage_path=sp,
|
||||
correspondent=Correspondent.objects.create(name="mi:kochkach"),
|
||||
document_type=DocumentType.objects.create(name="Mietvertrag"),
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
generate_filename(doc),
|
||||
Path("partner/mieter/kochkach/mietvertrag/scan_017562.pdf"),
|
||||
)
|
||||
|
||||
@override_settings(
|
||||
FILENAME_FORMAT="{{creation_date}}/{{ title_name_str }}",
|
||||
)
|
||||
@@ -1699,6 +1734,21 @@ class TestCustomFieldFilenameUpdates(
|
||||
self.assertTrue(Path(self.doc.source_path).is_file())
|
||||
self.assertLessEqual(m.call_count, 1)
|
||||
|
||||
@override_settings(FILENAME_FORMAT=None)
|
||||
def test_overlong_storage_path_keeps_existing_filename(self):
|
||||
initial_filename = generate_filename(self.doc)
|
||||
Document.objects.filter(pk=self.doc.pk).update(filename=str(initial_filename))
|
||||
self.doc.refresh_from_db()
|
||||
Path(self.doc.source_path).parent.mkdir(parents=True, exist_ok=True)
|
||||
Path(self.doc.source_path).touch()
|
||||
|
||||
self.doc.storage_path = StoragePath.objects.create(path="a" * 1100)
|
||||
self.doc.save()
|
||||
|
||||
self.doc.refresh_from_db()
|
||||
self.assertEqual(Path(self.doc.filename), initial_filename)
|
||||
self.assertTrue(Path(self.doc.source_path).is_file())
|
||||
|
||||
|
||||
class TestPathDateLocalization:
|
||||
"""
|
||||
|
||||
@@ -147,6 +147,16 @@ class TestTagHierarchy(APITestCase):
|
||||
assert serializer.data # triggers serialization
|
||||
assert "document_count_filter" in context
|
||||
|
||||
def test_tag_list_can_order_by_document_count_with_children(self) -> None:
|
||||
self.document.tags.add(self.child)
|
||||
|
||||
response = self.client.get(
|
||||
"/api/tags/",
|
||||
{"ordering": "document_count"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_cannot_set_parent_to_self(self):
|
||||
tag = Tag.objects.create(name="Selfie")
|
||||
resp = self.client.patch(
|
||||
|
||||
@@ -487,13 +487,13 @@ class TagViewSet(PermissionsAwareDocumentCountMixin, ModelViewSet):
|
||||
user = getattr(getattr(self, "request", None), "user", None)
|
||||
children_source = list(
|
||||
annotate_document_count_for_related_queryset(
|
||||
Tag.objects.filter(pk__in=descendant_pks | {t.pk for t in all_tags})
|
||||
.select_related("owner")
|
||||
.order_by(*ordering),
|
||||
Tag.objects.filter(
|
||||
pk__in=descendant_pks | {t.pk for t in all_tags},
|
||||
).select_related("owner"),
|
||||
through_model=self.document_count_through,
|
||||
related_object_field=self.document_count_source_field,
|
||||
user=user,
|
||||
),
|
||||
).order_by(*ordering),
|
||||
)
|
||||
else:
|
||||
children_source = all_tags
|
||||
@@ -1850,6 +1850,13 @@ class SelectionDataView(GenericAPIView):
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
ids = serializer.validated_data.get("documents")
|
||||
permitted_documents = get_objects_for_user_owner_aware(
|
||||
request.user,
|
||||
"documents.view_document",
|
||||
Document,
|
||||
)
|
||||
if permitted_documents.filter(pk__in=ids).count() != len(ids):
|
||||
return HttpResponseForbidden("Insufficient permissions")
|
||||
|
||||
correspondents = Correspondent.objects.annotate(
|
||||
document_count=Count(
|
||||
|
||||
Reference in New Issue
Block a user