From f7e08b635fbddbfe0be893d40f8acf22128663f7 Mon Sep 17 00:00:00 2001
From: Julian Dehm <j.dehm@liqd.net>
Date: Mon, 11 Dec 2023 18:28:01 +0100
Subject: [PATCH] projects: add docs and test for m2m topics

---
 adhocracy4/projects/models.py         | 10 +++++
 adhocracy4/test/factories/__init__.py | 31 +++++++++++++++
 changelog/7885.md                     |  5 +++
 docs/topic_enums.md                   | 57 +++++++++++++++++++++++++++
 tests/conftest.py                     |  1 +
 tests/projects/test_project_models.py | 14 +++++++
 6 files changed, 118 insertions(+)
 create mode 100644 changelog/7885.md
 create mode 100644 docs/topic_enums.md

diff --git a/adhocracy4/projects/models.py b/adhocracy4/projects/models.py
index fa2b3c6fc..f4ca8d00a 100644
--- a/adhocracy4/projects/models.py
+++ b/adhocracy4/projects/models.py
@@ -25,9 +25,19 @@
 
 
 class Topic(models.Model):
+    """
+    A class that provides topics to Project class through an m2m field.
+    Topic objects are created from a TopicEnum class,
+    thus the TopicEnum.name is saved as the Topic.code field.
+    """
+
     code = models.CharField(blank=True, max_length=10, unique=True)
 
     def __str__(self):
+        """
+        Returns the descriptive and translatable TopicEnum label.
+        """
+
         if hasattr(settings, "A4_PROJECT_TOPICS"):
             topics_enum = import_string(settings.A4_PROJECT_TOPICS)
             return str(topics_enum(self.code).label)
diff --git a/adhocracy4/test/factories/__init__.py b/adhocracy4/test/factories/__init__.py
index f53746cb1..5c7c3b810 100644
--- a/adhocracy4/test/factories/__init__.py
+++ b/adhocracy4/test/factories/__init__.py
@@ -13,6 +13,8 @@
 from adhocracy4.phases.models import Phase
 from adhocracy4.projects.enums import Access
 from adhocracy4.projects.models import Project
+from adhocracy4.projects.models import Topic
+from tests.project.enums import TopicEnum
 
 
 class GroupFactory(factory.django.DjangoModelFactory):
@@ -86,6 +88,35 @@ def moderators(self, create, extracted, **kwargs):
             for user in extracted:
                 self.moderators.add(user)
 
+    @factory.post_generation
+    def topics(self, create, extracted, **kwargs):
+        if not create:
+            return
+        if extracted:
+            for topic in extracted:
+                self.topics.add(topic)
+
+
+class TopicFactory(factory.django.DjangoModelFactory):
+    """Create Topics from the TopicEnum class
+
+    Note: This factory can only create len(TopicEnum) topics because of the unique
+    constraint of the Topic model
+    """
+
+    class Meta:
+        model = Topic
+
+    code = factory.Sequence(lambda n: TopicEnum.names[n])
+
+    @factory.post_generation
+    def projects(self, create, extracted, **kwargs):
+        if not create:
+            return
+        if extracted:
+            for project in extracted:
+                self.project_set.add(project)
+
 
 class ModuleFactory(factory.django.DjangoModelFactory):
     class Meta:
