diff --git a/src/open_inwoner/accounts/admin.py b/src/open_inwoner/accounts/admin.py index ebecd86293..56de040c54 100644 --- a/src/open_inwoner/accounts/admin.py +++ b/src/open_inwoner/accounts/admin.py @@ -103,6 +103,7 @@ class _UserAdmin(ImageCroppingMixin, UserAdmin): "image", "cropping", "phonenumber", + "selected_categories", ) }, ), diff --git a/src/open_inwoner/accounts/forms.py b/src/open_inwoner/accounts/forms.py index f00d2594da..0997162f79 100644 --- a/src/open_inwoner/accounts/forms.py +++ b/src/open_inwoner/accounts/forms.py @@ -290,6 +290,18 @@ def send_mail( email_message.send() +class CategoriesForm(forms.ModelForm): + selected_categories = forms.ModelMultipleChoiceField( + queryset=Category.objects.published(), + widget=forms.widgets.CheckboxSelectMultiple, + required=False, + ) + + class Meta: + model = User + fields = ("selected_categories",) + + class UserNotificationsForm(forms.ModelForm): class Meta: model = User diff --git a/src/open_inwoner/accounts/migrations/0073_user_selected_categories.py b/src/open_inwoner/accounts/migrations/0073_user_selected_categories.py new file mode 100644 index 0000000000..01147df7a8 --- /dev/null +++ b/src/open_inwoner/accounts/migrations/0073_user_selected_categories.py @@ -0,0 +1,24 @@ +# Generated by Django 3.2.23 on 2024-02-08 13:39 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("pdc", "0066_category_access_groups"), + ("accounts", "0072_merge_20240129_1610"), + ] + + operations = [ + migrations.AddField( + model_name="user", + name="selected_categories", + field=models.ManyToManyField( + blank=True, + related_name="selected_by", + to="pdc.Category", + verbose_name="Selected categories", + ), + ), + ] diff --git a/src/open_inwoner/accounts/models.py b/src/open_inwoner/accounts/models.py index 9781833106..491dfe2062 100644 --- a/src/open_inwoner/accounts/models.py +++ b/src/open_inwoner/accounts/models.py @@ -178,6 +178,12 @@ class User(AbstractBaseUser, PermissionsMixin): default=False, help_text=_("Indicates if fields have been prepopulated by Haal Central API."), ) + selected_categories = models.ManyToManyField( + "pdc.Category", + verbose_name=_("Selected categories"), + related_name="selected_by", + blank=True, + ) oidc_id = models.CharField( verbose_name=_("OpenId Connect id"), max_length=250, @@ -350,6 +356,12 @@ def get_new_messages_total(self) -> int: def get_all_files(self): return self.documents.order_by("-created_on") + def get_interests(self) -> list: + if not self.selected_categories.exists(): + return [] + + return list(self.selected_categories.values_list("name", flat=True)) + def get_active_notifications(self) -> str: from open_inwoner.cms.utils.page_display import ( case_page_is_published, diff --git a/src/open_inwoner/accounts/tests/test_logging.py b/src/open_inwoner/accounts/tests/test_logging.py index 96d5c3e34b..e01cc49d53 100644 --- a/src/open_inwoner/accounts/tests/test_logging.py +++ b/src/open_inwoner/accounts/tests/test_logging.py @@ -16,6 +16,7 @@ from open_inwoner.accounts.models import Invite from open_inwoner.configurations.models import SiteConfiguration +from open_inwoner.pdc.tests.factories import CategoryFactory from open_inwoner.utils.logentry import LOG_ACTIONS from ..choices import LoginTypeChoices, StatusChoices @@ -92,6 +93,30 @@ def test_users_modification_is_logged(self): }, ) + def test_categories_modification_is_logged(self): + CategoryFactory() + CategoryFactory() + form = self.app.get(reverse("profile:categories"), user=self.user).forms[ + "change-categories" + ] + + form.get("selected_categories", index=1).checked = True + form.submit() + log_entry = TimelineLog.objects.last() + + self.assertEqual( + log_entry.timestamp.strftime("%m/%d/%Y, %H:%M:%S"), "10/18/2021, 13:00:00" + ) + self.assertEqual(log_entry.content_object.id, self.user.id) + self.assertEqual( + log_entry.extra_data, + { + "message": _("categories were modified"), + "action_flag": list(LOG_ACTIONS[CHANGE]), + "content_object_repr": str(self.user), + }, + ) + @patch("open_inwoner.cms.utils.page_display._is_published", return_value=True) def test_user_notifications_update_is_logged(self, mock_cms_page_display): form = self.app.get(reverse("profile:notifications"), user=self.user).forms[ diff --git a/src/open_inwoner/accounts/tests/test_profile_views.py b/src/open_inwoner/accounts/tests/test_profile_views.py index d643e41eb4..87e5cb8902 100644 --- a/src/open_inwoner/accounts/tests/test_profile_views.py +++ b/src/open_inwoner/accounts/tests/test_profile_views.py @@ -143,6 +143,7 @@ def test_get_empty_profile_page(self): response = self.app.get(self.url, user=self.user) self.assertEquals(response.status_code, 200) + self.assertContains(response, _("U heeft geen interesses gekozen.")) self.assertContains(response, _("U heeft nog geen contacten")) self.assertContains(response, "0 acties staan open") self.assertNotContains(response, reverse("products:questionnaire_list")) @@ -151,11 +152,13 @@ def test_get_filled_profile_page(self): ActionFactory(created_by=self.user) contact = UserFactory() self.user.user_contacts.add(contact) - CategoryFactory() + category = CategoryFactory() + self.user.selected_categories.add(category) QuestionnaireStepFactory(published=True) response = self.app.get(self.url, user=self.user) self.assertEquals(response.status_code, 200) + self.assertContains(response, category.name) self.assertContains( response, f"{contact.first_name} ({contact.get_contact_type_display()})", @@ -881,6 +884,29 @@ def test_wrong_date_format_shows_birthday_none_brp_v_1_3(self, m): ) +@override_settings(ROOT_URLCONF="open_inwoner.cms.tests.urls") +class EditIntrestsTests(WebTest): + def setUp(self): + self.url = reverse("profile:categories") + self.user = UserFactory() + + def test_login_required(self): + login_url = reverse("login") + response = self.app.get(self.url) + self.assertRedirects(response, f"{login_url}?next={self.url}") + + def test_preselected_values(self): + category = CategoryFactory(name="a") + CategoryFactory(name="b") + CategoryFactory(name="c") + self.user.selected_categories.add(category) + response = self.app.get(self.url, user=self.user) + form = response.forms["change-categories"] + self.assertTrue(form.get("selected_categories", index=0).checked) + self.assertFalse(form.get("selected_categories", index=1).checked) + self.assertFalse(form.get("selected_categories", index=2).checked) + + @override_settings(ROOT_URLCONF="open_inwoner.cms.tests.urls") @patch("open_inwoner.cms.utils.page_display._is_published", return_value=True) class EditNotificationsTests(WebTest): diff --git a/src/open_inwoner/accounts/views/__init__.py b/src/open_inwoner/accounts/views/__init__.py index 1174dbcd8d..f4ba0ef62a 100644 --- a/src/open_inwoner/accounts/views/__init__.py +++ b/src/open_inwoner/accounts/views/__init__.py @@ -34,7 +34,13 @@ VerifyTokenView, ) from .password_reset import PasswordResetView -from .profile import EditProfileView, MyDataView, MyNotificationsView, MyProfileView +from .profile import ( + EditProfileView, + MyCategoriesView, + MyDataView, + MyNotificationsView, + MyProfileView, +) from .registration import CustomRegistrationView, NecessaryFieldsUserView __all__ = [ diff --git a/src/open_inwoner/accounts/views/profile.py b/src/open_inwoner/accounts/views/profile.py index e9232a0a6a..c5d8ce3ed0 100644 --- a/src/open_inwoner/accounts/views/profile.py +++ b/src/open_inwoner/accounts/views/profile.py @@ -30,7 +30,7 @@ from open_inwoner.questionnaire.models import QuestionnaireStep from open_inwoner.utils.views import CommonPageMixin, LogMixin -from ..forms import BrpUserForm, UserForm, UserNotificationsForm +from ..forms import BrpUserForm, CategoriesForm, UserForm, UserNotificationsForm from ..models import Action, User @@ -118,6 +118,8 @@ def get_context_data(self, **kwargs): context["files"] = user_files + context["selected_categories"] = user.get_interests() + context["questionnaire_exists"] = QuestionnaireStep.objects.filter( published=True ).exists() @@ -224,6 +226,31 @@ def get_form_kwargs(self): return kwargs +class MyCategoriesView( + LogMixin, LoginRequiredMixin, CommonPageMixin, BaseBreadcrumbMixin, UpdateView +): + template_name = "pages/profile/categories.html" + model = User + form_class = CategoriesForm + success_url = reverse_lazy("profile:detail") + + @cached_property + def crumbs(self): + return [ + (_("Mijn profiel"), reverse("profile:detail")), + (_("Mijn interessegebieden"), reverse("profile:categories")), + ] + + def get_object(self): + return self.request.user + + def form_valid(self, form): + form.save() + messages.success(self.request, _("Uw wijzigingen zijn opgeslagen")) + self.log_change(self.object, _("categories were modified")) + return HttpResponseRedirect(self.get_success_url()) + + class MyDataView( LogMixin, LoginRequiredMixin, CommonPageMixin, BaseBreadcrumbMixin, TemplateView ): diff --git a/src/open_inwoner/cms/products/cms_plugins.py b/src/open_inwoner/cms/products/cms_plugins.py index bec424e4df..02b11f4b41 100644 --- a/src/open_inwoner/cms/products/cms_plugins.py +++ b/src/open_inwoner/cms/products/cms_plugins.py @@ -22,19 +22,25 @@ class CategoriesPlugin(CMSActiveAppMixin, CMSPluginBase): def render(self, context, instance, placeholder): config = OpenZaakConfig.get_solo() request = context["request"] - # Show the all the highlighted categories the user has access to, as well as - # categories that are linked to ZaakTypen for which the user has Zaken within - # the specified period - visible_categories = Category.objects.published().visible_for_user(request.user) - categories = visible_categories.filter(highlighted=True) - if ( - config.enable_categories_filtering_with_zaken - and request.user.is_authenticated - and (request.user.bsn or request.user.kvk) - ): - categories |= visible_categories.filter_by_zaken_for_request(request) - - context["categories"] = categories.order_by("path") + + if request.user.is_authenticated and request.user.selected_categories.exists(): + context["categories"] = request.user.selected_categories.all() + else: + # Show the all the highlighted categories the user has access to, as well as + # categories that are linked to ZaakTypen for which the user has Zaken within + # the specified period + visible_categories = Category.objects.published().visible_for_user( + request.user + ) + categories = visible_categories.filter(highlighted=True) + if ( + config.enable_categories_filtering_with_zaken + and request.user.is_authenticated + and (request.user.bsn or request.user.kvk) + ): + categories |= visible_categories.filter_by_zaken_for_request(request) + + context["categories"] = categories.order_by("path") return context diff --git a/src/open_inwoner/cms/products/tests/test_plugin_categories.py b/src/open_inwoner/cms/products/tests/test_plugin_categories.py index da69c768fa..b441f2e965 100644 --- a/src/open_inwoner/cms/products/tests/test_plugin_categories.py +++ b/src/open_inwoner/cms/products/tests/test_plugin_categories.py @@ -588,6 +588,22 @@ def test_categories_based_on_cases(self, m): self.assertEqual(context["categories"][1], self.category6) self.assertEqual(context["categories"].last(), self.category7) + @requests_mock.Mocker() + def test_categories_based_on_selected_categories(self, m): + """ + If the user has selected categories, only these categories should show up on + the homepage + """ + self._setUpMocks(m) + + self.user.selected_categories.set([self.category1, self.category2]) + + html, context = cms_tools.render_plugin(CategoriesPlugin, user=self.user) + + self.assertEqual(context["categories"].count(), 2) + self.assertEqual(context["categories"].first(), self.category1) + self.assertEqual(context["categories"].last(), self.category2) + @requests_mock.Mocker() def test_categories_based_on_cases_for_eherkenning_user(self, m): self._setUpMocks(m) diff --git a/src/open_inwoner/cms/profile/admin.py b/src/open_inwoner/cms/profile/admin.py index 5221894037..4877e16ea3 100644 --- a/src/open_inwoner/cms/profile/admin.py +++ b/src/open_inwoner/cms/profile/admin.py @@ -10,6 +10,7 @@ class ProfileConfigAdmin(BaseAppHookConfig, admin.ModelAdmin): def get_config_fields(self): return ( "my_data", + "selected_categories", "mentors", "my_contacts", "selfdiagnose", diff --git a/src/open_inwoner/cms/profile/cms_appconfig.py b/src/open_inwoner/cms/profile/cms_appconfig.py index ab674f13c7..6aaee08cfd 100644 --- a/src/open_inwoner/cms/profile/cms_appconfig.py +++ b/src/open_inwoner/cms/profile/cms_appconfig.py @@ -12,6 +12,13 @@ class ProfileConfig(AppHookConfig): "Designates whether 'My data' section is rendered or not (Only for digid users)." ), ) + selected_categories = models.BooleanField( + verbose_name=_("Gekozen onderwerpen"), + default=True, + help_text=_( + "Designates whether 'selected categories' section is rendered or not." + ), + ) mentors = models.BooleanField( verbose_name=_("Begeleiders"), default=True, diff --git a/src/open_inwoner/cms/profile/migrations/0007_profileconfig_selected_categories.py b/src/open_inwoner/cms/profile/migrations/0007_profileconfig_selected_categories.py new file mode 100644 index 0000000000..bf9e67d0e8 --- /dev/null +++ b/src/open_inwoner/cms/profile/migrations/0007_profileconfig_selected_categories.py @@ -0,0 +1,22 @@ +# Generated by Django 3.2.23 on 2024-02-08 13:39 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("profile", "0006_alter_profileconfig_ssd"), + ] + + operations = [ + migrations.AddField( + model_name="profileconfig", + name="selected_categories", + field=models.BooleanField( + default=True, + help_text="Designates whether 'selected categories' section is rendered or not.", + verbose_name="Gekozen onderwerpen", + ), + ), + ] diff --git a/src/open_inwoner/cms/profile/urls.py b/src/open_inwoner/cms/profile/urls.py index 2a0069e5e2..2f2eb71193 100644 --- a/src/open_inwoner/cms/profile/urls.py +++ b/src/open_inwoner/cms/profile/urls.py @@ -17,6 +17,7 @@ DocumentPrivateMediaView, EditProfileView, InviteAcceptView, + MyCategoriesView, MyDataView, MyNotificationsView, MyProfileView, @@ -90,6 +91,7 @@ path("actions/", include(action_patterns)), path("contacts/", include(contact_patterns)), path("documenten/", include(documents_patterns)), + path("onderwerpen/", MyCategoriesView.as_view(), name="categories"), path("notificaties/", MyNotificationsView.as_view(), name="notifications"), path("mydata/", MyDataView.as_view(), name="data"), path("edit/", EditProfileView.as_view(), name="edit"), diff --git a/src/open_inwoner/conf/fixtures/profile_apphook_config.json b/src/open_inwoner/conf/fixtures/profile_apphook_config.json index a6145f5345..222d3fc219 100644 --- a/src/open_inwoner/conf/fixtures/profile_apphook_config.json +++ b/src/open_inwoner/conf/fixtures/profile_apphook_config.json @@ -4,6 +4,7 @@ "fields": { "namespace": "profile-apphook-config", "my_data": true, + "selected_categories": true, "mentors": true, "my_contacts": true, "selfdiagnose": true, diff --git a/src/open_inwoner/pdc/tests/test_category.py b/src/open_inwoner/pdc/tests/test_category.py index 49fa348f19..c0ec9c0219 100644 --- a/src/open_inwoner/pdc/tests/test_category.py +++ b/src/open_inwoner/pdc/tests/test_category.py @@ -189,6 +189,19 @@ def test_auto_redirect_to_link(self): self.assertEqual(response.status_code, 302) self.assertEqual(response.url, "http://www.example.com") + def test_only_published_categories_exist_in_my_categories_page(self): + response = self.app.get(reverse("profile:categories"), user=self.user) + self.assertEqual( + list(response.context["form"].fields["selected_categories"].queryset.all()), + [ + self.published1, + self.published2, + self.subcategory, + self.published3, + self.published4, + ], + ) + @override_settings(ROOT_URLCONF="open_inwoner.cms.tests.urls") class TestHighlightedQuestionnaire(WebTest): diff --git a/src/open_inwoner/templates/pages/profile/categories.html b/src/open_inwoner/templates/pages/profile/categories.html new file mode 100644 index 0000000000..1303524f7e --- /dev/null +++ b/src/open_inwoner/templates/pages/profile/categories.html @@ -0,0 +1,13 @@ +{% extends 'master.html' %} +{% load i18n form_tags %} + +{% block content %} +
+ {% trans "Selecteer hier welke onderwerpen u interesseren, om op maat gemaakte inhoud voorgeschoteld te krijgen en nog beter te kunnen zoeken en vinden" %} +
+ +{% form form_object=form method="POST" id="change-categories" submit_text=_("Opslaan") secondary_href='profile:contact_list' secondary_text=_('Terug') secondary_icon='arrow_backward' secondary_icon_position="before" extra_classes="select-grid" %} +{% endblock content %} diff --git a/src/open_inwoner/templates/pages/profile/me.html b/src/open_inwoner/templates/pages/profile/me.html index 6908f7b5b8..b7ed04f2ed 100644 --- a/src/open_inwoner/templates/pages/profile/me.html +++ b/src/open_inwoner/templates/pages/profile/me.html @@ -106,6 +106,33 @@{% trans "Mijn Interessegebieden" %}
+ + {% render_list %} + + {% for name in selected_categories %} + {% list_item text=name compact=True strong=False %} + {% empty %} +{% trans "U heeft geen interesses gekozen." %}
+ {% endfor %} + + {% endrender_list %} + + +