From 2d98aba7e74b7bb88c4ba9e29d4f38a05f28e78d Mon Sep 17 00:00:00 2001 From: Jonathan Hooth Date: Wed, 15 Oct 2025 20:29:38 -0400 Subject: [PATCH 1/5] Fixed models --- app/app/asgi.py | 5 ++- app/clubs/forms.py | 3 -- app/clubs/views.py | 19 ++++---- app/core/urls.py | 9 +++- app/lib/allauth.py | 11 +++-- app/polls/models.py | 10 +++-- app/polls/serializers.py | 9 +++- app/polls/services.py | 32 +++++++++++--- app/polls/tests/test_poll_services.py | 63 ++++++++++++++++----------- app/polls/viewsets.py | 8 ++++ 10 files changed, 110 insertions(+), 59 deletions(-) diff --git a/app/app/asgi.py b/app/app/asgi.py index 00417f00..c0bde199 100644 --- a/app/app/asgi.py +++ b/app/app/asgi.py @@ -12,13 +12,14 @@ from django.core.asgi import get_asgi_application from uvicorn.workers import UvicornWorker + class DjangoUvicornWorker(UvicornWorker): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self.config.lifespan = 'off' + self.config.lifespan = "off" -os.environ.setdefault("DJANGO_SETTINGS_MODULE", "app.settings") +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "app.settings") application = get_asgi_application() diff --git a/app/clubs/forms.py b/app/clubs/forms.py index d0e87eb5..b994ca20 100644 --- a/app/clubs/forms.py +++ b/app/clubs/forms.py @@ -1,5 +1,4 @@ from django import forms -from django.db.models import query from clubs.models import Club, Team, TeamMembership from users.models import User @@ -35,5 +34,3 @@ class AdminInviteForm(forms.Form): email = forms.CharField(max_length=100) club = forms.ModelChoiceField(queryset=Club.objects.all()) send_inv = forms.BooleanField(label="Send Invite", required=True) - - \ No newline at end of file diff --git a/app/clubs/views.py b/app/clubs/views.py index 2660e243..63327653 100644 --- a/app/clubs/views.py +++ b/app/clubs/views.py @@ -8,12 +8,11 @@ from django.shortcuts import get_object_or_404, redirect, render from django.urls import reverse -#from asgiref import sync_to_async - +# from asgiref import sync_to_async from clubs.forms import AdminInviteForm from clubs.models import Club, RoleType from clubs.services import ClubService -from users.models import User, UserManager +from users.models import User from users.services import UserService from utils.admin import get_admin_context @@ -48,7 +47,7 @@ def available_clubs_view(request: HttpRequest): @login_required def invite_club_admin_view(request): context = get_admin_context(request) - + form = AdminInviteForm() if request.method == "POST": @@ -61,21 +60,23 @@ def invite_club_admin_view(request): try: user = get_object_or_404(User, email=email) - except: + except Exception: user = User.objects.create_user(email) club_id = data["club"] club = Club.objects.get(pk=club_id) # Get list of club roles, and pick an admin role - admin_roles = club.roles.filter(role_type=RoleType.ADMIN).exclude(name__iexact="President") + admin_roles = club.roles.filter(role_type=RoleType.ADMIN).exclude( + name__iexact="President" + ) assigned_role = [admin_roles.first()] send_inv = data["send_inv"] - #Email for account set up if needed + # Email for account set up if needed ClubService(club).add_member(user, assigned_role, send_email=send_inv) - #Email for account set up if needed + # Email for account set up if needed UserService(user).send_account_setup_link() else: @@ -83,4 +84,4 @@ def invite_club_admin_view(request): context["form"] = form - return render(request, "admin/clubs/invite_club_admin.html", context=context) \ No newline at end of file + return render(request, "admin/clubs/invite_club_admin.html", context=context) diff --git a/app/core/urls.py b/app/core/urls.py index 97477a47..c0d9c52e 100644 --- a/app/core/urls.py +++ b/app/core/urls.py @@ -1,14 +1,19 @@ from django.urls import path from django.views.generic import RedirectView -from . import views from clubs import views as club_views +from . import views + app_name = "core" urlpatterns = [ path("", RedirectView.as_view(url="/admin"), name="index"), path("health/", views.health_check, name="health"), path("admin/sysinfo/", views.sys_info, name="sysinfo"), - path("admin/clubs/invite-club-admin", club_views.invite_club_admin_view, name="invite_club_admin") + path( + "admin/clubs/invite-club-admin", + club_views.invite_club_admin_view, + name="invite_club_admin", + ), ] diff --git a/app/lib/allauth.py b/app/lib/allauth.py index 5dad6a69..3a0d2c8d 100644 --- a/app/lib/allauth.py +++ b/app/lib/allauth.py @@ -3,7 +3,6 @@ from allauth.socialaccount.adapter import DefaultSocialAccountAdapter from allauth.socialaccount.providers.base import Provider from allauth.socialaccount.providers.oauth2.provider import OAuth2Provider -from rest_framework import exceptions from users.models import User @@ -24,11 +23,11 @@ def populate_user(self, request, sociallogin, data): email = data.get("email") existing_user = User.objects.find_by_email(email=email) -# if existing_user: -# raise exceptions.AuthenticationFailed( -# detail=f"User already exists with email {email}" -# ) -# + # if existing_user: + # raise exceptions.AuthenticationFailed( + # detail=f"User already exists with email {email}" + # ) + # return super().populate_user(request, sociallogin, data) diff --git a/app/polls/models.py b/app/polls/models.py index 6cfc3863..8f664f56 100644 --- a/app/polls/models.py +++ b/app/polls/models.py @@ -196,6 +196,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"] @@ -300,10 +305,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(poll_type=PollType.STANDARD) ), ), ] @@ -355,6 +358,7 @@ class PollTemplateManager(ManagerBase["PollTemplate"]): """Manage poll template queries.""" def create(self, template_name: str, poll_name: str, **kwargs): + kwargs.setdefault("poll_type", PollType.TEMPLATE) return super().create(template_name=template_name, name=poll_name, **kwargs) diff --git a/app/polls/serializers.py b/app/polls/serializers.py index 099531fb..a7303e54 100644 --- a/app/polls/serializers.py +++ b/app/polls/serializers.py @@ -11,7 +11,7 @@ ModelSerializerBase, UpdateListSerializer, ) -from events.models import Event +from events.models import Event, EventType from polls import models from users.models import User from users.serializers import UserNestedSerializer @@ -342,6 +342,13 @@ def create(self, validated_data): return super().create(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) + + class PollPreviewSerializer(ModelSerializer): """Fields guest users can see for polls.""" diff --git a/app/polls/services.py b/app/polls/services.py index 7f35327e..1c32bcbf 100644 --- a/app/polls/services.py +++ b/app/polls/services.py @@ -41,14 +41,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: @@ -59,7 +68,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: @@ -67,14 +75,24 @@ 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) - for field_tpl in self.obj.fields.all(): + # Get template fields ordered by their order field + template_fields = self.obj.fields.all().order_by('order') + + # 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 846b02a6..a514ba16 100644 --- a/app/polls/tests/test_poll_services.py +++ b/app/polls/tests/test_poll_services.py @@ -3,8 +3,11 @@ import pytz from django.utils import timezone +from clubs.models import Club +from clubs.tests.utils import create_test_club from core.abstracts.tests import PeriodicTaskTestsBase, TestsBase -from polls.models import Poll, PollStatusType, PollTemplate +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 +198,39 @@ 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, - # ) + 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() - # self.assertIsNotNone(poll) - # self.assertEqual(poll.fields.count(), 2) - # self.assertEqual(PollField.objects.count(), 4) + poll = self.service.create_poll(club=club) + self.assertIsNotNone(poll) + + + for field in poll.fields.all(): + print(field.question.label) + - # self.assertEqual(poll.fields.get(order=1).question.label, expected_q1.label) - # self.assertEqual(poll.fields.get(order=2).question.label, expected_q2.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 387bdfb2..35ecc406 100644 --- a/app/polls/viewsets.py +++ b/app/polls/viewsets.py @@ -18,6 +18,7 @@ PollPreviewSerializer, PollSerializer, PollSubmissionSerializer, + PollTemplateSerializer, ) from polls.services import PollService @@ -91,6 +92,12 @@ def get_queryset(self): ) ) +class PollTemplateViewSet(PollViewset): + """Manage poll templates in api""" + + serializer_class = PollTemplateSerializer + + class PollFieldViewSet(ModelViewSetBase): """API for managing poll fields.""" @@ -199,3 +206,4 @@ def perform_update(self, serializer): @extend_schema(auth=[{"security": []}, {}]) def create(self, request, *args, **kwargs): return super().create(request, *args, **kwargs) + From 6192535040f93ee4c94fc14383fe368fbaae56bc Mon Sep 17 00:00:00 2001 From: Jonathan Hooth Date: Wed, 22 Oct 2025 20:46:09 -0400 Subject: [PATCH 2/5] working draft --- app/polls/admin.py | 7 ++++ app/polls/apis.py | 6 +++ ...poll_templates_allow_null_club_and_more.py | 29 +++++++++++++ ...poll_templates_allow_null_club_and_more.py | 29 +++++++++++++ app/polls/models.py | 9 ++-- app/polls/serializers.py | 28 +++++++++++++ app/polls/tests/test_poll_services.py | 4 +- app/polls/viewsets.py | 42 ++++++++++++++++++- 8 files changed, 147 insertions(+), 7 deletions(-) create mode 100644 app/polls/migrations/0024_remove_poll_only_poll_templates_allow_null_club_and_more.py create mode 100644 app/polls/migrations/0025_remove_poll_only_poll_templates_allow_null_club_and_more.py diff --git a/app/polls/admin.py b/app/polls/admin.py index 0e7af979..7d457ecf 100644 --- a/app/polls/admin.py +++ b/app/polls/admin.py @@ -17,6 +17,7 @@ PollQuestionAnswer, PollSubmission, PollSubmissionLink, + PollTemplate, ScaleInput, TextInput, UploadInput, @@ -105,6 +106,11 @@ def sync_submission_links(self, request, queryset): ) return + + +class PollTemplateAdmin(PollAdmin): + """Manage poll templates in admin""" + class TextInputInlineAdmin(admin.TabularInline): @@ -192,3 +198,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 8f664f56..d547f4fb 100644 --- a/app/polls/models.py +++ b/app/polls/models.py @@ -305,8 +305,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) ), ), ] @@ -357,9 +357,10 @@ class PollSubmissionLink(Link): class PollTemplateManager(ManagerBase["PollTemplate"]): """Manage poll template queries.""" - def create(self, template_name: str, poll_name: str, **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, name=poll_name, **kwargs) + return super().create(template_name=template_name, **kwargs) class PollTemplate(Poll): diff --git a/app/polls/serializers.py b/app/polls/serializers.py index a7303e54..98dc26d5 100644 --- a/app/polls/serializers.py +++ b/app/polls/serializers.py @@ -347,6 +347,34 @@ class PollTemplateSerializer(PollSerializer): template_name = serializers.CharField() event_type = serializers.ChoiceField(choices=EventType.choices, allow_blank=True, required=True) + #poll_name = serializers.CharField() + club = PollClubNestedSerializer(required=False, allow_null=True) + + 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') + + print(event["id"]) + + 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 + + print(validated_data) + + poll_name = validated_data.pop('name') + + return models.PollTemplate.objects.create(poll_name=poll_name, **validated_data) + class PollPreviewSerializer(ModelSerializer): diff --git a/app/polls/tests/test_poll_services.py b/app/polls/tests/test_poll_services.py index a514ba16..d924aa2c 100644 --- a/app/polls/tests/test_poll_services.py +++ b/app/polls/tests/test_poll_services.py @@ -225,8 +225,8 @@ def test_create_poll(self): self.assertIsNotNone(poll) - for field in poll.fields.all(): - print(field.question.label) + #for field in poll.fields.all(): + # print(field.question.label) self.assertEqual(poll.fields.count(), 3) diff --git a/app/polls/viewsets.py b/app/polls/viewsets.py index 35ecc406..f1c078eb 100644 --- a/app/polls/viewsets.py +++ b/app/polls/viewsets.py @@ -1,3 +1,4 @@ +from urllib import request from django.db import models, transaction from django.shortcuts import get_object_or_404 from drf_spectacular.utils import extend_schema @@ -11,6 +12,7 @@ PollQuestionAnswer, PollStatusType, PollSubmission, + PollTemplate, ) from polls.serializers import ( ChoiceInputOptionSerializer, @@ -92,12 +94,50 @@ def get_queryset(self): ) ) -class PollTemplateViewSet(PollViewset): +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.""" From 04a66dd95afac33e30291efde282fc3ad66297b9 Mon Sep 17 00:00:00 2001 From: Jonathan Hooth Date: Wed, 22 Oct 2025 21:05:23 -0400 Subject: [PATCH 3/5] Hid Poll only fields --- app/polls/serializers.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/app/polls/serializers.py b/app/polls/serializers.py index 98dc26d5..7128fc9f 100644 --- a/app/polls/serializers.py +++ b/app/polls/serializers.py @@ -347,8 +347,13 @@ class PollTemplateSerializer(PollSerializer): template_name = serializers.CharField() event_type = serializers.ChoiceField(choices=EventType.choices, allow_blank=True, required=True) - #poll_name = serializers.CharField() club = PollClubNestedSerializer(required=False, allow_null=True) + + + #Hiding Fields + submissions_download_url = None + event = None + is_published = None class Meta: model = models.PollTemplate @@ -361,7 +366,6 @@ def create(self, validated_data): #is_published = validated_data.pop("is_published") event = validated_data.pop('event') - print(event["id"]) club = validated_data.pop("club", None) if club is not None: @@ -369,8 +373,6 @@ def create(self, validated_data): else: validated_data["club"] = club - print(validated_data) - poll_name = validated_data.pop('name') return models.PollTemplate.objects.create(poll_name=poll_name, **validated_data) From 925bf9e1b1b9090e2c40cc4e64e03ec1abccdd76 Mon Sep 17 00:00:00 2001 From: Jonathan Hooth Date: Wed, 22 Oct 2025 21:09:47 -0400 Subject: [PATCH 4/5] Fixed lint --- app/clubs/serializers.py | 18 +++++++++----- app/clubs/tests/utils.py | 5 +++- app/events/models.py | 36 ++++++++++++++++++++------- app/events/serializers.py | 28 +++++++++++++++------ app/events/tests/test_event_api.py | 8 ++++-- app/polls/admin.py | 3 +-- app/polls/models.py | 10 ++++---- app/polls/serializers.py | 21 +++++++--------- app/polls/services.py | 20 ++++++++------- app/polls/tests/test_poll_services.py | 19 ++++++++------ app/polls/viewsets.py | 6 +---- 11 files changed, 109 insertions(+), 65 deletions(-) diff --git a/app/clubs/serializers.py b/app/clubs/serializers.py index 8c898a8c..39d12dca 100644 --- a/app/clubs/serializers.py +++ b/app/clubs/serializers.py @@ -355,7 +355,9 @@ class Meta: class ClubMembershipCreateSerializer(ClubMembershipSerializer): """Connects a User to a Club, determines how memberships should be added.""" - send_email = serializers.BooleanField(default=False, write_only=True, required=False) + send_email = serializers.BooleanField( + default=False, write_only=True, required=False + ) club_redirect_url = serializers.URLField( required=False, write_only=True, @@ -555,7 +557,9 @@ def create(self, validated_data): continue if not ClubRole.objects.filter(name=role, club=club).exists(): - validated_data["roles"].append(ClubRole.objects.create(name=role, club=club)) + validated_data["roles"].append( + ClubRole.objects.create(name=role, club=club) + ) return super().create(validated_data) @@ -632,7 +636,9 @@ class TeamCsvSerializer(CsvModelSerializer): """Represent teams in csvs.""" club = serializers.SlugRelatedField(slug_field="name", queryset=Club.objects.all()) - members = TeamMemberNestedCsvSerializer(many=True, required=False, source="memberships") + members = TeamMemberNestedCsvSerializer( + many=True, required=False, source="memberships" + ) class Meta: model = Team @@ -665,9 +671,9 @@ def initialize_instance(self, data=None): for role in roles: TeamRole.objects.get_or_create(team=self.instance, name=role) - self.fields["members"].child.fields["roles"].child_relation.queryset = ( - TeamRole.objects.filter(team=self.instance) - ) + self.fields["members"].child.fields[ + "roles" + ].child_relation.queryset = TeamRole.objects.filter(team=self.instance) class ClubRoleCsvSerializer(CsvModelSerializer): diff --git a/app/clubs/tests/utils.py b/app/clubs/tests/utils.py index 052e970b..8780a81f 100644 --- a/app/clubs/tests/utils.py +++ b/app/clubs/tests/utils.py @@ -73,7 +73,10 @@ def create_test_clubfile(club: Club, **kwargs): def create_test_club( - name=None, members: Optional[list[User]] = None, admins: Optional[list[User]] = None, **kwargs + name=None, + members: Optional[list[User]] = None, + admins: Optional[list[User]] = None, + **kwargs, ) -> Club: """Create unique club for unit tests.""" if name is None: diff --git a/app/events/models.py b/app/events/models.py index 2b8041a7..a01ba2ab 100644 --- a/app/events/models.py +++ b/app/events/models.py @@ -81,11 +81,15 @@ class EventFields(ClubScopedModel, ModelBase): """Common fields for club event models.""" name = models.CharField(max_length=128) - event_type = models.CharField(choices=EventType.choices, default=EventType.OTHER, blank=True) + event_type = models.CharField( + choices=EventType.choices, default=EventType.OTHER, blank=True + ) description = models.TextField(null=True, blank=True) location = models.CharField(null=True, blank=True, max_length=255) - attachments = models.ManyToManyField(ClubFile, blank=True, related_name="%(class)ss") + attachments = models.ManyToManyField( + ClubFile, blank=True, related_name="%(class)ss" + ) enable_attendance = models.BooleanField( default=False, help_text="Create poll for event and users to attend." ) @@ -189,7 +193,9 @@ class RecurringEvent(EventFields): # Dynamic properties & methods @property def expected_event_count(self): - return sum([get_day_count(self.start_date, self.end_date, day) for day in self.days]) + return sum( + [get_day_count(self.start_date, self.end_date, day) for day in self.days] + ) @property def is_all_day(self) -> bool: @@ -288,7 +294,9 @@ class Event(EventFields): # is_poll_submission_required = models.BooleanField(default=True) # Foreign Relationships - clubs = models.ManyToManyField(Club, through="events.EventHost", blank=True, db_index=True) + clubs = models.ManyToManyField( + Club, through="events.EventHost", blank=True, db_index=True + ) attendance_links: models.QuerySet["EventAttendanceLink"] hosts: models.QuerySet["EventHost"] attendances: models.QuerySet["EventAttendance"] @@ -367,7 +375,9 @@ def __str__(self) -> str: def clean(self, *args, **kwargs): if self.start_at > self.end_at: - raise exceptions.ValidationError("Start date cannot be greater than end date") + raise exceptions.ValidationError( + "Start date cannot be greater than end date" + ) # If creating event, ensure no name clashes if self.pk is None and Event.objects.filter( @@ -439,7 +449,9 @@ class EventHost(ClubScopedModel, ModelBase): """Attach clubs to events.""" event = models.ForeignKey(Event, on_delete=models.CASCADE, related_name="hosts") - club = models.ForeignKey(Club, on_delete=models.CASCADE, related_name="event_hostings") + club = models.ForeignKey( + Club, on_delete=models.CASCADE, related_name="event_hostings" + ) is_primary = models.BooleanField( default=False, blank=True, @@ -476,7 +488,9 @@ class EventAttendance(ClubScopedModel, ModelBase): # blank=True, # null=True, ) - user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="event_attendances") + user = models.ForeignKey( + User, on_delete=models.CASCADE, related_name="event_attendances" + ) @property def clubs(self): @@ -531,7 +545,9 @@ class EventAttendanceLink(Link): # TODO: How to handle permissions with multiple clubs and event hosts? - event = models.ForeignKey(Event, on_delete=models.CASCADE, related_name="attendance_links") + event = models.ForeignKey( + Event, on_delete=models.CASCADE, related_name="attendance_links" + ) reference = models.CharField( null=True, blank=True, help_text="Used to differentiate between links" ) @@ -565,7 +581,9 @@ class Meta: class EventCancellation(ClubScopedModel, ModelBase): """Record when an event is canceled.""" - event = models.OneToOneField(Event, on_delete=models.CASCADE, related_name="cancellation") + event = models.OneToOneField( + Event, on_delete=models.CASCADE, related_name="cancellation" + ) reason = models.TextField(blank=True) cancelled_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True) cancelled_at = models.DateTimeField(auto_now_add=True) diff --git a/app/events/serializers.py b/app/events/serializers.py index d93171d3..8912b238 100644 --- a/app/events/serializers.py +++ b/app/events/serializers.py @@ -24,8 +24,12 @@ class EventHostSerializer(ModelSerializerBase): """JSON representation for hosts inside events.""" # TODO: Rename to "club" or change to serializers.IntegerField - club_id = serializers.PrimaryKeyRelatedField(source="club", queryset=Club.objects.all()) - club_name = serializers.SlugRelatedField(source="club", read_only=True, slug_field="name") + club_id = serializers.PrimaryKeyRelatedField( + source="club", queryset=Club.objects.all() + ) + club_name = serializers.SlugRelatedField( + source="club", read_only=True, slug_field="name" + ) club_logo = serializers.ImageField( source="club.logo", read_only=True, required=False, allow_null=True ) @@ -89,10 +93,14 @@ def validate(self, attrs): hosts = attrs.get("hosts", None) if not self.instance: - primary_hosts = [host for host in hosts if host.get("is_primary", False) is True] + primary_hosts = [ + host for host in hosts if host.get("is_primary", False) is True + ] if len(primary_hosts) == 0 and len(hosts) > 0: - raise exceptions.ValidationError("Event with hosts must have a primary host.") + raise exceptions.ValidationError( + "Event with hosts must have a primary host." + ) return super().validate(attrs) @@ -215,9 +223,15 @@ class Meta: class EventAttendanceCsvSerializer(CsvModelSerializer): event = None - name = serializers.CharField(write_only=True, max_length=128, help_text="Name of event") - start_at = serializers.DateTimeField(write_only=True, help_text="Start datetime of event") - end_at = serializers.DateTimeField(write_only=True, help_text="End datetime of event") + name = serializers.CharField( + write_only=True, max_length=128, help_text="Name of event" + ) + start_at = serializers.DateTimeField( + write_only=True, help_text="Start datetime of event" + ) + end_at = serializers.DateTimeField( + write_only=True, help_text="End datetime of event" + ) user = WritableSlugRelatedField( slug_field="email", diff --git a/app/events/tests/test_event_api.py b/app/events/tests/test_event_api.py index 00ce398b..cf6345dc 100644 --- a/app/events/tests/test_event_api.py +++ b/app/events/tests/test_event_api.py @@ -304,7 +304,9 @@ def test_get_end_filter(self): create_test_event( host=club, start_at=today_event_time + mod_shift - timedelta(days=1), - end_at=(today_event_time + mod_shift) + timedelta(hours=1) - timedelta(days=1), + end_at=(today_event_time + mod_shift) + + timedelta(hours=1) + - timedelta(days=1), ) # 2 events are one day between range (3 valid) create_test_event( @@ -402,7 +404,9 @@ def test_get_both_filters(self): create_test_event( host=club, start_at=today_event_time + mod_shift - timedelta(days=1), - end_at=(today_event_time + mod_shift) + timedelta(hours=1) - timedelta(days=1), + end_at=(today_event_time + mod_shift) + + timedelta(hours=1) + - timedelta(days=1), ) # 2 events are one day between range (3 valid) create_test_event( diff --git a/app/polls/admin.py b/app/polls/admin.py index 7d457ecf..f532b309 100644 --- a/app/polls/admin.py +++ b/app/polls/admin.py @@ -106,13 +106,12 @@ 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.""" diff --git a/app/polls/models.py b/app/polls/models.py index d547f4fb..707ec55c 100644 --- a/app/polls/models.py +++ b/app/polls/models.py @@ -197,9 +197,9 @@ class Poll(ClubScopedModel, ModelBase): ) # Hopefully this works - #poll_template = models.ForeignKey( + # poll_template = models.ForeignKey( # "polls.pollTemplate", null=True, blank=True, on_delete=models.SET_NULL - #) + # ) # Foreign Relationships fields: models.QuerySet["PollField"] @@ -305,8 +305,8 @@ class Meta: models.CheckConstraint( name="only_poll_templates_allow_null_club", check=( - models.Q(club__isnull=False) - | models.Q(poll_type=PollType.TEMPLATE.value) + models.Q(club__isnull=False) + | models.Q(poll_type=PollType.TEMPLATE.value) ), ), ] @@ -357,7 +357,7 @@ class PollSubmissionLink(Link): class PollTemplateManager(ManagerBase["PollTemplate"]): """Manage poll template queries.""" - def create(self, template_name: str, poll_name: str=None, **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) diff --git a/app/polls/serializers.py b/app/polls/serializers.py index eeba03e8..285d19e6 100644 --- a/app/polls/serializers.py +++ b/app/polls/serializers.py @@ -372,26 +372,24 @@ class PollTemplateSerializer(PollSerializer): """Json definition for poll templates""" template_name = serializers.CharField() - event_type = serializers.ChoiceField(choices=EventType.choices, allow_blank=True, required=True) + event_type = serializers.ChoiceField( + choices=EventType.choices, allow_blank=True, required=True + ) club = PollClubNestedSerializer(required=False, allow_null=True) - - #Hiding Fields + # 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') - + # is_published = validated_data.pop("is_published") + event = validated_data.pop("event") club = validated_data.pop("club", None) if club is not None: @@ -399,10 +397,9 @@ def create(self, validated_data): else: validated_data["club"] = club - poll_name = validated_data.pop('name') - + poll_name = validated_data.pop("name") + return models.PollTemplate.objects.create(poll_name=poll_name, **validated_data) - class PollPreviewSerializer(ModelSerializer): diff --git a/app/polls/services.py b/app/polls/services.py index 1c32bcbf..1bedd533 100644 --- a/app/polls/services.py +++ b/app/polls/services.py @@ -41,9 +41,9 @@ def _clone_input(self, question_tpl: PollQuestion, target_question: PollQuestion max_length=question_tpl.text_input.max_length, ) case PollInputType.CHOICE: - #ChoiceInput.object.create( - #questin=target_question, - #) + # ChoiceInput.object.create( + # questin=target_question, + # ) choice_input = ChoiceInput.objects.create( question=target_question, ) @@ -51,8 +51,8 @@ def _clone_input(self, question_tpl: PollQuestion, target_question: PollQuestion 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) - #print(field) + # 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, @@ -79,18 +79,20 @@ def create_poll(self, **kwargs) -> Poll: """Create a new poll from this one if it is a template.""" # Create the poll without any auto-created fields - poll = Poll.objects.create(name=self.obj.name, description=self.obj.description, **kwargs) + 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') - + template_fields = self.obj.fields.all().order_by("order") + # 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 diff --git a/app/polls/tests/test_poll_services.py b/app/polls/tests/test_poll_services.py index d924aa2c..a11d9602 100644 --- a/app/polls/tests/test_poll_services.py +++ b/app/polls/tests/test_poll_services.py @@ -7,7 +7,14 @@ from clubs.tests.utils import create_test_club from core.abstracts.tests import PeriodicTaskTestsBase, TestsBase from lib.faker import fake -from polls.models import Poll, PollField, PollInputType, PollQuestion, PollStatusType, PollTemplate +from polls.models import ( + Poll, + PollField, + PollInputType, + PollQuestion, + PollStatusType, + PollTemplate, +) from polls.services import PollService, PollTemplateService from polls.tests.utils import ( create_test_poll, @@ -220,17 +227,15 @@ def test_create_poll(self): create_input=True, ) - # # Generate poll + # # Generate poll poll = self.service.create_poll(club=club) self.assertIsNotNone(poll) - - - #for field in poll.fields.all(): - # print(field.question.label) + # 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 2c98c4b8..6aa5dc16 100644 --- a/app/polls/viewsets.py +++ b/app/polls/viewsets.py @@ -95,13 +95,13 @@ 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) @@ -135,9 +135,6 @@ def get_queryset(self): last_submission_at=models.Max("submissions__created_at"), ) ) - - - class PollFieldViewSet(ModelViewSetBase): @@ -247,4 +244,3 @@ def perform_update(self, serializer): @extend_schema(auth=[{"security": []}, {}]) def create(self, request, *args, **kwargs): return super().create(request, *args, **kwargs) - From 81cff176bd049ec02eae3a30b7d9cc5724065b1a Mon Sep 17 00:00:00 2001 From: Jonathan Hooth Date: Wed, 5 Nov 2025 18:09:59 -0500 Subject: [PATCH 5/5] Pushing changes --- app/clubs/models.py | 6 ++++ app/clubs/tests/test_club_apis.py | 60 ++++++++++++++++++++++++++++++- app/clubs/tests/utils.py | 15 ++++++-- app/clubs/viewsets.py | 22 +++++++++++- 4 files changed, 99 insertions(+), 4 deletions(-) diff --git a/app/clubs/models.py b/app/clubs/models.py index 7f38312a..36773e11 100644 --- a/app/clubs/models.py +++ b/app/clubs/models.py @@ -528,6 +528,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): @@ -599,6 +604,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 9e97c13e..932b7ac4 100644 --- a/app/clubs/tests/test_club_apis.py +++ b/app/clubs/tests/test_club_apis.py @@ -4,7 +4,7 @@ from rest_framework.test import APIClient -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 8780a81f..e34668ee 100644 --- a/app/clubs/tests/utils.py +++ b/app/clubs/tests/utils.py @@ -2,6 +2,7 @@ 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 @@ -39,8 +40,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 59747332..4c02baae 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