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