diff --git a/changelog/8664.md b/changelog/8664.md new file mode 100644 index 0000000000..1d61b9227b --- /dev/null +++ b/changelog/8664.md @@ -0,0 +1,10 @@ +### Added + +- add a new field `number` to SearchProfile which indexes the search profiles per + user +- add auto-naming to SearchProfiles for unnamed search profiles + +### Changed + +- make `name` field on SearchProfile optional as users can only change it after +creation and it makes accommodating the auto-naming scheme easier diff --git a/meinberlin/apps/kiezradar/admin.py b/meinberlin/apps/kiezradar/admin.py index a08330c3cd..91d46e6850 100644 --- a/meinberlin/apps/kiezradar/admin.py +++ b/meinberlin/apps/kiezradar/admin.py @@ -6,7 +6,8 @@ class SearchProfileAdmin(admin.ModelAdmin): - list_display = ("id", "name", "user") + list_display = ("id", "name", "user", "number") + readonly_fields = ("number",) list_filter = ( "name", "status", diff --git a/meinberlin/apps/kiezradar/migrations/0006_searchprofile_number.py b/meinberlin/apps/kiezradar/migrations/0006_searchprofile_number.py new file mode 100644 index 0000000000..05ba7fec90 --- /dev/null +++ b/meinberlin/apps/kiezradar/migrations/0006_searchprofile_number.py @@ -0,0 +1,31 @@ +# Generated by Django 4.2.11 on 2025-01-09 15:25 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("meinberlin_kiezradar", "0005_populate_project_status"), + ] + + operations = [ + migrations.AddField( + model_name="searchprofile", + name="number", + field=models.PositiveSmallIntegerField(null=True), + ), + migrations.AddIndex( + model_name="searchprofile", + index=models.Index(models.F("number"), name="searchprofile_number_idx"), + ), + migrations.AlterModelOptions( + name="searchprofile", + options={"ordering": ["number"]}, + ), + migrations.AlterField( + model_name="searchprofile", + name="name", + field=models.CharField(max_length=255, null=True), + ), + ] diff --git a/meinberlin/apps/kiezradar/migrations/0007_alter_searchprofile_number_and_more.py b/meinberlin/apps/kiezradar/migrations/0007_alter_searchprofile_number_and_more.py new file mode 100644 index 0000000000..e69f93d0e6 --- /dev/null +++ b/meinberlin/apps/kiezradar/migrations/0007_alter_searchprofile_number_and_more.py @@ -0,0 +1,41 @@ +# Generated by Django 4.2.11 on 2025-01-09 15:26 + +from django.db import migrations, models + + +def add_number_to_search_profiles(apps, schema_editor): + SearchProfile = apps.get_model("meinberlin_kiezradar", "SearchProfile") + for sp in SearchProfile.objects.all(): + if sp.number is None: + latest = None + try: + latest = ( + SearchProfile.objects.filter(number__isnull=False) + .filter(user=sp.user) + .latest("number") + ) + except SearchProfile.DoesNotExist: + pass + sp.number = latest.number + 1 if latest else 1 + sp.save() + + +class Migration(migrations.Migration): + dependencies = [ + ("meinberlin_kiezradar", "0006_searchprofile_number"), + ] + + operations = [ + migrations.RunPython(add_number_to_search_profiles, migrations.RunPython.noop), + migrations.AlterField( + model_name="searchprofile", + name="number", + field=models.PositiveSmallIntegerField(), + ), + migrations.AddConstraint( + model_name="searchprofile", + constraint=models.UniqueConstraint( + models.F("user"), models.F("number"), name="unique-search-profile" + ), + ), + ] diff --git a/meinberlin/apps/kiezradar/models.py b/meinberlin/apps/kiezradar/models.py index 578b7e4338..83f3a47ea5 100644 --- a/meinberlin/apps/kiezradar/models.py +++ b/meinberlin/apps/kiezradar/models.py @@ -63,7 +63,8 @@ class SearchProfile(models.Model): on_delete=models.CASCADE, related_name="search_profiles", ) - name = models.CharField(max_length=255) + name = models.CharField(max_length=255, null=True) + number = models.PositiveSmallIntegerField() description = models.TextField(blank=True, null=True) disabled = models.BooleanField(default=False) notification = models.BooleanField(default=False) @@ -100,8 +101,23 @@ class SearchProfile(models.Model): blank=True, ) + def save(self, update_fields=None, *args, **kwargs): + """Custom save() to add the next unused number per user on creation""" + if self.number is None: + latest = None + try: + latest = SearchProfile.objects.filter(user=self.user).latest("number") + except SearchProfile.DoesNotExist: + pass + self.number = latest.number + 1 if latest else 1 + super().save(update_fields=update_fields, *args, **kwargs) + class Meta: - ordering = ["name"] + ordering = ["number"] + constraints = [ + models.UniqueConstraint("user", "number", name="unique-search-profile") + ] + indexes = [models.Index("number", name="searchprofile_number_idx")] 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 index d172142980..2916aa124d 100644 --- a/meinberlin/apps/kiezradar/serializers.py +++ b/meinberlin/apps/kiezradar/serializers.py @@ -1,5 +1,6 @@ from django.conf import settings from django.utils.module_loading import import_string +from django.utils.translation import gettext as _ from rest_framework import serializers from adhocracy4.administrative_districts.models import AdministrativeDistrict @@ -45,6 +46,7 @@ class Meta: "id", "user", "name", + "number", "description", "disabled", "notification", @@ -57,7 +59,7 @@ class Meta: "topics", ] - read_only_fields = ["user"] + read_only_fields = ["user", "number"] def create(self, validated_data): # Pop many-to-many and one-to-many fields from validated_data @@ -112,6 +114,8 @@ def to_representation(self, instance): {"id": topic.id, "code": topic.code, "name": topics_enum(topic.code).label} for topic in instance.topics.all() ] - representation["query_text"] = instance.query.text if instance.query else "" + + if not instance.name: + representation["name"] = _("Searchprofile %d") % instance.number return representation diff --git a/tests/kiezradar/test_api_kiezradar.py b/tests/kiezradar/test_api_kiezradar.py index 6aac9dac07..f19f415089 100644 --- a/tests/kiezradar/test_api_kiezradar.py +++ b/tests/kiezradar/test_api_kiezradar.py @@ -55,6 +55,7 @@ def test_create_search_profile(client, user, setup_data): data = response.json() assert data["name"] == payload["name"] + assert data["number"] == 1 assert data["description"] == payload["description"] assert data["disabled"] == payload["disabled"] assert data["query"] == payload["query"] @@ -77,6 +78,67 @@ def test_create_search_profile(client, user, setup_data): ) +@pytest.mark.django_db +def test_create_and_update_search_profile_without_name(client, user, setup_data): + """Test creating and updating a SearchProfile without name via the API.""" + client.login(email=user.email, password="password") + + payload = { + "description": "A description for the filters profile.", + "disabled": False, + "status": setup_data["project_status"], + "query": setup_data["query"], + "districts": setup_data["districts"], + "topics": setup_data["topics"], + "organisation": setup_data["organisations"], + "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"] == "Searchprofile 1" + assert data["number"] == 1 + assert data["description"] == payload["description"] + assert data["disabled"] == payload["disabled"] + assert data["query"] == payload["query"] + + # Check if the object was created in the database + search_profile = SearchProfile.objects.get(id=data["id"]) + assert search_profile.name is None + assert search_profile.description == payload["description"] + assert search_profile.disabled == payload["disabled"] + assert search_profile.query.id == payload["query"] + assert ( + list(search_profile.districts.values_list("id", flat=True)) + == payload["districts"] + ) + assert list(search_profile.status.values_list("id", flat=True)) == payload["status"] + 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"] + ) + + payload = { + "name": "Test Search Profile", + } + url = reverse("searchprofiles-detail", kwargs={"pk": search_profile.id}) + response = client.patch(url, data=payload, content_type="application/json") + data = response.json() + + assert response.status_code == 200 + assert data["name"] == payload["name"] + assert data["number"] == 1 + + # Check if the object was updated in the database + search_profile = SearchProfile.objects.get(id=data["id"]) + assert search_profile.name == payload["name"] + + @pytest.mark.django_db def test_update_search_profile(client, user, setup_data, search_profile_factory): """Test updating a SearchProfile via the API.""" @@ -139,3 +201,35 @@ def test_update_search_profile(client, user, setup_data, search_profile_factory) # Check if the object was updated in the database search_profile = SearchProfile.objects.get(id=data["id"]) assert search_profile.query.text == payload["query_text"] + + +@pytest.mark.django_db +def test_creating_multiple_search_profiles_assign_correct_number( + client, user, setup_data +): + """Test creating and updating a SearchProfile without name via the API.""" + client.login(email=user.email, password="password") + + payload = { + "description": "A description for the filters profile.", + "disabled": False, + "status": setup_data["project_status"], + "query": setup_data["query"], + "districts": setup_data["districts"], + "topics": setup_data["topics"], + "organisation": setup_data["organisations"], + "project_types": setup_data["project_types"], + } + + url = reverse("searchprofiles-list") + for i in range(1, 6): + response = client.post(url, payload, format="json") + assert response.status_code == 201 + data = response.json() + assert data["name"] == "Searchprofile " + str(i) + assert data["number"] == i + + assert SearchProfile.objects.count() == 5 + for i, search_profile in enumerate(SearchProfile.objects.all()): + assert search_profile.name is None + assert search_profile.number == i + 1 diff --git a/tests/kiezradar/test_search_profile.py b/tests/kiezradar/test_search_profile.py index 2522f63bff..82e69941ae 100644 --- a/tests/kiezradar/test_search_profile.py +++ b/tests/kiezradar/test_search_profile.py @@ -1,6 +1,7 @@ import pytest from adhocracy4.projects.models import Topic +from meinberlin.apps.kiezradar.models import SearchProfile @pytest.mark.django_db @@ -17,6 +18,7 @@ def test_create_search_profile( assert search_profile.districts.all().count() == 0 assert search_profile.project_types.all().count() == 0 assert search_profile.organisations.all().count() == 0 + assert search_profile.number == 1 topic1 = Topic.objects.first() topic2 = Topic.objects.last() @@ -53,3 +55,22 @@ def test_create_search_profile( search_profile.query = query search_profile.save() assert search_profile.query == query + + +@pytest.mark.django_db +def test_search_profile_save_adds_number( + user, + search_profile_factory, +): + user_2 = search_profile_factory().user + assert user is not user_2 + for i in range(5): + search_profile_factory(user=user) + for i in range(2): + search_profile_factory(user=user_2) + + assert SearchProfile.objects.count() == 8 + for i, search_profile in enumerate(SearchProfile.objects.filter(user=user)): + assert search_profile.number == i + 1 + for i, search_profile in enumerate(SearchProfile.objects.filter(user=user_2)): + assert search_profile.number == i + 1