From 72df20703946a4496b77c55912df8f69b964cfc7 Mon Sep 17 00:00:00 2001
From: Erwan Rouchet <rouchet@teklia.com>
Date: Tue, 11 Feb 2025 08:57:03 +0000
Subject: [PATCH] Budget models

---
 arkindex/budget/__init__.py                   |  0
 arkindex/budget/apps.py                       |  5 +++
 arkindex/budget/migrations/0001_initial.py    | 44 +++++++++++++++++++
 arkindex/budget/migrations/__init__.py        |  0
 arkindex/budget/models.py                     | 44 +++++++++++++++++++
 .../migrations/0014_corpus_budget.py          | 20 +++++++++
 arkindex/documents/models.py                  |  8 ++++
 arkindex/documents/serializers/elements.py    |  4 ++
 arkindex/documents/tests/test_corpus.py       | 10 +++++
 .../process/tests/process/test_destroy.py     |  6 +--
 arkindex/project/settings.py                  |  1 +
 arkindex/sql_validation/corpus_delete.sql     |  3 +-
 .../corpus_delete_top_level_type.sql          |  3 +-
 .../sql_validation/corpus_rights_filter.sql   |  1 +
 .../corpus_rights_filter_public.sql           |  2 +
 arkindex/sql_validation/list_elements.sql     |  3 +-
 .../process_elements_filter_ml_class.sql      |  3 +-
 .../process_elements_filter_type.sql          |  3 +-
 .../process_elements_top_level.sql            |  3 +-
 .../process_elements_with_image.sql           |  3 +-
 20 files changed, 156 insertions(+), 10 deletions(-)
 create mode 100644 arkindex/budget/__init__.py
 create mode 100644 arkindex/budget/apps.py
 create mode 100644 arkindex/budget/migrations/0001_initial.py
 create mode 100644 arkindex/budget/migrations/__init__.py
 create mode 100644 arkindex/budget/models.py
 create mode 100644 arkindex/documents/migrations/0014_corpus_budget.py

