From 7f0b1d63bde82a8364ba2e1265cc0e07a6455db1 Mon Sep 17 00:00:00 2001 From: Philipp Veller Date: Tue, 7 Jan 2025 10:05:36 +0100 Subject: [PATCH] search-profile: load preset filters from search profile into project overview --- changelog/8646.md | 6 +++ .../migrations/0004_add_project_status.py | 47 +++++++++++++++++++ .../0005_populate_project_status.py | 25 ++++++++++ meinberlin/apps/kiezradar/models.py | 29 +++++++++--- meinberlin/apps/kiezradar/serializers.py | 18 +++++-- .../templates/meinberlin_plans/plan_list.html | 1 + meinberlin/apps/plans/views.py | 23 +++++++++ meinberlin/react/kiezradar/SearchProfile.jsx | 1 + meinberlin/react/plans/react_plans_map.jsx | 2 + .../react/projects/ProjectsControlBar.jsx | 4 +- .../react/projects/ProjectsListMapBox.jsx | 24 ++++------ meinberlin/react/projects/getDefaultState.js | 37 +++++++++++++++ meinberlin/test/factories/kiezradar.py | 9 +++- tests/kiezradar/conftest.py | 2 + tests/kiezradar/test_api_kiezradar.py | 12 +++-- 15 files changed, 207 insertions(+), 33 deletions(-) create mode 100644 changelog/8646.md create mode 100644 meinberlin/apps/kiezradar/migrations/0004_add_project_status.py create mode 100644 meinberlin/apps/kiezradar/migrations/0005_populate_project_status.py create mode 100644 meinberlin/react/projects/getDefaultState.js diff --git a/changelog/8646.md b/changelog/8646.md new file mode 100644 index 0000000000..ec4502c58c --- /dev/null +++ b/changelog/8646.md @@ -0,0 +1,6 @@ +### Added +- functions for the project overview to get its default state based on if a search profile exists or not +- functionality to load a search profile's preset filters into project overview + +### Changed +- added project status model instead of a field to be able to store a list of project statuses inside a search profile diff --git a/meinberlin/apps/kiezradar/migrations/0004_add_project_status.py b/meinberlin/apps/kiezradar/migrations/0004_add_project_status.py new file mode 100644 index 0000000000..dfde62a247 --- /dev/null +++ b/meinberlin/apps/kiezradar/migrations/0004_add_project_status.py @@ -0,0 +1,47 @@ +# Generated by Django 4.2.11 on 2025-01-07 07:58 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("meinberlin_kiezradar", "0003_searchprofile_notification"), + ] + + operations = [ + migrations.CreateModel( + name="ProjectStatus", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "status", + models.SmallIntegerField( + choices=[(0, "running"), (1, "done"), (2, "future")], + verbose_name="Status", + ), + ), + ], + ), + migrations.RemoveField( + model_name="searchprofile", + name="status", + ), + migrations.AddField( + model_name="searchprofile", + name="status", + field=models.ManyToManyField( + blank=True, + related_name="search_profiles", + to="meinberlin_kiezradar.projectstatus", + ), + ), + ] diff --git a/meinberlin/apps/kiezradar/migrations/0005_populate_project_status.py b/meinberlin/apps/kiezradar/migrations/0005_populate_project_status.py new file mode 100644 index 0000000000..ab17e90176 --- /dev/null +++ b/meinberlin/apps/kiezradar/migrations/0005_populate_project_status.py @@ -0,0 +1,25 @@ +# Generated by Django 4.2.11 on 2025-01-07 07:59 + +from django.db import migrations + + +def populate_project_status(apps, schema_editor): + ProjectStatus = apps.get_model("meinberlin_kiezradar", "ProjectStatus") + ProjectStatus.objects.bulk_create( + [ + ProjectStatus(status=0), + ProjectStatus(status=1), + ProjectStatus(status=2), + ] + ) + + +class Migration(migrations.Migration): + + dependencies = [ + ("meinberlin_kiezradar", "0004_add_project_status"), + ] + + operations = [ + migrations.RunPython(populate_project_status), + ] diff --git a/meinberlin/apps/kiezradar/models.py b/meinberlin/apps/kiezradar/models.py index 7875ff0bbe..578b7e4338 100644 --- a/meinberlin/apps/kiezradar/models.py +++ b/meinberlin/apps/kiezradar/models.py @@ -38,11 +38,26 @@ def __str__(self): return f"participation type - {self.participation}" -class SearchProfile(models.Model): +class ProjectStatus(models.Model): STATUS_ONGOING = 0 STATUS_DONE = 1 - STATUS_CHOICES = ((STATUS_ONGOING, _("running")), (STATUS_DONE, _("done"))) + STATUS_FUTURE = 2 + STATUS_CHOICES = ( + (STATUS_ONGOING, _("running")), + (STATUS_DONE, _("done")), + (STATUS_FUTURE, _("future")), + ) + + status = models.SmallIntegerField( + choices=STATUS_CHOICES, + verbose_name=_("Status"), + ) + + def __str__(self): + return f"project status - {self.status}" + +class SearchProfile(models.Model): user = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.CASCADE, @@ -52,11 +67,6 @@ class SearchProfile(models.Model): description = models.TextField(blank=True, null=True) disabled = models.BooleanField(default=False) notification = models.BooleanField(default=False) - status = models.SmallIntegerField( - choices=STATUS_CHOICES, - default=0, - verbose_name=_("Status"), - ) query = models.ForeignKey( KiezradarQuery, models.SET_NULL, @@ -64,6 +74,11 @@ class SearchProfile(models.Model): blank=True, null=True, ) + status = models.ManyToManyField( + ProjectStatus, + related_name="search_profiles", + blank=True, + ) organisations = models.ManyToManyField( Organisation, related_name="search_profiles", diff --git a/meinberlin/apps/kiezradar/serializers.py b/meinberlin/apps/kiezradar/serializers.py index 5d62d8c49d..d172142980 100644 --- a/meinberlin/apps/kiezradar/serializers.py +++ b/meinberlin/apps/kiezradar/serializers.py @@ -5,6 +5,7 @@ from adhocracy4.administrative_districts.models import AdministrativeDistrict from adhocracy4.projects.models import Topic from meinberlin.apps.kiezradar.models import KiezradarQuery +from meinberlin.apps.kiezradar.models import ProjectStatus from meinberlin.apps.kiezradar.models import ProjectType from meinberlin.apps.kiezradar.models import SearchProfile from meinberlin.apps.organisations.models import Organisation @@ -31,10 +32,12 @@ class SearchProfileSerializer(serializers.ModelSerializer): project_types = serializers.PrimaryKeyRelatedField( queryset=ProjectType.objects.all(), many=True, allow_null=True, required=False ) + status = serializers.PrimaryKeyRelatedField( + queryset=ProjectStatus.objects.all(), many=True, allow_null=True, required=False + ) topics = serializers.PrimaryKeyRelatedField( queryset=Topic.objects.all(), many=True, allow_null=True, required=False ) - status_display = serializers.CharField(source="get_status_display", read_only=True) class Meta: model = SearchProfile @@ -46,7 +49,6 @@ class Meta: "disabled", "notification", "status", - "status_display", "query", "query_text", "organisations", @@ -96,10 +98,20 @@ def to_representation(self, instance): {"id": project_type.id, "name": project_type.get_participation_display()} for project_type in instance.project_types.all() ] + representation["status"] = [ + { + "id": project_status.id, + "status": project_status.status, + "name": project_status.get_status_display(), + } + for project_status in instance.status.all() + ] topics_enum = import_string(settings.A4_PROJECT_TOPICS) representation["topics"] = [ - {"id": topic.id, "name": topics_enum(topic.code).label} + {"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 "" return representation diff --git a/meinberlin/apps/plans/templates/meinberlin_plans/plan_list.html b/meinberlin/apps/plans/templates/meinberlin_plans/plan_list.html index 099a1e0d44..6c51d1d0cb 100644 --- a/meinberlin/apps/plans/templates/meinberlin_plans/plan_list.html +++ b/meinberlin/apps/plans/templates/meinberlin_plans/plan_list.html @@ -33,6 +33,7 @@ data-use_vector_map="{{ use_vector_map }}" data-baseurl="{{ baseurl }}" data-bounds="{{ bounds }}" + data-search-profile="{{ search_profile|default:"" }}" data-participation-choices="{{ participation_choices }}"> {% endblock content %} diff --git a/meinberlin/apps/plans/views.py b/meinberlin/apps/plans/views.py index 32dfa556d4..a367dcac5f 100644 --- a/meinberlin/apps/plans/views.py +++ b/meinberlin/apps/plans/views.py @@ -10,6 +10,7 @@ from django.utils.translation import gettext_lazy as _ from django.views import generic from django.views.generic.detail import SingleObjectMixin +from rest_framework.renderers import JSONRenderer from adhocracy4.administrative_districts.models import AdministrativeDistrict from adhocracy4.dashboard import mixins as a4dashboard_mixins @@ -21,6 +22,8 @@ from meinberlin.apps.contrib.enums import TopicEnum from meinberlin.apps.contrib.views import CanonicalURLDetailView from meinberlin.apps.dashboard.mixins import DashboardProjectListGroupMixin +from meinberlin.apps.kiezradar.models import SearchProfile +from meinberlin.apps.kiezradar.serializers import SearchProfileSerializer from meinberlin.apps.maps.models import MapPreset from meinberlin.apps.organisations.models import Organisation from meinberlin.apps.plans import models @@ -97,6 +100,25 @@ def get_participation_choices(self): choices = [str(choice[1]) for choice in Plan.participation.field.choices] return json.dumps(choices) + def get_search_profile(self): + if ( + self.request.GET.get("search-profile", None) + and self.request.user.is_authenticated + ): + search_profile_id = self.request.GET.get("search-profile") + try: + search_profile = SearchProfile.objects.get( + id=search_profile_id, user=self.request.user + ) + return ( + JSONRenderer() + .render(SearchProfileSerializer(instance=search_profile).data) + .decode("utf-8") + ) + except SearchProfile.DoesNotExist: + pass + return None + def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) @@ -113,6 +135,7 @@ def get_context_data(self, **kwargs): if hasattr(settings, "A4_OPENMAPTILES_TOKEN"): omt_token = settings.A4_OPENMAPTILES_TOKEN + context["search_profile"] = self.get_search_profile() context["districts"] = self.get_district_polygons() context["organisations"] = self.get_organisations() context["district_names"] = self.get_district_names() diff --git a/meinberlin/react/kiezradar/SearchProfile.jsx b/meinberlin/react/kiezradar/SearchProfile.jsx index 3be623a01a..240c9b39bc 100644 --- a/meinberlin/react/kiezradar/SearchProfile.jsx +++ b/meinberlin/react/kiezradar/SearchProfile.jsx @@ -72,6 +72,7 @@ export default function SearchProfile ({ apiUrl, planListUrl, profile: profile_, ] .map((filter) => filter.map(({ name }) => name)) .map((names) => names.join(', ')) + .filter(Boolean) return (
diff --git a/meinberlin/react/plans/react_plans_map.jsx b/meinberlin/react/plans/react_plans_map.jsx index 3e299c4576..6928c2cbe4 100644 --- a/meinberlin/react/plans/react_plans_map.jsx +++ b/meinberlin/react/plans/react_plans_map.jsx @@ -12,6 +12,7 @@ function init () { const attribution = el.getAttribute('data-attribution') const baseUrl = el.getAttribute('data-baseurl') const bounds = JSON.parse(el.getAttribute('data-bounds')) + const searchProfile = el.getAttribute('data-search-profile') && JSON.parse(el.getAttribute('data-search-profile')) const selectedDistrict = el.getAttribute('data-selected-district') const selectedTopic = el.getAttribute('data-selected-topic') const districts = JSON.parse(el.getAttribute('data-districts')) @@ -43,6 +44,7 @@ function init () { districtNames={districtNames} topicChoices={topicChoices} participationChoices={participationChoices} + searchProfile={searchProfile} /> ) }) diff --git a/meinberlin/react/projects/ProjectsControlBar.jsx b/meinberlin/react/projects/ProjectsControlBar.jsx index 09a2ebc903..78e2b3d33c 100644 --- a/meinberlin/react/projects/ProjectsControlBar.jsx +++ b/meinberlin/react/projects/ProjectsControlBar.jsx @@ -29,7 +29,7 @@ const statusNames = { past: django.gettext('done') } -export const initialState = { +const initialState = { search: '', districts: [], organisation: [], @@ -63,7 +63,7 @@ export const ProjectsControlBar = ({ hasContainer }) => { const [expandFilters, setExpandFilters] = useState(false) - const [filters, setFilters] = useState(initialState) + const [filters, setFilters] = useState(appliedFilters) const onFilterChange = (type, choice) => { setFilters({ ...filters, [type]: choice }) } diff --git a/meinberlin/react/projects/ProjectsListMapBox.jsx b/meinberlin/react/projects/ProjectsListMapBox.jsx index 0fd50acffe..d0e9d6a507 100644 --- a/meinberlin/react/projects/ProjectsListMapBox.jsx +++ b/meinberlin/react/projects/ProjectsListMapBox.jsx @@ -9,6 +9,10 @@ import ProjectsMap from './ProjectsMap' import Spinner from '../contrib/Spinner' import { ProjectsControlBar } from './ProjectsControlBar' import { filterProjects } from './filter-projects' +import { + getDefaultProjectState, + getDefaultState +} from './getDefaultState' const pageHeader = django.gettext('Project overview') const showMapStr = django.gettext('Show map') @@ -44,22 +48,15 @@ const ProjectsListMapBox = ({ districtNames, districts, participationChoices, - organisations + organisations, + searchProfile }) => { const [showMap, setShowMap] = useState(true) const [loading, setLoading] = useState(true) - const [projectState, setProjectState] = useState(['active', 'future']) + const [projectState, setProjectState] = useState(getDefaultProjectState(searchProfile)) const [items, setItems] = useState([]) const fetchCache = useRef({}) - const [appliedFilters, setAppliedFilters] = useState({ - search: '', - districts: [], - // organisation is a single select but its simpler to just work with an - // array because of the typeahead component - organisation: [], - participations: [], - topics: [] - }) + const [appliedFilters, setAppliedFilters] = useState(getDefaultState(searchProfile)) const fetchItems = useCallback(async () => { setLoading(true) @@ -93,10 +90,7 @@ const ProjectsListMapBox = ({ console.error(e) } }) - ) - .finally(() => setLoading(false)) - // TODO: Check if needed when implementing filter story: - // this.updateList() + ).finally(() => setLoading(false)) }, [plansApiUrl, extprojectApiUrl, privateprojectApiUrl, projectState, projectApiUrl]) useEffect(() => { diff --git a/meinberlin/react/projects/getDefaultState.js b/meinberlin/react/projects/getDefaultState.js new file mode 100644 index 0000000000..dc11d62616 --- /dev/null +++ b/meinberlin/react/projects/getDefaultState.js @@ -0,0 +1,37 @@ +const defaultState = { + search: '', + districts: [], + // organisation is a single select but its simpler to just work with an + // array because of the typeahead component + organisation: [], + participations: [], + topics: [] +} + +export const getDefaultState = (searchProfile) => { + let mergeData = {} + + if (searchProfile) { + mergeData = { + search: searchProfile.query_text ?? '', + districts: searchProfile.districts.map(d => d.name), + organisation: searchProfile.organisations.map(o => o.name), + participations: searchProfile.project_types.map(p => p.id), + topics: searchProfile.topics.map((t) => t.code) + } + } + + return { + ...defaultState, + ...mergeData + } +} + +export const getDefaultProjectState = (searchProfile) => { + const mapping = ['active', 'past', 'future'] + if (searchProfile && searchProfile.status.length) { + return searchProfile.status.map(s => mapping[s.status]) + } + + return ['active', 'future'] +} diff --git a/meinberlin/test/factories/kiezradar.py b/meinberlin/test/factories/kiezradar.py index 2c362105b3..dbbb0ce252 100644 --- a/meinberlin/test/factories/kiezradar.py +++ b/meinberlin/test/factories/kiezradar.py @@ -2,6 +2,7 @@ from adhocracy4.test import factories as a4_factories from meinberlin.apps.kiezradar.models import KiezradarQuery +from meinberlin.apps.kiezradar.models import ProjectStatus from meinberlin.apps.kiezradar.models import ProjectType from meinberlin.apps.kiezradar.models import SearchProfile @@ -13,7 +14,6 @@ class Meta: 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): @@ -28,3 +28,10 @@ class Meta: model = ProjectType participation = ProjectType.PARTICIPATION_INFORMATION + + +class ProjectStatusFactory(factory.django.DjangoModelFactory): + class Meta: + model = ProjectStatus + + status = ProjectStatus.STATUS_ONGOING diff --git a/tests/kiezradar/conftest.py b/tests/kiezradar/conftest.py index 3150b0747e..d60bfd383b 100644 --- a/tests/kiezradar/conftest.py +++ b/tests/kiezradar/conftest.py @@ -1,6 +1,7 @@ from pytest_factoryboy import register from meinberlin.test.factories.kiezradar import KiezradarQueryFactory +from meinberlin.test.factories.kiezradar import ProjectStatusFactory from meinberlin.test.factories.kiezradar import ProjectTypeFactory from meinberlin.test.factories.kiezradar import SearchProfileFactory from meinberlin.test.factories.organisations import OrganisationFactory @@ -8,4 +9,5 @@ register(SearchProfileFactory) register(KiezradarQueryFactory) register(ProjectTypeFactory) +register(ProjectStatusFactory) register(OrganisationFactory) diff --git a/tests/kiezradar/test_api_kiezradar.py b/tests/kiezradar/test_api_kiezradar.py index 009a4ec151..6aac9dac07 100644 --- a/tests/kiezradar/test_api_kiezradar.py +++ b/tests/kiezradar/test_api_kiezradar.py @@ -11,6 +11,7 @@ def setup_data( kiezradar_query_factory, organisation_factory, project_type_factory, + project_status_factory, ): """Fixture to create required data for the test.""" district = administrative_district_factory() @@ -18,6 +19,7 @@ def setup_data( topic = Topic.objects.first() organisation = organisation_factory() project_type = project_type_factory() + project_status = project_status_factory() return { "districts": [district.id], @@ -25,6 +27,7 @@ def setup_data( "organisations": [organisation.id], "topics": [topic.id], "project_types": [project_type.id], + "project_status": [project_status.id], } @@ -37,7 +40,7 @@ def test_create_search_profile(client, user, setup_data): "name": "Test Search Profile", "description": "A description for the filters profile.", "disabled": False, - "status": SearchProfile.STATUS_ONGOING, + "status": setup_data["project_status"], "query": setup_data["query"], "districts": setup_data["districts"], "topics": setup_data["topics"], @@ -54,7 +57,6 @@ def test_create_search_profile(client, user, setup_data): 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"] # Check if the object was created in the database @@ -62,12 +64,12 @@ def test_create_search_profile(client, user, setup_data): 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.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)) @@ -93,7 +95,7 @@ def test_update_search_profile(client, user, setup_data, search_profile_factory) "name": "Test Search Profile", "description": "A description for the filters profile.", "disabled": False, - "status": SearchProfile.STATUS_DONE, + "status": setup_data["project_status"], "query_text": "updated query", "districts": setup_data["districts"], "topics": setup_data["topics"], @@ -111,13 +113,13 @@ def test_update_search_profile(client, user, setup_data, search_profile_factory) 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.text == payload["query_text"] 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.status.values_list("id", flat=True)) == payload["status"] assert ( list(search_profile.project_types.values_list("id", flat=True)) == payload["project_types"]