diff --git a/app/clubs/models.py b/app/clubs/models.py index aea0a43c..ca0d070d 100644 --- a/app/clubs/models.py +++ b/app/clubs/models.py @@ -527,6 +527,11 @@ def filter_is_admin(self): """Filter memberships that are admin memberships.""" return self.filter(roles__role_type=RoleType.ADMIN) + + def filter_is_not_admin(self): + """Filter memberships that are not admin memberships.""" + + return self.exclude(roles__role_type=RoleType.ADMIN) class ClubMembership(ClubScopedModel, ModelBase): @@ -598,6 +603,7 @@ def team_memberships(self): # Fallback to DB query return self.user.team_memberships.filter(team__club_id=self.club_id) + @cached_property def is_admin(self) -> bool: diff --git a/app/clubs/tests/test_club_apis.py b/app/clubs/tests/test_club_apis.py index 87a0b4e0..e56f71bd 100644 --- a/app/clubs/tests/test_club_apis.py +++ b/app/clubs/tests/test_club_apis.py @@ -10,7 +10,7 @@ from users.tests.utils import create_test_user from utils.testing import create_test_uploadable_image -from clubs.models import ClubApiKey, ClubFile, ClubRole +from clubs.models import ClubApiKey, ClubFile, ClubRole, RoleType from clubs.services import ClubService from clubs.tests.utils import ( CLUBS_JOIN_URL, @@ -421,3 +421,61 @@ def test_get_club_members(self): # Accepted, has permission res = self.client.get(url) self.assertResOk(res) + + def test_is_admin_none(self): + """Tests club response if is_admin is None""" + + MY_CLUB_COUNT = 3 + + + #clubs = create_test_clubs(CLUBS_COUNT) + + ClubService(self.clubs[0]).add_member(self.user, roles=["President"]) + ClubService(self.clubs[1]).add_member(self.user, roles=["Member"]) + ClubService(self.clubs[2]).add_member(self.user, roles=["Member"]) + + url = club_list_url_member() + + res = self.client.get(url) + + self.assertResOk(res) + data = res.json() + + self.assertEqual(len(data), MY_CLUB_COUNT) + + + def test_is_admin_true(self): + """Tests club response if is_admin is True""" + + MY_CLUB_COUNT = 3 + + ClubService(self.clubs[0]).add_member(self.user, roles=["President"]) + ClubService(self.clubs[1]).add_member(self.user, roles=["Member"]) + ClubService(self.clubs[2]).add_member(self.user, roles=["Member"]) + + url = club_list_url_member(is_admin=True) + + res = self.client.get(url) + + self.assertResOk(res) + data = res.json() + + self.assertEqual(len(data), MY_CLUB_COUNT) + + def test_is_admin_false(self): + """Tests club response if is_admin is False""" + + MY_CLUB_COUNT = 3 + + ClubService(self.clubs[0]).add_member(self.user, roles=["President"]) + ClubService(self.clubs[1]).add_member(self.user, roles=["Member"]) + ClubService(self.clubs[2]).add_member(self.user, roles=["Member"]) + + url = club_list_url_member(is_admin=False) + + res = self.client.get(url) + + self.assertResOk(res) + data = res.json() + + self.assertEqual(len(data), MY_CLUB_COUNT) \ No newline at end of file diff --git a/app/clubs/tests/utils.py b/app/clubs/tests/utils.py index 07d34f6e..2534b017 100644 --- a/app/clubs/tests/utils.py +++ b/app/clubs/tests/utils.py @@ -2,6 +2,10 @@ from typing import Optional from django.urls import reverse +from django.utils.http import urlencode + +from clubs.models import Club, ClubFile, ClubRole, RoleType, Team, TeamRole +from clubs.services import ClubService from lib.faker import fake from users.models import User from utils.testing import create_test_image @@ -39,8 +43,18 @@ def club_apikey_list_url(club_id: int): CLUBS_PREVIEW_LIST_URL = reverse("api-clubs:clubpreview-list") -def club_list_url_member(): - return reverse("api-clubs:club-list") +def club_list_url_member(is_admin:bool=None): + url = reverse("api-clubs:club-list") + + query_params = {} + + if is_admin: + query_params["is_admin"] = is_admin + + if query_params: + return f"{url}?{urlencode(query_params)}" + + return url def club_file_list_url(club_id: int): diff --git a/app/clubs/viewsets.py b/app/clubs/viewsets.py index cddc7c52..79360c16 100644 --- a/app/clubs/viewsets.py +++ b/app/clubs/viewsets.py @@ -90,13 +90,33 @@ class IsClubAdminFilter(FilterBackendBase): def filter_queryset(self, request, queryset, view): is_admin = request.query_params.get("is_admin", None) - if is_admin is not None: + is_admin_bool = False + + + if is_admin is None: + all_clubs = list( + request.user.club_memberships.values_list( + "club__id", flat=True + ) + ) + queryset = queryset.filter(id__in=all_clubs) + + # When type conversion works and is_admin is a boolean, update the code + if is_admin == "true": admin_clubs = list( request.user.club_memberships.filter_is_admin().values_list( "club__id", flat=True ) ) queryset = queryset.filter(id__in=admin_clubs) + + elif is_admin == "false": + member_clubs = list( + request.user.club_memberships.filter_is_not_admin().values_list( + "club__id", flat=True + ) + ) + queryset = queryset.filter(id__in=member_clubs) return queryset diff --git a/app/polls/admin.py b/app/polls/admin.py index d96708bd..a277d878 100644 --- a/app/polls/admin.py +++ b/app/polls/admin.py @@ -18,6 +18,7 @@ PollQuestionAnswer, PollSubmission, PollSubmissionLink, + PollTemplate, ScaleInput, TextInput, UploadInput, @@ -107,6 +108,10 @@ def sync_submission_links(self, request, queryset): return +class PollTemplateAdmin(PollAdmin): + """Manage poll templates in admin""" + + class TextInputInlineAdmin(admin.TabularInline): """Manage text inputs in questions admin.""" @@ -192,3 +197,4 @@ class PollSubmissionAdmin(ModelAdminBase): admin.site.register(PollMarkup) admin.site.register(ChoiceInput, ChoiceInputAdmin) admin.site.register(PollSubmission, PollSubmissionAdmin) +admin.site.register(PollTemplate, PollTemplateAdmin) diff --git a/app/polls/apis.py b/app/polls/apis.py index 6b5ff8bf..56e3e323 100644 --- a/app/polls/apis.py +++ b/app/polls/apis.py @@ -20,6 +20,12 @@ basename="pollchoiceoption", ) +router.register( + r"polltemplates", + viewsets.PollTemplateViewSet, + basename="polltemplate", +) + app_name = "api-polls" urlpatterns = [path("", include(router.urls))] diff --git a/app/polls/migrations/0024_remove_poll_only_poll_templates_allow_null_club_and_more.py b/app/polls/migrations/0024_remove_poll_only_poll_templates_allow_null_club_and_more.py new file mode 100644 index 00000000..c85c5bad --- /dev/null +++ b/app/polls/migrations/0024_remove_poll_only_poll_templates_allow_null_club_and_more.py @@ -0,0 +1,29 @@ +# Generated by Django 5.2.7 on 2025-10-20 21:43 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("clubs", "0054_clubfile_origin"), + ("django_celery_beat", "0019_alter_periodictasks_options"), + ("events", "0024_alter_event_clubs_alter_event_is_draft_and_more"), + ("polls", "0023_remove_numberinput_decimal_places_and_more"), + ] + + operations = [ + migrations.RemoveConstraint( + model_name="poll", + name="only_poll_templates_allow_null_club", + ), + migrations.AddConstraint( + model_name="poll", + constraint=models.CheckConstraint( + condition=models.Q( + ("club__isnull", True), ("poll_type", "standard"), _connector="OR" + ), + name="only_poll_templates_allow_null_club", + ), + ), + ] diff --git a/app/polls/migrations/0025_remove_poll_only_poll_templates_allow_null_club_and_more.py b/app/polls/migrations/0025_remove_poll_only_poll_templates_allow_null_club_and_more.py new file mode 100644 index 00000000..ee991a29 --- /dev/null +++ b/app/polls/migrations/0025_remove_poll_only_poll_templates_allow_null_club_and_more.py @@ -0,0 +1,29 @@ +# Generated by Django 5.2.7 on 2025-10-23 00:42 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("clubs", "0054_clubfile_origin"), + ("django_celery_beat", "0019_alter_periodictasks_options"), + ("events", "0024_alter_event_clubs_alter_event_is_draft_and_more"), + ("polls", "0024_remove_poll_only_poll_templates_allow_null_club_and_more"), + ] + + operations = [ + migrations.RemoveConstraint( + model_name="poll", + name="only_poll_templates_allow_null_club", + ), + migrations.AddConstraint( + model_name="poll", + constraint=models.CheckConstraint( + condition=models.Q( + ("club__isnull", False), ("poll_type", "template"), _connector="OR" + ), + name="only_poll_templates_allow_null_club", + ), + ), + ] diff --git a/app/polls/models.py b/app/polls/models.py index 6f586bd5..30f2e106 100644 --- a/app/polls/models.py +++ b/app/polls/models.py @@ -195,6 +195,11 @@ class Poll(ClubScopedModel, ModelBase): related_name="+", ) + # Hopefully this works + # poll_template = models.ForeignKey( + # "polls.pollTemplate", null=True, blank=True, on_delete=models.SET_NULL + # ) + # Foreign Relationships fields: models.QuerySet["PollField"] submissions: models.QuerySet["PollSubmission"] @@ -299,10 +304,8 @@ class Meta: models.CheckConstraint( name="only_poll_templates_allow_null_club", check=( - ~( - models.Q(club__isnull=True) - & models.Q(poll_type=PollType.STANDARD) - ) + models.Q(club__isnull=False) + | models.Q(poll_type=PollType.TEMPLATE.value) ), ), ] @@ -353,8 +356,10 @@ class PollSubmissionLink(Link): class PollTemplateManager(ManagerBase["PollTemplate"]): """Manage poll template queries.""" - def create(self, template_name: str, poll_name: str, **kwargs): - return super().create(template_name=template_name, name=poll_name, **kwargs) + def create(self, template_name: str, poll_name: str = None, **kwargs): + kwargs.setdefault("name", poll_name) + kwargs.setdefault("poll_type", PollType.TEMPLATE) + return super().create(template_name=template_name, **kwargs) class PollTemplate(Poll): diff --git a/app/polls/serializers.py b/app/polls/serializers.py index c4e880bf..18330732 100644 --- a/app/polls/serializers.py +++ b/app/polls/serializers.py @@ -9,7 +9,7 @@ UpdateListSerializer, ) from django.shortcuts import get_object_or_404 -from events.models import Event +from events.models import Event, EventType from rest_framework import exceptions, serializers from users.models import User from users.serializers import UserNestedSerializer @@ -368,6 +368,40 @@ def update(self, instance, validated_data): return super().update(instance, validated_data) +class PollTemplateSerializer(PollSerializer): + """Json definition for poll templates""" + + template_name = serializers.CharField() + event_type = serializers.ChoiceField( + choices=EventType.choices, allow_blank=True, required=True + ) + club = PollClubNestedSerializer(required=False, allow_null=True) + + # Hiding Fields + submissions_download_url = None + event = None + is_published = None + + class Meta: + model = models.PollTemplate + exclude = ["open_task", "close_task"] + read_only_fields = ["id", "created_at", "updated_at"] + + def create(self, validated_data): + # is_published = validated_data.pop("is_published") + event = validated_data.pop("event") + + club = validated_data.pop("club", None) + if club is not None: + validated_data["club"] = get_object_or_404(Club, id=club.get("id")) + else: + validated_data["club"] = club + + poll_name = validated_data.pop("name") + + return models.PollTemplate.objects.create(poll_name=poll_name, **validated_data) + + class PollPreviewSerializer(ModelSerializer): """Fields guest users can see for polls.""" diff --git a/app/polls/services.py b/app/polls/services.py index 94c2d5d5..008de390 100644 --- a/app/polls/services.py +++ b/app/polls/services.py @@ -44,14 +44,23 @@ def _clone_input(self, question_tpl: PollQuestion, target_question: PollQuestion max_length=question_tpl.text_input.max_length, ) case PollInputType.CHOICE: - ChoiceInput.objects.create( - questin=target_question, + # ChoiceInput.object.create( + # questin=target_question, + # ) + choice_input = ChoiceInput.objects.create( + question=target_question, ) def _clone_field(self, field_tpl: PollField, target_poll: Poll): """Clone field to poll.""" - field = target_poll.add_field(field_type=field_tpl.field_type) + # field = target_poll.add_field(field_type=field_tpl.field_type) + # print(field) + # Create new field directly instead of using add_field + field = PollField.objects.create( + poll=target_poll, + field_type=field_tpl.field_type, + ) match field.field_type: case PollFieldType.QUESTION: @@ -62,7 +71,6 @@ def _clone_field(self, field_tpl: PollField, target_poll: Poll): input_type=q_tpl.input_type, create_input=False, description=q_tpl.description, - required=q_tpl.is_required, ) self._clone_input(q_tpl, question) case PollFieldType.MARKUP: @@ -70,14 +78,26 @@ def _clone_field(self, field_tpl: PollField, target_poll: Poll): return field - def create_poll(self) -> Poll: + def create_poll(self, **kwargs) -> Poll: """Create a new poll from this one if it is a template.""" - poll = Poll.objects.create(name=self.obj.name, description=self.obj.description) + # Create the poll without any auto-created fields + poll = Poll.objects.create( + name=self.obj.name, description=self.obj.description, **kwargs + ) + + # Get template fields ordered by their order field + template_fields = self.obj.fields.all().order_by("order") - for field_tpl in self.obj.fields.all(): + # Clone each field + for field_tpl in template_fields: self._clone_field(field_tpl, poll) + # Refresh to get accurate field count + poll.refresh_from_db() + + return poll + class PollService(ServiceBase[Poll]): """Business logic for polls.""" diff --git a/app/polls/tests/test_poll_services.py b/app/polls/tests/test_poll_services.py index 15a18651..f3343999 100644 --- a/app/polls/tests/test_poll_services.py +++ b/app/polls/tests/test_poll_services.py @@ -4,7 +4,17 @@ from core.abstracts.tests import PeriodicTaskTestsBase, TestsBase from django.utils import timezone -from polls.models import Poll, PollStatusType, PollTemplate +from clubs.models import Club +from clubs.tests.utils import create_test_club +from lib.faker import fake +from polls.models import ( + Poll, + PollField, + PollInputType, + PollQuestion, + PollStatusType, + PollTemplate, +) from polls.services import PollService, PollTemplateService from polls.tests.utils import ( create_test_poll, @@ -195,31 +205,37 @@ def setUp(self): ) self.service = PollTemplateService(self.tpl) - # def test_create_poll(self): - # """Should create new poll from template.""" - - # # Setup fields - # f1 = PollField.objects.create(poll=self.tpl, order=1) - # f2 = PollField.objects.create(poll=self.tpl, order=2) - - # expected_q1 = PollQuestion.objects.create( - # field=f1, - # label=fake.sentence(), - # input_type=PollInputType.TEXT, - # create_input=True, - # ) - # expected_q2 = PollQuestion.objects.create( - # field=f2, - # label=fake.sentence(), - # input_type=PollInputType.TEXT, - # create_input=True, - # ) - - # # Generate poll - # poll = self.service.create_poll() - # self.assertIsNotNone(poll) - # self.assertEqual(poll.fields.count(), 2) - # self.assertEqual(PollField.objects.count(), 4) - - # self.assertEqual(poll.fields.get(order=1).question.label, expected_q1.label) - # self.assertEqual(poll.fields.get(order=2).question.label, expected_q2.label) + def test_create_poll(self): + """Should create new poll from template.""" + + # Setup fields + f1 = PollField.objects.create(poll=self.tpl, order=2) + f2 = PollField.objects.create(poll=self.tpl, order=3) + + club = create_test_club() + + expected_q1 = PollQuestion.objects.create( + field=f1, + label=fake.sentence(), + input_type=PollInputType.TEXT, + create_input=True, + ) + expected_q2 = PollQuestion.objects.create( + field=f2, + label=fake.sentence(), + input_type=PollInputType.TEXT, + create_input=True, + ) + + # # Generate poll + poll = self.service.create_poll(club=club) + self.assertIsNotNone(poll) + + # for field in poll.fields.all(): + # print(field.question.label) + + self.assertEqual(poll.fields.count(), 3) + self.assertEqual(PollField.objects.count(), 5) + + self.assertEqual(poll.fields.get(order=2).question.label, expected_q1.label) + self.assertEqual(poll.fields.get(order=3).question.label, expected_q2.label) diff --git a/app/polls/viewsets.py b/app/polls/viewsets.py index 44ac2acc..1dc9a35e 100644 --- a/app/polls/viewsets.py +++ b/app/polls/viewsets.py @@ -1,3 +1,4 @@ +from urllib import request from core.abstracts.viewsets import ModelViewSetBase, ViewSetBase from django.db import models, transaction from django.shortcuts import get_object_or_404 @@ -11,6 +12,7 @@ PollQuestionAnswer, PollStatusType, PollSubmission, + PollTemplate, ) from polls.serializers import ( ChoiceInputOptionSerializer, @@ -18,6 +20,7 @@ PollPreviewSerializer, PollSerializer, PollSubmissionSerializer, + PollTemplateSerializer, ) from polls.services import PollService @@ -93,6 +96,47 @@ def get_queryset(self): ) +class PollTemplateViewSet(ModelViewSetBase): + """Manage poll templates in api""" + + queryset = PollTemplate.objects.all() + serializer_class = PollTemplateSerializer + + def get_queryset(self): + user_clubs = self.request.user.clubs.all().values_list("id", flat=True) + + return ( + PollTemplate.objects.filter(club__id__in=user_clubs) + .select_related("club", "event") + .prefetch_related( + models.Prefetch( + "fields", + queryset=PollField.objects.select_related( + "_markup", + "_question", + "_question___textinput", + "_question___choiceinput", + "_question___scaleinput", + "_question___uploadinput", + "_question___numberinput", + "_question___emailinput", + "_question___phoneinput", + "_question___dateinput", + "_question___timeinput", + "_question___urlinput", + "_question___checkboxinput", + ).order_by("order", "id"), + ), + "_submission_link__qrcode", + "submissions", + ) + .annotate( + submissions_count=models.Count("submissions", distinct=True), + last_submission_at=models.Max("submissions__created_at"), + ) + ) + + class PollFieldViewSet(ModelViewSetBase): """API for managing poll fields."""