diff --git a/Makefile b/Makefile index 795d61e94..1738dc9d6 100644 --- a/Makefile +++ b/Makefile @@ -45,11 +45,15 @@ lint: $(VIRTUAL_ENV)/bin/isort --diff -c $(SOURCE_DIRS) || EXIT_STATUS=$$?; \ $(VIRTUAL_ENV)/bin/flake8 $(SOURCE_DIRS) --exclude migrations,settings || EXIT_STATUS=$$?; \ npm run lint || EXIT_STATUS=$$?; \ + $(VIRTUAL_ENV)/bin/python manage.py makemigrations --dry-run --check --noinput || EXIT_STATUS=$$?; \ + exit $${EXIT_STATUS} .PHONY: lint-quick lint-quick: EXIT_STATUS=0; \ npm run lint-staged || EXIT_STATUS=$$?; \ + $(VIRTUAL_ENV)/bin/python manage.py makemigrations --dry-run --check --noinput || EXIT_STATUS=$$?; \ + exit $${EXIT_STATUS} .PHONY: lint-python-files lint-python-files: diff --git a/adhocracy4/projects/fields.py b/adhocracy4/projects/fields.py index e07a3523a..ff3b1beb3 100644 --- a/adhocracy4/projects/fields.py +++ b/adhocracy4/projects/fields.py @@ -1,8 +1,10 @@ -from django.conf import settings from multiselectfield import MultiSelectField class TopicField(MultiSelectField): + """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 @@ -11,11 +13,7 @@ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) def contribute_to_class(self, cls, name, **kwargs): - """Initialize the choices from the project's settings if they exist.""" - if hasattr(settings, "A4_PROJECT_TOPICS"): - self.choices = settings.A4_PROJECT_TOPICS - else: - self.choices = () + self.choices = () # Call the super method at last so that choices are already initialized super().contribute_to_class(cls, name, **kwargs) diff --git a/adhocracy4/projects/migrations/0042_topic_alter_project_topics_project_m2mtopics.py b/adhocracy4/projects/migrations/0042_topic_alter_project_topics_project_m2mtopics.py new file mode 100644 index 000000000..4a3ffad7e --- /dev/null +++ b/adhocracy4/projects/migrations/0042_topic_alter_project_topics_project_m2mtopics.py @@ -0,0 +1,33 @@ +# Generated by Django 4.2 on 2023-11-29 13:18 + +import adhocracy4.projects.fields +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("a4projects", "0041_ckeditor5_iframes"), + ] + + operations = [ + migrations.CreateModel( + name="Topic", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("code", models.CharField(blank=True, max_length=10, unique=True)), + ], + ), + migrations.AddField( + model_name="project", + name="m2mtopics", + field=models.ManyToManyField(to="a4projects.topic"), + ), + ] diff --git a/adhocracy4/projects/migrations/0043_migrate_topics_to_m2m_topics.py b/adhocracy4/projects/migrations/0043_migrate_topics_to_m2m_topics.py new file mode 100644 index 000000000..36c8a090c --- /dev/null +++ b/adhocracy4/projects/migrations/0043_migrate_topics_to_m2m_topics.py @@ -0,0 +1,40 @@ +# Generated by Django 4.2 on 2023-11-29 13:20 +import sys +import importlib + +from django.db import migrations +from django.conf import settings + + +def add_topics_to_m2m_table(apps, schema_editor): + if hasattr(settings, "A4_PROJECT_TOPICS"): + project = apps.get_model("a4projects", "Project") + topic = apps.get_model("a4projects", "Topic") + for project in project.objects.all(): + for topic_code in project.topics: + proj_topic, _ = topic.objects.get_or_create( + code=topic_code, + ) + project.m2mtopics.add(proj_topic) + else: + pass + + +def reverse_func(apps, schema_editor): + if hasattr(settings, "A4_PROJECT_TOPICS"): + project = apps.get_model("a4projects", "Project") + for project in project.objects.all(): + for topic in project.m2mtopics.all(): + project.m2mtopics.remove(topic) + else: + pass + + +class Migration(migrations.Migration): + dependencies = [ + ("a4projects", "0042_topic_alter_project_topics_project_m2mtopics"), + ] + + operations = [ + migrations.RunPython(add_topics_to_m2m_table, reverse_func), + ] diff --git a/adhocracy4/projects/migrations/0044_remove_project_field_topics.py b/adhocracy4/projects/migrations/0044_remove_project_field_topics.py new file mode 100644 index 000000000..c183a2d36 --- /dev/null +++ b/adhocracy4/projects/migrations/0044_remove_project_field_topics.py @@ -0,0 +1,16 @@ +# Generated by Django 4.2 on 2023-11-29 15:03 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("a4projects", "0043_migrate_topics_to_m2m_topics"), + ] + + operations = [ + migrations.RemoveField( + model_name="project", + name="topics", + ), + ] diff --git a/adhocracy4/projects/migrations/0045_rename_field_m2mtopics_to_topics.py b/adhocracy4/projects/migrations/0045_rename_field_m2mtopics_to_topics.py new file mode 100644 index 000000000..1ba59921b --- /dev/null +++ b/adhocracy4/projects/migrations/0045_rename_field_m2mtopics_to_topics.py @@ -0,0 +1,15 @@ +# Generated by Django 4.2 on 2023-11-29 15:14 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("a4projects", "0044_remove_project_field_topics"), + ] + + operations = [ + migrations.RenameField( + model_name="project", old_name="m2mtopics", new_name="topics" + ), + ] diff --git a/adhocracy4/projects/models.py b/adhocracy4/projects/models.py index f227c447c..fa2b3c6fc 100644 --- a/adhocracy4/projects/models.py +++ b/adhocracy4/projects/models.py @@ -8,6 +8,7 @@ from django.urls import reverse from django.utils import timezone from django.utils.functional import cached_property +from django.utils.module_loading import import_string from django.utils.translation import gettext_lazy as _ from django_ckeditor_5.fields import CKEditor5Field from django_enumfield.enum import EnumField @@ -19,11 +20,20 @@ from adhocracy4.models import base from .enums import Access -from .fields import TopicField from .utils import get_module_clusters from .utils import get_module_clusters_dict +class Topic(models.Model): + code = models.CharField(blank=True, max_length=10, unique=True) + + def __str__(self): + if hasattr(settings, "A4_PROJECT_TOPICS"): + topics_enum = import_string(settings.A4_PROJECT_TOPICS) + return str(topics_enum(self.code).label) + return self.code + + class ProjectManager(models.Manager): def get_by_natural_key(self, name): return self.get(name=name) @@ -259,9 +269,8 @@ class Project( "dashboard." ), ) - topics = TopicField( - verbose_name=_("Project topics"), help_text=_("Add topics to your project.") - ) + topics = models.ManyToManyField(Topic) + project_type = models.CharField( blank=True, max_length=256, default="a4projects.Project" ) @@ -321,8 +330,7 @@ def has_moderator(self, user): @cached_property def topic_names(self): if hasattr(settings, "A4_PROJECT_TOPICS"): - choices = dict(settings.A4_PROJECT_TOPICS) - return [choices[topic] for topic in self.topics] + return [str(topic) for topic in self.topics.all()] return [] @cached_property diff --git a/changelog/0007.md b/changelog/0007.md new file mode 100644 index 000000000..ebd389319 --- /dev/null +++ b/changelog/0007.md @@ -0,0 +1,6 @@ +# Changed + +- apps/projects: + add topics model/table + make topics a m2m relation to projects + stop making use of django-multiselectfield as it's not maintained diff --git a/changelog/0008.md b/changelog/0008.md new file mode 100644 index 000000000..440e3ef20 --- /dev/null +++ b/changelog/0008.md @@ -0,0 +1,3 @@ +### Changed + +- apps/projects: topics should be first created and then added to project in migration diff --git a/changelog/0009.md b/changelog/0009.md new file mode 100644 index 000000000..5f19a8e1c --- /dev/null +++ b/changelog/0009.md @@ -0,0 +1,3 @@ +### Fixed + +- apps/projects: project's topics_name property should iterate through m2m relation diff --git a/tests/project/enums.py b/tests/project/enums.py new file mode 100644 index 000000000..2f891b8bf --- /dev/null +++ b/tests/project/enums.py @@ -0,0 +1,22 @@ +# fmt: off + +from django.db import models +from django.utils.translation import gettext_lazy as _ + + +class TopicEnum(models.TextChoices): + """Choices for project topics.""" + + ANT = "ANT", _("Anti-discrimination"), + WOR = "WOR", _("Work & economy"), + BUI = "BUI", _("Building & living"), + EDU = "EDU", _("Education & research"), + CHI = "CHI", _("Children, youth & family"), + FIN = "FIN", _("Finances"), + HEA = "HEA", _("Health & sports"), + INT = "INT", _("Integration"), + CUL = "CUL", _("Culture & leisure"), + NEI = "NEI", _("Neighborhood & participation"), + URB = "URB", _("Urban development"), + ENV = "ENV", _("Environment & public green space"), + TRA = "TRA", _("Traffic") diff --git a/tests/project/settings.py b/tests/project/settings.py index ccd3dbe02..9d0da6ee1 100644 --- a/tests/project/settings.py +++ b/tests/project/settings.py @@ -226,11 +226,7 @@ }, } -A4_PROJECT_TOPICS = ( - ("ANT", "Anti-discrimination"), - ("WOR", "Work & economy"), - ("BUI", "Building & living"), -) +A4_PROJECT_TOPICS = "tests.project.enums.TopicEnum" A4_COMMENT_CATEGORIES = ( ("QUE", "Question"),