Skip to content

Commit

Permalink
add unregistered participants field to project insights to account fo…
Browse files Browse the repository at this point in the history
…r the

new feature in the poll module which allows participating without
account
  • Loading branch information
goapunk committed Nov 27, 2024
1 parent ce91244 commit 876ac6d
Show file tree
Hide file tree
Showing 13 changed files with 317 additions and 43 deletions.
30 changes: 29 additions & 1 deletion apps/projects/insights.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
Original file line number Diff line number Diff line change
@@ -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),
),
]
6 changes: 5 additions & 1 deletion apps/projects/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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),
]
Expand Down
13 changes: 11 additions & 2 deletions apps/projects/signals.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
4 changes: 2 additions & 2 deletions apps/userdashboard/apps.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,5 @@


class Config(AppConfig):
name = 'apps.userdashboard'
label = 'a4_candy_userdashboard'
name = "apps.userdashboard"
label = "a4_candy_userdashboard"
7 changes: 2 additions & 5 deletions apps/userdashboard/routers.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,8 @@

class ModerationDetailRouterMixin(CustomRouterMixin):

prefix_regex = (
r'userdashboard/moderation/(?P<project_pk>[\d]+)/{prefix}'
)
prefix_regex = r"userdashboard/moderation/(?P<project_pk>[\d]+)/{prefix}"


class ModerationDetailDefaultRouter(ModerationDetailRouterMixin,
routers.DefaultRouter):
class ModerationDetailDefaultRouter(ModerationDetailRouterMixin, routers.DefaultRouter):
pass
13 changes: 7 additions & 6 deletions apps/userdashboard/rules.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
40 changes: 25 additions & 15 deletions apps/userdashboard/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -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<slug>[-\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<slug>[-\w_]+)/$",
views.UserDashboardModerationDetailView.as_view(),
name="userdashboard-moderation-detail",
),
]
7 changes: 7 additions & 0 deletions changelog/8381.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
13 changes: 13 additions & 0 deletions docs/project_insights.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
2 changes: 2 additions & 0 deletions tests/polls/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,7 @@

register(factories.AnswerFactory)
register(factories.OpenQuestionFactory, "open_question")
register(factories.QuestionFactory)
register(factories.ChoiceFactory)
register(factories.PollFactory)
register(ProjectInsightFactory)
113 changes: 108 additions & 5 deletions tests/polls/test_project_with_polls_view.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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",
Expand All @@ -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
Loading

0 comments on commit 876ac6d

Please sign in to comment.