From f938401b94fed19430a3561253da92dd5e2b0e48 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, serialisers, api ,admin --- changelog/8473.md | 10 ++ meinberlin/apps/kiezradar/__init__.py | 0 meinberlin/apps/kiezradar/admin.py | 28 ++++ meinberlin/apps/kiezradar/api.py | 21 +++ meinberlin/apps/kiezradar/apps.py | 6 + .../apps/kiezradar/migrations/0001_initial.py | 142 ++++++++++++++++++ .../apps/kiezradar/migrations/__init__.py | 0 meinberlin/apps/kiezradar/models.py | 91 +++++++++++ meinberlin/apps/kiezradar/serializers.py | 113 ++++++++++++++ meinberlin/apps/kiezradar/tests.py | 3 + meinberlin/apps/projects/serializers.py | 4 +- meinberlin/config/settings/base.py | 1 + meinberlin/config/urls.py | 2 + meinberlin/test/factories/kiezradar.py | 30 ++++ restore | 0 tests/kiezradar/conftest.py | 11 ++ tests/kiezradar/test_api_kiezradar.py | 77 ++++++++++ tests/kiezradar/test_search_profile.py | 55 +++++++ 18 files changed, 592 insertions(+), 2 deletions(-) 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..5716c07026 --- /dev/null +++ b/changelog/8473.md @@ -0,0 +1,10 @@ +### Added + +- kiezradar app +- SearchProfile model with fields: + - name, description, disabled, status + - m2m relations for districts, project-types, topics, organisations + - FK relation to user, query +- KiezRadarQuery model with a FK relation with SearchProfile for queries +- serialiser with custom create() and update() methods for the m2m relations +- api view for 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..a08330c3cd --- /dev/null +++ b/meinberlin/apps/kiezradar/admin.py @@ -0,0 +1,28 @@ +from django.contrib import admin + +from .models import KiezradarQuery +from .models import ProjectType +from .models import SearchProfile + + +class SearchProfileAdmin(admin.ModelAdmin): + list_display = ("id", "name", "user") + list_filter = ( + "name", + "status", + ) + + +class KiezradarQueryAdmin(admin.ModelAdmin): + list_display = ("id", "text") + list_filter = ("text",) + + +class ProjectTypeAdmin(admin.ModelAdmin): + list_display = ("id", "participation") + list_filter = ("participation",) + + +admin.site.register(SearchProfile, SearchProfileAdmin) +admin.site.register(KiezradarQuery, KiezradarQueryAdmin) +admin.site.register(ProjectType, ProjectTypeAdmin) diff --git a/meinberlin/apps/kiezradar/api.py b/meinberlin/apps/kiezradar/api.py new file mode 100644 index 0000000000..4d84c7ef92 --- /dev/null +++ b/meinberlin/apps/kiezradar/api.py @@ -0,0 +1,21 @@ +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 save the user from the request. + """ + serializer.save(user=self.request.user) 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..05b106ba46 --- /dev/null +++ b/meinberlin/apps/kiezradar/migrations/0001_initial.py @@ -0,0 +1,142 @@ +# Generated by Django 4.2.11 on 2024-11-21 13:17 + +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"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("a4projects", "0047_alter_project_image_alter_project_tile_image"), + ] + + 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)), + ], + options={ + "verbose_name_plural": "queries", + }, + ), + 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")], + default=1, + 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", + ), + ), + ( + "query", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + 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..be2fe74c67 --- /dev/null +++ b/meinberlin/apps/kiezradar/models.py @@ -0,0 +1,91 @@ +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 Meta: + verbose_name_plural = "queries" + + +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, + default=1, + verbose_name=_("Status"), + ) + query = models.ForeignKey( + KiezradarQuery, + models.SET_NULL, + related_name="search_profiles", + blank=True, + null=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..bb80a1a609 --- /dev/null +++ b/meinberlin/apps/kiezradar/serializers.py @@ -0,0 +1,113 @@ +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.""" + + query = KiezradarQuerySerializer(allow_null=True) + organisations = serializers.PrimaryKeyRelatedField( + many=True, queryset=Organisation.objects.all(), allow_null=True + ) + districts = serializers.PrimaryKeyRelatedField( + many=True, queryset=AdministrativeDistrict.objects.all(), allow_null=True + ) + project_types = ProjectTypeSerializer(many=True, allow_null=True) + topics = TopicSerializer(many=True, allow_null=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", + "query", + "organisations", + "districts", + "project_types", + "topics", + ] + + read_only_fields = ["user"] + + def create(self, validated_data): + # Pop many-to-many fields from validated_data + query_data = validated_data.pop("query", 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) + + search_profile = SearchProfile.objects.create(**validated_data) + print("DATA", validated_data) + if query_data: + query, _ = KiezradarQuery.objects.get_or_create(text=query_data) + query.search_profiles.add(search_profile) + if organisations_data: + search_profile.organisations.set(organisations_data) + if districts_data: + search_profile.districts.set(districts_data) + if project_types_data: + search_profile.project_types.set(project_types_data) + if topics_data: + search_profile.topics.set(topics_data) + + return search_profile + + def update(self, instance, validated_data): + """ + Custom update to handle many-to-many relationships. + """ + # Pop many-to-many fields from validated_data + 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 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/apps/projects/serializers.py b/meinberlin/apps/projects/serializers.py index d57176468d..4d97289abf 100644 --- a/meinberlin/apps/projects/serializers.py +++ b/meinberlin/apps/projects/serializers.py @@ -42,7 +42,7 @@ def get_created_or_modified(instance): class TopicSerializer(serializers.ModelSerializer): class Meta: model = Topic - fields = ["code", "name"] + fields = ["code"] class ProjectSerializer(serializers.ModelSerializer, CommonFields): @@ -104,7 +104,7 @@ class Meta: "topics", "type", "url", - "id" + "id", ] def get_topics(self, instance): 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..3150b0747e --- /dev/null +++ b/tests/kiezradar/conftest.py @@ -0,0 +1,11 @@ +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 + +register(SearchProfileFactory) +register(KiezradarQueryFactory) +register(ProjectTypeFactory) +register(OrganisationFactory) diff --git a/tests/kiezradar/test_api_kiezradar.py b/tests/kiezradar/test_api_kiezradar.py new file mode 100644 index 0000000000..7477fdf1c6 --- /dev/null +++ b/tests/kiezradar/test_api_kiezradar.py @@ -0,0 +1,77 @@ +import pytest +from django.urls import reverse + +from adhocracy4.projects.models import Topic +from meinberlin.apps.kiezradar.models import SearchProfile + + +@pytest.fixture +def setup_data( + administrative_district_factory, + kiezradar_query_factory, + organisation_factory, + project_type_factory, +): + """Fixture to create required data for the test.""" + query = kiezradar_query_factory() + district = administrative_district_factory() + topic = Topic.objects.first() + organisation = organisation_factory() + project_type = project_type_factory() + + return { + "query": {"text": query.text}, + "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, + "query": setup_data["query"], + "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 data["query"] == payload["query"] + 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 search_profile.query == payload["query"] + 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..2522f63bff --- /dev/null +++ b/tests/kiezradar/test_search_profile.py @@ -0,0 +1,55 @@ +import pytest + +from adhocracy4.projects.models import Topic + + +@pytest.mark.django_db +def test_create_search_profile( + search_profile_factory, + project_type_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.objects.first() + topic2 = Topic.objects.last() + 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.query = query + search_profile.save() + assert search_profile.query == query