Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[7885] replace multiselect with m2m relation for project topics #1515

Merged
merged 2 commits into from
Dec 12, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 2 additions & 16 deletions adhocracy4/projects/fields.py
Original file line number Diff line number Diff line change
@@ -1,19 +1,5 @@
from multiselectfield import MultiSelectField
from django.db import models


class TopicField(MultiSelectField):
class TopicField(models.CharField):
"""Deprecated, don't use"""

# TODO: remove once topic migrations are rolled out
def __init__(self, *args, **kwargs):
kwargs["max_length"] = 254
kwargs["max_choices"] = 2
kwargs["default"] = ""
kwargs["blank"] = False
super().__init__(*args, **kwargs)

def contribute_to_class(self, cls, name, **kwargs):
self.choices = ()

# Call the super method at last so that choices are already initialized
super().contribute_to_class(cls, name, **kwargs)
10 changes: 10 additions & 0 deletions adhocracy4/projects/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
31 changes: 31 additions & 0 deletions adhocracy4/test/factories/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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:
Expand Down
5 changes: 5 additions & 0 deletions changelog/7885.md
Original file line number Diff line number Diff line change
@@ -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.
57 changes: 57 additions & 0 deletions docs/topic_enums.md
Original file line number Diff line number Diff line change
@@ -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),
]
```
1 change: 0 additions & 1 deletion requirements/base.txt
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ https://github.com/liqd/django-ckeditor-5/releases/download/v0.2.11-liqd/django_
django-ckeditor==6.6.1
django-enumfield==3.1
django-filter==22.1
django-multiselectfield==0.1.12
django-widget-tweaks==1.4.12
djangorestframework==3.14.0
easy-thumbnails[svg]==2.8.5
Expand Down
1 change: 1 addition & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
14 changes: 14 additions & 0 deletions tests/projects/test_project_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Loading