mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2025-07-28 18:24:38 -05:00
Merge remote-tracking branch 'origin/dev' into feature-multiple-barcode-scanners
This commit is contained in:
@@ -4,6 +4,7 @@ from guardian.admin import GuardedModelAdmin
|
||||
from .models import Correspondent
|
||||
from .models import Document
|
||||
from .models import DocumentType
|
||||
from .models import Note
|
||||
from .models import PaperlessTask
|
||||
from .models import SavedView
|
||||
from .models import SavedViewFilterRule
|
||||
@@ -131,6 +132,13 @@ class TaskAdmin(admin.ModelAdmin):
|
||||
)
|
||||
|
||||
|
||||
class NotesAdmin(GuardedModelAdmin):
|
||||
|
||||
list_display = ("user", "created", "note", "document")
|
||||
list_filter = ("created", "user")
|
||||
list_display_links = ("created",)
|
||||
|
||||
|
||||
admin.site.register(Correspondent, CorrespondentAdmin)
|
||||
admin.site.register(Tag, TagAdmin)
|
||||
admin.site.register(DocumentType, DocumentTypeAdmin)
|
||||
@@ -138,3 +146,4 @@ admin.site.register(Document, DocumentAdmin)
|
||||
admin.site.register(SavedView, SavedViewAdmin)
|
||||
admin.site.register(StoragePath, StoragePathAdmin)
|
||||
admin.site.register(PaperlessTask, TaskAdmin)
|
||||
admin.site.register(Note, NotesAdmin)
|
||||
|
@@ -36,29 +36,30 @@ class DocumentTypeFilterSet(FilterSet):
|
||||
fields = {"name": CHAR_KWARGS}
|
||||
|
||||
|
||||
class TagsFilter(Filter):
|
||||
def __init__(self, exclude=False, in_list=False):
|
||||
class ObjectFilter(Filter):
|
||||
def __init__(self, exclude=False, in_list=False, field_name=""):
|
||||
super().__init__()
|
||||
self.exclude = exclude
|
||||
self.in_list = in_list
|
||||
self.field_name = field_name
|
||||
|
||||
def filter(self, qs, value):
|
||||
if not value:
|
||||
return qs
|
||||
|
||||
try:
|
||||
tag_ids = [int(x) for x in value.split(",")]
|
||||
object_ids = [int(x) for x in value.split(",")]
|
||||
except ValueError:
|
||||
return qs
|
||||
|
||||
if self.in_list:
|
||||
qs = qs.filter(tags__id__in=tag_ids).distinct()
|
||||
qs = qs.filter(**{f"{self.field_name}__id__in": object_ids}).distinct()
|
||||
else:
|
||||
for tag_id in tag_ids:
|
||||
for obj_id in object_ids:
|
||||
if self.exclude:
|
||||
qs = qs.exclude(tags__id=tag_id)
|
||||
qs = qs.exclude(**{f"{self.field_name}__id": obj_id})
|
||||
else:
|
||||
qs = qs.filter(tags__id=tag_id)
|
||||
qs = qs.filter(**{f"{self.field_name}__id": obj_id})
|
||||
|
||||
return qs
|
||||
|
||||
@@ -90,11 +91,17 @@ class DocumentFilterSet(FilterSet):
|
||||
exclude=True,
|
||||
)
|
||||
|
||||
tags__id__all = TagsFilter()
|
||||
tags__id__all = ObjectFilter(field_name="tags")
|
||||
|
||||
tags__id__none = TagsFilter(exclude=True)
|
||||
tags__id__none = ObjectFilter(field_name="tags", exclude=True)
|
||||
|
||||
tags__id__in = TagsFilter(in_list=True)
|
||||
tags__id__in = ObjectFilter(field_name="tags", in_list=True)
|
||||
|
||||
correspondent__id__none = ObjectFilter(field_name="correspondent", exclude=True)
|
||||
|
||||
document_type__id__none = ObjectFilter(field_name="document_type", exclude=True)
|
||||
|
||||
storage_path__id__none = ObjectFilter(field_name="storage_path", exclude=True)
|
||||
|
||||
is_in_inbox = InboxFilter()
|
||||
|
||||
|
@@ -6,8 +6,8 @@ from contextlib import contextmanager
|
||||
from dateutil.parser import isoparse
|
||||
from django.conf import settings
|
||||
from django.utils import timezone
|
||||
from documents.models import Comment
|
||||
from documents.models import Document
|
||||
from documents.models import Note
|
||||
from guardian.shortcuts import get_users_with_perms
|
||||
from whoosh import classify
|
||||
from whoosh import highlight
|
||||
@@ -52,7 +52,7 @@ def get_schema():
|
||||
path=TEXT(sortable=True),
|
||||
path_id=NUMERIC(),
|
||||
has_path=BOOLEAN(),
|
||||
comments=TEXT(),
|
||||
notes=TEXT(),
|
||||
owner=TEXT(),
|
||||
owner_id=NUMERIC(),
|
||||
has_owner=BOOLEAN(),
|
||||
@@ -98,7 +98,7 @@ def open_index_searcher():
|
||||
def update_document(writer: AsyncWriter, doc: Document):
|
||||
tags = ",".join([t.name for t in doc.tags.all()])
|
||||
tags_ids = ",".join([str(t.id) for t in doc.tags.all()])
|
||||
comments = ",".join([str(c.comment) for c in Comment.objects.filter(document=doc)])
|
||||
notes = ",".join([str(c.note) for c in Note.objects.filter(document=doc)])
|
||||
asn = doc.archive_serial_number
|
||||
if asn is not None and (
|
||||
asn < Document.ARCHIVE_SERIAL_NUMBER_MIN
|
||||
@@ -136,7 +136,7 @@ def update_document(writer: AsyncWriter, doc: Document):
|
||||
path=doc.storage_path.name if doc.storage_path else None,
|
||||
path_id=doc.storage_path.id if doc.storage_path else None,
|
||||
has_path=doc.storage_path is not None,
|
||||
comments=comments,
|
||||
notes=notes,
|
||||
owner=doc.owner.username if doc.owner else None,
|
||||
owner_id=doc.owner.id if doc.owner else None,
|
||||
has_owner=doc.owner is not None,
|
||||
@@ -293,7 +293,7 @@ class DelayedFullTextQuery(DelayedQuery):
|
||||
def _get_query(self):
|
||||
q_str = self.query_params["query"]
|
||||
qp = MultifieldParser(
|
||||
["content", "title", "correspondent", "tag", "type", "comments"],
|
||||
["content", "title", "correspondent", "tag", "type", "notes"],
|
||||
self.searcher.ixreader.schema,
|
||||
)
|
||||
qp.add_plugin(DateParserPlugin(basedate=timezone.now()))
|
||||
|
@@ -1,10 +1,10 @@
|
||||
import logging
|
||||
import os
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from fnmatch import filter
|
||||
from pathlib import Path
|
||||
from pathlib import PurePath
|
||||
from threading import Event
|
||||
from threading import Thread
|
||||
from time import monotonic
|
||||
from time import sleep
|
||||
from typing import Final
|
||||
@@ -168,11 +168,15 @@ def _consume_wait_unmodified(file: str) -> None:
|
||||
|
||||
|
||||
class Handler(FileSystemEventHandler):
|
||||
def __init__(self, pool: ThreadPoolExecutor) -> None:
|
||||
super().__init__()
|
||||
self._pool = pool
|
||||
|
||||
def on_created(self, event):
|
||||
Thread(target=_consume_wait_unmodified, args=(event.src_path,)).start()
|
||||
self._pool.submit(_consume_wait_unmodified, event.src_path)
|
||||
|
||||
def on_moved(self, event):
|
||||
Thread(target=_consume_wait_unmodified, args=(event.dest_path,)).start()
|
||||
self._pool.submit(_consume_wait_unmodified, event.dest_path)
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
@@ -246,17 +250,18 @@ class Command(BaseCommand):
|
||||
timeout = self.testing_timeout_s
|
||||
logger.debug(f"Configuring timeout to {timeout}s")
|
||||
|
||||
observer = PollingObserver(timeout=settings.CONSUMER_POLLING)
|
||||
observer.schedule(Handler(), directory, recursive=recursive)
|
||||
observer.start()
|
||||
try:
|
||||
while observer.is_alive():
|
||||
observer.join(timeout)
|
||||
if self.stop_flag.is_set():
|
||||
observer.stop()
|
||||
except KeyboardInterrupt:
|
||||
observer.stop()
|
||||
observer.join()
|
||||
with ThreadPoolExecutor(max_workers=4) as pool:
|
||||
observer = PollingObserver(timeout=settings.CONSUMER_POLLING)
|
||||
observer.schedule(Handler(pool), directory, recursive=recursive)
|
||||
observer.start()
|
||||
try:
|
||||
while observer.is_alive():
|
||||
observer.join(timeout)
|
||||
if self.stop_flag.is_set():
|
||||
observer.stop()
|
||||
except KeyboardInterrupt:
|
||||
observer.stop()
|
||||
observer.join()
|
||||
|
||||
def handle_inotify(self, directory, recursive, is_testing: bool):
|
||||
logger.info(f"Using inotify to watch directory for changes: {directory}")
|
||||
|
@@ -17,10 +17,10 @@ from django.core.management.base import BaseCommand
|
||||
from django.core.management.base import CommandError
|
||||
from django.db import transaction
|
||||
from django.utils import timezone
|
||||
from documents.models import Comment
|
||||
from documents.models import Correspondent
|
||||
from documents.models import Document
|
||||
from documents.models import DocumentType
|
||||
from documents.models import Note
|
||||
from documents.models import SavedView
|
||||
from documents.models import SavedViewFilterRule
|
||||
from documents.models import StoragePath
|
||||
@@ -206,7 +206,7 @@ class Command(BaseCommand):
|
||||
self.files_in_export_dir.add(x.resolve())
|
||||
|
||||
# 2. Create manifest, containing all correspondents, types, tags, storage paths
|
||||
# comments, documents and ui_settings
|
||||
# note, documents and ui_settings
|
||||
with transaction.atomic():
|
||||
manifest = json.loads(
|
||||
serializers.serialize("json", Correspondent.objects.all()),
|
||||
@@ -222,11 +222,11 @@ class Command(BaseCommand):
|
||||
serializers.serialize("json", StoragePath.objects.all()),
|
||||
)
|
||||
|
||||
comments = json.loads(
|
||||
serializers.serialize("json", Comment.objects.all()),
|
||||
notes = json.loads(
|
||||
serializers.serialize("json", Note.objects.all()),
|
||||
)
|
||||
if not self.split_manifest:
|
||||
manifest += comments
|
||||
manifest += notes
|
||||
|
||||
documents = Document.objects.order_by("id")
|
||||
document_map = {d.pk: d for d in documents}
|
||||
@@ -359,7 +359,7 @@ class Command(BaseCommand):
|
||||
content += list(
|
||||
filter(
|
||||
lambda d: d["fields"]["document"] == document_dict["pk"],
|
||||
comments,
|
||||
notes,
|
||||
),
|
||||
)
|
||||
manifest_name.write_text(json.dumps(content, indent=2))
|
||||
|
@@ -0,0 +1,54 @@
|
||||
# Generated by Django 4.1.5 on 2023-03-15 07:10
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("documents", "1033_alter_documenttype_options_alter_tag_options_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="savedviewfilterrule",
|
||||
name="rule_type",
|
||||
field=models.PositiveIntegerField(
|
||||
choices=[
|
||||
(0, "title contains"),
|
||||
(1, "content contains"),
|
||||
(2, "ASN is"),
|
||||
(3, "correspondent is"),
|
||||
(4, "document type is"),
|
||||
(5, "is in inbox"),
|
||||
(6, "has tag"),
|
||||
(7, "has any tag"),
|
||||
(8, "created before"),
|
||||
(9, "created after"),
|
||||
(10, "created year is"),
|
||||
(11, "created month is"),
|
||||
(12, "created day is"),
|
||||
(13, "added before"),
|
||||
(14, "added after"),
|
||||
(15, "modified before"),
|
||||
(16, "modified after"),
|
||||
(17, "does not have tag"),
|
||||
(18, "does not have ASN"),
|
||||
(19, "title or content contains"),
|
||||
(20, "fulltext query"),
|
||||
(21, "more like this"),
|
||||
(22, "has tags in"),
|
||||
(23, "ASN greater than"),
|
||||
(24, "ASN less than"),
|
||||
(25, "storage path is"),
|
||||
(26, "has correspondent in"),
|
||||
(27, "does not have correspondent in"),
|
||||
(28, "has document type in"),
|
||||
(29, "does not have document type in"),
|
||||
(30, "has storage path in"),
|
||||
(31, "does not have storage path in"),
|
||||
],
|
||||
verbose_name="rule type",
|
||||
),
|
||||
),
|
||||
]
|
61
src/documents/migrations/1035_rename_comment_note.py
Normal file
61
src/documents/migrations/1035_rename_comment_note.py
Normal file
@@ -0,0 +1,61 @@
|
||||
# Generated by Django 4.1.5 on 2023-03-17 22:15
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations
|
||||
from django.db import models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
("documents", "1034_alter_savedviewfilterrule_rule_type"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RenameModel(
|
||||
old_name="Comment",
|
||||
new_name="Note",
|
||||
),
|
||||
migrations.RenameField(model_name="note", old_name="comment", new_name="note"),
|
||||
migrations.AlterModelOptions(
|
||||
name="note",
|
||||
options={
|
||||
"ordering": ("created",),
|
||||
"verbose_name": "note",
|
||||
"verbose_name_plural": "notes",
|
||||
},
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="note",
|
||||
name="document",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="notes",
|
||||
to="documents.document",
|
||||
verbose_name="document",
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="note",
|
||||
name="note",
|
||||
field=models.TextField(
|
||||
blank=True, help_text="Note for the document", verbose_name="content"
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="note",
|
||||
name="user",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="notes",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
verbose_name="user",
|
||||
),
|
||||
),
|
||||
]
|
@@ -447,6 +447,12 @@ class SavedViewFilterRule(models.Model):
|
||||
(23, _("ASN greater than")),
|
||||
(24, _("ASN less than")),
|
||||
(25, _("storage path is")),
|
||||
(26, _("has correspondent in")),
|
||||
(27, _("does not have correspondent in")),
|
||||
(28, _("has document type in")),
|
||||
(29, _("does not have document type in")),
|
||||
(30, _("has storage path in")),
|
||||
(31, _("does not have storage path in")),
|
||||
]
|
||||
|
||||
saved_view = models.ForeignKey(
|
||||
@@ -629,11 +635,11 @@ class PaperlessTask(models.Model):
|
||||
)
|
||||
|
||||
|
||||
class Comment(models.Model):
|
||||
comment = models.TextField(
|
||||
class Note(models.Model):
|
||||
note = models.TextField(
|
||||
_("content"),
|
||||
blank=True,
|
||||
help_text=_("Comment for the document"),
|
||||
help_text=_("Note for the document"),
|
||||
)
|
||||
|
||||
created = models.DateTimeField(
|
||||
@@ -646,7 +652,7 @@ class Comment(models.Model):
|
||||
Document,
|
||||
blank=True,
|
||||
null=True,
|
||||
related_name="documents",
|
||||
related_name="notes",
|
||||
on_delete=models.CASCADE,
|
||||
verbose_name=_("document"),
|
||||
)
|
||||
@@ -655,15 +661,15 @@ class Comment(models.Model):
|
||||
User,
|
||||
blank=True,
|
||||
null=True,
|
||||
related_name="users",
|
||||
related_name="notes",
|
||||
on_delete=models.SET_NULL,
|
||||
verbose_name=_("user"),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
ordering = ("created",)
|
||||
verbose_name = _("comment")
|
||||
verbose_name_plural = _("comments")
|
||||
verbose_name = _("note")
|
||||
verbose_name_plural = _("notes")
|
||||
|
||||
def __str__(self):
|
||||
return self.content
|
||||
return self.note
|
||||
|
@@ -78,10 +78,11 @@ class MatchingModelSerializer(serializers.ModelSerializer):
|
||||
if hasattr(self, "user")
|
||||
else None
|
||||
)
|
||||
pk = self.instance.pk if hasattr(self.instance, "pk") else None
|
||||
if ("name" in data or "owner" in data) and self.Meta.model.objects.filter(
|
||||
name=name,
|
||||
owner=owner,
|
||||
).exists():
|
||||
).exclude(pk=pk).exists():
|
||||
raise serializers.ValidationError(
|
||||
{"error": "Object violates owner / name unique constraint"},
|
||||
)
|
||||
@@ -442,6 +443,7 @@ class DocumentSerializer(OwnedObjectSerializer, DynamicFieldsModelSerializer):
|
||||
"owner",
|
||||
"permissions",
|
||||
"set_permissions",
|
||||
"notes",
|
||||
)
|
||||
|
||||
|
||||
|
@@ -38,7 +38,7 @@ from documents.models import PaperlessTask
|
||||
from documents.models import SavedView
|
||||
from documents.models import StoragePath
|
||||
from documents.models import Tag
|
||||
from documents.models import Comment
|
||||
from documents.models import Note
|
||||
from documents.tests.utils import DirectoriesMixin
|
||||
from paperless import version
|
||||
from rest_framework.test import APITestCase
|
||||
@@ -1039,9 +1039,24 @@ class TestDocumentApi(DirectoriesMixin, APITestCase):
|
||||
|
||||
def test_statistics(self):
|
||||
|
||||
doc1 = Document.objects.create(title="none1", checksum="A")
|
||||
doc2 = Document.objects.create(title="none2", checksum="B")
|
||||
doc3 = Document.objects.create(title="none3", checksum="C")
|
||||
doc1 = Document.objects.create(
|
||||
title="none1",
|
||||
checksum="A",
|
||||
mime_type="application/pdf",
|
||||
content="abc",
|
||||
)
|
||||
doc2 = Document.objects.create(
|
||||
title="none2",
|
||||
checksum="B",
|
||||
mime_type="application/pdf",
|
||||
content="123",
|
||||
)
|
||||
doc3 = Document.objects.create(
|
||||
title="none3",
|
||||
checksum="C",
|
||||
mime_type="text/plain",
|
||||
content="hello",
|
||||
)
|
||||
|
||||
tag_inbox = Tag.objects.create(name="t1", is_inbox_tag=True)
|
||||
|
||||
@@ -1051,6 +1066,16 @@ class TestDocumentApi(DirectoriesMixin, APITestCase):
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(response.data["documents_total"], 3)
|
||||
self.assertEqual(response.data["documents_inbox"], 1)
|
||||
self.assertEqual(response.data["inbox_tag"], tag_inbox.pk)
|
||||
self.assertEqual(
|
||||
response.data["document_file_type_counts"][0]["mime_type_count"],
|
||||
2,
|
||||
)
|
||||
self.assertEqual(
|
||||
response.data["document_file_type_counts"][1]["mime_type_count"],
|
||||
1,
|
||||
)
|
||||
self.assertEqual(response.data["character_count"], 11)
|
||||
|
||||
def test_statistics_no_inbox_tag(self):
|
||||
Document.objects.create(title="none1", checksum="A")
|
||||
@@ -1058,6 +1083,7 @@ class TestDocumentApi(DirectoriesMixin, APITestCase):
|
||||
response = self.client.get("/api/statistics/")
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(response.data["documents_inbox"], None)
|
||||
self.assertEqual(response.data["inbox_tag"], None)
|
||||
|
||||
@mock.patch("documents.views.consume_file.delay")
|
||||
def test_upload(self, m):
|
||||
@@ -1717,28 +1743,28 @@ class TestDocumentApi(DirectoriesMixin, APITestCase):
|
||||
1,
|
||||
)
|
||||
|
||||
def test_get_existing_comments(self):
|
||||
def test_get_existing_notes(self):
|
||||
"""
|
||||
GIVEN:
|
||||
- A document with a single comment
|
||||
- A document with a single note
|
||||
WHEN:
|
||||
- API reuqest for document comments is made
|
||||
- API reuqest for document notes is made
|
||||
THEN:
|
||||
- The associated comment is returned
|
||||
- The associated note is returned
|
||||
"""
|
||||
doc = Document.objects.create(
|
||||
title="test",
|
||||
mime_type="application/pdf",
|
||||
content="this is a document which will have comments!",
|
||||
content="this is a document which will have notes!",
|
||||
)
|
||||
comment = Comment.objects.create(
|
||||
comment="This is a comment.",
|
||||
note = Note.objects.create(
|
||||
note="This is a note.",
|
||||
document=doc,
|
||||
user=self.user,
|
||||
)
|
||||
|
||||
response = self.client.get(
|
||||
f"/api/documents/{doc.pk}/comments/",
|
||||
f"/api/documents/{doc.pk}/notes/",
|
||||
format="json",
|
||||
)
|
||||
|
||||
@@ -1754,39 +1780,39 @@ class TestDocumentApi(DirectoriesMixin, APITestCase):
|
||||
self.assertDictEqual(
|
||||
resp_data,
|
||||
{
|
||||
"id": comment.id,
|
||||
"comment": comment.comment,
|
||||
"id": note.id,
|
||||
"note": note.note,
|
||||
"user": {
|
||||
"id": comment.user.id,
|
||||
"username": comment.user.username,
|
||||
"first_name": comment.user.first_name,
|
||||
"last_name": comment.user.last_name,
|
||||
"id": note.user.id,
|
||||
"username": note.user.username,
|
||||
"first_name": note.user.first_name,
|
||||
"last_name": note.user.last_name,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
def test_create_comment(self):
|
||||
def test_create_note(self):
|
||||
"""
|
||||
GIVEN:
|
||||
- Existing document
|
||||
WHEN:
|
||||
- API request is made to add a comment
|
||||
- API request is made to add a note
|
||||
THEN:
|
||||
- Comment is created and associated with document
|
||||
- note is created and associated with document
|
||||
"""
|
||||
doc = Document.objects.create(
|
||||
title="test",
|
||||
mime_type="application/pdf",
|
||||
content="this is a document which will have comments added",
|
||||
content="this is a document which will have notes added",
|
||||
)
|
||||
resp = self.client.post(
|
||||
f"/api/documents/{doc.pk}/comments/",
|
||||
data={"comment": "this is a posted comment"},
|
||||
f"/api/documents/{doc.pk}/notes/",
|
||||
data={"note": "this is a posted note"},
|
||||
)
|
||||
self.assertEqual(resp.status_code, status.HTTP_200_OK)
|
||||
|
||||
response = self.client.get(
|
||||
f"/api/documents/{doc.pk}/comments/",
|
||||
f"/api/documents/{doc.pk}/notes/",
|
||||
format="json",
|
||||
)
|
||||
|
||||
@@ -1798,48 +1824,48 @@ class TestDocumentApi(DirectoriesMixin, APITestCase):
|
||||
|
||||
resp_data = resp_data[0]
|
||||
|
||||
self.assertEqual(resp_data["comment"], "this is a posted comment")
|
||||
self.assertEqual(resp_data["note"], "this is a posted note")
|
||||
|
||||
def test_delete_comment(self):
|
||||
def test_delete_note(self):
|
||||
"""
|
||||
GIVEN:
|
||||
- Existing document
|
||||
WHEN:
|
||||
- API request is made to add a comment
|
||||
- API request is made to add a note
|
||||
THEN:
|
||||
- Comment is created and associated with document
|
||||
- note is created and associated with document
|
||||
"""
|
||||
doc = Document.objects.create(
|
||||
title="test",
|
||||
mime_type="application/pdf",
|
||||
content="this is a document which will have comments!",
|
||||
content="this is a document which will have notes!",
|
||||
)
|
||||
comment = Comment.objects.create(
|
||||
comment="This is a comment.",
|
||||
note = Note.objects.create(
|
||||
note="This is a note.",
|
||||
document=doc,
|
||||
user=self.user,
|
||||
)
|
||||
|
||||
response = self.client.delete(
|
||||
f"/api/documents/{doc.pk}/comments/?id={comment.pk}",
|
||||
f"/api/documents/{doc.pk}/notes/?id={note.pk}",
|
||||
format="json",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
|
||||
self.assertEqual(len(Comment.objects.all()), 0)
|
||||
self.assertEqual(len(Note.objects.all()), 0)
|
||||
|
||||
def test_get_comments_no_doc(self):
|
||||
def test_get_notes_no_doc(self):
|
||||
"""
|
||||
GIVEN:
|
||||
- A request to get comments from a non-existent document
|
||||
- A request to get notes from a non-existent document
|
||||
WHEN:
|
||||
- API request for document comments is made
|
||||
- API request for document notes is made
|
||||
THEN:
|
||||
- HTTP status.HTTP_404_NOT_FOUND is returned
|
||||
"""
|
||||
response = self.client.get(
|
||||
"/api/documents/500/comments/",
|
||||
"/api/documents/500/notes/",
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
|
||||
|
@@ -13,10 +13,10 @@ from django.test import override_settings
|
||||
from django.test import TestCase
|
||||
from django.utils import timezone
|
||||
from documents.management.commands import document_exporter
|
||||
from documents.models import Comment
|
||||
from documents.models import Correspondent
|
||||
from documents.models import Document
|
||||
from documents.models import DocumentType
|
||||
from documents.models import Note
|
||||
from documents.models import StoragePath
|
||||
from documents.models import Tag
|
||||
from documents.models import User
|
||||
@@ -66,8 +66,8 @@ class TestExportImport(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
|
||||
storage_type=Document.STORAGE_TYPE_GPG,
|
||||
)
|
||||
|
||||
self.comment = Comment.objects.create(
|
||||
comment="This is a comment. amaze.",
|
||||
self.note = Note.objects.create(
|
||||
note="This is a note. amaze.",
|
||||
document=self.d1,
|
||||
user=self.user,
|
||||
)
|
||||
@@ -199,8 +199,8 @@ class TestExportImport(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
|
||||
checksum = hashlib.md5(f.read()).hexdigest()
|
||||
self.assertEqual(checksum, element["fields"]["archive_checksum"])
|
||||
|
||||
elif element["model"] == "documents.comment":
|
||||
self.assertEqual(element["fields"]["comment"], self.comment.comment)
|
||||
elif element["model"] == "documents.note":
|
||||
self.assertEqual(element["fields"]["note"], self.note.note)
|
||||
self.assertEqual(element["fields"]["document"], self.d1.id)
|
||||
self.assertEqual(element["fields"]["user"], self.user.id)
|
||||
|
||||
|
@@ -20,7 +20,9 @@ from django.db.models import Case
|
||||
from django.db.models import Count
|
||||
from django.db.models import IntegerField
|
||||
from django.db.models import Max
|
||||
from django.db.models import Sum
|
||||
from django.db.models import When
|
||||
from django.db.models.functions import Length
|
||||
from django.db.models.functions import Lower
|
||||
from django.http import Http404
|
||||
from django.http import HttpResponse
|
||||
@@ -72,10 +74,10 @@ from .matching import match_correspondents
|
||||
from .matching import match_document_types
|
||||
from .matching import match_storage_paths
|
||||
from .matching import match_tags
|
||||
from .models import Comment
|
||||
from .models import Correspondent
|
||||
from .models import Document
|
||||
from .models import DocumentType
|
||||
from .models import Note
|
||||
from .models import PaperlessTask
|
||||
from .models import SavedView
|
||||
from .models import StoragePath
|
||||
@@ -186,6 +188,7 @@ class TagViewSet(ModelViewSet, PassUserMixin):
|
||||
)
|
||||
|
||||
def get_serializer_class(self, *args, **kwargs):
|
||||
print(self.request.version)
|
||||
if int(self.request.version) == 1:
|
||||
return TagSerializerVersion1
|
||||
else:
|
||||
@@ -230,7 +233,7 @@ class DocumentViewSet(
|
||||
GenericViewSet,
|
||||
):
|
||||
model = Document
|
||||
queryset = Document.objects.all()
|
||||
queryset = Document.objects.annotate(num_notes=Count("notes"))
|
||||
serializer_class = DocumentSerializer
|
||||
pagination_class = StandardPagination
|
||||
permission_classes = (IsAuthenticated, PaperlessObjectPermissions)
|
||||
@@ -251,10 +254,11 @@ class DocumentViewSet(
|
||||
"modified",
|
||||
"added",
|
||||
"archive_serial_number",
|
||||
"num_notes",
|
||||
)
|
||||
|
||||
def get_queryset(self):
|
||||
return Document.objects.distinct()
|
||||
return Document.objects.distinct().annotate(num_notes=Count("notes"))
|
||||
|
||||
def get_serializer(self, *args, **kwargs):
|
||||
super().get_serializer(*args, **kwargs)
|
||||
@@ -441,11 +445,11 @@ class DocumentViewSet(
|
||||
except (FileNotFoundError, Document.DoesNotExist):
|
||||
raise Http404()
|
||||
|
||||
def getComments(self, doc):
|
||||
def getNotes(self, doc):
|
||||
return [
|
||||
{
|
||||
"id": c.id,
|
||||
"comment": c.comment,
|
||||
"note": c.note,
|
||||
"created": c.created,
|
||||
"user": {
|
||||
"id": c.user.id,
|
||||
@@ -454,11 +458,11 @@ class DocumentViewSet(
|
||||
"last_name": c.user.last_name,
|
||||
},
|
||||
}
|
||||
for c in Comment.objects.filter(document=doc).order_by("-created")
|
||||
for c in Note.objects.filter(document=doc).order_by("-created")
|
||||
]
|
||||
|
||||
@action(methods=["get", "post", "delete"], detail=True)
|
||||
def comments(self, request, pk=None):
|
||||
def notes(self, request, pk=None):
|
||||
try:
|
||||
doc = Document.objects.get(pk=pk)
|
||||
except Document.DoesNotExist:
|
||||
@@ -468,17 +472,17 @@ class DocumentViewSet(
|
||||
|
||||
if request.method == "GET":
|
||||
try:
|
||||
return Response(self.getComments(doc))
|
||||
return Response(self.getNotes(doc))
|
||||
except Exception as e:
|
||||
logger.warning(f"An error occurred retrieving comments: {str(e)}")
|
||||
logger.warning(f"An error occurred retrieving notes: {str(e)}")
|
||||
return Response(
|
||||
{"error": "Error retreiving comments, check logs for more detail."},
|
||||
{"error": "Error retreiving notes, check logs for more detail."},
|
||||
)
|
||||
elif request.method == "POST":
|
||||
try:
|
||||
c = Comment.objects.create(
|
||||
c = Note.objects.create(
|
||||
document=doc,
|
||||
comment=request.data["comment"],
|
||||
note=request.data["note"],
|
||||
user=currentUser,
|
||||
)
|
||||
c.save()
|
||||
@@ -487,23 +491,23 @@ class DocumentViewSet(
|
||||
|
||||
index.add_or_update_document(self.get_object())
|
||||
|
||||
return Response(self.getComments(doc))
|
||||
return Response(self.getNotes(doc))
|
||||
except Exception as e:
|
||||
logger.warning(f"An error occurred saving comment: {str(e)}")
|
||||
logger.warning(f"An error occurred saving note: {str(e)}")
|
||||
return Response(
|
||||
{
|
||||
"error": "Error saving comment, check logs for more detail.",
|
||||
"error": "Error saving note, check logs for more detail.",
|
||||
},
|
||||
)
|
||||
elif request.method == "DELETE":
|
||||
comment = Comment.objects.get(id=int(request.GET.get("id")))
|
||||
comment.delete()
|
||||
note = Note.objects.get(id=int(request.GET.get("id")))
|
||||
note.delete()
|
||||
|
||||
from documents import index
|
||||
|
||||
index.add_or_update_document(self.get_object())
|
||||
|
||||
return Response(self.getComments(doc))
|
||||
return Response(self.getNotes(doc))
|
||||
|
||||
return Response(
|
||||
{
|
||||
@@ -515,14 +519,14 @@ class DocumentViewSet(
|
||||
class SearchResultSerializer(DocumentSerializer, PassUserMixin):
|
||||
def to_representation(self, instance):
|
||||
doc = Document.objects.get(id=instance["id"])
|
||||
comments = ",".join(
|
||||
[str(c.comment) for c in Comment.objects.filter(document=instance["id"])],
|
||||
notes = ",".join(
|
||||
[str(c.note) for c in Note.objects.filter(document=instance["id"])],
|
||||
)
|
||||
r = super().to_representation(doc)
|
||||
r["__search_hit__"] = {
|
||||
"score": instance.score,
|
||||
"highlights": instance.highlights("content", text=doc.content),
|
||||
"comment_highlights": instance.highlights("comments", text=comments)
|
||||
"note_highlights": instance.highlights("notes", text=notes)
|
||||
if doc
|
||||
else None,
|
||||
"rank": instance.rank,
|
||||
@@ -794,17 +798,38 @@ class StatisticsView(APIView):
|
||||
|
||||
def get(self, request, format=None):
|
||||
documents_total = Document.objects.all().count()
|
||||
if Tag.objects.filter(is_inbox_tag=True).exists():
|
||||
documents_inbox = (
|
||||
Document.objects.filter(tags__is_inbox_tag=True).distinct().count()
|
||||
|
||||
inbox_tag = Tag.objects.filter(is_inbox_tag=True)
|
||||
|
||||
documents_inbox = (
|
||||
Document.objects.filter(tags__is_inbox_tag=True).distinct().count()
|
||||
if inbox_tag.exists()
|
||||
else None
|
||||
)
|
||||
|
||||
document_file_type_counts = (
|
||||
Document.objects.values("mime_type")
|
||||
.annotate(mime_type_count=Count("mime_type"))
|
||||
.order_by("-mime_type_count")
|
||||
if documents_total > 0
|
||||
else 0
|
||||
)
|
||||
|
||||
character_count = (
|
||||
Document.objects.annotate(
|
||||
characters=Length("content"),
|
||||
)
|
||||
else:
|
||||
documents_inbox = None
|
||||
.aggregate(Sum("characters"))
|
||||
.get("characters__sum")
|
||||
)
|
||||
|
||||
return Response(
|
||||
{
|
||||
"documents_total": documents_total,
|
||||
"documents_inbox": documents_inbox,
|
||||
"inbox_tag": inbox_tag.first().pk if inbox_tag.exists() else None,
|
||||
"document_file_type_counts": document_file_type_counts,
|
||||
"character_count": character_count,
|
||||
},
|
||||
)
|
||||
|
||||
|
Reference in New Issue
Block a user