diff --git a/apps/projects/insights.py b/apps/projects/insights.py index c6f075952..e01f6742a 100644 --- a/apps/projects/insights.py +++ b/apps/projects/insights.py @@ -1,6 +1,8 @@ from typing import Iterable from typing import List +from django.core.exceptions import FieldDoesNotExist + from adhocracy4.polls.models import Answer from adhocracy4.polls.models import Poll from adhocracy4.polls.models import Vote @@ -73,8 +75,34 @@ def create_insight(project: Project) -> ProjectInsight: insight.save() insight.active_participants.clear() + unregistered_participants = set() + for obj in creator_objects: - ids = list(obj.values_list("creator", flat=True).distinct().order_by()) + # ignore objects which don't have a creator, they are counted in the next step. + ids = list( + obj.filter(creator__isnull=False) + .values_list("creator", flat=True) + .distinct() + .order_by() + ) insight.active_participants.add(*ids) + # content from unregistered users doesn't have a creator but a content_id + if model_field_exists(obj.model, "content_id"): + content_ids = set( + obj.filter(content_id__isnull=False) + .values_list("content_id", flat=True) + .distinct() + .order_by() + ) + unregistered_participants = unregistered_participants.union(content_ids) + insight.unregistered_participants = len(unregistered_participants) return insight + + +def model_field_exists(cls, field): + try: + cls._meta.get_field(field) + return True + except FieldDoesNotExist: + return False diff --git a/apps/projects/migrations/0007_projectinsight_unregistered_participants.py b/apps/projects/migrations/0007_projectinsight_unregistered_participants.py new file mode 100644 index 000000000..9895ee124 --- /dev/null +++ b/apps/projects/migrations/0007_projectinsight_unregistered_participants.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.16 on 2024-11-04 15:18 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("a4_candy_projects", "0006_initialize_insights"), + ] + + operations = [ + migrations.AddField( + model_name="projectinsight", + name="unregistered_participants", + field=models.PositiveIntegerField(default=0), + ), + ] diff --git a/apps/projects/models.py b/apps/projects/models.py index 2959ddf8b..095f5cb7b 100644 --- a/apps/projects/models.py +++ b/apps/projects/models.py @@ -93,6 +93,7 @@ class ProjectInsight(base.TimeStampedModel): Project, related_name="insight", on_delete=models.CASCADE ) active_participants = models.ManyToManyField(settings.AUTH_USER_MODEL) + unregistered_participants = models.PositiveIntegerField(default=0) comments = models.PositiveIntegerField(default=0) ratings = models.PositiveIntegerField(default=0) written_ideas = models.PositiveIntegerField(default=0) @@ -134,7 +135,10 @@ def create_insight_context(insight: ProjectInsight) -> dict: show_ideas = bool(blueprint_types.intersection({"BS", "IC", "MBS", "MIC", "PB"})) counts = [ - (_("active participants"), insight.active_participants.count()), + ( + _("active participants"), + insight.active_participants.count() + insight.unregistered_participants, + ), (_("comments"), insight.comments), (_("ratings"), insight.ratings), ] diff --git a/apps/projects/signals.py b/apps/projects/signals.py index 7a450e909..8c2759a65 100644 --- a/apps/projects/signals.py +++ b/apps/projects/signals.py @@ -4,6 +4,7 @@ from adhocracy4.comments.models import Comment from adhocracy4.polls.models import Answer from adhocracy4.polls.models import Vote +from adhocracy4.polls.signals import poll_voted from adhocracy4.projects.models import Project from adhocracy4.ratings.models import Rating from apps.budgeting.models import Proposal @@ -93,6 +94,14 @@ def increase_poll_answers_count(sender, instance, created, **kwargs): insight, _ = ProjectInsight.objects.get_or_create(project=project) insight.poll_answers += 1 - if instance.creator: - insight.active_participants.add(instance.creator.id) insight.save() + + +@receiver(poll_voted) +def increase_poll_participant_count(sender, poll, creator, content_id, **kwargs): + insight, _ = ProjectInsight.objects.get_or_create(project=poll.module.project) + if creator: + insight.active_participants.add(creator.id) + else: + insight.unregistered_participants += 1 + insight.save() diff --git a/apps/userdashboard/apps.py b/apps/userdashboard/apps.py index 56a1756be..e0517adfe 100644 --- a/apps/userdashboard/apps.py +++ b/apps/userdashboard/apps.py @@ -2,5 +2,5 @@ class Config(AppConfig): - name = 'apps.userdashboard' - label = 'a4_candy_userdashboard' + name = "apps.userdashboard" + label = "a4_candy_userdashboard" diff --git a/apps/userdashboard/routers.py b/apps/userdashboard/routers.py index 24323edd3..40cfe7e82 100644 --- a/apps/userdashboard/routers.py +++ b/apps/userdashboard/routers.py @@ -5,11 +5,8 @@ class ModerationDetailRouterMixin(CustomRouterMixin): - prefix_regex = ( - r'userdashboard/moderation/(?P[\d]+)/{prefix}' - ) + prefix_regex = r"userdashboard/moderation/(?P[\d]+)/{prefix}" -class ModerationDetailDefaultRouter(ModerationDetailRouterMixin, - routers.DefaultRouter): +class ModerationDetailDefaultRouter(ModerationDetailRouterMixin, routers.DefaultRouter): pass diff --git a/apps/userdashboard/rules.py b/apps/userdashboard/rules.py index 6a096f847..90841eb77 100644 --- a/apps/userdashboard/rules.py +++ b/apps/userdashboard/rules.py @@ -3,11 +3,12 @@ from adhocracy4.modules.predicates import is_allowed_moderate_project from apps.users.predicates import is_moderator -rules.add_perm('a4_candy_userdashboard.view_moderation_dashboard', - is_moderator) +rules.add_perm("a4_candy_userdashboard.view_moderation_dashboard", is_moderator) -rules.add_perm('a4_candy_userdashboard.view_moderation_comment', - is_allowed_moderate_project) +rules.add_perm( + "a4_candy_userdashboard.view_moderation_comment", is_allowed_moderate_project +) -rules.add_perm('a4_candy_userdashboard.change_moderation_comment', - is_allowed_moderate_project) +rules.add_perm( + "a4_candy_userdashboard.change_moderation_comment", is_allowed_moderate_project +) diff --git a/apps/userdashboard/urls.py b/apps/userdashboard/urls.py index 322a623ed..09c36d5e4 100644 --- a/apps/userdashboard/urls.py +++ b/apps/userdashboard/urls.py @@ -4,19 +4,29 @@ from . import views urlpatterns = [ - path('overview/', - views.UserDashboardOverviewView.as_view(), - name='userdashboard-overview'), - path('moderation/', - views.UserDashboardModerationView.as_view(), - name='userdashboard-moderation'), - path('overview/activities/', - views.UserDashboardActivitiesView.as_view(), - name='userdashboard-activities'), - path('overview/following/', - views.UserDashboardFollowingView.as_view(), - name='userdashboard-following'), - re_path(r'^moderation/detail/(?P[-\w_]+)/$', - views.UserDashboardModerationDetailView.as_view(), - name='userdashboard-moderation-detail'), + path( + "overview/", + views.UserDashboardOverviewView.as_view(), + name="userdashboard-overview", + ), + path( + "moderation/", + views.UserDashboardModerationView.as_view(), + name="userdashboard-moderation", + ), + path( + "overview/activities/", + views.UserDashboardActivitiesView.as_view(), + name="userdashboard-activities", + ), + path( + "overview/following/", + views.UserDashboardFollowingView.as_view(), + name="userdashboard-following", + ), + re_path( + r"^moderation/detail/(?P[-\w_]+)/$", + views.UserDashboardModerationDetailView.as_view(), + name="userdashboard-moderation-detail", + ), ] diff --git a/changelog/8381.md b/changelog/8381.md index 9ea149a4d..7f1912e40 100644 --- a/changelog/8381.md +++ b/changelog/8381.md @@ -1,3 +1,10 @@ +### Added + +- add a new field `unregistered_participants` to `ProjectInsights` to allow +tracking unregistered participants in polls. +- add a signal handler for the new `poll_voted` signal which increases the +unregistered participants count in the project insights. + ### Changed - add a check for the creator field in the Answer/Vote signal for the poll to diff --git a/docs/project_insights.md b/docs/project_insights.md index e332656f7..6f1cbe3a0 100644 --- a/docs/project_insights.md +++ b/docs/project_insights.md @@ -48,3 +48,16 @@ Finally, we decided that we want to add a custom migration that initializes the The custom migration (0006_initialize_insights.py) was hard to write because of the limited query manager access in custom migrations (Django's fake migration models). In particular, the generic relations on ratings and comments forced us to migrate by "looping and counting". Finally, we added a management command ("reset_insights_table") to refresh the insights table. This code makes use of regular models (instead of Django's fake migration models) and can therefore be tested and keeps our options open for future background tasks and bug fixes. + +## Updates + +- With the new feature of the poll module optionally allowing unregistered users + to vote, we had to find a way for the number of participants to include +unregistered users, as currently they are tied to the user as part of a m2m +relation. We introduced a new base model `GeneratedContent` which has an +optional field `creator` which is used if a registered user participated and an optional field +`content_id` for unregistered users. In case of a poll submission from an +unregistered user a unique uuid4 is created and +stored in `content_id` to allow counting the amount of unregistered users which +participated in the poll. We extended the `ProjectInsight` model with a +`unregistered_participants` field which stores this number. diff --git a/tests/polls/conftest.py b/tests/polls/conftest.py index 0da022829..6af17da97 100644 --- a/tests/polls/conftest.py +++ b/tests/polls/conftest.py @@ -5,5 +5,7 @@ register(factories.AnswerFactory) register(factories.OpenQuestionFactory, "open_question") +register(factories.QuestionFactory) +register(factories.ChoiceFactory) register(factories.PollFactory) register(ProjectInsightFactory) diff --git a/tests/polls/test_project_with_polls_view.py b/tests/polls/test_project_with_polls_view.py index 66acf7f5d..28256f331 100644 --- a/tests/polls/test_project_with_polls_view.py +++ b/tests/polls/test_project_with_polls_view.py @@ -1,9 +1,14 @@ import pytest from django.urls import reverse +from rest_framework import status from adhocracy4.polls import phases +from adhocracy4.polls.models import Answer from adhocracy4.polls.models import Poll +from adhocracy4.polls.models import Vote +from adhocracy4.test.helpers import freeze_phase from adhocracy4.test.helpers import setup_phase +from apps.projects.models import ProjectInsight @pytest.mark.django_db @@ -20,16 +25,16 @@ def test_project_with_single_poll_module_and_insights( ) project_insight = project_insight_factory(project=project) + poll_answers = project_insight.poll_answers assert hasattr(project, "insight") poll = Poll.objects.first() question = open_question_factory(poll=poll) - # post save signal in answer obj is called for - # adding the creator of the answer to the insights - answer = answer_factory(question=question) - - assert project_insight.active_participants.first() == answer.creator + # post save signal in answer obj is called to increase the poll answer count + answer_factory(question=question) + project_insight.refresh_from_db() + assert project_insight.poll_answers == poll_answers + 1 url = reverse( "project-detail", @@ -38,3 +43,101 @@ def test_project_with_single_poll_module_and_insights( response = client.get(url) assert "insight_label" in response.context_data.keys() + + +@pytest.mark.django_db +def test_normal_user_vote_is_added_as_participant( + user, apiclient, poll_factory, phase_factory, question_factory, choice_factory +): + + phase, module, project, _ = setup_phase( + phase_factory, poll_factory, phases.VotingPhase + ) + + poll = Poll.objects.first() + question = question_factory(poll=poll) + choice1 = choice_factory(question=question) + choice_factory(question=question) + open_question = question_factory(poll=poll, is_open=True) + + assert Vote.objects.count() == 0 + + apiclient.force_authenticate(user=user) + + url = reverse("polls-vote", kwargs={"pk": poll.pk}) + + data = { + "votes": { + question.pk: { + "choices": [choice1.pk], + "other_choice_answer": "", + "open_answer": "", + }, + open_question.pk: { + "choices": [], + "other_choice_answer": "", + "open_answer": "an open answer", + }, + }, + "agreed_terms_of_use": True, + } + + with freeze_phase(phase): + response = apiclient.post(url, data, format="json") + assert response.status_code == status.HTTP_201_CREATED + + insight = ProjectInsight.objects.first() + + assert Vote.objects.count() == 1 + assert Answer.objects.count() == 1 + assert insight.active_participants.count() == 1 + + +@pytest.mark.django_db +def test_unregistered_user_vote_is_added_as_participant( + user, apiclient, poll_factory, phase_factory, question_factory, choice_factory +): + + phase, module, project, _ = setup_phase( + phase_factory, poll_factory, phases.VotingPhase + ) + + poll = Poll.objects.first() + poll.allow_unregistered_users = True + poll.save() + question = question_factory(poll=poll) + choice1 = choice_factory(question=question) + choice_factory(question=question) + open_question = question_factory(poll=poll, is_open=True) + + assert Vote.objects.count() == 0 + + url = reverse("polls-vote", kwargs={"pk": poll.pk}) + + data = { + "votes": { + question.pk: { + "choices": [choice1.pk], + "other_choice_answer": "", + "open_answer": "", + }, + open_question.pk: { + "choices": [], + "other_choice_answer": "", + "open_answer": "an open answer", + }, + }, + "agreed_terms_of_use": True, + "captcha": "testpass:1", + } + + with freeze_phase(phase): + response = apiclient.post(url, data, format="json") + assert response.status_code == status.HTTP_201_CREATED + + insight = ProjectInsight.objects.first() + + assert Vote.objects.count() == 1 + assert Answer.objects.count() == 1 + assert insight.active_participants.count() == 0 + assert insight.unregistered_participants == 1 diff --git a/tests/projects/test_insights.py b/tests/projects/test_insights.py index fbfe97fdd..55556df70 100644 --- a/tests/projects/test_insights.py +++ b/tests/projects/test_insights.py @@ -1,5 +1,12 @@ import pytest - +from django.urls import reverse +from rest_framework import status + +from adhocracy4.polls import phases +from adhocracy4.polls.models import Poll +from adhocracy4.polls.models import Vote +from adhocracy4.test.helpers import freeze_phase +from adhocracy4.test.helpers import setup_phase from apps.dashboard.blueprints import blueprints from apps.projects.insights import create_insight from apps.projects.models import ProjectInsight @@ -18,19 +25,18 @@ def test_draft_modules_do_not_trigger_show_results( question_factory, answer_factory, ): - n_active_participants = 2 + n_answers = 2 expected_label = "poll answers" project = project_factory() module = module_factory(project=project, is_draft=False, blueprint_type="PO") poll = poll_factory(module=module) question = question_factory(poll=poll, is_open=True) - answer_factory.create_batch(size=n_active_participants, question=question) + answer_factory.create_batch(size=n_answers, question=question) insight = insight_provider(project=project) - assert insight.active_participants.count() == n_active_participants - assert insight.poll_answers == n_active_participants + assert insight.poll_answers == n_answers context = create_insight_context(insight=insight) labels = [label for label, count in context["counts"]] @@ -229,7 +235,6 @@ def test_complex_example( assert insight.comments == len(comments) assert insight.poll_answers == len(answers) + len(votes) assert insight.ratings == len(ratings) + len(likes) - assert insight.active_participants.count() == len(users) @pytest.mark.django_db @@ -263,3 +268,80 @@ def test_create_insight_for_ideas( assert insight.ratings == 3 assert insight.comments == 3 assert insight.active_participants.count() == 4 + + +@pytest.mark.django_db +@pytest.mark.parametrize("insight_provider", [create_insight, get_insight]) +def test_create_insight_contexts_combines_unregistered_users_and_registered_users( + apiclient, + user_factory, + phase_factory, + poll_factory, + choice_factory, + question_factory, + insight_provider, +): + phase, module, project, _ = setup_phase( + phase_factory, poll_factory, phases.VotingPhase + ) + + poll = Poll.objects.first() + poll.allow_unregistered_users = True + poll.save() + question = question_factory(poll=poll) + choice1 = choice_factory(question=question) + choice_factory(question=question) + open_question = question_factory(poll=poll, is_open=True) + + assert Vote.objects.count() == 0 + + url = reverse("polls-vote", kwargs={"pk": poll.pk}) + + users = user_factory.create_batch(size=4) + n_unregistered_users = 2 + with freeze_phase(phase): + for i in range(n_unregistered_users): + data = { + "votes": { + question.pk: { + "choices": [choice1.pk], + "other_choice_answer": "", + "open_answer": "", + }, + open_question.pk: { + "choices": [], + "other_choice_answer": "", + "open_answer": "an open answer", + }, + }, + "agreed_terms_of_use": True, + "captcha": "testpass:1", + } + response = apiclient.post(url, data, format="json") + assert response.status_code == status.HTTP_201_CREATED + + for user in users: + apiclient.force_authenticate(user=user) + data = { + "votes": { + question.pk: { + "choices": [choice1.pk], + "other_choice_answer": "", + "open_answer": "", + }, + open_question.pk: { + "choices": [], + "other_choice_answer": "", + "open_answer": "an open answer", + }, + }, + "agreed_terms_of_use": True, + } + response = apiclient.post(url, data, format="json") + assert response.status_code == status.HTTP_201_CREATED + + insight = insight_provider(project=project) + assert insight.active_participants.count() == len(users) + assert insight.unregistered_participants == n_unregistered_users + context = create_insight_context(insight) + assert context["counts"][0][1] == len(users) + n_unregistered_users