Skip to content

Commit

Permalink
insights: add a+ insights logic and display on project detail, also a…
Browse files Browse the repository at this point in the history
…dd project result page
  • Loading branch information
vellip committed Feb 27, 2025
1 parent b4a7cb7 commit 52f6fc1
Show file tree
Hide file tree
Showing 14 changed files with 845 additions and 14 deletions.
3 changes: 3 additions & 0 deletions changelog/8875.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
### Added
- logic & model for insights from adhocracy+ used
- project result page
118 changes: 118 additions & 0 deletions meinberlin/apps/projects/insights.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
from typing import Iterable
from typing import List

from django.core.exceptions import FieldDoesNotExist
from django.db.models import Q

from adhocracy4.comments.models import Comment
from adhocracy4.polls.models import Answer
from adhocracy4.polls.models import Poll
from adhocracy4.polls.models import Vote
from adhocracy4.projects.models import Project
from adhocracy4.ratings.models import Rating
from meinberlin.apps.budgeting.models import Proposal
from meinberlin.apps.ideas.models import Idea
from meinberlin.apps.likes.models import Like
from meinberlin.apps.livequestions.models import LiveQuestion
from meinberlin.apps.mapideas.models import MapIdea
from meinberlin.apps.maptopicprio.models import MapTopic
from meinberlin.apps.projects.models import ProjectInsight
from meinberlin.apps.topicprio.models import Topic


def create_insights(projects: Iterable[Project]) -> List[ProjectInsight]:
return [create_insight(project=project) for project in projects]


def create_insight(project: Project) -> ProjectInsight:
modules = project.modules.all()

ideas = Idea.objects.filter(module__in=modules)
map_ideas = MapIdea.objects.filter(module__in=modules)
comments = Comment.objects.filter(
Q(project=project) | Q(parent_comment__project=project)
)
proposals = Proposal.objects.filter(module__in=modules)
polls = Poll.objects.filter(module__in=modules)
votes = Vote.objects.filter(choice__question__poll__in=polls)
answers = Answer.objects.filter(question__poll__in=polls)
live_questions = LiveQuestion.objects.filter(module__in=modules)
likes = Like.objects.filter(question__in=live_questions)
topics = Topic.objects.filter(module__in=modules)
map_topics = MapTopic.objects.filter(module__in=modules)

values = [Rating.POSITIVE, Rating.NEGATIVE]
ratings_ideas = Rating.objects.filter(idea__in=ideas, value__in=values)
ratings_map_ideas = Rating.objects.filter(mapidea__in=map_ideas, value__in=values)
ratings_comments = Rating.objects.filter(comment__in=comments, value__in=values)
ratings_topics = Rating.objects.filter(topic__in=topics, value__in=values)
ratings_map_topics = Rating.objects.filter(
maptopic__in=map_topics, value__in=values
)

creator_objects = [
comments,
ratings_comments,
ratings_ideas,
ratings_map_ideas,
ratings_topics,
ratings_map_topics,
ideas,
map_ideas,
votes,
answers,
proposals,
]

rating_objects = [
ratings_comments,
ratings_map_ideas,
ratings_topics,
ratings_map_topics,
ratings_ideas,
likes,
]

idea_objects = [ideas, map_ideas, proposals, topics, map_topics]

insight, _ = ProjectInsight.objects.get_or_create(project=project)

insight.comments = comments.count()
insight.ratings = sum(x.count() for x in rating_objects)
insight.written_ideas = sum(x.count() for x in idea_objects)
insight.poll_answers = votes.count() + answers.count()
insight.live_questions = live_questions.count()
insight.save()

insight.active_participants.clear()
unregistered_participants = set()

for obj in creator_objects:
# 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,39 @@
from argparse import ArgumentParser

from django.core.management.base import BaseCommand

from meinberlin.apps import logger
from meinberlin.apps.projects.insights import create_insights
from meinberlin.apps.projects.models import Project


class Command(BaseCommand):
help = "Resets the insights and participation tables."

def add_arguments(self, parser: ArgumentParser):
parser.add_argument(
"--project",
help="project slug, resets data for this project only",
)

