From 7619a7ff8868186587fbf89a1ce83790716d610f Mon Sep 17 00:00:00 2001 From: Mara Karagianni Date: Mon, 11 Nov 2024 19:38:32 +0100 Subject: [PATCH] apps: add kiezradar models --- changelog/8473.md | 6 + meinberlin/apps/kiezradar/__init__.py | 0 meinberlin/apps/kiezradar/admin.py | 3 + meinberlin/apps/kiezradar/api.py | 36 +++++ meinberlin/apps/kiezradar/apps.py | 6 + .../apps/kiezradar/migrations/0001_initial.py | 135 ++++++++++++++++++ .../apps/kiezradar/migrations/__init__.py | 0 meinberlin/apps/kiezradar/models.py | 85 +++++++++++ meinberlin/apps/kiezradar/serializers.py | 92 ++++++++++++ meinberlin/apps/kiezradar/tests.py | 3 + meinberlin/config/settings/base.py | 1 + meinberlin/config/urls.py | 2 + meinberlin/test/factories/kiezradar.py | 30 ++++ restore | 0 tests/kiezradar/conftest.py | 13 ++ tests/kiezradar/test_api_kiezradar.py | 80 +++++++++++ tests/kiezradar/test_search_profile.py | 53 +++++++ 17 files changed, 545 insertions(+) create mode 100644 changelog/8473.md create mode 100644 meinberlin/apps/kiezradar/__init__.py create mode 100644 meinberlin/apps/kiezradar/admin.py create mode 100644 meinberlin/apps/kiezradar/api.py create mode 100644 meinberlin/apps/kiezradar/apps.py create mode 100644 meinberlin/apps/kiezradar/migrations/0001_initial.py create mode 100644 meinberlin/apps/kiezradar/migrations/__init__.py create mode 100644 meinberlin/apps/kiezradar/models.py create mode 100644 meinberlin/apps/kiezradar/serializers.py create mode 100644 meinberlin/apps/kiezradar/tests.py create mode 100644 meinberlin/test/factories/kiezradar.py create mode 100644 restore create mode 100644 tests/kiezradar/conftest.py create mode 100644 tests/kiezradar/test_api_kiezradar.py create mode 100644 tests/kiezradar/test_search_profile.py diff --git a/changelog/8473.md b/changelog/8473.md new file mode 100644 index 0000000000..31d11e8b26 --- /dev/null +++ b/changelog/8473.md @@ -0,0 +1,6 @@ +### Added + +- kiezradar app +- SearchProfile model +- KiezRadarFilter model with a m2m relation with SearchProfile for multiselect topics +- custom signal to add filtering topics via the viewset create api to the searchprofile diff --git a/meinberlin/apps/kiezradar/__init__.py b/meinberlin/apps/kiezradar/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/meinberlin/apps/kiezradar/admin.py b/meinberlin/apps/kiezradar/admin.py new file mode 100644 index 0000000000..8c38f3f3da --- /dev/null +++ b/meinberlin/apps/kiezradar/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/meinberlin/apps/kiezradar/api.py b/meinberlin/apps/kiezradar/api.py new file mode 100644 index 0000000000..ce97f95823 --- /dev/null +++ b/meinberlin/apps/kiezradar/api.py @@ -0,0 +1,36 @@ +from rest_framework import viewsets +from rest_framework.permissions import IsAuthenticated + +from .models import SearchProfile +from .serializers import SearchProfileSerializer + + +class SearchProfileViewSet(viewsets.ModelViewSet): + """ + ViewSet for managing SearchProfile objects. + """ + + queryset = SearchProfile.objects.all() + serializer_class = SearchProfileSerializer + permission_classes = [IsAuthenticated] + + def perform_create(self, serializer): + """ + Override to handle saving of many-to-many relations. + """ + # Save the instance first to get a primary key. + search_profile = serializer.save(user=self.request.user) + + # Get the many-to-many data from the request. + queries_data = self.request.data.get("queries", []) + organisations_data = self.request.data.get("organisations", []) + districts_data = self.request.data.get("districts", []) + project_types_data = self.request.data.get("project_types", []) + topics_data = self.request.data.get("topics", []) + + # Add many-to-many relations. + search_profile.queries.set(queries_data) + search_profile.organisations.set(organisations_data) + search_profile.districts.set(districts_data) + search_profile.project_types.set(project_types_data) + search_profile.topics.set(topics_data) diff --git a/meinberlin/apps/kiezradar/apps.py b/meinberlin/apps/kiezradar/apps.py new file mode 100644 index 0000000000..fc0de7a61e --- /dev/null +++ b/meinberlin/apps/kiezradar/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class Config(AppConfig): + name = "meinberlin.apps.kiezradar" + label = "meinberlin_kiezradar" diff --git a/meinberlin/apps/kiezradar/migrations/0001_initial.py b/meinberlin/apps/kiezradar/migrations/0001_initial.py new file mode 100644 index 0000000000..1ac7ee0943 --- /dev/null +++ b/meinberlin/apps/kiezradar/migrations/0001_initial.py @@ -0,0 +1,135 @@ +# Generated by Django 4.2.11 on 2024-11-19 16:45 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.A4_ORGANISATIONS_MODEL), + ("a4administrative_districts", "0001_initial"), + ("a4projects", "0046_alter_project_information_alter_project_result"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="KiezradarQuery", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("text", models.CharField(max_length=256, unique=True)), + ], + ), + migrations.CreateModel( + name="ProjectType", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "participation", + models.SmallIntegerField( + choices=[ + (0, "information (no participation)"), + (1, "consultation"), + (2, "cooperation"), + (3, "decision-making"), + ], + verbose_name="Type", + ), + ), + ], + ), + migrations.CreateModel( + name="SearchProfile", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(max_length=255)), + ("description", models.TextField(blank=True, null=True)), + ("disabled", models.BooleanField(default=False)), + ( + "status", + models.SmallIntegerField( + choices=[(0, "running"), (1, "done")], verbose_name="Status" + ), + ), + ( + "districts", + models.ManyToManyField( + blank=True, + related_name="search_profiles", + to="a4administrative_districts.administrativedistrict", + ), + ), + ( + "organisations", + models.ManyToManyField( + blank=True, + related_name="search_profiles", + to=settings.A4_ORGANISATIONS_MODEL, + ), + ), + ( + "project_types", + models.ManyToManyField( + blank=True, + related_name="search_profiles", + to="meinberlin_kiezradar.projecttype", + ), + ), + ( + "queries", + models.ManyToManyField( + blank=True, + related_name="search_profiles", + to="meinberlin_kiezradar.kiezradarquery", + ), + ), + ( + "topics", + models.ManyToManyField( + blank=True, + related_name="search_profiles", + to="a4projects.topic", + ), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="search_profiles", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "ordering": ["name"], + }, + ), + ] diff --git a/meinberlin/apps/kiezradar/migrations/__init__.py b/meinberlin/apps/kiezradar/migrations/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/meinberlin/apps/kiezradar/models.py b/meinberlin/apps/kiezradar/models.py new file mode 100644 index 0000000000..cb981a451c --- /dev/null +++ b/meinberlin/apps/kiezradar/models.py @@ -0,0 +1,85 @@ +from django.conf import settings +from django.db import models +from django.utils.translation import gettext_lazy as _ + +from adhocracy4.administrative_districts.models import AdministrativeDistrict +from adhocracy4.projects.models import Topic + +Organisation = settings.A4_ORGANISATIONS_MODEL + + +class KiezradarQuery(models.Model): + text = models.CharField(max_length=256, unique=True) + + def __str__(self): + return f"kiezradar query - {self.text}" + + +class ProjectType(models.Model): + PARTICIPATION_INFORMATION = 0 + PARTICIPATION_CONSULTATION = 1 + PARTICIPATION_COOPERATION = 2 + PARTICIPATION_DECISION_MAKING = 3 + PARTICIPATION_CHOICES = ( + (PARTICIPATION_INFORMATION, _("information (no participation)")), + (PARTICIPATION_CONSULTATION, _("consultation")), + (PARTICIPATION_COOPERATION, _("cooperation")), + (PARTICIPATION_DECISION_MAKING, _("decision-making")), + ) + participation = models.SmallIntegerField( + choices=PARTICIPATION_CHOICES, + verbose_name=_("Type"), + ) + + def __str__(self): + return f"participation type - {self.participation}" + + +class SearchProfile(models.Model): + STATUS_ONGOING = 0 + STATUS_DONE = 1 + STATUS_CHOICES = ((STATUS_ONGOING, _("running")), (STATUS_DONE, _("done"))) + + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="search_profiles", + ) + name = models.CharField(max_length=255) + description = models.TextField(blank=True, null=True) + disabled = models.BooleanField(default=False) + status = models.SmallIntegerField( + choices=STATUS_CHOICES, + verbose_name=_("Status"), + ) + queries = models.ManyToManyField( + KiezradarQuery, + related_name="search_profiles", + blank=True, + ) + organisations = models.ManyToManyField( + Organisation, + related_name="search_profiles", + blank=True, + ) + districts = models.ManyToManyField( + AdministrativeDistrict, + related_name="search_profiles", + blank=True, + ) + project_types = models.ManyToManyField( + ProjectType, + related_name="search_profiles", + blank=True, + ) + topics = models.ManyToManyField( + Topic, + related_name="search_profiles", + blank=True, + ) + + class Meta: + ordering = ["name"] + + def __str__(self): + return f"kiezradar search profile - {self.name}, disabled {self.disabled}" diff --git a/meinberlin/apps/kiezradar/serializers.py b/meinberlin/apps/kiezradar/serializers.py new file mode 100644 index 0000000000..f124b7751f --- /dev/null +++ b/meinberlin/apps/kiezradar/serializers.py @@ -0,0 +1,92 @@ +from rest_framework import serializers + +from adhocracy4.administrative_districts.models import AdministrativeDistrict +from meinberlin.apps.kiezradar.models import KiezradarQuery +from meinberlin.apps.kiezradar.models import ProjectType +from meinberlin.apps.kiezradar.models import SearchProfile +from meinberlin.apps.organisations.models import Organisation +from meinberlin.apps.projects.serializers import TopicSerializer + + +class KiezradarQuerySerializer(serializers.ModelSerializer): + """Serializer for the KiezradarQuery model.""" + + class Meta: + model = KiezradarQuery + fields = ["id", "text"] + + +class ProjectTypeSerializer(serializers.ModelSerializer): + """Serializer for the ProjectType model.""" + + participation_display = serializers.CharField( + source="get_participation_display", read_only=True + ) + + class Meta: + model = ProjectType + fields = ["id", "participation", "participation_display"] + + +class SearchProfileSerializer(serializers.ModelSerializer): + """Serializer for the SearchProfile model.""" + + queries = KiezradarQuerySerializer(many=True) + organisations = serializers.PrimaryKeyRelatedField( + many=True, queryset=Organisation.objects.all() + ) + districts = serializers.PrimaryKeyRelatedField( + many=True, queryset=AdministrativeDistrict.objects.all() + ) + project_types = ProjectTypeSerializer(many=True) + topics = TopicSerializer(many=True) + status_display = serializers.CharField(source="get_status_display", read_only=True) + + class Meta: + model = SearchProfile + fields = [ + "id", + "user", + "name", + "description", + "disabled", + "status", + "status_display", + "queries", + "organisations", + "districts", + "project_types", + "topics", + ] + + read_only_fields = ["user"] + + def update(self, instance, validated_data): + """ + Custom update to handle many-to-many relationships. + """ + # Pop many-to-many fields from validated_data + queries_data = validated_data.pop("queries", None) + organisations_data = validated_data.pop("organisations", None) + districts_data = validated_data.pop("districts", None) + project_types_data = validated_data.pop("project_types", None) + topics_data = validated_data.pop("topics", None) + + # Update non-m2m fields + for attr, value in validated_data.items(): + setattr(instance, attr, value) + instance.save() + + # Update many-to-many relationships + if queries_data: + instance.queries.set(queries_data) + if organisations_data: + instance.organisations.set(organisations_data) + if districts_data: + instance.districts.set(districts_data) + if project_types_data: + instance.project_types.set(project_types_data) + if topics_data: + instance.topics.set(topics_data) + + return instance diff --git a/meinberlin/apps/kiezradar/tests.py b/meinberlin/apps/kiezradar/tests.py new file mode 100644 index 0000000000..7ce503c2dd --- /dev/null +++ b/meinberlin/apps/kiezradar/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/meinberlin/config/settings/base.py b/meinberlin/config/settings/base.py index 8519b5d1bb..465999ea1b 100644 --- a/meinberlin/config/settings/base.py +++ b/meinberlin/config/settings/base.py @@ -89,6 +89,7 @@ "meinberlin.apps.captcha", "meinberlin.apps.cms", "meinberlin.apps.contrib", + "meinberlin.apps.kiezradar", "meinberlin.apps.likes", "meinberlin.apps.livequestions", "meinberlin.apps.maps", diff --git a/meinberlin/config/urls.py b/meinberlin/config/urls.py index 195df962e0..dd177ca6fa 100644 --- a/meinberlin/config/urls.py +++ b/meinberlin/config/urls.py @@ -27,6 +27,7 @@ from meinberlin.apps.extprojects.api import ExternalProjectListViewSet from meinberlin.apps.ideas.api import IdeaViewSet from meinberlin.apps.kiezkasse.api import KiezkasseViewSet +from meinberlin.apps.kiezradar.api import SearchProfileViewSet from meinberlin.apps.likes.api import LikesViewSet from meinberlin.apps.likes.routers import LikesDefaultRouter from meinberlin.apps.livequestions.api import LiveQuestionViewSet @@ -47,6 +48,7 @@ router = routers.DefaultRouter() router.register(r"follows", FollowViewSet, basename="follows") +router.register(r"searchprofiles", SearchProfileViewSet, basename="searchprofiles") router.register(r"reports", ReportViewSet, basename="reports") router.register(r"polls", PollViewSet, basename="polls") router.register(r"projects", ProjectListViewSet, basename="projects") diff --git a/meinberlin/test/factories/kiezradar.py b/meinberlin/test/factories/kiezradar.py new file mode 100644 index 0000000000..2c362105b3 --- /dev/null +++ b/meinberlin/test/factories/kiezradar.py @@ -0,0 +1,30 @@ +import factory + +from adhocracy4.test import factories as a4_factories +from meinberlin.apps.kiezradar.models import KiezradarQuery +from meinberlin.apps.kiezradar.models import ProjectType +from meinberlin.apps.kiezradar.models import SearchProfile + + +class SearchProfileFactory(factory.django.DjangoModelFactory): + class Meta: + model = SearchProfile + + user = factory.SubFactory(a4_factories.USER_FACTORY) + name = factory.Faker("sentence", nb_words=4) + description = factory.Faker("sentence", nb_words=16) + status = SearchProfile.STATUS_ONGOING + + +class KiezradarQueryFactory(factory.django.DjangoModelFactory): + class Meta: + model = KiezradarQuery + + text = factory.Faker("sentence", nb_words=8) + + +class ProjectTypeFactory(factory.django.DjangoModelFactory): + class Meta: + model = ProjectType + + participation = ProjectType.PARTICIPATION_INFORMATION diff --git a/restore b/restore new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/kiezradar/conftest.py b/tests/kiezradar/conftest.py new file mode 100644 index 0000000000..f229a656c1 --- /dev/null +++ b/tests/kiezradar/conftest.py @@ -0,0 +1,13 @@ +from pytest_factoryboy import register + +from meinberlin.test.factories.kiezradar import KiezradarQueryFactory +from meinberlin.test.factories.kiezradar import ProjectTypeFactory +from meinberlin.test.factories.kiezradar import SearchProfileFactory +from meinberlin.test.factories.organisations import OrganisationFactory +from meinberlin.test.factories.topicprio import TopicFactory + +register(SearchProfileFactory) +register(KiezradarQueryFactory) +register(ProjectTypeFactory) +register(OrganisationFactory) +register(TopicFactory) diff --git a/tests/kiezradar/test_api_kiezradar.py b/tests/kiezradar/test_api_kiezradar.py new file mode 100644 index 0000000000..96f61e0632 --- /dev/null +++ b/tests/kiezradar/test_api_kiezradar.py @@ -0,0 +1,80 @@ +import pytest +from django.urls import reverse + +from meinberlin.apps.kiezradar.models import SearchProfile + + +@pytest.fixture +def setup_data( + administrative_district_factory, + kiezradar_query_factory, + topic_factory, + organisation_factory, + project_type_factory, +): + """Fixture to create required data for the test.""" + query1 = kiezradar_query_factory() + query2 = kiezradar_query_factory() + district = administrative_district_factory() + topic = topic_factory() + organisation = organisation_factory() + project_type = project_type_factory() + + return { + "queries": [query1.id, query2.id], + "districts": [district.id], + "topics": [topic.id], + "organisations": [organisation.id], + "project_types": [project_type.id], + } + + +@pytest.mark.django_db +def test_create_search_profile(client, user, setup_data): + """Test creating a SearchProfile via the API.""" + client.login(email=user.email, password="password") + + payload = { + "name": "Test Search Profile", + "description": "A description for the filters profile.", + "disabled": False, + "status": SearchProfile.STATUS_ONGOING, + "queries": setup_data["queries"], + "districts": setup_data["districts"], + "topics": setup_data["topics"], + "project_types": setup_data["project_types"], + } + + url = reverse("searchprofiles-list") + response = client.post(url, payload, format="json") + + assert response.status_code == 201 + data = response.json() + + assert data["name"] == payload["name"] + assert data["description"] == payload["description"] + assert data["disabled"] == payload["disabled"] + assert data["status"] == payload["status"] + assert set(data["queries"]) == set(payload["queries"]) + assert set(data["districts"]) == set(payload["districts"]) + assert set(data["topics"]) == set(payload["topics"]) + assert set(data["project_types"]) == set(payload["project_types"]) + + # Check if the object was created in the database + search_profile = SearchProfile.objects.get(id=data["id"]) + assert search_profile.name == payload["name"] + assert search_profile.description == payload["description"] + assert search_profile.disabled == payload["disabled"] + assert search_profile.status == payload["status"] + assert ( + list(search_profile.queries.values_list("id", flat=True)) == payload["queries"] + ) + assert ( + list(search_profile.districts.values_list("id", flat=True)) + == payload["districts"] + ) + assert list(search_profile.topics.values_list("id", flat=True)) == payload["topics"] + assert ( + list(search_profile.project_types.values_list("id", flat=True)) + == payload["project_types"] + ) diff --git a/tests/kiezradar/test_search_profile.py b/tests/kiezradar/test_search_profile.py new file mode 100644 index 0000000000..03f47657c6 --- /dev/null +++ b/tests/kiezradar/test_search_profile.py @@ -0,0 +1,53 @@ +import pytest + + +@pytest.mark.django_db +def test_create_search_profile( + search_profile_factory, + project_type_factory, + topic_factory, + kiezradar_query_factory, + organisation_factory, + administrative_district_factory, +): + + search_profile = search_profile_factory() + assert search_profile.topics.all().count() == 0 + assert search_profile.districts.all().count() == 0 + assert search_profile.project_types.all().count() == 0 + assert search_profile.organisations.all().count() == 0 + + topic1 = topic_factory() + topic2 = topic_factory() + search_profile.topics.add(topic1.id) + search_profile.topics.add(topic2.id) + assert search_profile.topics.all().count() == 2 + + # adding same topic is not changing the profile's topics + search_profile.topics.add(topic1.id) + assert search_profile.topics.all().count() == 2 + assert topic1.id in [topic.id for topic in search_profile.topics.all()] + + participation1 = project_type_factory() + participation2 = project_type_factory() + search_profile.project_types.add(participation1.id) + assert search_profile.project_types.all().count() == 1 + search_profile.project_types.add(participation2.id) + assert search_profile.project_types.all().count() == 2 + assert participation2.id in [ + participation.id for participation in search_profile.project_types.all() + ] + + organisation1 = organisation_factory() + organisation2 = organisation_factory() + search_profile.organisations.add(organisation1.id) + search_profile.organisations.add(organisation2.id) + assert search_profile.organisations.all().count() == 2 + + district = administrative_district_factory() + search_profile.districts.add(district.id) + assert search_profile.districts.all().count() == 1 + + query = kiezradar_query_factory() + search_profile.queries.add(query.id) + assert search_profile.queries.all().count() == 1