diff --git a/changelog/7885.md b/changelog/7885.md
new file mode 100644
index 000000000..bd7ab35a5
--- /dev/null
+++ b/changelog/7885.md
@@ -0,0 +1,5 @@
+### Removed
+
+- requirements: django-multiselectfield.  
+  Project.topics are now m2m with Topic, as the django-multiselectfield lib has not been supported.  
+  see the [docs](https://github.com/liqd/adhocracy4/blob/main/docs/topic_enums.md) for more info.
diff --git a/docs/topic_enums.md b/docs/topic_enums.md
new file mode 100644
index 000000000..2ecf75aa3
--- /dev/null
+++ b/docs/topic_enums.md
@@ -0,0 +1,57 @@
+# A4 Project Topics
+
+## Introduction
+Each project has the option to choose up to two topics relevant to the project's context. For the choises of topics we used to work with a list of tuples defined in the settings directly, and the django-multiselectfield library, but we needed to replace this library as it is no longer maintained. So a `Project` object now has a many-to-many relation with `topics`, and `Topic` objects are created based on the `TopicEnum` class. Enums are also more flexible to work with, as we can call the enum values, label and code properties depending on where and how these are called in the code.
+
+## Configuration
+The topics should be defined in the settings as a string path to an Enum class (TopicEnum):
+```
+A4_PROJECT_TOPICS = "tests.project.enums.TopicEnum"
+```
+[ref: tests/project/settings](https://github.com/liqd/adhocracy4/blob/main/tests/project/settings.py)
+
+## Development
+
+`Project` property `topic_names` has now changed to accommodate the new TopicEnum class, mostly for html template use.  
+[ref: Topic/Project/topic_names](https://github.com/liqd/adhocracy4/blob/main/adhocracy4/projects/models.py)
+
+## Migration
+If a django application which is based on `adhocracy4` has projects with existing topics and using the django-multiselectfield, those will be added to a many-to-many topics field after updating `adhocracy4` and run the migrations. Your django application would need to define a TopicEnum and define its path as a string in the settings like the example in the configuration section above. The previous tuples for topic choices that used to be, for example, in the form of:
+```
+A4_PROJECT_TOPICS = (
+    ("ANT", _("Anti-discrimination")),
+    ("WOR", _("Work & economy")),
+    ("BUI", _("Building & living")),
+)
+```
+will now need to move to an Enum class in your django application, or also possible to move to django's `models.TextChoices`, which is a subclass of Enum and allows for applying dynamic translations for the labels with gettext `_` as in the following example:
+```
+class TopicEnum(models.TextChoices):
+    """Choices for project topics."""
+
+    ANT = "ANT", _("Anti-discrimination"),
+    WOR = "WOR", _("Work & economy"),
+    BUI = "BUI", _("Building & living")
+```
+
+Once this is done, your django application needs to make these enums as `Topic` objects with a custom migration. Generate an empty migration with `python manage.py makemigrations <app_name> --empty`. Edit the new migration. Here some boilerplate code for such a migration:
+```
+from my_application.apps.projects.enums import TopicEnum
+
+
+def add_topics(apps, schema_editor):
+    if hasattr(settings, "A4_PROJECT_TOPICS"):
+        Topic = apps.get_model("a4projects", "Topic")
+        for topic in TopicEnum:
+            Topic.objects.get_or_create(code=topic)
+
+
+class Migration(migrations.Migration):
+    dependencies = [
+        ("app_label", "xxxx_previous_migration"),
+    ]
+
+    operations = [
+        migrations.RunPython(add_topics, migrations.RunPython.noop),
+    ]
+```
diff --git a/tests/conftest.py b/tests/conftest.py
index e8edffee0..14bfa5188 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -35,6 +35,7 @@ def image_factory():
 register(factories.UserFactory, "another_user")
 register(factories.UserFactory, "staff_user", is_staff=True)
 register(factories.ProjectFactory)
+register(factories.TopicFactory)
 register(factories.ModuleFactory)
 register(factories.PhaseFactory)
 register(q_factories.QuestionFactory)
diff --git a/tests/projects/test_project_models.py b/tests/projects/test_project_models.py
index 2a6c19bd3..8ecb7adfb 100644
--- a/tests/projects/test_project_models.py
+++ b/tests/projects/test_project_models.py
@@ -145,3 +145,17 @@ def test_is_archivable(project_factory, phase_factory):
         assert not project1.is_archivable
         assert not project2.is_archivable
         assert project3.is_archivable
+
+
+@pytest.mark.django_db
+def test_project_topics(project_factory, topic_factory):
+    project = project_factory()
+    assert project.topic_names == []
+    # delete to clear the cache of the cached_property
+    del project.topic_names
+    topic1 = topic_factory(projects=[project])
+    assert project.topic_names == [str(topic1)]
+    # delete to clear the cache of the cached_property
+    del project.topic_names
+    topic2 = topic_factory(projects=[project])
+    assert project.topic_names == [str(topic1), str(topic2)]