diff --git a/arkindex/budget/__init__.py b/arkindex/budget/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/arkindex/budget/apps.py b/arkindex/budget/apps.py
new file mode 100644
index 0000000000..d24215cc34
--- /dev/null
+++ b/arkindex/budget/apps.py
@@ -0,0 +1,5 @@
+from django.apps import AppConfig
+
+
+class BudgetConfig(AppConfig):
+    name = "arkindex.budget"
diff --git a/arkindex/budget/migrations/0001_initial.py b/arkindex/budget/migrations/0001_initial.py
new file mode 100644
index 0000000000..67ebfb8b71
--- /dev/null
+++ b/arkindex/budget/migrations/0001_initial.py
@@ -0,0 +1,44 @@
+# Generated by Django 5.0.8 on 2025-02-10 09:37
+
+import uuid
+
+import django.core.validators
+import django.db.models.deletion
+from django.conf import settings
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    initial = True
+
+    dependencies = [
+        ("process", "0048_worker_cost_fields"),
+        migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name="Budget",
+            fields=[
+                ("id", models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
+                ("name", models.CharField(max_length=100, validators=[django.core.validators.MinLengthValidator(1)])),
+                ("total", models.DecimalField(decimal_places=3, default=0, max_digits=12)),
+            ],
+        ),
+        migrations.CreateModel(
+            name="BudgetEntry",
+            fields=[
+                ("id", models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
+                ("created", models.DateTimeField(auto_now_add=True)),
+                ("description", models.CharField(max_length=255, validators=[django.core.validators.MinLengthValidator(1)])),
+                ("value", models.DecimalField(decimal_places=3, max_digits=12)),
+                ("budget", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="entries", to="budget.budget")),
+                ("process", models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="budget_entries", to="process.process")),
+                ("user", models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="budget_entries", to=settings.AUTH_USER_MODEL)),
+            ],
+            options={
+                "verbose_name_plural": "budget entries",
+            },
+        ),
+    ]
diff --git a/arkindex/budget/migrations/__init__.py b/arkindex/budget/migrations/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/arkindex/budget/models.py b/arkindex/budget/models.py
new file mode 100644
index 0000000000..c9567cb6ad
--- /dev/null
+++ b/arkindex/budget/models.py
@@ -0,0 +1,44 @@
+import uuid
+
+from django.conf import settings
+from django.core.validators import MinLengthValidator
+from django.db import models
+
+
+class Budget(models.Model):
+    id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
+    name = models.CharField(max_length=100, validators=[MinLengthValidator(1)])
+    total = models.DecimalField(max_digits=12, decimal_places=3, default=0)
+
+    def __str__(self) -> str:
+        return self.name
+
+
+class BudgetEntry(models.Model):
+    id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
+    created = models.DateTimeField(auto_now_add=True)
+
+    budget = models.ForeignKey(Budget, on_delete=models.CASCADE, related_name="entries")
+    description = models.CharField(max_length=255, validators=[MinLengthValidator(1)])
+    value = models.DecimalField(max_digits=12, decimal_places=3)
+
+    user = models.ForeignKey(
+        settings.AUTH_USER_MODEL,
+        blank=True,
+        null=True,
+        on_delete=models.SET_NULL,
+        related_name="budget_entries",
+    )
+    process = models.ForeignKey(
+        "process.Process",
+        blank=True,
+        null=True,
+        on_delete=models.SET_NULL,
+        related_name="budget_entries",
+    )
+
+    class Meta:
+        verbose_name_plural = "budget entries"
+
+    def __str__(self) -> str:
+        return self.description
diff --git a/arkindex/documents/migrations/0014_corpus_budget.py b/arkindex/documents/migrations/0014_corpus_budget.py
new file mode 100644
index 0000000000..026c25cfb4
--- /dev/null
+++ b/arkindex/documents/migrations/0014_corpus_budget.py
@@ -0,0 +1,20 @@
+# Generated by Django 5.0.8 on 2025-02-10 09:41
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ("budget", "0001_initial"),
+        ("documents", "0013_corpus_maximum_task_ttl"),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name="corpus",
+            name="budget",
+            field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="corpora", to="budget.budget"),
+        ),
+    ]
diff --git a/arkindex/documents/models.py b/arkindex/documents/models.py
index 6ea6ee7745..e21ef63b10 100644
--- a/arkindex/documents/models.py
+++ b/arkindex/documents/models.py
@@ -59,6 +59,14 @@ class Corpus(IndexableModel):
                   "When not set, the instance-wide maximum time-to-live will be used instead.",
     )
 
+    budget = models.ForeignKey(
+        "budget.Budget",
+        on_delete=models.SET_NULL,
+        related_name="corpora",
+        blank=True,
+        null=True,
+    )
+
     # Specific manager for ACL
     objects = CorpusManager()
 
diff --git a/arkindex/documents/serializers/elements.py b/arkindex/documents/serializers/elements.py
index ee9ec29171..528cd7b0d2 100644
--- a/arkindex/documents/serializers/elements.py
+++ b/arkindex/documents/serializers/elements.py
@@ -285,6 +285,7 @@ class CorpusSerializer(serializers.ModelSerializer):
             "authorized_users",
             "indexable",
             "maximum_task_ttl",
+            "budget_id",
         )
         extra_kwargs = {
             "public": {
@@ -293,6 +294,9 @@ class CorpusSerializer(serializers.ModelSerializer):
             },
             "indexable": {
                 "help_text": "Whether this corpus should be indexed in Solr and searchable using the `SearchCorpus` endpoint."
+            },
+            "budget_id": {
+                "read_only": True,
             }
         }
 
diff --git a/arkindex/documents/tests/test_corpus.py b/arkindex/documents/tests/test_corpus.py
index 805447a33f..3055b5e8e6 100644
--- a/arkindex/documents/tests/test_corpus.py
+++ b/arkindex/documents/tests/test_corpus.py
@@ -104,6 +104,7 @@ class TestCorpus(FixtureAPITestCase):
                     "authorized_users": 1,
                     "top_level_type": None,
                     "maximum_task_ttl": 3600,
