From c153000d3d3b6c6d360edaaba765bdd2895f1e6d Mon Sep 17 00:00:00 2001
From: "Edgar P. Burkhart" <git@edgarpierre.fr>
Date: Sun, 5 Jan 2025 16:01:26 +0100
Subject: [PATCH] Add invoice model with metadata and tags; update search and
 templates for invoice handling Progress #44

---
 nummi/main/utils.py                           |  9 ++++++
 .../templates/search/search_results.html      |  8 ++++-
 nummi/search/views.py                         |  1 +
 .../0005_invoice_metadata_invoice_tags.py     | 22 +++++++++++++
 nummi/transaction/models.py                   | 32 +++++++++++++++++++
 .../templates/transaction/invoice_table.html  | 17 ++++++----
 .../templatetags/transaction_extras.py        |  6 ++--
 pkgbuild/PKGBUILD                             |  1 +
 8 files changed, 87 insertions(+), 9 deletions(-)
 create mode 100644 nummi/transaction/migrations/0005_invoice_metadata_invoice_tags.py

diff --git a/nummi/main/utils.py b/nummi/main/utils.py
index 2a8bb28..d041dba 100644
--- a/nummi/main/utils.py
+++ b/nummi/main/utils.py
@@ -7,3 +7,12 @@ def get_icons():
     data = json.loads(request.urlopen(url).read())
 
     return [i.removesuffix("-line") for i in data.keys() if i.endswith("-line")]
+
+
+def pdf_outline_to_str(outline):
+    return " ".join(
+        (
+            dest.title if not isinstance(dest, list) else pdf_outline_to_str(dest)
+            for dest in outline
+        )
+    )
diff --git a/nummi/search/templates/search/search_results.html b/nummi/search/templates/search/search_results.html
index d19a974..197a668 100644
--- a/nummi/search/templates/search/search_results.html
+++ b/nummi/search/templates/search/search_results.html
@@ -41,7 +41,13 @@
       {% transaction_table transactions n_max=8 transactions_url=t_url %}
     </section>
   {% endif %}
-  {% if not accounts and not categories and not transactions %}
+  {% if invoices %}
+    <section>
+      <h3>{% translate "Invoices" %}</h3>
+      {% invoice_table invoices=invoices %}
+    </section>
+  {% endif %}
+  {% if not accounts and not categories and not transactions and not invoices %}
     <p>{% translate "No results found." %}</p>
   {% endif %}
 {% endblock body %}
diff --git a/nummi/search/views.py b/nummi/search/views.py
index 55abba1..5a64a04 100644
--- a/nummi/search/views.py
+++ b/nummi/search/views.py
@@ -26,5 +26,6 @@ class SearchView(LoginRequiredMixin, TemplateView):
         context["transactions"] = _user.transaction_set.search(self.kwargs["search"])
         context["accounts"] = _user.account_set.search(self.kwargs["search"])
         context["categories"] = _user.category_set.search(self.kwargs["search"])
+        context["invoices"] = _user.invoice_set.search(self.kwargs["search"])[:10]
 
         return context
diff --git a/nummi/transaction/migrations/0005_invoice_metadata_invoice_tags.py b/nummi/transaction/migrations/0005_invoice_metadata_invoice_tags.py
new file mode 100644
index 0000000..af684f5
--- /dev/null
+++ b/nummi/transaction/migrations/0005_invoice_metadata_invoice_tags.py
@@ -0,0 +1,22 @@
+# Generated by Django 4.2.7 on 2025-01-05 14:51
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+    dependencies = [
+        ("transaction", "0004_remove_transaction_account"),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name="invoice",
+            name="metadata",
+            field=models.TextField(blank=True),
+        ),
+        migrations.AddField(
+            model_name="invoice",
+            name="tags",
+            field=models.TextField(blank=True),
+        ),
+    ]
diff --git a/nummi/transaction/models.py b/nummi/transaction/models.py
index 317b39b..f50e4fa 100644
--- a/nummi/transaction/models.py
+++ b/nummi/transaction/models.py
@@ -8,7 +8,9 @@ from django.db import models
 from django.urls import reverse
 from django.utils.translation import gettext_lazy as _
 from main.models import NummiModel, NummiQuerySet
+from main.utils import pdf_outline_to_str
 from media.utils import get_path
+from pypdf import PdfReader
 from statement.models import Statement
 
 
