Skip to content

Commit

Permalink
polls: add option to allow unregistered users to participate
Browse files Browse the repository at this point in the history
  • Loading branch information
goapunk authored and m4ra committed Nov 5, 2024
1 parent a5f3871 commit 1796224
Show file tree
Hide file tree
Showing 24 changed files with 1,085 additions and 211 deletions.
6 changes: 5 additions & 1 deletion adhocracy4/follows/signals.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,11 @@


def autofollow_hook(instance, **kwargs):
if hasattr(instance.project, "id"):
"""Hook which makes a user automatically follow a project they created content
in. Used in cominbation with the post_save signal for all models defined in
settings.A4_AUTO_FOLLOWABLES. Content by unregistered users needs to be ignored,
hence the check for instance.creator"""
if hasattr(instance.project, "id") and instance.creator:
models.Follow.objects.get_or_create(
project=instance.project,
creator=instance.creator,
Expand Down
22 changes: 22 additions & 0 deletions adhocracy4/models/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@


class TimeStampedModel(models.Model):
"""Base model which stores the date and time of its creation and its last
modification"""

created = models.DateTimeField(
verbose_name=_("Created"),
editable=False,
Expand All @@ -29,7 +32,26 @@ def save(self, ignore_modified=False, update_fields=None, *args, **kwargs):


class UserGeneratedContentModel(TimeStampedModel):
"""Base model for all content created by registered users on the platform. The
user who created the content is stored in the creator field."""

creator = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)

class Meta:
abstract = True


class GeneratedContentModel(TimeStampedModel):
"""Base model for content on the platform created by unregistered or registered
users. The tie to the user who created it is optional. For content from
unregistered users the content_id field can be used to differentiate between
different users. This can be useful for example to count the number of
unregistered participants in a poll."""

content_id = models.UUIDField(null=True, blank=True)
creator = models.ForeignKey(
settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True
)

class Meta:
abstract = True
1 change: 1 addition & 0 deletions adhocracy4/modules/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -426,6 +426,7 @@ def first_phase_start_date(self):


class Item(base.UserGeneratedContentModel):

module = models.ForeignKey(Module, on_delete=models.CASCADE)

@cached_property
Expand Down
120 changes: 93 additions & 27 deletions adhocracy4/polls/api.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
import json
import re
import uuid

import requests
from django.apps import apps
from django.conf import settings
from django.db import transaction
Expand Down Expand Up @@ -30,6 +35,9 @@
class PollViewSet(
mixins.RetrieveModelMixin, mixins.UpdateModelMixin, viewsets.GenericViewSet
):
"""ViewSet used to edit a poll from the dashboard and to display and vote on the
user side."""

queryset = Poll.objects.all()
serializer_class = PollSerializer
permission_classes = (ViewSetRulesPermission,)
Expand All @@ -40,6 +48,9 @@ def get_permission_object(self):

@property
def rules_method_map(self):
"""This modifies the default rules to require the add_vote permission to POST
instead of the default add_poll. This works because we only allow POST for
votes and not for creating Polls."""
return ViewSetRulesPermission.default_rules_method_map._replace(
POST="a4polls.add_vote",
)
Expand All @@ -53,6 +64,8 @@ def _get_org_terms_model(self):
return OrganisationTermsOfUse

def _user_has_agreed(self, user):
if not user.is_authenticated:
return False
OrganisationTermsOfUse = self._get_org_terms_model()
organisation = self.get_object().project.organisation
user_has_agreed = OrganisationTermsOfUse.objects.filter(
Expand All @@ -66,7 +79,7 @@ def add_terms_of_use_info(self, request, data):
hasattr(settings, "A4_USE_ORGANISATION_TERMS_OF_USE")
and settings.A4_USE_ORGANISATION_TERMS_OF_USE
):
user_has_agreed = None
user_has_agreed = False
use_org_terms_of_use = True
organisation = self.get_object().project.organisation
try:
Expand All @@ -93,57 +106,108 @@ def retrieve(self, request, *args, **kwargs):
response.data = self.add_terms_of_use_info(request, response.data)
return response

@action(detail=True, methods=["post"], permission_classes=[ViewSetRulesPermission])
def vote(self, request, pk):
if (
hasattr(settings, "A4_USE_ORGANISATION_TERMS_OF_USE")
and settings.A4_USE_ORGANISATION_TERMS_OF_USE
and not self._user_has_agreed(self.request.user)
def __verify_captcha(self, answer, session):
if hasattr(settings, "CAPTCHA_TEST_ACCEPTED_ANSWER"):
return answer == settings.CAPTCHA_TEST_ACCEPTED_ANSWER

if not hasattr(settings, "CAPTCHA_URL"):
return False

url = settings.CAPTCHA_URL
data = {"session_id": session, "answer_id": answer, "action": "verify"}
response = requests.post(url, data)
return json.loads(response.text)["result"]

def check_captcha(self):
if not self.request.data.get("captcha", False):
raise ValidationError(_("Your answer to the captcha was wrong."))

combined_answer = self.request.data["captcha"].split(":")
if len(combined_answer) != 2:
raise ValidationError(
_("Something about the answer to the captcha was wrong.")
)

answer, session = combined_answer

if not re.match(r"[0-9a-zA-ZäöüÄÖÜß]+", answer) or not re.match(
r"[0-9a-fA-F]+", session
):
if (
"agreed_terms_of_use" in self.request.data
and self.request.data["agreed_terms_of_use"]
):
OrganisationTermsOfUse = self._get_org_terms_model()
OrganisationTermsOfUse.objects.update_or_create(
user=self.request.user,
organisation=self.get_object().project.organisation,
defaults={"has_agreed": self.request.data["agreed_terms_of_use"]},
)
raise ValidationError(
_("Something about the answer to the captcha was wrong.")
)

if not self.__verify_captcha(answer, session):
raise ValidationError(_("Your answer to the captcha was wrong."))

def check_terms_of_use(self):
if getattr(
settings, "A4_USE_ORGANISATION_TERMS_OF_USE", False
) and not self._user_has_agreed(self.request.user):
if self.request.data.get("agreed_terms_of_use", False):
if self.request.user.is_authenticated:
OrganisationTermsOfUse = self._get_org_terms_model()
OrganisationTermsOfUse.objects.update_or_create(
user=self.request.user,
organisation=self.get_object().project.organisation,
defaults={
"has_agreed": self.request.data["agreed_terms_of_use"]
},
)
else:
raise ValidationError(
{_("Please agree to the organisation's terms of use.")}
)

@action(detail=True, methods=["post"], permission_classes=[ViewSetRulesPermission])
def vote(self, request, pk):
if not self.request.user.is_authenticated:
self.check_captcha()
self.check_terms_of_use()

creator = None
content_id = None
if self.request.user.is_authenticated:
creator = self.request.user
else:
content_id = uuid.uuid4()
for question_id in request.data["votes"]:
question = self.get_question(question_id)
validators.question_belongs_to_poll(question, int(pk))
vote_data = self.request.data["votes"][question_id]
self.save_vote(question, vote_data)
self.save_vote(question, vote_data, creator, content_id)

poll = self.get_object()
poll_serializer = self.get_serializer(poll)
poll_data = self.add_terms_of_use_info(request, poll_serializer.data)
if not self.request.user.is_authenticated:
# set poll to read only after voting to prevent users from seeing the
# voting screen again
poll_data["questions"][0]["isReadOnly"] = True
return Response(poll_data, status=status.HTTP_201_CREATED)

def save_vote(self, question, vote_data):
def save_vote(self, question, vote_data, creator, content_id):
choices, other_choice_answer, open_answer = self.get_data(vote_data)

if not question.is_open:
self.validate_choices(question, choices)

with transaction.atomic():

if question.is_open:
self.clear_open_answer(question)
if open_answer:
Answer.objects.create(
question=question, answer=open_answer, creator=self.request.user
question=question,
answer=open_answer,
creator=creator,
content_id=content_id,
)
else:
self.clear_current_choices(question)
for choice in choices:
vote = Vote.objects.create(
choice_id=choice.id, creator=self.request.user
choice_id=choice.id, creator=creator, content_id=content_id
)
if choice.is_other_choice:
if other_choice_answer:
Expand Down Expand Up @@ -176,14 +240,16 @@ def get_question(self, id):
return get_object_or_404(Question, pk=id)

def clear_open_answer(self, question):
Answer.objects.filter(
question_id=question.id, creator=self.request.user
).delete()
if self.request.user.is_authenticated:
Answer.objects.filter(
question_id=question.id, creator=self.request.user
).delete()

def clear_current_choices(self, question):
Vote.objects.filter(
choice__question_id=question.id, creator=self.request.user
).delete()
if self.request.user.is_authenticated:
Vote.objects.filter(
choice__question_id=question.id, creator=self.request.user
).delete()

def validate_choices(self, question, choices):
if len(choices) > len(set(choices)):
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
# Generated by Django 4.2.13 on 2024-10-29 08:25

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


class Migration(migrations.Migration):

dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
("a4polls", "0005_verbose_name_created_modified"),
]

operations = [
migrations.AlterUniqueTogether(
name="answer",
unique_together=set(),
),
migrations.AddField(
model_name="answer",
name="content_id",
field=models.UUIDField(blank=True, null=True),
),
migrations.AddField(
model_name="poll",
name="allow_unregistered_users",
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name="vote",
name="content_id",
field=models.UUIDField(blank=True, null=True),
),
migrations.AlterField(
model_name="answer",
name="creator",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
to=settings.AUTH_USER_MODEL,
),
),
migrations.AlterField(
model_name="vote",
name="creator",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
to=settings.AUTH_USER_MODEL,
),
),
migrations.AlterUniqueTogether(
name="answer",
unique_together={("question", "creator", "content_id")},
),
]
Loading

0 comments on commit 1796224

Please sign in to comment.