+                    "budget_id": None,
                 }
             ]
         )
@@ -138,6 +139,7 @@ class TestCorpus(FixtureAPITestCase):
                     "authorized_users": 2,
                     "top_level_type": None,
                     "maximum_task_ttl": 3600,
+                    "budget_id": None,
                 },
                 {
                     "id": str(self.corpus_public.id),
@@ -150,6 +152,7 @@ class TestCorpus(FixtureAPITestCase):
                     "authorized_users": 1,
                     "top_level_type": None,
                     "maximum_task_ttl": 3600,
+                    "budget_id": None,
                 }
             ]
         )
@@ -184,6 +187,7 @@ class TestCorpus(FixtureAPITestCase):
                     "authorized_users": 2,
                     "top_level_type": None,
                     "maximum_task_ttl": 3600,
+                    "budget_id": None,
                 },
                 {
                     "id": str(self.corpus_hidden.id),
@@ -196,6 +200,7 @@ class TestCorpus(FixtureAPITestCase):
                     "authorized_users": 0,
                     "top_level_type": None,
                     "maximum_task_ttl": 3600,
+                    "budget_id": None,
                 },
                 {
                     "id": str(self.corpus_public.id),
@@ -208,6 +213,7 @@ class TestCorpus(FixtureAPITestCase):
                     "authorized_users": 1,
                     "top_level_type": None,
                     "maximum_task_ttl": 3600,
+                    "budget_id": None,
                 }
             ]
         )
@@ -356,6 +362,7 @@ class TestCorpus(FixtureAPITestCase):
             "authorized_users": 1,
             "top_level_type": None,
             "maximum_task_ttl": 3600,
+            "budget_id": None,
         })
 
     def test_retrieve(self):
@@ -375,6 +382,7 @@ class TestCorpus(FixtureAPITestCase):
             "authorized_users": 2,
             "top_level_type": None,
             "maximum_task_ttl": 3600,
+            "budget_id": None,
         })
 
     def test_retrieve_not_found(self):
@@ -405,6 +413,7 @@ class TestCorpus(FixtureAPITestCase):
             "authorized_users": 1,
             "top_level_type": None,
             "maximum_task_ttl": 3600,
+            "budget_id": None,
         })
 
     @expectedFailure
@@ -434,6 +443,7 @@ class TestCorpus(FixtureAPITestCase):
             "authorized_users": 2,
             "top_level_type": None,
             "maximum_task_ttl": 3600,
+            "budget_id": None,
         })
 
     def test_retrieve_maximum_task_ttl(self):
diff --git a/arkindex/process/tests/process/test_destroy.py b/arkindex/process/tests/process/test_destroy.py
index b87856e50c..ffa37334ae 100644
--- a/arkindex/process/tests/process/test_destroy.py
+++ b/arkindex/process/tests/process/test_destroy.py
@@ -148,7 +148,7 @@ class TestProcessDestroy(FixtureAPITestCase):
         """
         self.client.force_login(self.user)
 
-        with self.assertNumQueries(19):
+        with self.assertNumQueries(20):
             response = self.client.delete(reverse("api:process-details", kwargs={"pk": self.process.id}))
             self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT)
 
@@ -161,7 +161,7 @@ class TestProcessDestroy(FixtureAPITestCase):
         """
         self.client.force_login(self.superuser)
 
-        with self.assertNumQueries(19):
+        with self.assertNumQueries(20):
             response = self.client.delete(reverse("api:process-details", kwargs={"pk": self.process.id}))
             self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT)
 
@@ -211,7 +211,7 @@ class TestProcessDestroy(FixtureAPITestCase):
             state=WorkerActivityState.Processed,
         )
 
-        with self.assertNumQueries(20):
+        with self.assertNumQueries(21):
             response = self.client.delete(reverse("api:process-details", kwargs={"pk": self.process.id}))
             self.assertEqual(response.status_code, status.HTTP_202_ACCEPTED)
 
