Merge remote-tracking branch 'origin/dev' into feature-multiple-barcode-scanners

This commit is contained in:
Trenton H
2023-03-29 09:40:10 -07:00
104 changed files with 2615 additions and 1098 deletions

View File

@@ -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)

View File

@@ -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()

View File

@@ -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()))

View File

@@ -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}")

View File

@@ -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))

View File

@@ -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",
),
),
]

View 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",
),
),
]

View File

@@ -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

View File

@@ -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",
)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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,
},
)