def handle(self, *args, **options):
slug = options["project"]

if slug:
project = Project.objects.filter(slug=slug).first()
if not project:
known = Project.objects.order_by("slug").values_list("slug", flat=True)
logger.warning(f"unknown project slug: {slug=}, {list(known)=}")
return

projects = [project]
else:
projects = Project.objects.all()

if not projects:
logger.info("no projects found")
return

insights = create_insights(projects=projects)

logger.info(f"created insights: {len(projects)=}, {len(insights)=}")
67 changes: 67 additions & 0 deletions meinberlin/apps/projects/migrations/0006_projectinsight.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
# Generated by Django 4.2.16 on 2025-02-20 14:21

from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import django.utils.timezone


class Migration(migrations.Migration):

dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
("a4projects", "0047_alter_project_image_alter_project_tile_image"),
("meinberlin_projects", "0005_topics"),
]

operations = [
migrations.CreateModel(
name="ProjectInsight",
fields=[
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"created",
models.DateTimeField(
default=django.utils.timezone.now,
editable=False,
verbose_name="Created",
),
),
(
"modified",
models.DateTimeField(
blank=True, editable=False, null=True, verbose_name="Modified"
),
),
("unregistered_participants", models.PositiveIntegerField(default=0)),
("comments", models.PositiveIntegerField(default=0)),
("ratings", models.PositiveIntegerField(default=0)),
("written_ideas", models.PositiveIntegerField(default=0)),
("poll_answers", models.PositiveIntegerField(default=0)),
("live_questions", models.PositiveIntegerField(default=0)),
(
"active_participants",
models.ManyToManyField(to=settings.AUTH_USER_MODEL),
),
(
"project",
models.OneToOneField(
on_delete=django.db.models.deletion.CASCADE,
related_name="insight",
to="a4projects.project",
),
),
],
options={
"abstract": False,
},
),
]
68 changes: 68 additions & 0 deletions meinberlin/apps/projects/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from django.conf import settings
from django.db import models
from django.urls import reverse
from django.utils.translation import gettext_lazy as _

from adhocracy4.models import base
from adhocracy4.projects.models import Project
Expand Down Expand Up @@ -71,3 +72,70 @@ def get_absolute_url(self):
def accept(self, user):
self.project.moderators.add(user)
super().accept(user)


class ProjectInsight(base.TimeStampedModel):
project = models.OneToOneField(
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)
poll_answers = models.PositiveIntegerField(default=0)
live_questions = models.PositiveIntegerField(default=0)

@staticmethod
def update_context(project, context):
insight, created = ProjectInsight.objects.get_or_create(project=project)
context.update(create_insight_context(insight=insight))
return context

def __str__(self):
return "Insights for project %s" % self.project.name


def create_insight_context(insight: ProjectInsight) -> dict:
"""
("BS", _("brainstorming")),
("MBS", _("spatial brainstorming")),
("IC", _("idea challenge")),
("MIC", _("spatial idea challenge")),
("TR", _("text review")),
("PO", _("poll")),
("PB", _("participatory budgeting")),
("IE", _("interactive event")),
("TP", _("prioritization")),
("DB", _("debate")),
"""

active_modules = [
module for module in insight.project.modules if not module.is_draft
]
blueprint_types = {module.blueprint_type for module in active_modules}
show_polls = "PO" in blueprint_types
show_live_questions = "IE" in blueprint_types
show_ideas = bool(blueprint_types.intersection({"BS", "IC", "MBS", "MIC", "PB"}))

counts = [
(
_("active participants"),
insight.active_participants.count() + insight.unregistered_participants,
),
(_("comments"), insight.comments),
(_("ratings"), insight.ratings),
]

if show_ideas:
counts.append((_("written ideas"), insight.written_ideas))

if show_polls:
counts.append((_("poll answers"), insight.poll_answers))

if show_live_questions:
counts.append((_("interactive event questions"), insight.live_questions))

return dict(
counts=counts,
)
Loading

0 comments on commit 52f6fc1

Please sign in to comment.