diff --git a/arkindex/project/settings.py b/arkindex/project/settings.py
index b9fbcad8c7..fee1ef5ac2 100644
--- a/arkindex/project/settings.py
+++ b/arkindex/project/settings.py
@@ -127,6 +127,7 @@ INSTALLED_APPS = [
     "arkindex.users",
     "arkindex.process",
     "arkindex.training",
+    "arkindex.budget",
 ]
 
 MIDDLEWARE = [
diff --git a/arkindex/sql_validation/corpus_delete.sql b/arkindex/sql_validation/corpus_delete.sql
index bf5f227130..f789e6f8c7 100644
--- a/arkindex/sql_validation/corpus_delete.sql
+++ b/arkindex/sql_validation/corpus_delete.sql
@@ -6,7 +6,8 @@ SELECT "documents_corpus"."created",
        "documents_corpus"."top_level_type_id",
        "documents_corpus"."public",
        "documents_corpus"."indexable",
-       "documents_corpus"."maximum_task_ttl"
+       "documents_corpus"."maximum_task_ttl",
+       "documents_corpus"."budget_id"
 FROM "documents_corpus"
 WHERE "documents_corpus"."id" = '{corpus_id}'::uuid
 LIMIT 21;
diff --git a/arkindex/sql_validation/corpus_delete_top_level_type.sql b/arkindex/sql_validation/corpus_delete_top_level_type.sql
index dbbe418e73..d3ccdb0d2d 100644
--- a/arkindex/sql_validation/corpus_delete_top_level_type.sql
+++ b/arkindex/sql_validation/corpus_delete_top_level_type.sql
@@ -6,7 +6,8 @@ SELECT "documents_corpus"."created",
        "documents_corpus"."top_level_type_id",
        "documents_corpus"."public",
        "documents_corpus"."indexable",
-       "documents_corpus"."maximum_task_ttl"
+       "documents_corpus"."maximum_task_ttl",
+       "documents_corpus"."budget_id"
 FROM "documents_corpus"
 WHERE "documents_corpus"."id" = '{corpus_id}'::uuid
 LIMIT 21;
diff --git a/arkindex/sql_validation/corpus_rights_filter.sql b/arkindex/sql_validation/corpus_rights_filter.sql
index 6456c7da35..f914e4f909 100644
--- a/arkindex/sql_validation/corpus_rights_filter.sql
+++ b/arkindex/sql_validation/corpus_rights_filter.sql
@@ -23,6 +23,7 @@ SELECT "documents_corpus"."created",
        "documents_corpus"."public",
        "documents_corpus"."indexable",
        "documents_corpus"."maximum_task_ttl",
+       "documents_corpus"."budget_id",
        LEAST("users_right"."level", T5."level") AS "max_level"
 FROM "documents_corpus"
 INNER JOIN "users_right" ON ("documents_corpus"."id" = "users_right"."content_id"
diff --git a/arkindex/sql_validation/corpus_rights_filter_public.sql b/arkindex/sql_validation/corpus_rights_filter_public.sql
index 6042778029..d46840b4d4 100644
--- a/arkindex/sql_validation/corpus_rights_filter_public.sql
+++ b/arkindex/sql_validation/corpus_rights_filter_public.sql
@@ -24,6 +24,7 @@ LIMIT 21;
             "documents_corpus"."public",
             "documents_corpus"."indexable",
             "documents_corpus"."maximum_task_ttl",
+            "documents_corpus"."budget_id",
             LEAST("users_right"."level", T5."level") AS "max_level"
      FROM "documents_corpus"
      INNER JOIN "users_right" ON ("documents_corpus"."id" = "users_right"."content_id"
@@ -44,6 +45,7 @@ UNION
             "documents_corpus"."public",
             "documents_corpus"."indexable",
             "documents_corpus"."maximum_task_ttl",
+            "documents_corpus"."budget_id",
             10 AS "max_level"
      FROM "documents_corpus"
      WHERE "documents_corpus"."public")
diff --git a/arkindex/sql_validation/list_elements.sql b/arkindex/sql_validation/list_elements.sql
index 98e3284cfa..2cca682e6a 100644
--- a/arkindex/sql_validation/list_elements.sql
+++ b/arkindex/sql_validation/list_elements.sql
@@ -6,7 +6,8 @@ SELECT "documents_corpus"."created",
        "documents_corpus"."top_level_type_id",
        "documents_corpus"."public",
        "documents_corpus"."indexable",
-       "documents_corpus"."maximum_task_ttl"
+       "documents_corpus"."maximum_task_ttl",
+       "documents_corpus"."budget_id"
 FROM "documents_corpus"
 WHERE "documents_corpus"."id" = '{corpus_id}'::uuid
 LIMIT 21;
diff --git a/arkindex/sql_validation/process_elements_filter_ml_class.sql b/arkindex/sql_validation/process_elements_filter_ml_class.sql
index fb1e9d4389..bc15a46955 100644
--- a/arkindex/sql_validation/process_elements_filter_ml_class.sql
+++ b/arkindex/sql_validation/process_elements_filter_ml_class.sql
@@ -52,7 +52,8 @@ SELECT "documents_corpus"."created",
        "documents_corpus"."top_level_type_id",
        "documents_corpus"."public",
        "documents_corpus"."indexable",
-       "documents_corpus"."maximum_task_ttl"
+       "documents_corpus"."maximum_task_ttl",
+       "documents_corpus"."budget_id"
 FROM "documents_corpus"
 WHERE "documents_corpus"."id" = '{corpus_id}'::uuid
 LIMIT 21;
diff --git a/arkindex/sql_validation/process_elements_filter_type.sql b/arkindex/sql_validation/process_elements_filter_type.sql
index a566bc4a78..931808c1ed 100644
--- a/arkindex/sql_validation/process_elements_filter_type.sql
+++ b/arkindex/sql_validation/process_elements_filter_type.sql
@@ -52,7 +52,8 @@ SELECT "documents_corpus"."created",
        "documents_corpus"."top_level_type_id",
        "documents_corpus"."public",
        "documents_corpus"."indexable",
-       "documents_corpus"."maximum_task_ttl"
+       "documents_corpus"."maximum_task_ttl",
+       "documents_corpus"."budget_id"
 FROM "documents_corpus"
 WHERE "documents_corpus"."id" = '{corpus_id}'::uuid
 LIMIT 21;
diff --git a/arkindex/sql_validation/process_elements_top_level.sql b/arkindex/sql_validation/process_elements_top_level.sql
index 77423e4582..d10220c4fe 100644
--- a/arkindex/sql_validation/process_elements_top_level.sql
+++ b/arkindex/sql_validation/process_elements_top_level.sql
@@ -52,7 +52,8 @@ SELECT "documents_corpus"."created",
        "documents_corpus"."top_level_type_id",
        "documents_corpus"."public",
        "documents_corpus"."indexable",
-       "documents_corpus"."maximum_task_ttl"
+       "documents_corpus"."maximum_task_ttl",
+       "documents_corpus"."budget_id"
 FROM "documents_corpus"
 WHERE "documents_corpus"."id" = '{corpus_id}'::uuid
 LIMIT 21;
diff --git a/arkindex/sql_validation/process_elements_with_image.sql b/arkindex/sql_validation/process_elements_with_image.sql
index 2b6d311781..c2197f8318 100644
--- a/arkindex/sql_validation/process_elements_with_image.sql
+++ b/arkindex/sql_validation/process_elements_with_image.sql
@@ -52,7 +52,8 @@ SELECT "documents_corpus"."created",
        "documents_corpus"."top_level_type_id",
        "documents_corpus"."public",
        "documents_corpus"."indexable",
-       "documents_corpus"."maximum_task_ttl"
+       "documents_corpus"."maximum_task_ttl",
+       "documents_corpus"."budget_id"
 FROM "documents_corpus"
 WHERE "documents_corpus"."id" = '{corpus_id}'::uuid
 LIMIT 21;
-- 
GitLab