diff --git a/backend/clubs/management/commands/update_club_counts.py b/backend/clubs/management/commands/update_club_counts.py index 01ff17676..09274fac6 100644 --- a/backend/clubs/management/commands/update_club_counts.py +++ b/backend/clubs/management/commands/update_club_counts.py @@ -1,5 +1,5 @@ from django.core.management.base import BaseCommand -from django.db.models import Count, Q +from django.db.models import Count, OuterRef, Q, Subquery from clubs.models import Club @@ -9,17 +9,23 @@ class Command(BaseCommand): def handle(self, *args, **kwargs): try: - queryset = Club.objects.all().annotate( - temp_favorite_count=Count("favorite", distinct=True), - temp_membership_count=Count( - "membership", distinct=True, filter=Q(active=True) - ), + favorite_count_subquery = ( + Club.objects.filter(pk=OuterRef("pk")) + .annotate(count=Count("favorite", distinct=True)) + .values("count") + ) + membership_count_subquery = ( + Club.objects.filter(pk=OuterRef("pk")) + .annotate( + count=Count("membership", distinct=True, filter=Q(active=True)) + ) + .values("count") ) - for club in queryset: - club.favorite_count = club.temp_favorite_count - club.membership_count = club.temp_membership_count - Club.objects.bulk_update(queryset, ["favorite_count", "membership_count"]) + Club.objects.update( + favorite_count=Subquery(favorite_count_subquery), + membership_count=Subquery(membership_count_subquery), + ) self.stdout.write( self.style.SUCCESS( diff --git a/backend/clubs/models.py b/backend/clubs/models.py index 47c983c7a..550f5be48 100644 --- a/backend/clubs/models.py +++ b/backend/clubs/models.py @@ -13,7 +13,7 @@ from django.core.exceptions import ValidationError from django.core.mail import EmailMultiAlternatives from django.core.validators import validate_email -from django.db import models, transaction +from django.db import models from django.db.models import Sum from django.dispatch import receiver from django.template.loader import render_to_string @@ -833,21 +833,33 @@ def create_events( club_query = self.participating_clubs.all() if filter is not None: club_query = club_query.filter(filter) - events = [] - with transaction.atomic(): - for club in club_query: - obj, _ = Event.objects.get_or_create( - code=f"fair-{club.code}-{self.id}-{suffix}", - club=club, - type=Event.FAIR, - defaults={ - "name": self.name, - "start_time": start_time, - "end_time": end_time, - }, + + # Find events that already exist + event_codes = [f"fair-{club.code}-{self.id}-{suffix}" for club in club_query] + existing_events = Event.objects.filter(code__in=event_codes).values_list( + "code", flat=True + ) + + # Create new events in bulk + new_events = [] + for club in club_query: + event_code = f"fair-{club.code}-{self.id}-{suffix}" + if event_code not in existing_events: + new_events.append( + Event( + code=event_code, + club=club, + type=Event.FAIR, + name=self.name, + start_time=start_time, + end_time=end_time, + ) ) - events.append(obj) - return events + Event.objects.bulk_create(new_events) + + # Return all event objects + events = Event.objects.filter(code__in=event_codes) + return list(events) def __str__(self): fmt = "%b %d, %Y" diff --git a/backend/clubs/views.py b/backend/clubs/views.py index ab4e79d67..66507a6b6 100644 --- a/backend/clubs/views.py +++ b/backend/clubs/views.py @@ -190,19 +190,20 @@ def file_upload_endpoint_helper(request, code): - obj = get_object_or_404(Club, code=code) - if "file" in request.data and isinstance(request.data["file"], UploadedFile): - asset = Asset.objects.create( - creator=request.user, - club=obj, - file=request.data["file"], - name=request.data["file"].name, - ) - else: + club = get_object_or_404(Club, code=code) + + if "file" not in request.data or not isinstance(request.data["file"], UploadedFile): return Response( {"detail": "No image file was uploaded!"}, status=status.HTTP_400_BAD_REQUEST, ) + + asset = Asset.objects.create( + creator=request.user, + club=club, + file=request.data["file"], + name=request.data["file"].name, + ) return Response({"detail": "Club file uploaded!", "id": asset.id}) @@ -322,141 +323,143 @@ class ClubsSearchFilter(filters.BaseFilterBackend): If model is not a Club, expects the model to have a club foreign key to Club. """ - def filter_queryset(self, request, queryset, view): - params = request.GET.dict() + def parse_year(self, field, value, operation, queryset): + if value.isdigit(): + suffix = "" + if operation in {"lt", "gt", "lte", "gte"}: + suffix = f"__{operation}" + return {f"{field}__year{suffix}": int(value)} + if value.lower() in {"none", "null"}: + return {f"{field}__isnull": True} + return {} - def parse_year(field, value, operation, queryset): - if value.isdigit(): - suffix = "" - if operation in {"lt", "gt", "lte", "gte"}: - suffix = f"__{operation}" - return {f"{field}__year{suffix}": int(value)} - if value.lower() in {"none", "null"}: - return {f"{field}__isnull": True} - return {} - - def parse_int(field, value, operation, queryset): - if operation == "in": - values = value.strip().split(",") - sizes = [int(size) for size in values if size] - return {f"{field}__in": sizes} - - if "," in value: - values = [int(x.strip()) for x in value.split(",") if x] - if operation == "and": - for value in values: - queryset = queryset.filter(**{field: value}) - return queryset - return {f"{field}__in": values} - - if value.isdigit(): - suffix = "" - if operation in {"lt", "gt", "lte", "gte"}: - suffix = f"__{operation}" - return {f"{field}{suffix}": int(value)} - if value.lower() in {"none", "null"}: - return {f"{field}__isnull": True} - return {} - - def parse_many_to_many(label, field, value, operation, queryset): - tags = value.strip().split(",") - if operation == "or": - if tags[0].isdigit(): - tags = [int(tag) for tag in tags if tag] - return {f"{field}__id__in": tags} - else: - return {f"{field}__{label}__in": tags} + def parse_int(self, field, value, operation, queryset): + if operation == "in": + values = value.strip().split(",") + sizes = [int(size) for size in values if size] + return {f"{field}__in": sizes} + + if "," in value: + values = [int(x.strip()) for x in value.split(",") if x] + if operation == "and": + for value in values: + queryset = queryset.filter(**{field: value}) + return queryset + return {f"{field}__in": values} + + if value.isdigit(): + suffix = "" + if operation in {"lt", "gt", "lte", "gte"}: + suffix = f"__{operation}" + return {f"{field}{suffix}": int(value)} + if value.lower() in {"none", "null"}: + return {f"{field}__isnull": True} + return {} - if tags[0].isdigit() or operation == "id": + def parse_many_to_many(self, label, field, value, operation, queryset): + tags = value.strip().split(",") + if operation == "or": + if tags[0].isdigit(): tags = [int(tag) for tag in tags if tag] - if settings.BRANDING == "fyh": - queryset = queryset.filter(**{f"{field}__id__in": tags}) - else: - for tag in tags: - queryset = queryset.filter(**{f"{field}__id": tag}) + return {f"{field}__id__in": tags} + else: + return {f"{field}__{label}__in": tags} + + if tags[0].isdigit() or operation == "id": + tags = [int(tag) for tag in tags if tag] + if settings.BRANDING == "fyh": + queryset = queryset.filter(**{f"{field}__id__in": tags}) else: + queryset = queryset.filter(**{f"{field}__id__in": tags}) for tag in tags: - queryset = queryset.filter(**{f"{field}__{label}": tag}) - return queryset + queryset = queryset.filter(**{f"{field}__id": tag}) + else: + for tag in tags: + queryset = queryset.filter(**{f"{field}__{label}": tag}) - def parse_badges(field, value, operation, queryset): - return parse_many_to_many("label", field, value, operation, queryset) + return queryset - def parse_tags(field, value, operation, queryset): - return parse_many_to_many("name", field, value, operation, queryset) + def parse_badges(self, field, value, operation, queryset): + return self.parse_many_to_many("label", field, value, operation, queryset) - def parse_boolean(field, value, operation, queryset): - value = value.strip().lower() + def parse_tags(self, field, value, operation, queryset): + return self.parse_many_to_many("name", field, value, operation, queryset) - if operation == "in": - if set(value.split(",")) == {"true", "false"}: - return + def parse_boolean(self, field, value, operation, queryset): + value = value.strip().lower() - if value in {"true", "yes"}: - boolval = True - elif value in {"false", "no"}: - boolval = False - elif value in {"null", "none"}: - boolval = None - else: + if operation == "in": + if set(value.split(",")) == {"true", "false"}: return - if boolval is None: - return {f"{field}__isnull": True} + if value in {"true", "yes"}: + boolval = True + elif value in {"false", "no"}: + boolval = False + elif value in {"null", "none"}: + boolval = None + else: + return - return {f"{field}": boolval} + if boolval is None: + return {f"{field}__isnull": True} - def parse_string(field, value, operation, queryset): - if operation == "in": - values = [x.strip() for x in value.split(",")] - values = [x for x in values if x] - return {f"{field}__in": values} - return {f"{field}": value} + return {f"{field}": boolval} - def parse_datetime(field, value, operation, queryset): - try: - value = parse(value.strip()) - except (ValueError, OverflowError): - return + def parse_string(self, field, value, operation, queryset): + if operation == "in": + values = [x.strip() for x in value.split(",")] + values = [x for x in values if x] + return {f"{field}__in": values} + return {f"{field}": value} - if operation in {"gt", "lt", "gte", "lte"}: - return {f"{field}__{operation}": value} + def parse_datetime(self, field, value, operation, queryset): + try: + value = parse(value.strip()) + except (ValueError, OverflowError): return - fields = { - "accepting_members": parse_boolean, - "active": parse_boolean, - "application_required": parse_int, - "appointment_needed": parse_boolean, - "approved": parse_boolean, - "available_virtually": parse_boolean, - "badges": parse_badges, - "code": parse_string, - "enables_subscription": parse_boolean, - "favorite_count": parse_int, - "founded": parse_year, - "recruiting_cycle": parse_int, - "size": parse_int, - "tags": parse_tags, - "target_majors": parse_tags, - "target_schools": parse_tags, - "target_years": parse_tags, - "target_students": parse_tags, - "student_types": parse_tags, - } + if operation in {"gt", "lt", "gte", "lte"}: + return {f"{field}__{operation}": value} + return - def parse_fair(field, value, operation, queryset): - try: - value = int(value.strip()) - except ValueError: - return + def parse_fair(self, field, value, operation, queryset): + try: + value = int(value.strip()) + except ValueError: + return - fair = ClubFair.objects.filter(id=value).first() - if fair: - return { - "start_time__gte": fair.start_time, - "end_time__lte": fair.end_time, - } + fair = ClubFair.objects.filter(id=value).first() + if fair: + return { + "start_time__gte": fair.start_time, + "end_time__lte": fair.end_time, + } + + def filter_queryset(self, request, queryset, view): + params = request.GET.dict() + + fields = { + "accepting_members": self.parse_boolean, + "active": self.parse_boolean, + "application_required": self.parse_int, + "appointment_needed": self.parse_boolean, + "approved": self.parse_boolean, + "available_virtually": self.parse_boolean, + "badges": self.parse_badges, + "code": self.parse_string, + "enables_subscription": self.parse_boolean, + "favorite_count": self.parse_int, + "founded": self.parse_year, + "recruiting_cycle": self.parse_int, + "size": self.parse_int, + "tags": self.parse_tags, + "target_majors": self.parse_tags, + "target_schools": self.parse_tags, + "target_years": self.parse_tags, + "target_students": self.parse_tags, + "student_types": self.parse_tags, + } if not queryset.model == Club: fields = {f"club__{k}": v for k, v in fields.items()} @@ -464,10 +467,10 @@ def parse_fair(field, value, operation, queryset): if queryset.model == Event: fields.update( { - "type": parse_int, - "start_time": parse_datetime, - "end_time": parse_datetime, - "fair": parse_fair, + "type": self.parse_int, + "start_time": self.parse_datetime, + "end_time": self.parse_datetime, + "fair": self.parse_fair, } ) @@ -540,6 +543,7 @@ def filter_queryset(self, request, queryset, view): if "featured" in ordering: if queryset.model == Club: return queryset.order_by("-rank", "-favorite_count", "-id") + return queryset.order_by( "-club__rank", "-club__favorite_count", "-club__id" ) @@ -615,10 +619,11 @@ def current(self, request, *args, **kwargs): start_time__lte=now + datetime.timedelta(minutes=2), end_time__gte=now - datetime.timedelta(minutes=2), ).first() + if fair is None: return Response([]) - else: - return Response([ClubFairSerializer(instance=fair).data]) + + return Response([ClubFairSerializer(instance=fair).data]) @action(detail=True, methods=["get"]) def live(self, *args, **kwargs): @@ -1020,6 +1025,7 @@ class ClubViewSet(XLSXFormatterMixin, viewsets.ModelViewSet): Club.objects.all().prefetch_related("tags").order_by("-favorite_count", "name") ) permission_classes = [ClubPermission | IsSuperuser] + filter_backends = [filters.SearchFilter, ClubsSearchFilter, ClubsOrderingFilter] search_fields = ["name", "subtitle", "code", "terms"] ordering_fields = ["favorite_count", "name"] @@ -2982,16 +2988,13 @@ def create(self, request, *args, **kwargs): """ If a membership request object already exists, reuse it. """ - club = request.data.get("club", None) - obj = MembershipRequest.objects.filter( - club__code=club, person=request.user - ).first() - if obj is not None: - obj.withdrew = False - obj.save(update_fields=["withdrew"]) - return Response(UserMembershipRequestSerializer(obj).data) - - return super().create(request, *args, **kwargs) + club_code = request.data.get("club", None) + obj, _ = MembershipRequest.objects.update_or_create( + club__code=club_code, + person=request.user, + defaults={"withdrew": False}, + ) + return Response(UserMembershipRequestSerializer(obj).data) def destroy(self, request, *args, **kwargs): """ @@ -4899,36 +4902,36 @@ def update(self, *args, **kwargs): """ Updates times for all applications with cycle """ - applications = ClubApplication.objects.filter( - application_cycle=self.get_object() - ) - str_start_date = self.request.data.get("start_date").replace("T", " ") - str_end_date = self.request.data.get("end_date").replace("T", " ") - str_release_date = self.request.data.get("release_date").replace("T", " ") + cycle = self.get_object() + applications = ClubApplication.objects.filter(application_cycle=cycle) + + new_start = self.request.data.get("start_date").replace("T", " ") + new_end = self.request.data.get("end_date").replace("T", " ") + new_release = self.request.data.get("release_date").replace("T", " ") + time_format = "%Y-%m-%d %H:%M:%S%z" start = ( - datetime.datetime.strptime(str_start_date, time_format) - if str_start_date - else self.get_object().start_date + datetime.datetime.strptime(new_start, time_format) + if new_start + else cycle.start_date ) end = ( - datetime.datetime.strptime(str_end_date, time_format) - if str_end_date - else self.get_object().end_date + datetime.datetime.strptime(new_end, time_format) + if new_end + else cycle.end_date ) release = ( - datetime.datetime.strptime(str_release_date, time_format) - if str_release_date - else self.get_object().release_date + datetime.datetime.strptime(new_release, time_format) + if new_release + else cycle.release_date ) - for app in applications: - app.application_start_time = start - if app.application_end_time_exception: - continue - app.application_end_time = end - app.result_release_time = release - f = ["application_start_time", "application_end_time", "result_release_time"] - ClubApplication.objects.bulk_update(applications, f) + + applications.update(application_start_time=start) + # Exclude applications with an extension from end & release update + applications.filter(application_end_time_exception=False).update( + application_end_time=end, result_release_time=release + ) + return super().update(*args, **kwargs) @action(detail=True, methods=["GET"]) @@ -5449,29 +5452,30 @@ def status(self, *args, **kwargs): """ submission_pks = self.request.data.get("submissions", []) status = self.request.data.get("status", None) - if ( - status in map(lambda x: x[0], ApplicationSubmission.STATUS_TYPES) - and len(submission_pks) > 0 - ): - # Invalidate submission viewset cache - submissions = ApplicationSubmission.objects.filter(pk__in=submission_pks) - app_id = submissions.first().application.id if submissions.first() else None - if not app_id: - return Response({"detail": "No submissions found"}) - key = f"applicationsubmissions:{app_id}" - cache.delete(key) - - submissions.update(status=status) - return Response( - { - "detail": f"Successfully updated submissions' {submission_pks}" - f"status {status}" - } - ) - else: + if len(submission_pks) == 0 or status not in [ + status_type[0] for status_type in ApplicationSubmission.STATUS_TYPES + ]: return Response({"detail": "Invalid request"}) + submissions = ApplicationSubmission.objects.filter(pk__in=submission_pks) + if not submissions.exists(): + return Response({"detail": "No submissions found"}) + + # Invalidate submission viewset cache + app_id = submissions.first().application.id + key = f"applicationsubmissions:{app_id}" + cache.delete(key) + + submissions.update(status=status) + + return Response( + { + "detail": f"Successfully updated submissions' {submission_pks} " + f"status to {status}" + } + ) + @action(detail=False, methods=["post"]) def reason(self, *args, **kwargs): """ @@ -5507,27 +5511,22 @@ def reason(self, *args, **kwargs): --- """ submissions = self.request.data.get("submissions", []) - pks = list(map(lambda x: x["id"], submissions)) - reasons = list(map(lambda x: x["reason"], submissions)) + pks = [submission["id"] for submission in submissions] + reasons = {submission["id"]: submission["reason"] for submission in submissions} submission_objs = ApplicationSubmission.objects.filter(pk__in=pks) - # Invalidate submission viewset cache - app_id = ( - submission_objs.first().application.id if submission_objs.first() else None - ) - if not app_id: + if not submission_objs.exists(): return Response({"detail": "No submissions found"}) + + # Invalidate submission viewset cache + app_id = submission_objs.first().application.id key = f"applicationsubmissions:{app_id}" cache.delete(key) - for idx, pk in enumerate(pks): - obj = submission_objs.filter(pk=pk).first() - if obj: - obj.reason = reasons[idx] - obj.save() - else: - return Response({"detail": "Object not found"}) + for submission in submission_objs: + submission.reason = reasons[submission.pk] + ApplicationSubmission.objects.bulk_update(submission_objs, ["reason"]) return Response({"detail": "Successfully updated submissions' reasons"}) diff --git a/k8s/main.ts b/k8s/main.ts index de603f726..31421ecfc 100644 --- a/k8s/main.ts +++ b/k8s/main.ts @@ -149,6 +149,13 @@ export class MyChart extends PennLabsChart { secret: fyhSecret, cmd: ["python", "manage.py", "import_paideia_events"], }); + + new CronJob(this, 'update-club-favorite-membership-counts', { + schedule: cronTime.everyDayAt(12), + image: backendImage, + secret: fyhSecret, + cmd: ["python", "manage.py", "update_club_counts"], + }); } }