@@ -74,6 +76,13 @@ class Transaction(NummiModel):
         verbose_name_plural = _("Transactions")
 
 
+class InvoiceQuerySet(NummiQuerySet):
+    fields = {
+        "metadata": "B",
+        "tags": "C",
+    }
+
+
 class Invoice(NummiModel):
     id = models.UUIDField(primary_key=True, default=uuid4, editable=False)
     name = models.CharField(
@@ -88,12 +97,35 @@ class Invoice(NummiModel):
     transaction = models.ForeignKey(
         Transaction, on_delete=models.CASCADE, editable=False
     )
+    metadata = models.TextField(blank=True)
+    tags = models.TextField(blank=True)
+
+    objects = InvoiceQuerySet.as_manager()
 
     def save(self, *args, **kwargs):
         if Invoice.objects.filter(id=self.id).exists():
             _prever = Invoice.objects.get(id=self.id)
             if _prever.file and _prever.file != self.file:
                 Path(_prever.file.path).unlink(missing_ok=True)
+
+        reader = PdfReader(self.file)
+
+        self.metadata = " ".join(
+            (
+                m
+                for m in (
+                    reader.metadata.title,
+                    reader.metadata.author,
+                    reader.metadata.subject,
+                )
+                if m
+            )
+        )
+
+        _tags = pdf_outline_to_str(reader.outline)
+        _tags += " ".join((page.extract_text() for page in reader.pages))
+        self.tags = " ".join((tag for tag in _tags.split() if len(tag) >= 3))
+
         super().save(*args, **kwargs)
 
     def __str__(self):
diff --git a/nummi/transaction/templates/transaction/invoice_table.html b/nummi/transaction/templates/transaction/invoice_table.html
index 7b5919b..07cd9c1 100644
--- a/nummi/transaction/templates/transaction/invoice_table.html
+++ b/nummi/transaction/templates/transaction/invoice_table.html
@@ -3,14 +3,19 @@
   <ul class="invoices">
     {% for invoice in invoices %}
       <li>
+        {% if not transaction %}<span>{{ invoice.transaction.name }}</span>{% endif %}
         <a class="title" href="{{ invoice.file.url }}">{{ "file"|remix }}{{ invoice.name }} [{{ invoice.file|extension }}]</a>
-        <a href="{{ invoice.get_absolute_url }}">{{ "file-edit"|remix }}{% translate "Edit" %}</a>
+        {% if transaction %}
+          <a href="{{ invoice.get_absolute_url }}">{{ "file-edit"|remix }}{% translate "Edit" %}</a>
+        {% endif %}
       </li>
     {% endfor %}
-    <li class="new">
-      <span>
-        <a href="{% url "new_invoice" transaction.pk %}">{{ "file-add"|remix }}{% translate "New invoice" %}</a>
-      </span>
-    </li>
+    {% if transaction %}
+      <li class="new">
+        <span>
+          <a href="{% url "new_invoice" transaction.pk %}">{{ "file-add"|remix }}{% translate "New invoice" %}</a>
+        </span>
+      </li>
+    {% endif %}
   </ul>
 </div>
diff --git a/nummi/transaction/templatetags/transaction_extras.py b/nummi/transaction/templatetags/transaction_extras.py
index e905a02..7e2c016 100644
--- a/nummi/transaction/templatetags/transaction_extras.py
+++ b/nummi/transaction/templatetags/transaction_extras.py
@@ -36,10 +36,12 @@ def transaction_filters(context, **kwargs):
 
 
 @register.inclusion_tag("transaction/invoice_table.html")
-def invoice_table(transaction, **kwargs):
+def invoice_table(transaction=None, **kwargs):
+    if transaction:
+        kwargs.setdefault("invoices", transaction.invoice_set.all())
+
     return kwargs | {
         "transaction": transaction,
-        "invoices": transaction.invoice_set.all(),
     }
 
 
diff --git a/pkgbuild/PKGBUILD b/pkgbuild/PKGBUILD
index 88e06a0..d960964 100644
--- a/pkgbuild/PKGBUILD
+++ b/pkgbuild/PKGBUILD
@@ -11,6 +11,7 @@ depends=(
 	"python-toml"
 	"python-psycopg"
 	"python-dateutil"
+	"python-pypdf"
 )
 makedepends=("git")
 optdepends=("postgresql: database")