From 4950ade99b1c93cde660e30e5330b45e8698cdc4 Mon Sep 17 00:00:00 2001 From: Ashley Zhang Date: Mon, 4 Nov 2024 20:45:09 -0800 Subject: [PATCH 01/12] =?UTF-8?q?=F0=9F=92=82=20type=20safety=20and=20type?= =?UTF-8?q?=20hinting?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/sublet/serializers.py | 117 ++++++++++++++++++++-------------- backend/sublet/views.py | 51 +++++++++------ 2 files changed, 98 insertions(+), 70 deletions(-) diff --git a/backend/sublet/serializers.py b/backend/sublet/serializers.py index 68a9ac6c..5622ae07 100644 --- a/backend/sublet/serializers.py +++ b/backend/sublet/serializers.py @@ -1,31 +1,45 @@ +from datetime import datetime +from typing import Any, Optional, cast + from phonenumber_field.serializerfields import PhoneNumberField from profanity_check import predict from rest_framework import serializers +from rest_framework.request import Request from sublet.models import Amenity, Offer, Sublet, SubletImage -class AmenitySerializer(serializers.ModelSerializer): +class BaseModelSerializer(serializers.ModelSerializer): + def get_request(self) -> Request: + return cast(Request, self.context.get("request")) + + +class AmenitySerializer(BaseModelSerializer): + name: str = serializers.CharField(max_length=255) + class Meta: model = Amenity fields = "__all__" -class OfferSerializer(serializers.ModelSerializer): - phone_number = PhoneNumberField() +class OfferSerializer(BaseModelSerializer): + phone_number: str = PhoneNumberField() + email: Optional[str] = serializers.EmailField(allow_null=True) + message: str = serializers.CharField(max_length=255) + created_date: datetime = serializers.DateTimeField(read_only=True) class Meta: model = Offer fields = "__all__" read_only_fields = ["id", "created_date", "user"] - def create(self, validated_data): - validated_data["user"] = self.context["request"].user + def create(self, validated_data: dict[str, Any]) -> Offer: + validated_data["user"] = self.get_request().user return super().create(validated_data) # Create/Update Image Serializer -class SubletImageSerializer(serializers.ModelSerializer): +class SubletImageSerializer(BaseModelSerializer): image = serializers.ImageField(write_only=True, required=False, allow_null=True) class Meta: @@ -34,20 +48,19 @@ class Meta: # Browse images -class SubletImageURLSerializer(serializers.ModelSerializer): +class SubletImageURLSerializer(BaseModelSerializer): image_url = serializers.SerializerMethodField("get_image_url") - def get_image_url(self, obj): - image = obj.image - - if not image: + def get_image_url(self, obj) -> Optional[str]: + if not obj.image: return None - if image.url.startswith("http"): - return image.url - elif "request" in self.context: - return self.context["request"].build_absolute_uri(image.url) - else: - return image.url + + image_url = obj.image.url + if image_url.startswith("http"): + return image_url + + request = self.get_request() + return request.build_absolute_uri(image_url) if request else image_url class Meta: model = SubletImage @@ -55,10 +68,10 @@ class Meta: # complex sublet serializer for use in C/U/D + getting info about a singular sublet -class SubletSerializer(serializers.ModelSerializer): +class SubletSerializer(BaseModelSerializer): # amenities = AmenitySerializer(many=True, required=False) # images = SubletImageURLSerializer(many=True, required=False) - amenities = serializers.PrimaryKeyRelatedField( + amenities: list[Amenity] = serializers.PrimaryKeyRelatedField( many=True, queryset=Amenity.objects.all(), required=False ) @@ -92,54 +105,48 @@ class Meta: # but gets on sublets will include ids/urls for images ] - def validate_title(self, value): - if self.contains_profanity(value): - raise serializers.ValidationError("The title contains inappropriate language.") - return value + def _validate_text_content(self, text: str, field_name: str) -> str: + """Validates text content for profanity""" + if predict([text])[0]: + raise serializers.ValidationError(f"The {field_name} contains inappropriate language.") + return text - def validate_description(self, value): - if self.contains_profanity(value): - raise serializers.ValidationError("The description contains inappropriate language.") - return value + def validate_title(self, value: str) -> str: + return self._validate_text_content(value, "title") - def contains_profanity(self, text): - return predict([text])[0] + def validate_description(self, value: str) -> str: + return self._validate_text_content(value, "description") - def create(self, validated_data): - validated_data["subletter"] = self.context["request"].user + def create(self, validated_data: dict[str, Any]) -> Sublet: + validated_data["subletter"] = self.get_request().user instance = super().create(validated_data) instance.save() return instance # delete_images is a list of image ids to delete - def update(self, instance, validated_data): + def update(self, instance: Sublet, validated_data: dict[str, Any]) -> Sublet: # Check if the user is the subletter before allowing the update - if ( - self.context["request"].user == instance.subletter - or self.context["request"].user.is_superuser - ): + user = self.get_request().user + if user == instance.subletter or user.is_superuser: instance = super().update(instance, validated_data) instance.save() return instance - else: - raise serializers.ValidationError("You do not have permission to update this sublet.") + raise serializers.ValidationError("You do not have permission to update this sublet.") - def destroy(self, instance): + def destroy(self, instance: Sublet) -> None: # Check if the user is the subletter before allowing the delete - if ( - self.context["request"].user == instance.subletter - or self.context["request"].user.is_superuser - ): + user = self.get_request().user + if user == instance.subletter or user.is_superuser: instance.delete() else: raise serializers.ValidationError("You do not have permission to delete this sublet.") -class SubletSerializerRead(serializers.ModelSerializer): - amenities = serializers.PrimaryKeyRelatedField( +class SubletSerializerRead(BaseModelSerializer): + amenities: list[Amenity] = serializers.PrimaryKeyRelatedField( many=True, queryset=Amenity.objects.all(), required=False ) - images = SubletImageURLSerializer(many=True, required=False) + images: list[SubletImage] = SubletImageURLSerializer(many=True, required=False) class Meta: model = Sublet @@ -162,13 +169,19 @@ class Meta: "images", ] + def to_representation(self, instance: Sublet) -> dict[str, Any]: + """Override to ensure proper typing of returned data""" + data = super().to_representation(instance) + assert isinstance(data, dict) + return data + # simple sublet serializer for use when pulling all serializers/etc -class SubletSerializerSimple(serializers.ModelSerializer): - amenities = serializers.PrimaryKeyRelatedField( +class SubletSerializerSimple(BaseModelSerializer): + amenities: list[Amenity] = serializers.PrimaryKeyRelatedField( many=True, queryset=Amenity.objects.all(), required=False ) - images = SubletImageURLSerializer(many=True, required=False) + images: list[SubletImage] = SubletImageURLSerializer(many=True, required=False) class Meta: model = Sublet @@ -187,3 +200,9 @@ class Meta: "images", ] read_only_fields = ["id", "subletter"] + + def to_representation(self, instance: Sublet) -> dict[str, Any]: + """Override to ensure proper typing of returned data""" + data = super().to_representation(instance) + assert isinstance(data, dict) + return data diff --git a/backend/sublet/views.py b/backend/sublet/views.py index cb37c2df..86502eb8 100644 --- a/backend/sublet/views.py +++ b/backend/sublet/views.py @@ -1,10 +1,13 @@ +from typing import TypeAlias + from django.contrib.auth import get_user_model -from django.db.models import prefetch_related_objects +from django.db.models import QuerySet, prefetch_related_objects from django.utils import timezone from rest_framework import exceptions, generics, mixins, status, viewsets from rest_framework.generics import get_object_or_404 from rest_framework.parsers import FormParser, MultiPartParser from rest_framework.permissions import IsAuthenticated +from rest_framework.request import Request from rest_framework.response import Response from pennmobile.analytics import Metric, record_analytics @@ -26,6 +29,12 @@ ) +SubletQuerySet: TypeAlias = QuerySet[Sublet] +OfferQuerySet: TypeAlias = QuerySet[Offer] +ImageList: TypeAlias = QuerySet[SubletImage] +FavoriteQuerySet: TypeAlias = QuerySet[Sublet] +UserOfferQuerySet: TypeAlias = QuerySet[Offer] + User = get_user_model() @@ -33,17 +42,16 @@ class Amenities(generics.ListAPIView): serializer_class = AmenitySerializer queryset = Amenity.objects.all() - def get(self, request, *args, **kwargs): - temp = super().get(self, request, *args, **kwargs).data - response_data = [a["name"] for a in temp] - return Response(response_data) + def get(self, request: Request, *args, **kwargs) -> Response: + temp = super().get(request, *args, **kwargs).data + return Response([a["name"] for a in temp]) class UserFavorites(generics.ListAPIView): serializer_class = SubletSerializerSimple permission_classes = [IsAuthenticated] - def get_queryset(self): + def get_queryset(self) -> FavoriteQuerySet: user = self.request.user return user.sublets_favorited @@ -52,7 +60,7 @@ class UserOffers(generics.ListAPIView): serializer_class = OfferSerializer permission_classes = [IsAuthenticated] - def get_queryset(self): + def get_queryset(self) -> UserOfferQuerySet: user = self.request.user return Offer.objects.filter(user=user) @@ -77,10 +85,10 @@ class Properties(viewsets.ModelViewSet): def get_serializer_class(self): return SubletSerializerRead if self.action == "retrieve" else SubletSerializer - def get_queryset(self): + def get_queryset(self) -> SubletQuerySet: return Sublet.objects.all() - def create(self, request, *args, **kwargs): + def create(self, request: Request, *args, **kwargs) -> Response: serializer = self.get_serializer(data=request.data) serializer.is_valid(raise_exception=True) # Check if the data is valid instance = serializer.save() # Create the Sublet @@ -90,7 +98,7 @@ def create(self, request, *args, **kwargs): return Response(instance_serializer.data, status=status.HTTP_201_CREATED) - def update(self, request, *args, **kwargs): + def update(self, request: Request, *args, **kwargs) -> Response: partial = kwargs.pop("partial", False) instance = self.get_object() serializer = self.get_serializer(instance, data=request.data, partial=partial) @@ -194,12 +202,12 @@ class CreateImages(generics.CreateAPIView): FormParser, ) - def get_queryset(self, *args, **kwargs): + def get_queryset(self, *args, **kwargs) -> ImageList: sublet = get_object_or_404(Sublet, id=int(self.kwargs["sublet_id"])) return SubletImage.objects.filter(sublet=sublet) # takes an image multipart form data and creates a new image object - def post(self, request, *args, **kwargs): + def post(self, request: Request, *args, **kwargs) -> Response: images = request.data.getlist("images") sublet_id = int(self.kwargs["sublet_id"]) self.get_queryset() # check if sublet exists @@ -219,7 +227,7 @@ class DeleteImage(generics.DestroyAPIView): permission_classes = [SubletImageOwnerPermission | IsSuperUser] queryset = SubletImage.objects.all() - def destroy(self, request, *args, **kwargs): + def destroy(self, request: Request, *args, **kwargs) -> Response: queryset = self.get_queryset() filter = {"id": self.kwargs["image_id"]} obj = get_object_or_404(queryset, **filter) @@ -234,11 +242,11 @@ class Favorites(mixins.DestroyModelMixin, mixins.CreateModelMixin, viewsets.Gene http_method_names = ["post", "delete"] permission_classes = [IsAuthenticated | IsSuperUser] - def get_queryset(self): + def get_queryset(self) -> FavoriteQuerySet: user = self.request.user return user.sublets_favorited - def create(self, request, *args, **kwargs): + def create(self, request: Request, *args, **kwargs) -> Response: sublet_id = int(self.kwargs["sublet_id"]) queryset = self.get_queryset() if queryset.filter(id=sublet_id).exists(): @@ -250,7 +258,7 @@ def create(self, request, *args, **kwargs): return Response(status=status.HTTP_201_CREATED) - def destroy(self, request, *args, **kwargs): + def destroy(self, request: Request, *args, **kwargs) -> Response: queryset = self.get_queryset() sublet = get_object_or_404(queryset, pk=int(self.kwargs["sublet_id"])) self.get_queryset().remove(sublet) @@ -272,12 +280,12 @@ class Offers(viewsets.ModelViewSet): permission_classes = [OfferOwnerPermission | IsSuperUser] serializer_class = OfferSerializer - def get_queryset(self): + def get_queryset(self) -> OfferQuerySet: return Offer.objects.filter(sublet_id=int(self.kwargs["sublet_id"])).order_by( "created_date" ) - def create(self, request, *args, **kwargs): + def create(self, request: Request, *args, **kwargs) -> Response: data = request.data request.POST._mutable = True if self.get_queryset().filter(user=self.request.user).exists(): @@ -292,7 +300,7 @@ def create(self, request, *args, **kwargs): return Response(serializer.data, status=status.HTTP_201_CREATED) - def destroy(self, request, *args, **kwargs): + def destroy(self, request: Request, *args, **kwargs) -> Response: queryset = self.get_queryset() filter = {"user": self.request.user.id, "sublet": int(self.kwargs["sublet_id"])} obj = get_object_or_404(queryset, **filter) @@ -301,6 +309,7 @@ def destroy(self, request, *args, **kwargs): self.perform_destroy(obj) return Response(status=status.HTTP_204_NO_CONTENT) - def list(self, request, *args, **kwargs): - self.check_object_permissions(request, Sublet.objects.get(pk=int(self.kwargs["sublet_id"]))) + def list(self, request: Request, *args, **kwargs) -> Response: + sublet = get_object_or_404(Sublet, pk=int(self.kwargs["sublet_id"])) + self.check_object_permissions(request, sublet) return super().list(request, *args, **kwargs) From 7bf5144143c0275c8f4677f07aee11e7e312bd80 Mon Sep 17 00:00:00 2001 From: Ashley Zhang Date: Mon, 4 Nov 2024 20:46:30 -0800 Subject: [PATCH 02/12] =?UTF-8?q?=F0=9F=92=84=20beautify=20list=20properti?= =?UTF-8?q?es=20filtering?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/sublet/views.py | 87 +++++++++++++++++++++-------------------- 1 file changed, 44 insertions(+), 43 deletions(-) diff --git a/backend/sublet/views.py b/backend/sublet/views.py index 86502eb8..a4983520 100644 --- a/backend/sublet/views.py +++ b/backend/sublet/views.py @@ -3,6 +3,7 @@ from django.contrib.auth import get_user_model from django.db.models import QuerySet, prefetch_related_objects from django.utils import timezone +from django.utils.dateparse import parse_date from rest_framework import exceptions, generics, mixins, status, viewsets from rest_framework.generics import get_object_or_404 from rest_framework.parsers import FormParser, MultiPartParser @@ -134,61 +135,61 @@ def update(self, request: Request, *args, **kwargs) -> Response: # sublet.save() # return Response(serializer.data, status=status.HTTP_201_CREATED) - def list(self, request, *args, **kwargs): + def list(self, request: Request, *args, **kwargs) -> Response: """Returns a list of Sublets that match query parameters and user ownership.""" # Get query parameters from request (e.g., amenities, user_owned) params = request.query_params - amenities = params.getlist("amenities") - title = params.get("title") - address = params.get("address") - subletter = params.get("subletter", "false") # Defaults to False if not specified - starts_before = params.get("starts_before", None) - starts_after = params.get("starts_after", None) - ends_before = params.get("ends_before", None) - ends_after = params.get("ends_after", None) - min_price = params.get("min_price", None) - max_price = params.get("max_price", None) - negotiable = params.get("negotiable", None) - beds = params.get("beds", None) - baths = params.get("baths", None) - - queryset = self.get_queryset() + queryset: SubletQuerySet = self.get_queryset() - # Apply filters based on query parameters - - if subletter.lower() == "true": + if params.get("subletter", "false").lower() == "true": queryset = queryset.filter(subletter=request.user) else: queryset = queryset.filter(expires_at__gte=timezone.now()) - if title: - queryset = queryset.filter(title__icontains=title) - if address: - queryset = queryset.filter(address__icontains=address) - if amenities: + + date_filters = {} + if end_before := params.get("ends_before"): + if parsed_date := parse_date(end_before): + date_filters["end_date__lte"] = parsed_date + if end_after := params.get("ends_after"): + if parsed_date := parse_date(end_after): + date_filters["end_date__gte"] = parsed_date + if starts_before := params.get("starts_before"): + if parsed_date := parse_date(starts_before): + date_filters["start_date__lte"] = parsed_date + if starts_after := params.get("starts_after"): + if parsed_date := parse_date(starts_after): + date_filters["start_date__gte"] = parsed_date + + numeric_filters = {} + if min_price := params.get("min_price"): + try: + numeric_filters["price__gte"] = int(min_price) + except ValueError: + pass + if max_price := params.get("max_price"): + try: + numeric_filters["price__lte"] = int(max_price) + except ValueError: + pass + + basic_filters = { + "title__icontains": params.get("title"), + "address__icontains": params.get("address"), + "beds": params.get("beds"), + "baths": params.get("baths"), + "negotiable": params.get("negotiable"), + } + + all_filters = {**basic_filters, **date_filters, **numeric_filters} + active_filters = {k: v for k, v in all_filters.items() if v is not None} + queryset = queryset.filter(**active_filters) + + if amenities := params.getlist("amenities"): for amenity in amenities: queryset = queryset.filter(amenities__name=amenity) - if starts_before: - queryset = queryset.filter(start_date__lt=starts_before) - if starts_after: - queryset = queryset.filter(start_date__gt=starts_after) - if ends_before: - queryset = queryset.filter(end_date__lt=ends_before) - if ends_after: - queryset = queryset.filter(end_date__gt=ends_after) - if min_price: - queryset = queryset.filter(price__gte=min_price) - if max_price: - queryset = queryset.filter(price__lte=max_price) - if negotiable: - queryset = queryset.filter(negotiable=negotiable) - if beds: - queryset = queryset.filter(beds=beds) - if baths: - queryset = queryset.filter(baths=baths) record_analytics(Metric.SUBLET_BROWSE, request.user.username) - # Serialize and return the queryset serializer = SubletSerializerSimple(queryset, many=True) return Response(serializer.data) From fc30c55bf866d19536017525ddaed58c5f359316 Mon Sep 17 00:00:00 2001 From: Ashley Zhang Date: Tue, 5 Nov 2024 11:36:34 -0800 Subject: [PATCH 03/12] =?UTF-8?q?=F0=9F=8C=B7=20add=20type=20safety=20and?= =?UTF-8?q?=20type=20hinting=20to=20portal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/portal/serializers.py | 32 ++++++++++------ backend/portal/views.py | 71 +++++++++++++++++++++-------------- 2 files changed, 64 insertions(+), 39 deletions(-) diff --git a/backend/portal/serializers.py b/backend/portal/serializers.py index 51e852ab..f96d9b45 100644 --- a/backend/portal/serializers.py +++ b/backend/portal/serializers.py @@ -1,9 +1,15 @@ +from typing import Any, Dict, List, TypeAlias + from rest_framework import serializers from portal.logic import check_targets, get_user_clubs, get_user_populations from portal.models import Poll, PollOption, PollVote, Post, TargetPopulation +ClubCode: TypeAlias = str +ValidationData: TypeAlias = Dict[str, Any] + + class TargetPopulationSerializer(serializers.ModelSerializer): class Meta: model = TargetPopulation @@ -28,8 +34,8 @@ class Meta: ) read_only_fields = ("id", "created_date") - def create(self, validated_data): - club_code = validated_data["club_code"] + def create(self, validated_data: ValidationData) -> Poll: + club_code: ClubCode = validated_data["club_code"] # ensures user is part of club if club_code not in [ x["club"]["code"] for x in get_user_clubs(self.context["request"].user) @@ -78,7 +84,7 @@ def create(self, validated_data): return super().create(validated_data) - def update(self, instance, validated_data): + def update(self, instance: Poll, validated_data: ValidationData) -> Poll: # if Poll is updated, then approve should be false if not self.context["request"].user.is_superuser: validated_data["status"] = Poll.STATUS_DRAFT @@ -96,7 +102,7 @@ class Meta: ) read_only_fields = ("id", "vote_count") - def create(self, validated_data): + def create(self, validated_data: ValidationData) -> PollOption: poll_options_count = PollOption.objects.filter(poll=validated_data["poll"]).count() if poll_options_count >= 5: raise serializers.ValidationError( @@ -142,7 +148,7 @@ class Meta: "created_date", ) - def create(self, validated_data): + def create(self, validated_data: ValidationData) -> PollVote: options = validated_data["poll_options"] id_hash = validated_data["id_hash"] @@ -209,7 +215,7 @@ class PostSerializer(serializers.ModelSerializer): image = serializers.ImageField(write_only=True, required=False, allow_null=True) image_url = serializers.SerializerMethodField("get_image_url") - def get_image_url(self, obj): + def get_image_url(self, obj: Post) -> str | None: # use thumbnail if exists image = obj.image @@ -243,7 +249,9 @@ class Meta: ) read_only_fields = ("id", "created_date", "target_populations") - def parse_target_populations(self, raw_target_populations): + def parse_target_populations( + self, raw_target_populations: List[int] | str + ) -> List[TargetPopulation]: if isinstance(raw_target_populations, list): ids = raw_target_populations else: @@ -254,7 +262,9 @@ def parse_target_populations(self, raw_target_populations): ) return TargetPopulation.objects.filter(id__in=ids) - def update_target_populations(self, target_populations): + def update_target_populations( + self, target_populations: List[TargetPopulation] + ) -> List[TargetPopulation]: year = False major = False school = False @@ -281,8 +291,8 @@ def update_target_populations(self, target_populations): return target_populations - def create(self, validated_data): - club_code = validated_data["club_code"] + def create(self, validated_data: ValidationData) -> Post: + club_code: ClubCode = validated_data["club_code"] # Ensures user is part of club if club_code not in [ x["club"]["code"] for x in get_user_clubs(self.context["request"].user) @@ -309,7 +319,7 @@ def create(self, validated_data): return instance - def update(self, instance, validated_data): + def update(self, instance: Post, validated_data: ValidationData) -> Post: # if post is updated, then approved should be false if not self.context["request"].user.is_superuser: validated_data["status"] = Post.STATUS_DRAFT diff --git a/backend/portal/views.py b/backend/portal/views.py index f22d936e..9b5d6504 100644 --- a/backend/portal/views.py +++ b/backend/portal/views.py @@ -1,10 +1,13 @@ +from typing import Any, Dict, List, TypeAlias + from django.contrib.auth import get_user_model -from django.db.models import Count, Q +from django.db.models import Count, Q, QuerySet from django.db.models.functions import Trunc from django.utils import timezone from rest_framework import generics, viewsets from rest_framework.decorators import action from rest_framework.permissions import IsAuthenticated +from rest_framework.request import Request from rest_framework.response import Response from rest_framework.views import APIView @@ -35,6 +38,14 @@ ) +PollQuerySet: TypeAlias = QuerySet[Poll] +PostQuerySet: TypeAlias = QuerySet[Post] +PollVoteQuerySet: TypeAlias = QuerySet[PollVote] +ClubData: TypeAlias = List[Dict[str, Any]] +PollOptionQuerySet: TypeAlias = QuerySet[PollOption] +TimeSeriesData: TypeAlias = Dict[str, Any] +VoteStatistics: TypeAlias = Dict[str, Any] + User = get_user_model() @@ -43,7 +54,7 @@ class UserInfo(APIView): permission_classes = [IsAuthenticated] - def get(self, request): + def get(self, request: Request) -> Response: return Response({"user": get_user_info(request.user)}) @@ -52,10 +63,11 @@ class UserClubs(APIView): permission_classes = [IsAuthenticated] - def get(self, request): - club_data = [] - for club in get_user_clubs(request.user): - club_data.append(get_club_info(request.user, club["club"]["code"])) + def get(self, request: Request) -> Response: + club_data: ClubData = [ + get_club_info(request.user, club["club"]["code"]) + for club in get_user_clubs(request.user) + ] return Response({"clubs": club_data}) @@ -90,7 +102,7 @@ class Polls(viewsets.ModelViewSet): permission_classes = [PollOwnerPermission | IsSuperUser] serializer_class = PollSerializer - def get_queryset(self): + def get_queryset(self) -> PollQuerySet: # all polls if superuser, polls corresponding to club for regular user return ( Poll.objects.all() @@ -101,7 +113,7 @@ def get_queryset(self): ) @action(detail=False, methods=["post"]) - def browse(self, request): + def browse(self, request: Request) -> Response: """Returns list of all possible polls user can answer but has yet to For admins, returns list of all polls they have not voted for and have yet to expire """ @@ -156,14 +168,14 @@ def browse(self, request): ) @action(detail=False, methods=["get"], permission_classes=[IsSuperUser]) - def review(self, request): + def review(self, request: Request) -> Response: """Returns list of all Polls that admins still need to approve of""" return Response( RetrievePollSerializer(Poll.objects.filter(status=Poll.STATUS_DRAFT), many=True).data ) @action(detail=True, methods=["get"]) - def option_view(self, request, pk=None): + def option_view(self, request: Request, pk: int = None) -> Response: """Returns information on specific poll, including options and vote counts""" return Response(RetrievePollSerializer(Poll.objects.filter(id=pk).first(), many=False).data) @@ -184,7 +196,7 @@ class PollOptions(viewsets.ModelViewSet): permission_classes = [OptionOwnerPermission | IsSuperUser] serializer_class = PollOptionSerializer - def get_queryset(self): + def get_queryset(self) -> PollOptionQuerySet: # if user is admin, they can update anything # if user is not admin, they can only update their own options return ( @@ -207,11 +219,11 @@ class PollVotes(viewsets.ModelViewSet): permission_classes = [PollOwnerPermission | IsSuperUser] serializer_class = PollVoteSerializer - def get_queryset(self): + def get_queryset(self) -> PollVoteQuerySet: return PollVote.objects.none() @action(detail=False, methods=["post"]) - def recent(self, request): + def recent(self, request: Request) -> Response: id_hash = request.data["id_hash"] @@ -219,14 +231,14 @@ def recent(self, request): return Response(RetrievePollVoteSerializer(poll_votes).data) @action(detail=False, methods=["post"]) - def all(self, request): + def all(self, request: Request) -> Response: id_hash = request.data["id_hash"] poll_votes = PollVote.objects.filter(id_hash=id_hash).order_by("-created_date") return Response(RetrievePollVoteSerializer(poll_votes, many=True).data) - def create(self, request, *args, **kwargs): + def create(self, request: Request, *args: Any, **kwargs: Any) -> Response: record_analytics(Metric.PORTAL_POLL_VOTED, request.user.username) return super().create(request, *args, **kwargs) @@ -236,18 +248,21 @@ class PollVoteStatistics(APIView): permission_classes = [TimeSeriesPermission | IsSuperUser] - def get(self, request, poll_id): - return Response( - { - "time_series": PollVote.objects.filter(poll__id=poll_id) - .annotate(date=Trunc("created_date", "day")) - .values("date") - .annotate(votes=Count("date")) - .order_by("date"), - "poll_statistics": get_demographic_breakdown(poll_id), - } + def get(self, request: Request, poll_id: int) -> Response: + time_series = ( + PollVote.objects.filter(poll__id=poll_id) + .annotate(date=Trunc("created_date", "day")) + .values("date") + .annotate(votes=Count("date")) + .order_by("date") ) + statistics: VoteStatistics = { + "time_series": time_series, + "poll_statistics": get_demographic_breakdown(poll_id), + } + return Response(statistics) + class Posts(viewsets.ModelViewSet): """ @@ -270,7 +285,7 @@ class Posts(viewsets.ModelViewSet): permission_classes = [PostOwnerPermission | IsSuperUser] serializer_class = PostSerializer - def get_queryset(self): + def get_queryset(self) -> PostQuerySet: return ( Post.objects.all() if self.request.user.is_superuser @@ -280,7 +295,7 @@ def get_queryset(self): ) @action(detail=False, methods=["get"]) - def browse(self, request): + def browse(self, request: Request) -> Response: """ Returns a list of all posts that are targeted at the current user For admins, returns list of posts that they have not approved and have yet to expire @@ -318,7 +333,7 @@ def browse(self, request): ) @action(detail=False, methods=["get"], permission_classes=[IsSuperUser]) - def review(self, request): + def review(self, request: Request) -> Response: """Returns a list of all Posts that admins still need to approve of""" return Response( PostSerializer(Post.objects.filter(status=Poll.STATUS_DRAFT), many=True).data From 2264a3b6c0669a9e486612934ba4ac38f86d7dc5 Mon Sep 17 00:00:00 2001 From: Ashley Zhang Date: Tue, 5 Nov 2024 13:12:51 -0800 Subject: [PATCH 04/12] =?UTF-8?q?=F0=9F=93=9A=20add=20type=20safety=20and?= =?UTF-8?q?=20type=20hinting=20to=20pmobile?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/pennmobile/admin.py | 13 +++++++++++-- backend/pennmobile/analytics.py | 3 ++- backend/pennmobile/celery.py | 2 +- backend/pennmobile/test_runner.py | 6 +++--- 4 files changed, 17 insertions(+), 7 deletions(-) diff --git a/backend/pennmobile/admin.py b/backend/pennmobile/admin.py index 65b149d5..2da5a734 100644 --- a/backend/pennmobile/admin.py +++ b/backend/pennmobile/admin.py @@ -1,11 +1,20 @@ # CUSTOM ADMIN SETTUP FOR PENN MOBILE +from typing import Any, Dict, Optional, Type, TypeAlias + from django.contrib import admin, messages from django.contrib.admin.apps import AdminConfig +from django.db.models import Model +from django.http import HttpRequest from django.urls import reverse from django.utils.html import format_html -def add_post_poll_message(request, model): +ModelType: TypeAlias = Type[Model] +AdminContext: TypeAlias = Dict[str, Any] +MessageText: TypeAlias = str + + +def add_post_poll_message(request: HttpRequest, model: ModelType) -> None: if (count := model.objects.filter(model.ACTION_REQUIRED_CONDITION).count()) > 0: link = reverse(f"admin:{model._meta.app_label}_{model._meta.model_name}_changelist") messages.info( @@ -21,7 +30,7 @@ def add_post_poll_message(request, model): class CustomAdminSite(admin.AdminSite): site_header = "Penn Mobile Backend Admin" - def index(self, request, extra_context=None): + def index(self, request: HttpRequest, extra_context: Optional[AdminContext] = None) -> Any: from portal.models import Poll, Post add_post_poll_message(request, Post) diff --git a/backend/pennmobile/analytics.py b/backend/pennmobile/analytics.py index 13e3f94c..1477f115 100644 --- a/backend/pennmobile/analytics.py +++ b/backend/pennmobile/analytics.py @@ -1,4 +1,5 @@ from enum import Enum +from typing import Optional from analytics.analytics import AnalyticsTxn, LabsAnalytics, Product @@ -23,7 +24,7 @@ class Metric(str, Enum): PORTAL_POLL_VOTED = "portal.poll.voted" -def record_analytics(metric: Metric, username=None): +def record_analytics(metric: Metric, username: Optional[str] = None) -> None: if not AnalyticsEngine: print("AnalyticsEngine not initialized") return diff --git a/backend/pennmobile/celery.py b/backend/pennmobile/celery.py index fed8cd36..7b81bab0 100644 --- a/backend/pennmobile/celery.py +++ b/backend/pennmobile/celery.py @@ -21,5 +21,5 @@ @app.task(bind=True) -def debug_task(self): +def debug_task(self) -> None: print(f"Request: {self.request!r}") diff --git a/backend/pennmobile/test_runner.py b/backend/pennmobile/test_runner.py index 1384c211..55937401 100644 --- a/backend/pennmobile/test_runner.py +++ b/backend/pennmobile/test_runner.py @@ -4,7 +4,7 @@ from xmlrunner.extra.djangotestrunner import XMLTestRunner -def check_wharton(*args): +def check_wharton(*args) -> bool: return False @@ -20,12 +20,12 @@ def submit(self, txn): class MobileTestCIRunner(XMLTestRunner): @mock.patch("analytics.analytics.LabsAnalytics", MockLabsAnalytics) @mock.patch("gsr_booking.models.GroupMembership.check_wharton", check_wharton) - def run_tests(self, test_labels, **kwargs): + def run_tests(self, test_labels, **kwargs) -> None: return super().run_tests(test_labels, **kwargs) class MobileTestLocalRunner(DiscoverRunner): @mock.patch("analytics.analytics.LabsAnalytics", MockLabsAnalytics) @mock.patch("gsr_booking.models.GroupMembership.check_wharton", check_wharton) - def run_tests(self, test_labels, **kwargs): + def run_tests(self, test_labels, **kwargs) -> None: return super().run_tests(test_labels, **kwargs) From 676b65a2d17e91dba2da1357c62f234695d8f716 Mon Sep 17 00:00:00 2001 From: Ashley Zhang Date: Tue, 5 Nov 2024 14:27:58 -0800 Subject: [PATCH 05/12] =?UTF-8?q?=F0=9F=92=A5=20add=20type=20safety=20and?= =?UTF-8?q?=20type=20checking=20everywehre?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/dining/api_wrapper.py | 17 ++-- .../management/commands/load_next_menu.py | 2 +- .../dining/management/commands/load_venues.py | 2 +- backend/dining/serializers.py | 3 +- backend/dining/views.py | 11 +-- backend/gsr_booking/admin.py | 8 +- backend/gsr_booking/api_wrapper.py | 79 ++++++++++++------- .../management/commands/change_group.py | 4 +- .../management/commands/get_reservations.py | 6 +- .../management/commands/individual_usage.py | 4 +- .../management/commands/labs_gsr_data.py | 2 +- .../management/commands/load_gsrs.py | 2 +- .../management/commands/send_gsr_reminders.py | 2 +- backend/gsr_booking/serializers.py | 17 ++-- backend/gsr_booking/views.py | 37 +++++---- backend/laundry/api_wrapper.py | 14 ++-- .../management/commands/get_snapshot.py | 2 +- .../management/commands/load_laundry_rooms.py | 2 +- backend/laundry/views.py | 23 +++--- backend/penndata/admin.py | 3 +- .../management/commands/get_calendar.py | 2 +- .../commands/get_college_house_events.py | 15 +++- .../commands/get_engineering_events.py | 2 +- .../commands/get_fitness_snapshot.py | 8 +- .../commands/get_penn_today_events.py | 7 +- .../commands/get_university_life_events.py | 9 ++- .../management/commands/get_venture_events.py | 2 +- .../management/commands/get_wharton_events.py | 2 +- .../management/commands/load_analytics.py | 2 +- .../management/commands/load_fitness_rooms.py | 2 +- .../commands/rename_fitness_room.py | 2 +- backend/penndata/serializers.py | 7 +- backend/penndata/views.py | 56 +++++++++---- 33 files changed, 223 insertions(+), 133 deletions(-) diff --git a/backend/dining/api_wrapper.py b/backend/dining/api_wrapper.py index e4632ac7..7bbac3dd 100644 --- a/backend/dining/api_wrapper.py +++ b/backend/dining/api_wrapper.py @@ -1,5 +1,6 @@ import datetime import json +from typing import Any import requests from django.conf import settings @@ -16,14 +17,14 @@ class DiningAPIWrapper: - def __init__(self): + def __init__(self) -> None: self.token = None self.expiration = timezone.localtime() self.openid_endpoint = ( "https://sso.apps.k8s.upenn.edu/auth/realms/master/protocol/openid-connect/token" ) - def update_token(self): + def update_token(self) -> None: if self.expiration > timezone.localtime(): return body = { @@ -37,7 +38,7 @@ def update_token(self): self.expiration = timezone.localtime() + datetime.timedelta(seconds=response["expires_in"]) self.token = response["access_token"] - def request(self, *args, **kwargs): + def request(self, *args, **kwargs) -> requests.Response: """Make a signed request to the dining API.""" self.update_token() @@ -54,7 +55,7 @@ def request(self, *args, **kwargs): except (ConnectTimeout, ReadTimeout, ConnectionError): raise APIError("Dining: Connection timeout") - def get_venues(self): + def get_venues(self) -> list[dict[str, Any]]: results = [] venues_route = OPEN_DATA_ENDPOINTS["VENUES"] response = self.request("GET", venues_route) @@ -107,7 +108,7 @@ def get_venues(self): results.append(value) return results - def load_menu(self, date=timezone.now().date()): + def load_menu(self, date: datetime.date = timezone.now().date()) -> None: """ Loads the weeks menu starting from today NOTE: This method should only be used in load_next_menu.py, which is @@ -147,7 +148,9 @@ def load_menu(self, date=timezone.now().date()): # Append stations to dining menu self.load_stations(daypart["stations"], dining_menu) - def load_stations(self, station_response, dining_menu): + def load_stations( + self, station_response: list[dict[str, Any]], dining_menu: DiningMenu + ) -> None: for station_data in station_response: # TODO: This is inefficient for venues such as Houston Market station = DiningStation.objects.create(name=station_data["label"], menu=dining_menu) @@ -158,7 +161,7 @@ def load_stations(self, station_response, dining_menu): station.items.add(*items) station.save() - def load_items(self, item_response): + def load_items(self, item_response: dict[str, Any]) -> None: item_list = [ DiningItem( item_id=key, diff --git a/backend/dining/management/commands/load_next_menu.py b/backend/dining/management/commands/load_next_menu.py index 42bb667a..6d1e60ca 100644 --- a/backend/dining/management/commands/load_next_menu.py +++ b/backend/dining/management/commands/load_next_menu.py @@ -13,7 +13,7 @@ class Command(BaseCommand): the next 7 days, including the original date. """ - def handle(self, *args, **kwargs): + def handle(self, *args, **kwargs) -> None: d = DiningAPIWrapper() d.load_menu(timezone.now().date() + datetime.timedelta(days=6)) self.stdout.write("Loaded new Dining Menu!") diff --git a/backend/dining/management/commands/load_venues.py b/backend/dining/management/commands/load_venues.py index 3da54006..2115b301 100644 --- a/backend/dining/management/commands/load_venues.py +++ b/backend/dining/management/commands/load_venues.py @@ -10,7 +10,7 @@ class Command(BaseCommand): Loads Venues based on CSV """ - def handle(self, *args, **kwargs): + def handle(self, *args, **kwargs) -> None: with open("dining/data/dining_venues.csv") as data: reader = csv.reader(data) diff --git a/backend/dining/serializers.py b/backend/dining/serializers.py index ef8b61c1..8bc5477d 100644 --- a/backend/dining/serializers.py +++ b/backend/dining/serializers.py @@ -1,4 +1,5 @@ import json +from typing import Any from rest_framework import serializers @@ -18,7 +19,7 @@ class Meta: model = DiningItem fields = "__all__" - def get_nutrition_info(self, obj): + def get_nutrition_info(self, obj: DiningItem) -> dict[str, Any]: try: return json.loads(obj.nutrition_info) except json.JSONDecodeError: diff --git a/backend/dining/views.py b/backend/dining/views.py index 3db05212..8b1d1ff5 100644 --- a/backend/dining/views.py +++ b/backend/dining/views.py @@ -1,12 +1,13 @@ import datetime from django.core.cache import cache -from django.db.models import Count +from django.db.models import Count, QuerySet from django.shortcuts import get_object_or_404 from django.utils import timezone from django.utils.timezone import make_aware from rest_framework import generics from rest_framework.permissions import IsAuthenticated +from rest_framework.request import Request from rest_framework.response import Response from rest_framework.views import APIView @@ -24,7 +25,7 @@ class Venues(APIView): GET: returns list of venue data provided by Penn API, as well as an image of the venue """ - def get(self, request): + def get(self, request: Request) -> Response: try: return Response(d.get_venues()) except APIError as e: @@ -39,7 +40,7 @@ class Menus(generics.ListAPIView): serializer_class = DiningMenuSerializer - def get_queryset(self): + def get_queryset(self) -> QuerySet[DiningMenu]: # TODO: We only have data for the next week, so we should 404 # if date_param is out of bounds if date_param := self.kwargs.get("date"): @@ -61,7 +62,7 @@ class Preferences(APIView): permission_classes = [IsAuthenticated] key = "dining_preferences:{user_id}" - def get(self, request): + def get(self, request: Request) -> Response: key = self.key.format(user_id=request.user.id) cached_preferences = cache.get(key) if cached_preferences is None: @@ -71,7 +72,7 @@ def get(self, request): cache.set(key, cached_preferences, Cache.MONTH) return Response({"preferences": cached_preferences}) - def post(self, request): + def post(self, request: Request) -> Response: key = self.key.format(user_id=request.user.id) profile = request.user.profile preferences = profile.dining_preferences diff --git a/backend/gsr_booking/admin.py b/backend/gsr_booking/admin.py index 93deb3b2..5c1586d0 100644 --- a/backend/gsr_booking/admin.py +++ b/backend/gsr_booking/admin.py @@ -1,4 +1,6 @@ from django.contrib import admin +from django.db.models import QuerySet +from rest_framework.request import Request from gsr_booking.models import GSR, Group, GroupMembership, GSRBooking, Reservation @@ -9,10 +11,10 @@ class GroupMembershipInline(admin.TabularInline): readonly_fields = ["name"] - def name(self, obj): + def name(self, obj: GroupMembership) -> str: return obj.user.get_full_name() - def get_fields(self, request, obj=None): + def get_fields(self, request, obj=None) -> list[str]: fields = super().get_fields(request, obj) to_remove = ["user", "name"] return ["name"] + [f for f in fields if f not in to_remove] @@ -31,7 +33,7 @@ class GroupMembershipAdmin(admin.ModelAdmin): class GSRAdmin(admin.ModelAdmin): - def get_queryset(self, request): + def get_queryset(self, request: Request) -> QuerySet[GSR]: return GSR.all_objects.all() list_display = ["name", "kind", "lid", "gid", "in_use"] diff --git a/backend/gsr_booking/api_wrapper.py b/backend/gsr_booking/api_wrapper.py index 18a18feb..cb21528f 100644 --- a/backend/gsr_booking/api_wrapper.py +++ b/backend/gsr_booking/api_wrapper.py @@ -2,24 +2,28 @@ from abc import ABC, abstractmethod from enum import Enum from random import randint +from typing import TYPE_CHECKING, Any, TypeAlias import requests from bs4 import BeautifulSoup from django.conf import settings from django.contrib.auth import get_user_model -from django.db.models import F, Prefetch, Q, Sum +from django.db.models import F, Prefetch, Q, QuerySet, Sum from django.db.models.functions import Coalesce from django.shortcuts import get_object_or_404 from django.utils import timezone from requests.exceptions import ConnectionError, ConnectTimeout, ReadTimeout -from gsr_booking.models import GSR, GroupMembership, GSRBooking, Reservation +from gsr_booking.models import GSR, Group, GroupMembership, GSRBooking, Reservation from gsr_booking.serializers import GSRBookingSerializer, GSRSerializer from utils.errors import APIError -User = get_user_model() +if TYPE_CHECKING: + from django.contrib.auth.models import User as DjangoUser +UserType: TypeAlias = "DjangoUser" +User = get_user_model() BASE_URL = "https://libcal.library.upenn.edu" API_URL = "https://api2.libcal.com" @@ -41,24 +45,26 @@ class CreditType(Enum): class AbstractBookingWrapper(ABC): @abstractmethod - def book_room(self, rid, start, end, user): + def book_room(self, rid: int, start: str, end: str, user: UserType) -> dict[str, Any]: raise NotImplementedError # pragma: no cover @abstractmethod - def cancel_room(self, booking_id, user): + def cancel_room(self, booking_id: str, user: UserType) -> dict[str, Any]: raise NotImplementedError # pragma: no cover @abstractmethod - def get_availability(self, lid, start, end, user): + def get_availability( + self, lid: int, start: str, end: str, user: UserType + ) -> list[dict[str, Any]]: raise NotImplementedError # pragma: no cover @abstractmethod - def get_reservations(self, user): + def get_reservations(self, user: UserType) -> list[dict[str, Any]]: raise NotImplementedError # pragma: no cover class WhartonBookingWrapper(AbstractBookingWrapper): - def request(self, *args, **kwargs): + def request(self, *args, **kwargs) -> requests.Response: """Make a signed request to the libcal API.""" # add authorization headers kwargs["headers"] = {"Authorization": f"Token {settings.WHARTON_TOKEN}"} @@ -73,7 +79,7 @@ def request(self, *args, **kwargs): return response - def book_room(self, rid, start, end, user): + def book_room(self, rid: int, start: str, end: str, user: UserType) -> dict[str, Any]: """Books room if pennkey is valid""" payload = { "start": start, @@ -87,7 +93,7 @@ def book_room(self, rid, start, end, user): raise APIError("Wharton: " + response["error"]) return response - def cancel_room(self, booking_id, user): + def cancel_room(self, booking_id: str, user: UserType) -> dict[str, Any]: """Cancels reservation given booking id""" url = f"{WHARTON_URL}{user.username}/reservations/{booking_id}/cancel" response = self.request("DELETE", url).json() @@ -95,7 +101,9 @@ def cancel_room(self, booking_id, user): raise APIError("Wharton: " + response["detail"]) return response - def get_availability(self, lid, start, end, user): + def get_availability( + self, lid: int, start: str, end: str, user: UserType + ) -> list[dict[str, Any]]: """Returns a list of rooms and their availabilities""" current_time = timezone.localtime() search_date = ( @@ -133,7 +141,7 @@ def get_availability(self, lid, start, end, user): room["availability"] = valid_slots return rooms - def get_reservations(self, user): + def get_reservations(self, user: UserType) -> list[dict[str, Any]]: url = f"{WHARTON_URL}{user.username}/reservations" bookings = self.request("GET", url).json()["bookings"] @@ -151,7 +159,7 @@ def get_reservations(self, user): if datetime.datetime.strptime(booking["end"], "%Y-%m-%dT%H:%M:%S%z") >= now ] - def is_wharton(self, user): + def is_wharton(self, user: UserType) -> bool | None: url = f"{WHARTON_URL}{user.username}/privileges" try: response = self.request("GET", url) @@ -168,7 +176,7 @@ def __init__(self): self.token = None self.expiration = timezone.localtime() - def update_token(self): + def update_token(self) -> None: # does not get new token if the current one is still usable if self.expiration > timezone.localtime(): return @@ -185,7 +193,7 @@ def update_token(self): self.expiration = timezone.localtime() + datetime.timedelta(seconds=response["expires_in"]) self.token = response["access_token"] - def request(self, *args, **kwargs): + def request(self, *args, **kwargs) -> requests.Response: """Make a signed request to the libcal API.""" self.update_token() @@ -202,7 +210,7 @@ def request(self, *args, **kwargs): except (ConnectTimeout, ReadTimeout, ConnectionError): raise APIError("LibCal: Connection timeout") - def book_room(self, rid, start, end, user): + def book_room(self, rid: int, start: str, end: str, user: UserType) -> dict[str, Any]: """ Books room if pennkey is valid @@ -247,17 +255,19 @@ def book_room(self, rid, start, end, user): raise APIError("LibCal: " + res_json["error"]) return res_json - def get_reservations(self, user): + def get_reservations(self, user: UserType) -> list[dict[str, Any]]: pass - def cancel_room(self, booking_id, user): + def cancel_room(self, booking_id: str, user: UserType) -> dict[str, Any]: """Cancels room""" response = self.request("POST", f"{API_URL}/1.1/space/cancel/{booking_id}").json() if "error" in response[0]: raise APIError("LibCal: " + response[0]["error"]) return response - def get_availability(self, gid, start, end, user): + def get_availability( + self, gid: int, start: str, end: str, user: UserType + ) -> list[dict[str, Any]]: """Returns a list of rooms and their availabilities""" # adjusts url based on start and end times @@ -302,7 +312,7 @@ def get_availability(self, gid, start, end, user): ] return rooms - def get_affiliation(self, email): + def get_affiliation(self, email: str) -> str: """Gets school from email""" if "wharton" in email: return "Wharton" @@ -319,7 +329,9 @@ def __init__(self, WBW=None, LBW=None): self.WBW = WBW or WhartonBookingWrapper() self.LBW = LBW or LibCalBookingWrapper() - def format_members(self, members): + def format_members( + self, members: QuerySet[GroupMembership] + ) -> list[tuple[UserType, datetime.timedelta]]: PREFIX = "user__" return [ ( @@ -331,7 +343,9 @@ def format_members(self, members): for member in members ] - def get_wharton_members(self, group, gsr_id): + def get_wharton_members( + self, group: Group, gsr_id: int + ) -> list[tuple[UserType, datetime.timedelta]]: now = timezone.localtime() ninty_min = datetime.timedelta(minutes=90) zero_min = datetime.timedelta(minutes=0) @@ -358,7 +372,7 @@ def get_wharton_members(self, group, gsr_id): ) return self.format_members(ret) - def get_libcal_members(self, group): + def get_libcal_members(self, group: Group) -> list[tuple[UserType, datetime.timedelta]]: day_start = timezone.localtime().replace(hour=0, minute=0, second=0, microsecond=0) day_end = day_start + datetime.timedelta(days=1) two_hours = datetime.timedelta(hours=2) @@ -394,7 +408,16 @@ def get_libcal_members(self, group): ) return self.format_members(ret) - def book_room(self, gid, rid, room_name, start, end, user, group=None): + def book_room( + self, + gid: int, + rid: int, + room_name: str, + start: str, + end: str, + user: UserType, + group: Group | None = None, + ) -> Reservation: # NOTE when booking with a group, we are only querying our db for existing bookings, # so users in a group who book through wharton may screw up the query gsr = get_object_or_404(GSR, gid=gid) @@ -454,7 +477,7 @@ def book_room(self, gid, rid, room_name, start, end, user, group=None): return reservation - def cancel_room(self, booking_id, user): + def cancel_room(self, booking_id: str, user: UserType) -> None: if ( gsr_booking := GSRBooking.objects.filter(booking_id=booking_id) .prefetch_related(Prefetch("reservation__gsrbooking_set"), Prefetch("gsr")) @@ -483,7 +506,9 @@ def cancel_room(self, booking_id, user): pass raise APIError("Error: Unknown booking id") - def get_availability(self, lid, gid, start, end, user, group=None): + def get_availability( + self, lid: int, gid: int, start: str, end: str, user: UserType, group: Group | None = None + ) -> list[dict[str, Any]]: gsr = get_object_or_404(GSR, gid=gid) # select a random user from the group if booking wharton gsr @@ -500,7 +525,7 @@ def get_availability(self, lid, gid, start, end, user, group=None): ) return {"name": gsr.name, "gid": gsr.gid, "rooms": rooms} - def get_reservations(self, user, group=None): + def get_reservations(self, user: UserType, group: Group | None = None) -> list[dict[str, Any]]: q = Q(user=user) | Q(reservation__creator=user) if group else Q(user=user) bookings = GSRBooking.objects.filter( q, is_cancelled=False, end__gte=timezone.localtime() diff --git a/backend/gsr_booking/management/commands/change_group.py b/backend/gsr_booking/management/commands/change_group.py index ed38bbba..93e79687 100644 --- a/backend/gsr_booking/management/commands/change_group.py +++ b/backend/gsr_booking/management/commands/change_group.py @@ -13,12 +13,12 @@ class Command(BaseCommand): Adds/remove users to a group. """ - def add_arguments(self, parser): + def add_arguments(self, parser) -> None: parser.add_argument("usernames", type=str, help="list of pennkeys") parser.add_argument("group", type=str, help="group name") parser.add_argument("mode", type=str, help="mode of operation (add/remove/reset)") - def handle(self, *args, **kwargs): + def handle(self, *args, **kwargs) -> None: usernames = kwargs["usernames"].split(",") group = kwargs["group"] mode = kwargs["mode"].lower() diff --git a/backend/gsr_booking/management/commands/get_reservations.py b/backend/gsr_booking/management/commands/get_reservations.py index 5da0a268..59b0b71a 100644 --- a/backend/gsr_booking/management/commands/get_reservations.py +++ b/backend/gsr_booking/management/commands/get_reservations.py @@ -27,7 +27,7 @@ class Command(BaseCommand): Note: --start/--end and --current are mutually exclusive """ - def add_arguments(self, parser): + def add_arguments(self, parser) -> None: # optional flags parser.add_argument("--group", type=str, default=None) parser.add_argument("--start", type=str, default=None) @@ -36,7 +36,7 @@ def add_arguments(self, parser): parser.add_argument("--time", type=bool, default=False) parser.add_argument("--user", type=bool, default=False) - def handle(self, *args, **kwargs): + def handle(self, *args, **kwargs) -> None: group = kwargs["group"] start = kwargs["start"] end = kwargs["end"] @@ -83,7 +83,7 @@ def handle(self, *args, **kwargs): self.stdout.write(f"Number of reservations: {reservations.count()}") - def __convert_date(self, date_str): + def __convert_date(self, date_str: str) -> datetime | None: """ Converts string in format YYYY-MM-DD to datetime object. Returns None if string is not in correct format. diff --git a/backend/gsr_booking/management/commands/individual_usage.py b/backend/gsr_booking/management/commands/individual_usage.py index 7a7d5a32..65a3c501 100644 --- a/backend/gsr_booking/management/commands/individual_usage.py +++ b/backend/gsr_booking/management/commands/individual_usage.py @@ -7,10 +7,10 @@ class Command(BaseCommand): help = "Provides usage stats for a given user." - def add_arguments(self, parser): + def add_arguments(self, parser) -> None: parser.add_argument("pennkey", type=str, help="Pennkey of user to check") - def handle(self, *args, **kwargs): + def handle(self, *args, **kwargs) -> None: pennkey = kwargs["pennkey"] groups = Group.objects.filter(memberships__user__username=pennkey) bookings = GSRBooking.objects.filter( diff --git a/backend/gsr_booking/management/commands/labs_gsr_data.py b/backend/gsr_booking/management/commands/labs_gsr_data.py index b45f6231..3f4ed41e 100644 --- a/backend/gsr_booking/management/commands/labs_gsr_data.py +++ b/backend/gsr_booking/management/commands/labs_gsr_data.py @@ -8,7 +8,7 @@ class Command(BaseCommand): help = "Provides visiblity data for Penn Labs Group GSRs." - def handle(self, *args, **kwargs): + def handle(self, *args, **kwargs) -> None: group = Group.objects.get(name="Penn Labs") reservations = Reservation.objects.filter( group=group, is_cancelled=False, start__gte=timezone.now() diff --git a/backend/gsr_booking/management/commands/load_gsrs.py b/backend/gsr_booking/management/commands/load_gsrs.py index 88a2bfba..50c1194c 100644 --- a/backend/gsr_booking/management/commands/load_gsrs.py +++ b/backend/gsr_booking/management/commands/load_gsrs.py @@ -6,7 +6,7 @@ class Command(BaseCommand): - def handle(self, *args, **kwargs): + def handle(self, *args, **kwargs) -> None: with open("gsr_booking/data/gsr_data.csv") as data: reader = csv.reader(data) diff --git a/backend/gsr_booking/management/commands/send_gsr_reminders.py b/backend/gsr_booking/management/commands/send_gsr_reminders.py index d125196e..cba1fc4d 100644 --- a/backend/gsr_booking/management/commands/send_gsr_reminders.py +++ b/backend/gsr_booking/management/commands/send_gsr_reminders.py @@ -11,7 +11,7 @@ class Command(BaseCommand): help = "Sends reminders for the GSR Bookings." - def handle(self, *args, **kwargs): + def handle(self, *args, **kwargs) -> None: # iterate through all reservations scheduled for the next 30 minutes for reservation in Reservation.objects.filter( diff --git a/backend/gsr_booking/serializers.py b/backend/gsr_booking/serializers.py index 1c94873e..42e35d78 100644 --- a/backend/gsr_booking/serializers.py +++ b/backend/gsr_booking/serializers.py @@ -1,9 +1,16 @@ +from typing import TYPE_CHECKING, Any, TypeAlias + from django.contrib.auth import get_user_model from rest_framework import serializers from gsr_booking.models import GSR, Group, GroupMembership, GSRBooking +if TYPE_CHECKING: + from django.contrib.auth.models import User as DjangoUser + +UserType: TypeAlias = "DjangoUser" +ValidatedData: TypeAlias = dict[str, Any] User = get_user_model() @@ -16,7 +23,7 @@ class GroupRoomBookingRequestSerializer(serializers.Serializer): is_wharton = serializers.SerializerMethodField() - def get_is_wharton(self, obj): + def get_is_wharton(self, obj: ValidatedData) -> bool: return obj["lid"] == 1 @@ -54,7 +61,7 @@ class Meta: model = Group fields = ["owner", "memberships", "name", "color", "id"] - def create(self, validated_data): + def create(self, validated_data: ValidatedData) -> Group: request = self.context.get("request", None) if request is None: return super().create(validated_data) @@ -66,17 +73,17 @@ def create(self, validated_data): class GroupField(serializers.RelatedField): - def to_representation(self, value): + def to_representation(self, value: Group) -> dict[str, Any]: return {"name": value.name, "id": value.id, "color": value.color} - def to_internal_value(self, data): + def to_internal_value(self, data: ValidatedData) -> None: return None # TODO: If you want to update based on BookingField, implement this. class UserSerializer(serializers.ModelSerializer): booking_groups = serializers.SerializerMethodField() - def get_booking_groups(self, obj): + def get_booking_groups(self, obj: UserType) -> list[dict[str, Any]]: result = [] for membership in GroupMembership.objects.filter(accepted=True, user=obj): result.append( diff --git a/backend/gsr_booking/views.py b/backend/gsr_booking/views.py index 9bf6aa3f..8a0b72b1 100644 --- a/backend/gsr_booking/views.py +++ b/backend/gsr_booking/views.py @@ -1,11 +1,14 @@ +from typing import TYPE_CHECKING, Optional, TypeAlias + from django.contrib.auth import get_user_model -from django.db.models import Prefetch, Q +from django.db.models import Prefetch, Q, QuerySet from django.http import HttpResponseForbidden from django.shortcuts import get_object_or_404 from django_filters.rest_framework import DjangoFilterBackend from rest_framework import generics, viewsets from rest_framework.decorators import action from rest_framework.permissions import IsAuthenticated +from rest_framework.request import Request from rest_framework.response import Response from rest_framework.views import APIView @@ -20,6 +23,10 @@ from pennmobile.analytics import Metric, record_analytics +if TYPE_CHECKING: + from django.contrib.auth.models import User as DjangoUser + +UserType: TypeAlias = "DjangoUser" User = get_user_model() @@ -37,7 +44,7 @@ class UserViewSet(viewsets.ReadOnlyModelViewSet): filter_backends = [DjangoFilterBackend] filterset_fields = ["username", "first_name", "last_name"] - def get_object(self): + def get_object(self) -> UserType: lookup_url_kwarg = self.lookup_url_kwarg or self.lookup_field param = self.kwargs[lookup_url_kwarg] if param == "me": @@ -45,7 +52,7 @@ def get_object(self): else: return super().get_object() - def get_queryset(self): + def get_queryset(self) -> QuerySet[UserType]: if not self.request.user.is_authenticated: return User.objects.none() @@ -61,7 +68,7 @@ def get_queryset(self): return queryset @action(detail=True, methods=["get"]) - def invites(self, request, username=None): + def invites(self, request: Request, username: Optional[str] = None) -> Response: """ Retrieve all invites for a given user. """ @@ -84,7 +91,7 @@ class GroupMembershipViewSet(viewsets.ModelViewSet): queryset = GroupMembership.objects.all() serializer_class = GroupMembershipSerializer - def get_queryset(self): + def get_queryset(self) -> QuerySet[GroupMembership]: if not self.request.user.is_authenticated or not hasattr(self.request.user, "memberships"): return GroupMembership.objects.none() return GroupMembership.objects.filter( @@ -97,7 +104,7 @@ def get_queryset(self): ) @action(detail=False, methods=["post"]) - def invite(self, request): + def invite(self, request: Request) -> Response: """ Invite a user to a group. """ @@ -111,7 +118,7 @@ def invite(self, request): return Response({"message": "invite(s) sent."}) @action(detail=True, methods=["post"]) - def accept(self, request, pk=None): + def accept(self, request: Request, pk: Optional[int] = None) -> Response: membership = get_object_or_404(GroupMembership, pk=pk, accepted=False) if membership.user is None or membership.user != request.user: return HttpResponseForbidden() @@ -130,7 +137,7 @@ def accept(self, request, pk=None): ) @action(detail=True, methods=["post"]) - def decline(self, request, pk=None): + def decline(self, request: Request, pk: Optional[int] = None) -> Response: membership = get_object_or_404(GroupMembership, pk=pk, accepted=False) if membership.user is None or membership.user != request.user: return HttpResponseForbidden() @@ -151,7 +158,7 @@ class GroupViewSet(viewsets.ModelViewSet): serializer_class = GroupSerializer permission_classes = [IsAuthenticated] - def get_queryset(self): + def get_queryset(self) -> QuerySet[Group]: if not self.request.user.is_authenticated: return Group.objects.none() return ( @@ -177,7 +184,7 @@ class RecentGSRs(generics.ListAPIView): serializer_class = GSRSerializer permission_classes = [IsAuthenticated] - def get_queryset(self): + def get_queryset(self) -> QuerySet[GSR]: return GSR.objects.filter( id__in=GSRBooking.objects.filter(user=self.request.user, is_cancelled=False) .distinct() @@ -190,7 +197,7 @@ class CheckWharton(APIView): permission_classes = [IsAuthenticated] - def get(self, request): + def get(self, request: Request) -> Response: return Response( { "is_wharton": request.user.booking_groups.filter(name="Penn Labs").exists() @@ -210,7 +217,7 @@ class Availability(APIView): permission_classes = [IsAuthenticated] - def get(self, request, lid, gid): + def get(self, request: Request, lid: int, gid: int) -> Response: start = request.GET.get("start") end = request.GET.get("end") @@ -235,7 +242,7 @@ class BookRoom(APIView): permission_classes = [IsAuthenticated] - def post(self, request): + def post(self, request: Request) -> Response: start = request.data["start_time"] end = request.data["end_time"] gid = request.data["gid"] @@ -267,7 +274,7 @@ class CancelRoom(APIView): permission_classes = [IsAuthenticated] - def post(self, request): + def post(self, request: Request) -> Response: booking_id = request.data["booking_id"] try: @@ -284,7 +291,7 @@ class ReservationsView(APIView): permission_classes = [IsAuthenticated] - def get(self, request): + def get(self, request: Request) -> Response: return Response( GSRBooker.get_reservations( request.user, request.user.booking_groups.filter(name="Penn Labs").first() diff --git a/backend/laundry/api_wrapper.py b/backend/laundry/api_wrapper.py index 53a99a95..938e9a18 100644 --- a/backend/laundry/api_wrapper.py +++ b/backend/laundry/api_wrapper.py @@ -1,3 +1,5 @@ +from typing import Any + import requests from bs4 import BeautifulSoup from django.conf import settings @@ -10,7 +12,7 @@ HALL_URL = f"{settings.LAUNDRY_URL}/?location=" -def update_machine_object(cols, machine_object): +def update_machine_object(cols: list[Any], machine_object: dict[str, Any]) -> dict[str, Any]: """ Updates Machine status and time remaining """ @@ -38,7 +40,7 @@ def update_machine_object(cols, machine_object): return machine_object -def parse_a_hall(hall_link): +def parse_a_hall(hall_link: str) -> dict[str, Any]: """ Return names, hall numbers, and the washers/dryers available for a certain hall_id """ @@ -87,7 +89,7 @@ def parse_a_hall(hall_link): return {"washers": washers, "dryers": dryers, "details": detailed} -def check_is_working(): +def check_is_working() -> bool: """ Returns True if the wash alert web interface seems to be working properly, or False otherwise. """ @@ -114,7 +116,7 @@ def check_is_working(): return False -def all_status(): +def all_status() -> dict[str, dict[str, Any]]: """ Return names, hall numbers, and the washers/dryers available for all rooms in the system """ @@ -124,7 +126,7 @@ def all_status(): } -def hall_status(room): +def hall_status(room: LaundryRoom) -> dict[str, Any]: """ Return the status of each specific washer/dryer in a particular hall_id """ @@ -134,7 +136,7 @@ def hall_status(room): return {"machines": machines, "hall_name": room.name, "location": room.location} -def save_data(): +def save_data() -> None: """ Retrieves current laundry info and saves it into the database. """ diff --git a/backend/laundry/management/commands/get_snapshot.py b/backend/laundry/management/commands/get_snapshot.py index 3a8bda3f..1ad47094 100644 --- a/backend/laundry/management/commands/get_snapshot.py +++ b/backend/laundry/management/commands/get_snapshot.py @@ -6,6 +6,6 @@ class Command(BaseCommand): help = "Captures a new Laundry Snapshot for every Laundry room." - def handle(self, *args, **kwargs): + def handle(self, *args, **kwargs) -> None: save_data() self.stdout.write("Captured snapshots!") diff --git a/backend/laundry/management/commands/load_laundry_rooms.py b/backend/laundry/management/commands/load_laundry_rooms.py index 6b26cc8b..1c3046b1 100644 --- a/backend/laundry/management/commands/load_laundry_rooms.py +++ b/backend/laundry/management/commands/load_laundry_rooms.py @@ -6,7 +6,7 @@ class Command(BaseCommand): - def handle(self, *args, **kwargs): + def handle(self, *args, **kwargs) -> None: with open("laundry/data/laundry_data.csv") as data: reader = csv.reader(data) diff --git a/backend/laundry/views.py b/backend/laundry/views.py index fa9a008f..85d8698c 100644 --- a/backend/laundry/views.py +++ b/backend/laundry/views.py @@ -2,11 +2,12 @@ import datetime from django.core.cache import cache -from django.db.models import Q +from django.db.models import Q, QuerySet from django.shortcuts import get_object_or_404 from django.utils import timezone from requests.exceptions import HTTPError from rest_framework.permissions import IsAuthenticated +from rest_framework.request import Request from rest_framework.response import Response from rest_framework.views import APIView @@ -22,7 +23,7 @@ class Ids(APIView): GET: returns list of all hall_ids """ - def get(self, request): + def get(self, request: Request) -> Response: return Response(LaundryRoomSerializer(LaundryRoom.objects.all(), many=True).data) @@ -31,7 +32,7 @@ class HallInfo(APIView): GET: returns list of a particular hall, its respective machines and machine details """ - def get(self, request, hall_id): + def get(self, request: Request, hall_id: int) -> Response: try: return Response(hall_status(get_object_or_404(LaundryRoom, hall_id=hall_id))) except HTTPError: @@ -43,7 +44,7 @@ class MultipleHallInfo(APIView): GET: returns list of hall information as well as hall usage """ - def get(self, request, hall_ids): + def get(self, request: Request, hall_ids: str) -> Response: halls = [int(x) for x in hall_ids.split(",")] output = {"rooms": []} @@ -63,10 +64,10 @@ class HallUsage(APIView): GET: returns usage data for dryers and washers of a particular hall """ - def safe_division(a, b): + def safe_division(a: int | None, b: int | None) -> float | None: return round(a / float(b), 3) if b > 0 else 0 - def get_snapshot_info(hall_id): + def get_snapshot_info(hall_id: int) -> tuple[LaundryRoom, QuerySet[LaundrySnapshot]]: # filters for LaundrySnapshots within timeframe room = get_object_or_404(LaundryRoom, hall_id=hall_id) @@ -83,7 +84,7 @@ def get_snapshot_info(hall_id): snapshots = LaundrySnapshot.objects.filter(filter).order_by("-date") return (room, snapshots) - def compute_usage(hall_id): + def compute_usage(hall_id: int) -> Response: try: (room, snapshots) = HallUsage.get_snapshot_info(hall_id) except ValueError: @@ -135,7 +136,7 @@ def compute_usage(hall_id): return content - def get(self, request, hall_id): + def get(self, request: Request, hall_id: int) -> Response: return Response(HallUsage.compute_usage(hall_id)) @@ -150,7 +151,7 @@ class Preferences(APIView): permission_classes = [IsAuthenticated] key = "laundry_preferences:{user_id}" - def get(self, request): + def get(self, request: Request) -> Response: key = self.key.format(user_id=request.user.id) cached_preferences = cache.get(key) if cached_preferences is None: @@ -160,7 +161,7 @@ def get(self, request): return Response({"rooms": cached_preferences}) - def post(self, request): + def post(self, request: Request) -> Response: key = self.key.format(user_id=request.user.id) profile = request.user.profile preferences = profile.laundry_preferences @@ -187,7 +188,7 @@ class Status(APIView): GET: returns Response according to whether or not Penn Laundry API is working or not """ - def get(self, request): + def get(self, request: Request) -> Response: if check_is_working(): return Response({"is_working": True, "error_msg": None}) else: diff --git a/backend/penndata/admin.py b/backend/penndata/admin.py index b427f355..2ba02fc3 100644 --- a/backend/penndata/admin.py +++ b/backend/penndata/admin.py @@ -1,5 +1,6 @@ from django.contrib import admin from django.utils.html import escape, mark_safe +from django.utils.safestring import SafeText from penndata.models import ( AnalyticsEvent, @@ -12,7 +13,7 @@ class FitnessRoomAdmin(admin.ModelAdmin): - def image_tag(self, instance): + def image_tag(self, instance: FitnessRoom) -> SafeText: return mark_safe('' % escape(instance.image_url)) image_tag.short_description = "Fitness Room Image" diff --git a/backend/penndata/management/commands/get_calendar.py b/backend/penndata/management/commands/get_calendar.py index a765dba7..3868b01f 100644 --- a/backend/penndata/management/commands/get_calendar.py +++ b/backend/penndata/management/commands/get_calendar.py @@ -12,7 +12,7 @@ class Command(BaseCommand): - def handle(self, *args, **kwargs): + def handle(self, *args, **kwargs) -> None: # Clears out previous CalendarEvents CalendarEvent.objects.all().delete() diff --git a/backend/penndata/management/commands/get_college_house_events.py b/backend/penndata/management/commands/get_college_house_events.py index 488bb8cf..3f3e3e49 100644 --- a/backend/penndata/management/commands/get_college_house_events.py +++ b/backend/penndata/management/commands/get_college_house_events.py @@ -1,4 +1,5 @@ import datetime +from typing import Optional import requests from bs4 import BeautifulSoup @@ -8,7 +9,7 @@ from penndata.models import Event -EVENT_TYPE_MAP = [ +EVENT_TYPE_MAP: list[tuple[str, str]] = [ ("https://rodin.house.upenn.edu/calendar", Event.TYPE_RODIN_COLLEGE_HOUSE), ("https://harnwell.house.upenn.edu/calendar", Event.TYPE_HARNWELL_COLLEGE_HOUSE), ("https://harrison.house.upenn.edu/calendar", Event.TYPE_HARRISON_COLLEGE_HOUSE), @@ -27,7 +28,7 @@ class Command(BaseCommand): - def handle(self, *args, **kwargs): + def handle(self, *args, **kwargs) -> None: for site, event_type in EVENT_TYPE_MAP: self.scrape_calendar_page(f"{site}", event_type) now = timezone.localtime() @@ -39,7 +40,13 @@ def handle(self, *args, **kwargs): self.stdout.write("Uploaded College House Events!") - def scrape_details(self, event_url): + def scrape_details(self, event_url: str) -> tuple[ + Optional[str], + Optional[datetime.datetime], + Optional[datetime.datetime], + Optional[str], + Optional[str], + ]: try: resp = requests.get(event_url) except ConnectionError: @@ -84,7 +91,7 @@ def scrape_details(self, event_url): ) return location, start_time, end_time, description, image_url - def scrape_calendar_page(self, calendar_url, event_type): + def scrape_calendar_page(self, calendar_url: str, event_type: str) -> None: try: resp = requests.get(calendar_url) except ConnectionError: diff --git a/backend/penndata/management/commands/get_engineering_events.py b/backend/penndata/management/commands/get_engineering_events.py index 1a304c4c..1baf89b2 100644 --- a/backend/penndata/management/commands/get_engineering_events.py +++ b/backend/penndata/management/commands/get_engineering_events.py @@ -12,7 +12,7 @@ class Command(BaseCommand): - def handle(self, *args, **kwargs): + def handle(self, *args, **kwargs) -> None: try: resp = requests.get(ENGINEERING_EVENTS_WEBSITE) except ConnectionError: diff --git a/backend/penndata/management/commands/get_fitness_snapshot.py b/backend/penndata/management/commands/get_fitness_snapshot.py index ec0792a8..85132d75 100644 --- a/backend/penndata/management/commands/get_fitness_snapshot.py +++ b/backend/penndata/management/commands/get_fitness_snapshot.py @@ -1,3 +1,5 @@ +import datetime + import requests from bs4 import BeautifulSoup from dateutil import parser @@ -7,11 +9,11 @@ from penndata.models import FitnessRoom, FitnessSnapshot -def cap_string(s): +def cap_string(s: str) -> str: return " ".join([word[0].upper() + word[1:] for word in s.split()]) -def get_usages(): +def get_usages() -> tuple[dict[str, dict[str, int | float]], datetime.datetime]: # count/capacities default to 0 since spreadsheet number appears blank if no one there locations = [ @@ -66,7 +68,7 @@ def get_usages(): class Command(BaseCommand): help = "Captures a new Fitness Snapshot for every Laundry room." - def handle(self, *args, **kwargs): + def handle(self, *args, **kwargs) -> None: usage_by_location, date = get_usages() # prevent double creating FitnessSnapshots diff --git a/backend/penndata/management/commands/get_penn_today_events.py b/backend/penndata/management/commands/get_penn_today_events.py index 6916b164..b9428c8f 100644 --- a/backend/penndata/management/commands/get_penn_today_events.py +++ b/backend/penndata/management/commands/get_penn_today_events.py @@ -1,4 +1,5 @@ import datetime +from typing import Optional from urllib.parse import urljoin from bs4 import BeautifulSoup @@ -20,7 +21,7 @@ class Command(BaseCommand): - def handle(self, *args, **kwargs): + def handle(self, *args, **kwargs) -> None: now = timezone.localtime() current_month, current_year = now.month, now.year @@ -117,7 +118,7 @@ def handle(self, *args, **kwargs): self.stdout.write("Uploaded Penn Today Events!") - def connect_and_parse_html(self, event_url, condition): + def connect_and_parse_html(self, event_url: str, condition: EC) -> Optional[str]: try: options = Options() options.add_argument("--headless") @@ -137,7 +138,7 @@ def connect_and_parse_html(self, event_url, condition): print("Connection Error to webdriver") return None - def get_end_time(self, event_url): + def get_end_time(self, event_url: str) -> Optional[str]: end_time_soup = self.connect_and_parse_html( event_url, EC.presence_of_element_located((By.CLASS_NAME, "event__topper-content")) ) diff --git a/backend/penndata/management/commands/get_university_life_events.py b/backend/penndata/management/commands/get_university_life_events.py index 1be2189e..2a7ced5d 100644 --- a/backend/penndata/management/commands/get_university_life_events.py +++ b/backend/penndata/management/commands/get_university_life_events.py @@ -1,3 +1,6 @@ +import datetime +from typing import Any + import requests from bs4 import BeautifulSoup from dateutil import parser @@ -12,10 +15,10 @@ class Command(BaseCommand): - def to_datetime(self, date_str): + def to_datetime(self, date_str: str) -> datetime.datetime: return timezone.make_aware(parser.parse(date_str)) - def parse_event_section(self, event_section): + def parse_event_section(self, event_section: Any) -> None: date_str = event_section.find(class_="heading").find("h2").get("id") events = event_section.find(class_="info").find_all("a", attrs={"attr-event-id": True}) @@ -53,7 +56,7 @@ def parse_event_section(self, event_section): email=None, ) - def handle(self, *args, **kwargs): + def handle(self, *args, **kwargs) -> None: response = requests.get(UNIVERSITY_LIFE_URL) diff --git a/backend/penndata/management/commands/get_venture_events.py b/backend/penndata/management/commands/get_venture_events.py index 97000199..303b7de0 100644 --- a/backend/penndata/management/commands/get_venture_events.py +++ b/backend/penndata/management/commands/get_venture_events.py @@ -14,7 +14,7 @@ class Command(BaseCommand): - def handle(self, *args, **kwargs): + def handle(self, *args, **kwargs) -> None: now = timezone.localtime() current_month, current_year = now.month, now.year diff --git a/backend/penndata/management/commands/get_wharton_events.py b/backend/penndata/management/commands/get_wharton_events.py index c82f9041..370e3856 100644 --- a/backend/penndata/management/commands/get_wharton_events.py +++ b/backend/penndata/management/commands/get_wharton_events.py @@ -13,7 +13,7 @@ class Command(BaseCommand): - def handle(self, *args, **kwargs): + def handle(self, *args, **kwargs) -> None: eastern = pytz.timezone("US/Eastern") try: diff --git a/backend/penndata/management/commands/load_analytics.py b/backend/penndata/management/commands/load_analytics.py index b87d58b5..cb12626f 100644 --- a/backend/penndata/management/commands/load_analytics.py +++ b/backend/penndata/management/commands/load_analytics.py @@ -12,7 +12,7 @@ class Command(BaseCommand): - def handle(self, *args, **kwargs): + def handle(self, *args, **kwargs) -> None: analytics_objects = [] diff --git a/backend/penndata/management/commands/load_fitness_rooms.py b/backend/penndata/management/commands/load_fitness_rooms.py index b7797d47..4d2ac933 100644 --- a/backend/penndata/management/commands/load_fitness_rooms.py +++ b/backend/penndata/management/commands/load_fitness_rooms.py @@ -4,7 +4,7 @@ class Command(BaseCommand): - def handle(self, *args, **kwargs): + def handle(self, *args, **kwargs) -> None: fitness_rooms = [ "4th Floor Fitness", "3rd Floor Fitness", diff --git a/backend/penndata/management/commands/rename_fitness_room.py b/backend/penndata/management/commands/rename_fitness_room.py index cba0c41a..4ea4bd21 100644 --- a/backend/penndata/management/commands/rename_fitness_room.py +++ b/backend/penndata/management/commands/rename_fitness_room.py @@ -7,7 +7,7 @@ class Command(BaseCommand): help = "Renames fitness rooms." - def handle(self, *args, **kwargs): + def handle(self, *args, **kwargs) -> None: for room in FitnessRoom.objects.all(): room.name = cap_string(room.name) room.save() diff --git a/backend/penndata/serializers.py b/backend/penndata/serializers.py index e2163eed..51933e26 100644 --- a/backend/penndata/serializers.py +++ b/backend/penndata/serializers.py @@ -1,3 +1,5 @@ +from typing import Any, TypeAlias + from rest_framework import serializers from penndata.models import ( @@ -10,6 +12,9 @@ ) +ValidatedData: TypeAlias = dict[str, Any] + + class EventSerializer(serializers.ModelSerializer): class Meta: model = Event @@ -58,7 +63,7 @@ class Meta: model = AnalyticsEvent fields = ("created_at", "cell_type", "index", "post", "poll", "is_interaction") - def create(self, validated_data): + def create(self, validated_data: ValidatedData) -> AnalyticsEvent: validated_data["user"] = self.context["request"].user if validated_data["poll"] and validated_data["post"]: raise serializers.ValidationError( diff --git a/backend/penndata/views.py b/backend/penndata/views.py index fba31b18..f7a27828 100644 --- a/backend/penndata/views.py +++ b/backend/penndata/views.py @@ -1,13 +1,16 @@ import datetime from datetime import timedelta +from typing import Any, TypeAlias import requests from bs4 import BeautifulSoup +from django.db.models import QuerySet from django.shortcuts import get_object_or_404 from django.utils import timezone from requests.exceptions import ConnectionError from rest_framework import generics from rest_framework.permissions import AllowAny, IsAuthenticated +from rest_framework.request import Request from rest_framework.response import Response from rest_framework.views import APIView @@ -28,12 +31,18 @@ ) +ValidatedData: TypeAlias = dict[str, Any] +CalendarEventList: TypeAlias = QuerySet[CalendarEvent] +EventList: TypeAlias = QuerySet[Event] +HomePageOrderList: TypeAlias = QuerySet[HomePageOrder] + + class News(APIView): """ GET: Get's news article from the DP """ - def get_article(self): + def get_article(self) -> dict[str, Any] | None: article = {"source": "The Daily Pennsylvanian"} try: headers = { @@ -79,7 +88,7 @@ def get_article(self): else: return None - def get(self, request): + def get(self, request: Request) -> Response: article = self.get_article() if article: return Response(article) @@ -95,7 +104,7 @@ class Calendar(generics.ListAPIView): permission_classes = [AllowAny] serializer_class = CalendarEventSerializer - def get_queryset(self): + def get_queryset(self) -> CalendarEventList: return CalendarEvent.objects.filter( date_obj__gte=timezone.localtime(), date_obj__lte=timezone.localtime() + timedelta(days=30), @@ -111,7 +120,7 @@ class Events(generics.ListAPIView): permission_classes = [AllowAny] serializer_class = EventSerializer - def get_queryset(self): + def get_queryset(self) -> EventList: queryset = Event.objects.all() event_type = self.kwargs.get("type") @@ -139,7 +148,7 @@ class HomePageOrdering(generics.ListAPIView): permission_classes = [AllowAny] serializer_class = HomePageOrderSerializer - def get_queryset(self): + def get_queryset(self) -> HomePageOrderList: return HomePageOrder.objects.all() @@ -151,15 +160,17 @@ class HomePage(APIView): permission_classes = [IsAuthenticated] class Cell: - def __init__(self, myType, myInfo=None, myWeight=0): + def __init__( + self, myType: str, myInfo: ValidatedData | None = None, myWeight: int = 0 + ) -> None: self.type = myType self.info = myInfo self.weight = myWeight - def getCell(self): + def getCell(self) -> dict[str, Any]: return {"type": self.type, "info": self.info} - def get(self, request): + def get(self, request: Request) -> Response: # NOTE: accept arguments: ?version= @@ -231,7 +242,7 @@ class FitnessRoomView(generics.ListAPIView): 6: (9, 22), } - def get(self, request): + def get(self, request: Request) -> Response: response = super().get(self, request) # also add last_updated and open/close times to each room in response for room in response.data: @@ -252,10 +263,17 @@ def get(self, request): class FitnessUsage(APIView): - def safe_add(self, a, b): + def safe_add(self, a: int | None, b: int | None) -> int | None: return None if a is None and b is None else (a or 0) + (b or 0) - def linear_interpolate(self, before_val, after_val, before_date, current_date, after_date): + def linear_interpolate( + self, + before_val: int | None, + after_val: int | None, + before_date: datetime.datetime, + current_date: datetime.datetime, + after_date: datetime.datetime, + ) -> int | None: return ( before_val + (after_val - before_val) @@ -263,7 +281,9 @@ def linear_interpolate(self, before_val, after_val, before_date, current_date, a / (after_date - before_date).total_seconds() ) - def get_usage_on_date(self, room, date, field): + def get_usage_on_date( + self, room: FitnessRoom, date: datetime.date, field: str + ) -> list[int | None]: """ Returns the number of people in the fitness center on a given date per hour """ @@ -324,7 +344,9 @@ def get_usage_on_date(self, room, date, field): return [None] * 24 return usage - def get_usage(self, room, date, num_samples, group_by, field): + def get_usage( + self, room: FitnessRoom, date: datetime.date, num_samples: int, group_by: str, field: str + ) -> tuple[list[int | None], datetime.date, datetime.date]: unit = 1 if group_by == "day" else 7 # skip by 1 or 7 days usage_aggs = [(None, 0)] * 24 # (sum, count) for each hour min_date = timezone.localtime().date() @@ -345,7 +367,7 @@ def get_usage(self, room, date, num_samples, group_by, field): ret = [(sum / count) if count else None for (sum, count) in usage_aggs] return ret, min_date, max_date - def get(self, request, room_id): + def get(self, request: Request, room_id: int) -> Response: """ GET: returns the usage in terms of count or capacity of a fitness center for a given date per hour aggregated by day or week for a given number of days @@ -395,14 +417,14 @@ class FitnessPreferences(APIView): permission_classes = [IsAuthenticated] - def get(self, request): + def get(self, request: Request) -> Response: preferences = request.user.profile.fitness_preferences.all() # returns all ids in a person's preferences return Response({"rooms": preferences.values_list("id", flat=True)}) - def post(self, request): + def post(self, request: Request) -> Response: if "rooms" not in request.data: return Response({"success": False, "error": "No rooms provided"}) @@ -431,7 +453,7 @@ class UniqueCounterView(APIView): permission_classes = [IsAuthenticated] - def get(self, request): + def get(self, request: Request) -> Response: query = dict() if "post_id" in request.query_params: query["post__id"] = request.query_params["post_id"] From 1d2d912c6daa76d5f15e7716cd0a9c8a199a501f Mon Sep 17 00:00:00 2001 From: Ashley Zhang Date: Tue, 5 Nov 2024 14:28:24 -0800 Subject: [PATCH 06/12] =?UTF-8?q?=F0=9F=90=8D=20make=20pollserializer=20mo?= =?UTF-8?q?re=20pythonic?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/portal/serializers.py | 53 ++++++++++++----------------------- 1 file changed, 18 insertions(+), 35 deletions(-) diff --git a/backend/portal/serializers.py b/backend/portal/serializers.py index f96d9b45..9f976458 100644 --- a/backend/portal/serializers.py +++ b/backend/portal/serializers.py @@ -44,43 +44,26 @@ def create(self, validated_data: ValidationData) -> Poll: detail={"detail": "You do not access to create a Poll under this club."} ) # ensuring user cannot create an admin comment upon creation - validated_data["admin_comment"] = None - validated_data["status"] = Poll.STATUS_DRAFT - - # TODO: toggle this off when multiselect functionality is available - validated_data["multiselect"] = False - - year = False - major = False - school = False - degree = False + validated_data.update( + { + "admin_comment": None, + "status": Poll.STATUS_DRAFT, + "multiselect": False, # TODO: toggle off when multiselect is available + } + ) - for population in validated_data["target_populations"]: - if population.kind == TargetPopulation.KIND_YEAR: - year = True - elif population.kind == TargetPopulation.KIND_MAJOR: - major = True - elif population.kind == TargetPopulation.KIND_SCHOOL: - school = True - elif population.kind == TargetPopulation.KIND_DEGREE: - degree = True + population_kinds = {pop.kind: True for pop in validated_data["target_populations"]} - if not year: - validated_data["target_populations"] += list( - TargetPopulation.objects.filter(kind=TargetPopulation.KIND_YEAR) - ) - if not major: - validated_data["target_populations"] += list( - TargetPopulation.objects.filter(kind=TargetPopulation.KIND_MAJOR) - ) - if not school: - validated_data["target_populations"] += list( - TargetPopulation.objects.filter(kind=TargetPopulation.KIND_SCHOOL) - ) - if not degree: - validated_data["target_populations"] += list( - TargetPopulation.objects.filter(kind=TargetPopulation.KIND_DEGREE) - ) + for kind in [ + TargetPopulation.KIND_YEAR, + TargetPopulation.KIND_MAJOR, + TargetPopulation.KIND_SCHOOL, + TargetPopulation.KIND_DEGREE, + ]: + if not population_kinds.get(kind): + validated_data["target_populations"].extend( + TargetPopulation.objects.filter(kind=kind) + ) return super().create(validated_data) From 3f5fd46c262ae32673a4041e68bcc8f43240fd7f Mon Sep 17 00:00:00 2001 From: Ashley Zhang Date: Tue, 5 Nov 2024 14:45:42 -0800 Subject: [PATCH 07/12] =?UTF-8?q?=F0=9F=9A=80=20add=20mypy=20to=20pre-comm?= =?UTF-8?q?it?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .pre-commit-config.yaml | 26 +++++++++++++++++++++++++- backend/utils/__init__.py | 0 2 files changed, 25 insertions(+), 1 deletion(-) create mode 100644 backend/utils/__init__.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index b7ee1239..91840a5e 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -8,4 +8,28 @@ repos: args: [] - id: flake8 args: [--config, backend/setup.cfg] - - id: detect-private-key \ No newline at end of file + - id: detect-private-key + + - repo: https://github.com/pre-commit/mirrors-mypy + rev: v1.8.0 # Use the latest version + hooks: + - id: mypy + additional_dependencies: [ + 'types-requests', + 'types-python-dateutil', + 'django-stubs', + 'djangorestframework-stubs', + ] + args: [ + --strict, + --ignore-missing-imports, + --disallow-untyped-defs, + --disallow-incomplete-defs, + --check-untyped-defs, + --disallow-untyped-decorators, + --no-implicit-optional, + --warn-redundant-casts, + --warn-unused-ignores, + --warn-return-any, + --no-implicit-reexport, + ] \ No newline at end of file diff --git a/backend/utils/__init__.py b/backend/utils/__init__.py new file mode 100644 index 00000000..e69de29b From 0df328170ffa5206b3209d2ae5451c0d5c957a79 Mon Sep 17 00:00:00 2001 From: Ashley Zhang Date: Thu, 7 Nov 2024 11:58:54 -0800 Subject: [PATCH 08/12] =?UTF-8?q?=F0=9F=92=94=20take=20out=20mypy=20for=20?= =?UTF-8?q?now?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .pre-commit-config.yaml | 24 ------------------------ 1 file changed, 24 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 91840a5e..aec50834 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -9,27 +9,3 @@ repos: - id: flake8 args: [--config, backend/setup.cfg] - id: detect-private-key - - - repo: https://github.com/pre-commit/mirrors-mypy - rev: v1.8.0 # Use the latest version - hooks: - - id: mypy - additional_dependencies: [ - 'types-requests', - 'types-python-dateutil', - 'django-stubs', - 'djangorestframework-stubs', - ] - args: [ - --strict, - --ignore-missing-imports, - --disallow-untyped-defs, - --disallow-incomplete-defs, - --check-untyped-defs, - --disallow-untyped-decorators, - --no-implicit-optional, - --warn-redundant-casts, - --warn-unused-ignores, - --warn-return-any, - --no-implicit-reexport, - ] \ No newline at end of file From 4d69e42cdd022ba2af72513d4e5a71ffc6224712 Mon Sep 17 00:00:00 2001 From: Ashley Zhang Date: Thu, 7 Nov 2024 11:59:17 -0800 Subject: [PATCH 09/12] =?UTF-8?q?=F0=9F=A4=96=20add=20more=20typing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/dining/api_wrapper.py | 4 +- .../management/commands/load_next_menu.py | 3 +- .../dining/management/commands/load_venues.py | 3 +- backend/gsr_booking/api_wrapper.py | 61 ++++++---- .../management/commands/change_group.py | 4 +- .../management/commands/get_reservations.py | 15 +-- .../management/commands/individual_usage.py | 4 +- .../management/commands/labs_gsr_data.py | 4 +- .../management/commands/load_gsrs.py | 3 +- .../management/commands/send_gsr_reminders.py | 3 +- backend/gsr_booking/models.py | 24 ++-- backend/gsr_booking/serializers.py | 9 +- backend/gsr_booking/views.py | 111 ++++++++++-------- .../management/commands/get_snapshot.py | 4 +- .../management/commands/load_laundry_rooms.py | 3 +- .../management/commands/get_calendar.py | 3 +- .../commands/get_college_house_events.py | 4 +- .../commands/get_engineering_events.py | 3 +- .../commands/get_fitness_snapshot.py | 3 +- .../commands/get_penn_today_events.py | 4 +- .../commands/get_university_life_events.py | 2 +- .../management/commands/get_venture_events.py | 3 +- .../management/commands/get_wharton_events.py | 3 +- .../management/commands/load_analytics.py | 3 +- .../management/commands/load_fitness_rooms.py | 4 +- .../commands/rename_fitness_room.py | 4 +- backend/penndata/views.py | 8 +- backend/portal/logic.py | 20 +++- backend/portal/views.py | 22 ++-- backend/sublet/views.py | 40 +++---- .../user/management/commands/clear_cache.py | 6 +- .../user/management/commands/profile_info.py | 4 +- .../management/commands/send_shadow_notifs.py | 6 +- backend/user/notifications.py | 33 +++++- backend/user/serializers.py | 13 +- backend/user/views.py | 25 ++-- backend/utils/r_request.py | 50 ++++---- 37 files changed, 317 insertions(+), 201 deletions(-) diff --git a/backend/dining/api_wrapper.py b/backend/dining/api_wrapper.py index 7bbac3dd..3c55cf18 100644 --- a/backend/dining/api_wrapper.py +++ b/backend/dining/api_wrapper.py @@ -34,11 +34,11 @@ def update_token(self) -> None: } response = requests.post(self.openid_endpoint, data=body).json() if "error" in response: - raise APIError(f"Dining: {response['error']}, {response.get('error_description')}") + raise APIError(f"Dining: {response['error']}, {response.get('error_description', '')}") self.expiration = timezone.localtime() + datetime.timedelta(seconds=response["expires_in"]) self.token = response["access_token"] - def request(self, *args, **kwargs) -> requests.Response: + def request(self, *args: Any, **kwargs: Any) -> requests.Response: """Make a signed request to the dining API.""" self.update_token() diff --git a/backend/dining/management/commands/load_next_menu.py b/backend/dining/management/commands/load_next_menu.py index 6d1e60ca..e4966158 100644 --- a/backend/dining/management/commands/load_next_menu.py +++ b/backend/dining/management/commands/load_next_menu.py @@ -1,4 +1,5 @@ import datetime +from typing import Any from django.core.management.base import BaseCommand from django.utils import timezone @@ -13,7 +14,7 @@ class Command(BaseCommand): the next 7 days, including the original date. """ - def handle(self, *args, **kwargs) -> None: + def handle(self, *args: Any, **kwargs: Any) -> None: d = DiningAPIWrapper() d.load_menu(timezone.now().date() + datetime.timedelta(days=6)) self.stdout.write("Loaded new Dining Menu!") diff --git a/backend/dining/management/commands/load_venues.py b/backend/dining/management/commands/load_venues.py index 2115b301..5472a052 100644 --- a/backend/dining/management/commands/load_venues.py +++ b/backend/dining/management/commands/load_venues.py @@ -1,4 +1,5 @@ import csv +from typing import Any from django.core.management.base import BaseCommand @@ -10,7 +11,7 @@ class Command(BaseCommand): Loads Venues based on CSV """ - def handle(self, *args, **kwargs) -> None: + def handle(self, *args: Any, **kwargs: Any) -> None: with open("dining/data/dining_venues.csv") as data: reader = csv.reader(data) diff --git a/backend/gsr_booking/api_wrapper.py b/backend/gsr_booking/api_wrapper.py index cb21528f..f4f2b1e1 100644 --- a/backend/gsr_booking/api_wrapper.py +++ b/backend/gsr_booking/api_wrapper.py @@ -2,7 +2,7 @@ from abc import ABC, abstractmethod from enum import Enum from random import randint -from typing import TYPE_CHECKING, Any, TypeAlias +from typing import TYPE_CHECKING, Any import requests from bs4 import BeautifulSoup @@ -20,9 +20,12 @@ if TYPE_CHECKING: - from django.contrib.auth.models import User as DjangoUser + from django.contrib.auth.models import AbstractUser + + UserType = AbstractUser +else: + UserType = Any -UserType: TypeAlias = "DjangoUser" User = get_user_model() BASE_URL = "https://libcal.library.upenn.edu" @@ -45,26 +48,26 @@ class CreditType(Enum): class AbstractBookingWrapper(ABC): @abstractmethod - def book_room(self, rid: int, start: str, end: str, user: UserType) -> dict[str, Any]: + def book_room(self, rid: int, start: str, end: str, user: "UserType") -> dict[str, Any]: raise NotImplementedError # pragma: no cover @abstractmethod - def cancel_room(self, booking_id: str, user: UserType) -> dict[str, Any]: + def cancel_room(self, booking_id: str, user: "UserType") -> dict[str, Any]: raise NotImplementedError # pragma: no cover @abstractmethod def get_availability( - self, lid: int, start: str, end: str, user: UserType + self, lid: int, start: str | None, end: str | None, user: "UserType" ) -> list[dict[str, Any]]: raise NotImplementedError # pragma: no cover @abstractmethod - def get_reservations(self, user: UserType) -> list[dict[str, Any]]: + def get_reservations(self, user: "UserType") -> list[dict[str, Any]]: raise NotImplementedError # pragma: no cover class WhartonBookingWrapper(AbstractBookingWrapper): - def request(self, *args, **kwargs) -> requests.Response: + def request(self, *args: Any, **kwargs: Any) -> requests.Response: """Make a signed request to the libcal API.""" # add authorization headers kwargs["headers"] = {"Authorization": f"Token {settings.WHARTON_TOKEN}"} @@ -79,7 +82,7 @@ def request(self, *args, **kwargs) -> requests.Response: return response - def book_room(self, rid: int, start: str, end: str, user: UserType) -> dict[str, Any]: + def book_room(self, rid: int, start: str, end: str, user: "UserType") -> dict[str, Any]: """Books room if pennkey is valid""" payload = { "start": start, @@ -93,7 +96,7 @@ def book_room(self, rid: int, start: str, end: str, user: UserType) -> dict[str, raise APIError("Wharton: " + response["error"]) return response - def cancel_room(self, booking_id: str, user: UserType) -> dict[str, Any]: + def cancel_room(self, booking_id: str, user: "UserType") -> dict[str, Any]: """Cancels reservation given booking id""" url = f"{WHARTON_URL}{user.username}/reservations/{booking_id}/cancel" response = self.request("DELETE", url).json() @@ -102,7 +105,7 @@ def cancel_room(self, booking_id: str, user: UserType) -> dict[str, Any]: return response def get_availability( - self, lid: int, start: str, end: str, user: UserType + self, lid: int, start: str | None, end: str | None, user: "UserType" ) -> list[dict[str, Any]]: """Returns a list of rooms and their availabilities""" current_time = timezone.localtime() @@ -141,7 +144,7 @@ def get_availability( room["availability"] = valid_slots return rooms - def get_reservations(self, user: UserType) -> list[dict[str, Any]]: + def get_reservations(self, user: "UserType") -> list[dict[str, Any]]: url = f"{WHARTON_URL}{user.username}/reservations" bookings = self.request("GET", url).json()["bookings"] @@ -159,7 +162,7 @@ def get_reservations(self, user: UserType) -> list[dict[str, Any]]: if datetime.datetime.strptime(booking["end"], "%Y-%m-%dT%H:%M:%S%z") >= now ] - def is_wharton(self, user: UserType) -> bool | None: + def is_wharton(self, user: "UserType") -> bool | None: url = f"{WHARTON_URL}{user.username}/privileges" try: response = self.request("GET", url) @@ -193,7 +196,7 @@ def update_token(self) -> None: self.expiration = timezone.localtime() + datetime.timedelta(seconds=response["expires_in"]) self.token = response["access_token"] - def request(self, *args, **kwargs) -> requests.Response: + def request(self, *args: Any, **kwargs: Any) -> requests.Response: """Make a signed request to the libcal API.""" self.update_token() @@ -210,7 +213,7 @@ def request(self, *args, **kwargs) -> requests.Response: except (ConnectTimeout, ReadTimeout, ConnectionError): raise APIError("LibCal: Connection timeout") - def book_room(self, rid: int, start: str, end: str, user: UserType) -> dict[str, Any]: + def book_room(self, rid: int, start: str, end: str, user: "UserType") -> dict[str, Any]: """ Books room if pennkey is valid @@ -255,10 +258,10 @@ def book_room(self, rid: int, start: str, end: str, user: UserType) -> dict[str, raise APIError("LibCal: " + res_json["error"]) return res_json - def get_reservations(self, user: UserType) -> list[dict[str, Any]]: + def get_reservations(self, user: "UserType") -> list[dict[str, Any]]: pass - def cancel_room(self, booking_id: str, user: UserType) -> dict[str, Any]: + def cancel_room(self, booking_id: str, user: "UserType") -> dict[str, Any]: """Cancels room""" response = self.request("POST", f"{API_URL}/1.1/space/cancel/{booking_id}").json() if "error" in response[0]: @@ -266,7 +269,7 @@ def cancel_room(self, booking_id: str, user: UserType) -> dict[str, Any]: return response def get_availability( - self, gid: int, start: str, end: str, user: UserType + self, gid: int, start: str | None, end: str | None, user: "UserType" ) -> list[dict[str, Any]]: """Returns a list of rooms and their availabilities""" @@ -331,7 +334,7 @@ def __init__(self, WBW=None, LBW=None): def format_members( self, members: QuerySet[GroupMembership] - ) -> list[tuple[UserType, datetime.timedelta]]: + ) -> list[tuple["UserType", datetime.timedelta]]: PREFIX = "user__" return [ ( @@ -345,7 +348,7 @@ def format_members( def get_wharton_members( self, group: Group, gsr_id: int - ) -> list[tuple[UserType, datetime.timedelta]]: + ) -> list[tuple["UserType", datetime.timedelta]]: now = timezone.localtime() ninty_min = datetime.timedelta(minutes=90) zero_min = datetime.timedelta(minutes=0) @@ -372,7 +375,7 @@ def get_wharton_members( ) return self.format_members(ret) - def get_libcal_members(self, group: Group) -> list[tuple[UserType, datetime.timedelta]]: + def get_libcal_members(self, group: Group) -> list[tuple["UserType", datetime.timedelta]]: day_start = timezone.localtime().replace(hour=0, minute=0, second=0, microsecond=0) day_end = day_start + datetime.timedelta(days=1) two_hours = datetime.timedelta(hours=2) @@ -415,7 +418,7 @@ def book_room( room_name: str, start: str, end: str, - user: UserType, + user: "UserType", group: Group | None = None, ) -> Reservation: # NOTE when booking with a group, we are only querying our db for existing bookings, @@ -477,7 +480,7 @@ def book_room( return reservation - def cancel_room(self, booking_id: str, user: UserType) -> None: + def cancel_room(self, booking_id: str, user: "UserType") -> None: if ( gsr_booking := GSRBooking.objects.filter(booking_id=booking_id) .prefetch_related(Prefetch("reservation__gsrbooking_set"), Prefetch("gsr")) @@ -507,7 +510,13 @@ def cancel_room(self, booking_id: str, user: UserType) -> None: raise APIError("Error: Unknown booking id") def get_availability( - self, lid: int, gid: int, start: str, end: str, user: UserType, group: Group | None = None + self, + lid: int, + gid: int, + start: str | None, + end: str | None, + user: "UserType", + group: Group | None = None, ) -> list[dict[str, Any]]: gsr = get_object_or_404(GSR, gid=gid) @@ -525,7 +534,9 @@ def get_availability( ) return {"name": gsr.name, "gid": gsr.gid, "rooms": rooms} - def get_reservations(self, user: UserType, group: Group | None = None) -> list[dict[str, Any]]: + def get_reservations( + self, user: "UserType", group: Group | None = None + ) -> list[dict[str, Any]]: q = Q(user=user) | Q(reservation__creator=user) if group else Q(user=user) bookings = GSRBooking.objects.filter( q, is_cancelled=False, end__gte=timezone.localtime() diff --git a/backend/gsr_booking/management/commands/change_group.py b/backend/gsr_booking/management/commands/change_group.py index 93e79687..b2832087 100644 --- a/backend/gsr_booking/management/commands/change_group.py +++ b/backend/gsr_booking/management/commands/change_group.py @@ -1,3 +1,5 @@ +from typing import Any + from django.contrib.auth import get_user_model from django.core.management.base import BaseCommand from django.db.models import Q @@ -18,7 +20,7 @@ def add_arguments(self, parser) -> None: parser.add_argument("group", type=str, help="group name") parser.add_argument("mode", type=str, help="mode of operation (add/remove/reset)") - def handle(self, *args, **kwargs) -> None: + def handle(self, *args: Any, **kwargs: Any) -> None: usernames = kwargs["usernames"].split(",") group = kwargs["group"] mode = kwargs["mode"].lower() diff --git a/backend/gsr_booking/management/commands/get_reservations.py b/backend/gsr_booking/management/commands/get_reservations.py index 59b0b71a..609478da 100644 --- a/backend/gsr_booking/management/commands/get_reservations.py +++ b/backend/gsr_booking/management/commands/get_reservations.py @@ -1,4 +1,5 @@ from datetime import datetime +from typing import Any from django.contrib.auth import get_user_model from django.core.management.base import BaseCommand @@ -36,13 +37,13 @@ def add_arguments(self, parser) -> None: parser.add_argument("--time", type=bool, default=False) parser.add_argument("--user", type=bool, default=False) - def handle(self, *args, **kwargs) -> None: - group = kwargs["group"] - start = kwargs["start"] - end = kwargs["end"] - current = kwargs["current"] - time = kwargs["time"] - user = kwargs["user"] + def handle(self, *args: Any, **kwargs: Any) -> None: + group = kwargs.get("group", None) + start = kwargs.get("start", None) + end = kwargs.get("end", None) + current = kwargs.get("current", False) + time = kwargs.get("time", False) + user = kwargs.get("user", False) if start and not (start := self.__convert_date(start)): self.stdout.write("Error: invalid start date format") diff --git a/backend/gsr_booking/management/commands/individual_usage.py b/backend/gsr_booking/management/commands/individual_usage.py index 65a3c501..6b27fbaf 100644 --- a/backend/gsr_booking/management/commands/individual_usage.py +++ b/backend/gsr_booking/management/commands/individual_usage.py @@ -1,3 +1,5 @@ +from typing import Any + from django.core.management.base import BaseCommand from django.utils import timezone @@ -10,7 +12,7 @@ class Command(BaseCommand): def add_arguments(self, parser) -> None: parser.add_argument("pennkey", type=str, help="Pennkey of user to check") - def handle(self, *args, **kwargs) -> None: + def handle(self, *args: Any, **kwargs: Any) -> None: pennkey = kwargs["pennkey"] groups = Group.objects.filter(memberships__user__username=pennkey) bookings = GSRBooking.objects.filter( diff --git a/backend/gsr_booking/management/commands/labs_gsr_data.py b/backend/gsr_booking/management/commands/labs_gsr_data.py index 3f4ed41e..58f47097 100644 --- a/backend/gsr_booking/management/commands/labs_gsr_data.py +++ b/backend/gsr_booking/management/commands/labs_gsr_data.py @@ -1,3 +1,5 @@ +from typing import Any + from django.core.management.base import BaseCommand from django.utils import timezone from django.utils.timezone import localtime @@ -8,7 +10,7 @@ class Command(BaseCommand): help = "Provides visiblity data for Penn Labs Group GSRs." - def handle(self, *args, **kwargs) -> None: + def handle(self, *args: Any, **kwargs: Any) -> None: group = Group.objects.get(name="Penn Labs") reservations = Reservation.objects.filter( group=group, is_cancelled=False, start__gte=timezone.now() diff --git a/backend/gsr_booking/management/commands/load_gsrs.py b/backend/gsr_booking/management/commands/load_gsrs.py index 50c1194c..eccc6857 100644 --- a/backend/gsr_booking/management/commands/load_gsrs.py +++ b/backend/gsr_booking/management/commands/load_gsrs.py @@ -1,4 +1,5 @@ import csv +from typing import Any from django.core.management.base import BaseCommand @@ -6,7 +7,7 @@ class Command(BaseCommand): - def handle(self, *args, **kwargs) -> None: + def handle(self, *args: Any, **kwargs: Any) -> None: with open("gsr_booking/data/gsr_data.csv") as data: reader = csv.reader(data) diff --git a/backend/gsr_booking/management/commands/send_gsr_reminders.py b/backend/gsr_booking/management/commands/send_gsr_reminders.py index cba1fc4d..0cc15e3b 100644 --- a/backend/gsr_booking/management/commands/send_gsr_reminders.py +++ b/backend/gsr_booking/management/commands/send_gsr_reminders.py @@ -1,4 +1,5 @@ import datetime +from typing import Any from django.core.management.base import BaseCommand from django.utils import timezone @@ -11,7 +12,7 @@ class Command(BaseCommand): help = "Sends reminders for the GSR Bookings." - def handle(self, *args, **kwargs) -> None: + def handle(self, *args: Any, **kwargs: Any) -> None: # iterate through all reservations scheduled for the next 30 minutes for reservation in Reservation.objects.filter( diff --git a/backend/gsr_booking/models.py b/backend/gsr_booking/models.py index 57a1b28f..003832c3 100644 --- a/backend/gsr_booking/models.py +++ b/backend/gsr_booking/models.py @@ -1,8 +1,18 @@ +from typing import TYPE_CHECKING, Any + from django.contrib.auth import get_user_model from django.db import models +from django.db.models import QuerySet from django.utils import timezone +if TYPE_CHECKING: + from django.contrib.auth.models import AbstractUser + + UserType = AbstractUser +else: + UserType = Any + User = get_user_model() @@ -61,14 +71,14 @@ class Group(models.Model): ADMIN = "A" MEMBER = "M" - def __str__(self): + def __str__(self) -> str: return f"{self.name}-{self.pk}" - def has_member(self, user): + def has_member(self, user: "UserType") -> bool: memberships = GroupMembership.objects.filter(group=self, user=user) return memberships.all().exists() - def has_admin(self, user): + def has_admin(self, user: "UserType") -> bool: memberships = GroupMembership.objects.filter(group=self, accepted=True) return memberships.all().filter(type="A").filter(user=user).exists() @@ -77,7 +87,7 @@ def get_pennkey_active_members(self): pennkey_active_members_list = memberships.all().filter(pennkey_allow=True).all() return [member for member in pennkey_active_members_list] - def save(self, *args, **kwargs): + def save(self, *args: Any, **kwargs: Any) -> None: super().save(*args, **kwargs) GroupMembership.objects.get_or_create( group=self, user=self.owner, type=GroupMembership.ADMIN, accepted=True @@ -85,7 +95,7 @@ def save(self, *args, **kwargs): class GSRManager(models.Manager): - def get_queryset(self): + def get_queryset(self) -> QuerySet[Any]: return super().get_queryset().filter(in_use=True) @@ -106,7 +116,7 @@ class GSR(models.Model): objects = GSRManager() all_objects = models.Manager() # for admin page - def __str__(self): + def __str__(self) -> str: return f"{self.name}: {self.lid}-{self.gid}" @@ -131,7 +141,7 @@ class GSRBooking(models.Model): end = models.DateTimeField(default=timezone.now) is_cancelled = models.BooleanField(default=False) - def __str__(self): + def __str__(self) -> str: return f"{self.user} - {self.gsr.name} - {self.start} - {self.end}" diff --git a/backend/gsr_booking/serializers.py b/backend/gsr_booking/serializers.py index 42e35d78..da1139a7 100644 --- a/backend/gsr_booking/serializers.py +++ b/backend/gsr_booking/serializers.py @@ -7,9 +7,12 @@ if TYPE_CHECKING: - from django.contrib.auth.models import User as DjangoUser + from django.contrib.auth.models import AbstractUser + + UserType = AbstractUser +else: + UserType = Any -UserType: TypeAlias = "DjangoUser" ValidatedData: TypeAlias = dict[str, Any] User = get_user_model() @@ -83,7 +86,7 @@ def to_internal_value(self, data: ValidatedData) -> None: class UserSerializer(serializers.ModelSerializer): booking_groups = serializers.SerializerMethodField() - def get_booking_groups(self, obj: UserType) -> list[dict[str, Any]]: + def get_booking_groups(self, obj: "UserType") -> list[dict[str, Any]]: result = [] for membership in GroupMembership.objects.filter(accepted=True, user=obj): result.append( diff --git a/backend/gsr_booking/views.py b/backend/gsr_booking/views.py index 8a0b72b1..bf82d6c8 100644 --- a/backend/gsr_booking/views.py +++ b/backend/gsr_booking/views.py @@ -1,7 +1,7 @@ -from typing import TYPE_CHECKING, Optional, TypeAlias +from typing import TYPE_CHECKING, Any, Optional, cast from django.contrib.auth import get_user_model -from django.db.models import Prefetch, Q, QuerySet +from django.db.models import Manager, Prefetch, Q, QuerySet from django.http import HttpResponseForbidden from django.shortcuts import get_object_or_404 from django_filters.rest_framework import DjangoFilterBackend @@ -12,7 +12,7 @@ from rest_framework.response import Response from rest_framework.views import APIView -from gsr_booking.api_wrapper import APIError, GSRBooker, WhartonGSRBooker +from gsr_booking.api_wrapper import GSRBooker, WhartonGSRBooker from gsr_booking.models import GSR, Group, GroupMembership, GSRBooking from gsr_booking.serializers import ( GroupMembershipSerializer, @@ -21,16 +21,21 @@ UserSerializer, ) from pennmobile.analytics import Metric, record_analytics +from utils.errors import APIError if TYPE_CHECKING: - from django.contrib.auth.models import User as DjangoUser + from django.contrib.auth.models import AbstractUser + + UserType = AbstractUser +else: + UserType = Any -UserType: TypeAlias = "DjangoUser" User = get_user_model() -class UserViewSet(viewsets.ReadOnlyModelViewSet): +# TODO: user model doesn't have a `booking_groups` attribute, so placing Any type for now +class UserViewSet(viewsets.ReadOnlyModelViewSet["UserType"]): """ Can specify `me` instead of the `username` to retrieve details on the current user. """ @@ -44,25 +49,23 @@ class UserViewSet(viewsets.ReadOnlyModelViewSet): filter_backends = [DjangoFilterBackend] filterset_fields = ["username", "first_name", "last_name"] - def get_object(self) -> UserType: + def get_object(self) -> "UserType": lookup_url_kwarg = self.lookup_url_kwarg or self.lookup_field param = self.kwargs[lookup_url_kwarg] if param == "me": - return self.request.user - else: - return super().get_object() + return cast("UserType", self.request.user) + return super().get_object() - def get_queryset(self) -> QuerySet[UserType]: + def get_queryset(self) -> QuerySet["UserType", Manager["UserType"]]: if not self.request.user.is_authenticated: return User.objects.none() + user: Any = self.request.user queryset = User.objects.all() queryset = queryset.prefetch_related( Prefetch( "memberships", - GroupMembership.objects.filter( - group__in=self.request.user.booking_groups.all(), accepted=True - ), + GroupMembership.objects.filter(group__in=user.booking_groups.all(), accepted=True), ) ) return queryset @@ -74,37 +77,40 @@ def invites(self, request: Request, username: Optional[str] = None) -> Response: """ user = get_object_or_404(User, username=username) + request_user: Any = self.request.user return Response( GroupMembershipSerializer( GroupMembership.objects.filter( - user=user, accepted=False, group__in=self.request.user.booking_groups.all() + user=user, accepted=False, group__in=request_user.booking_groups.all() ), many=True, ).data ) -class GroupMembershipViewSet(viewsets.ModelViewSet): +class GroupMembershipViewSet(viewsets.ModelViewSet[GroupMembership]): filter_backends = [DjangoFilterBackend] filterset_fields = ["user", "group"] permission_classes = [IsAuthenticated] queryset = GroupMembership.objects.all() serializer_class = GroupMembershipSerializer - def get_queryset(self) -> QuerySet[GroupMembership]: - if not self.request.user.is_authenticated or not hasattr(self.request.user, "memberships"): + def get_queryset(self) -> QuerySet[GroupMembership, Manager[GroupMembership]]: + user: Any = self.request.user + if not user.is_authenticated: return GroupMembership.objects.none() + return GroupMembership.objects.filter( - Q(id__in=self.request.user.memberships.all()) + Q(id__in=user.memberships.all()) | Q( group__in=Group.objects.filter( - memberships__in=GroupMembership.objects.filter(user=self.request.user, type="A") + memberships__in=GroupMembership.objects.filter(user=user, type="A") ) ) ) @action(detail=False, methods=["post"]) - def invite(self, request: Request) -> Response: + def invite(self, request: Request) -> Response | HttpResponseForbidden: """ Invite a user to a group. """ @@ -112,15 +118,18 @@ def invite(self, request: Request) -> Response: group = get_object_or_404(Group, pk=group_id) # don't invite when user already in group - if group.has_member(request.user): + if group.has_member(cast(Any, request.user)): return HttpResponseForbidden() return Response({"message": "invite(s) sent."}) @action(detail=True, methods=["post"]) - def accept(self, request: Request, pk: Optional[int] = None) -> Response: + def accept( + self, request: Request, pk: Optional[int] = None + ) -> Response | HttpResponseForbidden: membership = get_object_or_404(GroupMembership, pk=pk, accepted=False) - if membership.user is None or membership.user != request.user: + user = cast("UserType", request.user) + if membership.user is None or membership.user != user: return HttpResponseForbidden() if not membership.is_invite: @@ -137,9 +146,11 @@ def accept(self, request: Request, pk: Optional[int] = None) -> Response: ) @action(detail=True, methods=["post"]) - def decline(self, request: Request, pk: Optional[int] = None) -> Response: + def decline( + self, request: Request, pk: Optional[int] = None + ) -> Response | HttpResponseForbidden: membership = get_object_or_404(GroupMembership, pk=pk, accepted=False) - if membership.user is None or membership.user != request.user: + if membership.user is None or membership.user != cast("UserType", request.user): return HttpResponseForbidden() if not membership.is_invite: return Response({"message": "cannot decline an invite that has been accepted."}, 400) @@ -153,40 +164,42 @@ def decline(self, request: Request, pk: Optional[int] = None) -> Response: return Response(resp) -class GroupViewSet(viewsets.ModelViewSet): +class GroupViewSet(viewsets.ModelViewSet[Group]): queryset = Group.objects.all() serializer_class = GroupSerializer permission_classes = [IsAuthenticated] - def get_queryset(self) -> QuerySet[Group]: - if not self.request.user.is_authenticated: + def get_queryset(self) -> QuerySet[Group, Manager[Group]]: + user = cast("UserType", self.request.user) + if not user.is_authenticated: return Group.objects.none() return ( super() .get_queryset() - .filter(members=self.request.user) + .filter(members=user) .prefetch_related( Prefetch("memberships", GroupMembership.objects.filter(accepted=True)) ) ) -class Locations(generics.ListAPIView): +class Locations(generics.ListAPIView[GSR]): """Lists all available locations to book from""" serializer_class = GSRSerializer queryset = GSR.objects.all() -class RecentGSRs(generics.ListAPIView): +class RecentGSRs(generics.ListAPIView[GSR]): """Lists 2 most recent GSR rooms for Home page""" serializer_class = GSRSerializer permission_classes = [IsAuthenticated] - def get_queryset(self) -> QuerySet[GSR]: + def get_queryset(self) -> QuerySet[GSR, Manager[GSR]]: + user = cast("UserType", self.request.user) return GSR.objects.filter( - id__in=GSRBooking.objects.filter(user=self.request.user, is_cancelled=False) + id__in=GSRBooking.objects.filter(user=user, is_cancelled=False) .distinct() .order_by("-end")[:2] .values_list("gsr", flat=True) @@ -198,10 +211,11 @@ class CheckWharton(APIView): permission_classes = [IsAuthenticated] def get(self, request: Request) -> Response: + user: Any = request.user return Response( { - "is_wharton": request.user.booking_groups.filter(name="Penn Labs").exists() - or WhartonGSRBooker.is_wharton(request.user) + "is_wharton": user.booking_groups.filter(name="Penn Labs").exists() + or WhartonGSRBooker.is_wharton(user) } ) @@ -219,18 +233,19 @@ class Availability(APIView): def get(self, request: Request, lid: int, gid: int) -> Response: - start = request.GET.get("start") - end = request.GET.get("end") + start = request.GET.get("start", None) + end = request.GET.get("end", None) try: + user: Any = request.user return Response( GSRBooker.get_availability( lid, gid, start, end, - request.user, - request.user.booking_groups.filter(name="Penn Labs").first(), + user, + user.booking_groups.filter(name="Penn Labs").first(), ) ) except APIError as e: @@ -248,6 +263,7 @@ def post(self, request: Request) -> Response: gid = request.data["gid"] room_id = request.data["id"] room_name = request.data["room_name"] + user: Any = request.user try: GSRBooker.book_room( @@ -256,11 +272,11 @@ def post(self, request: Request) -> Response: room_name, start, end, - request.user, - request.user.booking_groups.filter(name="Penn Labs").first(), + user, + user.booking_groups.filter(name="Penn Labs").first(), ) - record_analytics(Metric.GSR_BOOK, request.user.username) + record_analytics(Metric.GSR_BOOK, user.username) return Response({"detail": "success"}) except APIError as e: @@ -276,9 +292,9 @@ class CancelRoom(APIView): def post(self, request: Request) -> Response: booking_id = request.data["booking_id"] - + user: Any = request.user try: - GSRBooker.cancel_room(booking_id, request.user) + GSRBooker.cancel_room(booking_id, user) return Response({"detail": "success"}) except APIError as e: return Response({"error": str(e)}, status=400) @@ -292,8 +308,7 @@ class ReservationsView(APIView): permission_classes = [IsAuthenticated] def get(self, request: Request) -> Response: + user: Any = request.user return Response( - GSRBooker.get_reservations( - request.user, request.user.booking_groups.filter(name="Penn Labs").first() - ) + GSRBooker.get_reservations(user, user.booking_groups.filter(name="Penn Labs").first()) ) diff --git a/backend/laundry/management/commands/get_snapshot.py b/backend/laundry/management/commands/get_snapshot.py index 1ad47094..3fc007ca 100644 --- a/backend/laundry/management/commands/get_snapshot.py +++ b/backend/laundry/management/commands/get_snapshot.py @@ -1,3 +1,5 @@ +from typing import Any + from django.core.management.base import BaseCommand from laundry.api_wrapper import save_data @@ -6,6 +8,6 @@ class Command(BaseCommand): help = "Captures a new Laundry Snapshot for every Laundry room." - def handle(self, *args, **kwargs) -> None: + def handle(self, *args: Any, **kwargs: Any) -> None: save_data() self.stdout.write("Captured snapshots!") diff --git a/backend/laundry/management/commands/load_laundry_rooms.py b/backend/laundry/management/commands/load_laundry_rooms.py index 1c3046b1..22f6a9ab 100644 --- a/backend/laundry/management/commands/load_laundry_rooms.py +++ b/backend/laundry/management/commands/load_laundry_rooms.py @@ -1,4 +1,5 @@ import csv +from typing import Any from django.core.management.base import BaseCommand @@ -6,7 +7,7 @@ class Command(BaseCommand): - def handle(self, *args, **kwargs) -> None: + def handle(self, *args: Any, **kwargs: Any) -> None: with open("laundry/data/laundry_data.csv") as data: reader = csv.reader(data) diff --git a/backend/penndata/management/commands/get_calendar.py b/backend/penndata/management/commands/get_calendar.py index 3868b01f..d2074fae 100644 --- a/backend/penndata/management/commands/get_calendar.py +++ b/backend/penndata/management/commands/get_calendar.py @@ -1,4 +1,5 @@ import datetime +from typing import Any import requests from bs4 import BeautifulSoup @@ -12,7 +13,7 @@ class Command(BaseCommand): - def handle(self, *args, **kwargs) -> None: + def handle(self, *args: Any, **kwargs: Any) -> None: # Clears out previous CalendarEvents CalendarEvent.objects.all().delete() diff --git a/backend/penndata/management/commands/get_college_house_events.py b/backend/penndata/management/commands/get_college_house_events.py index 3f3e3e49..7773f91b 100644 --- a/backend/penndata/management/commands/get_college_house_events.py +++ b/backend/penndata/management/commands/get_college_house_events.py @@ -1,5 +1,5 @@ import datetime -from typing import Optional +from typing import Any, Optional import requests from bs4 import BeautifulSoup @@ -28,7 +28,7 @@ class Command(BaseCommand): - def handle(self, *args, **kwargs) -> None: + def handle(self, *args: Any, **kwargs: Any) -> None: for site, event_type in EVENT_TYPE_MAP: self.scrape_calendar_page(f"{site}", event_type) now = timezone.localtime() diff --git a/backend/penndata/management/commands/get_engineering_events.py b/backend/penndata/management/commands/get_engineering_events.py index 1baf89b2..8b0bd6f9 100644 --- a/backend/penndata/management/commands/get_engineering_events.py +++ b/backend/penndata/management/commands/get_engineering_events.py @@ -1,6 +1,7 @@ import datetime import html import json +from typing import Any import requests from django.core.management.base import BaseCommand @@ -12,7 +13,7 @@ class Command(BaseCommand): - def handle(self, *args, **kwargs) -> None: + def handle(self, *args: Any, **kwargs: Any) -> None: try: resp = requests.get(ENGINEERING_EVENTS_WEBSITE) except ConnectionError: diff --git a/backend/penndata/management/commands/get_fitness_snapshot.py b/backend/penndata/management/commands/get_fitness_snapshot.py index 85132d75..09036cc0 100644 --- a/backend/penndata/management/commands/get_fitness_snapshot.py +++ b/backend/penndata/management/commands/get_fitness_snapshot.py @@ -1,4 +1,5 @@ import datetime +from typing import Any import requests from bs4 import BeautifulSoup @@ -68,7 +69,7 @@ def get_usages() -> tuple[dict[str, dict[str, int | float]], datetime.datetime]: class Command(BaseCommand): help = "Captures a new Fitness Snapshot for every Laundry room." - def handle(self, *args, **kwargs) -> None: + def handle(self, *args: Any, **kwargs: Any) -> None: usage_by_location, date = get_usages() # prevent double creating FitnessSnapshots diff --git a/backend/penndata/management/commands/get_penn_today_events.py b/backend/penndata/management/commands/get_penn_today_events.py index b9428c8f..8076bd1d 100644 --- a/backend/penndata/management/commands/get_penn_today_events.py +++ b/backend/penndata/management/commands/get_penn_today_events.py @@ -1,5 +1,5 @@ import datetime -from typing import Optional +from typing import Any, Optional from urllib.parse import urljoin from bs4 import BeautifulSoup @@ -21,7 +21,7 @@ class Command(BaseCommand): - def handle(self, *args, **kwargs) -> None: + def handle(self, *args: Any, **kwargs: Any) -> None: now = timezone.localtime() current_month, current_year = now.month, now.year diff --git a/backend/penndata/management/commands/get_university_life_events.py b/backend/penndata/management/commands/get_university_life_events.py index 2a7ced5d..c74c1fdb 100644 --- a/backend/penndata/management/commands/get_university_life_events.py +++ b/backend/penndata/management/commands/get_university_life_events.py @@ -56,7 +56,7 @@ def parse_event_section(self, event_section: Any) -> None: email=None, ) - def handle(self, *args, **kwargs) -> None: + def handle(self, *args: Any, **kwargs: Any) -> None: response = requests.get(UNIVERSITY_LIFE_URL) diff --git a/backend/penndata/management/commands/get_venture_events.py b/backend/penndata/management/commands/get_venture_events.py index 303b7de0..78cdf00d 100644 --- a/backend/penndata/management/commands/get_venture_events.py +++ b/backend/penndata/management/commands/get_venture_events.py @@ -1,5 +1,6 @@ import datetime import html +from typing import Any import requests from bs4 import BeautifulSoup @@ -14,7 +15,7 @@ class Command(BaseCommand): - def handle(self, *args, **kwargs) -> None: + def handle(self, *args: Any, **kwargs: Any) -> None: now = timezone.localtime() current_month, current_year = now.month, now.year diff --git a/backend/penndata/management/commands/get_wharton_events.py b/backend/penndata/management/commands/get_wharton_events.py index 370e3856..360cbb5e 100644 --- a/backend/penndata/management/commands/get_wharton_events.py +++ b/backend/penndata/management/commands/get_wharton_events.py @@ -1,5 +1,6 @@ import datetime import re +from typing import Any import pytz import requests @@ -13,7 +14,7 @@ class Command(BaseCommand): - def handle(self, *args, **kwargs) -> None: + def handle(self, *args: Any, **kwargs: Any) -> None: eastern = pytz.timezone("US/Eastern") try: diff --git a/backend/penndata/management/commands/load_analytics.py b/backend/penndata/management/commands/load_analytics.py index cb12626f..cb0e975d 100644 --- a/backend/penndata/management/commands/load_analytics.py +++ b/backend/penndata/management/commands/load_analytics.py @@ -1,4 +1,5 @@ from datetime import datetime +from typing import Any import pandas as pd from django.contrib.auth import get_user_model @@ -12,7 +13,7 @@ class Command(BaseCommand): - def handle(self, *args, **kwargs) -> None: + def handle(self, *args: Any, **kwargs: Any) -> None: analytics_objects = [] diff --git a/backend/penndata/management/commands/load_fitness_rooms.py b/backend/penndata/management/commands/load_fitness_rooms.py index 4d2ac933..9c0e4661 100644 --- a/backend/penndata/management/commands/load_fitness_rooms.py +++ b/backend/penndata/management/commands/load_fitness_rooms.py @@ -1,10 +1,12 @@ +from typing import Any + from django.core.management.base import BaseCommand from penndata.models import FitnessRoom class Command(BaseCommand): - def handle(self, *args, **kwargs) -> None: + def handle(self, *args: Any, **kwargs: Any) -> None: fitness_rooms = [ "4th Floor Fitness", "3rd Floor Fitness", diff --git a/backend/penndata/management/commands/rename_fitness_room.py b/backend/penndata/management/commands/rename_fitness_room.py index 4ea4bd21..e28db074 100644 --- a/backend/penndata/management/commands/rename_fitness_room.py +++ b/backend/penndata/management/commands/rename_fitness_room.py @@ -1,3 +1,5 @@ +from typing import Any + from django.core.management.base import BaseCommand from penndata.management.commands.get_fitness_snapshot import cap_string @@ -7,7 +9,7 @@ class Command(BaseCommand): help = "Renames fitness rooms." - def handle(self, *args, **kwargs) -> None: + def handle(self, *args: Any, **kwargs: Any) -> None: for room in FitnessRoom.objects.all(): room.name = cap_string(room.name) room.save() diff --git a/backend/penndata/views.py b/backend/penndata/views.py index f7a27828..8b722f68 100644 --- a/backend/penndata/views.py +++ b/backend/penndata/views.py @@ -4,7 +4,7 @@ import requests from bs4 import BeautifulSoup -from django.db.models import QuerySet +from django.db.models import Manager, QuerySet from django.shortcuts import get_object_or_404 from django.utils import timezone from requests.exceptions import ConnectionError @@ -32,9 +32,9 @@ ValidatedData: TypeAlias = dict[str, Any] -CalendarEventList: TypeAlias = QuerySet[CalendarEvent] -EventList: TypeAlias = QuerySet[Event] -HomePageOrderList: TypeAlias = QuerySet[HomePageOrder] +CalendarEventList: TypeAlias = QuerySet[CalendarEvent, Manager[CalendarEvent]] +EventList: TypeAlias = QuerySet[Event, Manager[Event]] +HomePageOrderList: TypeAlias = QuerySet[HomePageOrder, Manager[HomePageOrder]] class News(APIView): diff --git a/backend/portal/logic.py b/backend/portal/logic.py index 3572da13..8234a246 100644 --- a/backend/portal/logic.py +++ b/backend/portal/logic.py @@ -1,5 +1,6 @@ import json from collections import defaultdict +from typing import TYPE_CHECKING, Any from accounts.ipc import authenticated_request from django.contrib.auth import get_user_model @@ -8,10 +9,17 @@ from portal.models import Poll, PollOption, PollVote, TargetPopulation +if TYPE_CHECKING: + from django.contrib.auth.models import AbstractUser + + UserType = AbstractUser +else: + UserType = Any + User = get_user_model() -def get_user_info(user): +def get_user_info(user: "UserType") -> dict[str, Any]: """Returns Platform user information""" response = authenticated_request(user, "GET", "https://platform.pennlabs.org/accounts/me/") if response.status_code == 403: @@ -19,7 +27,7 @@ def get_user_info(user): return json.loads(response.content) -def get_user_clubs(user): +def get_user_clubs(user: "UserType") -> list[dict[str, Any]]: """Returns list of clubs that user is a member of""" response = authenticated_request(user, "GET", "https://pennclubs.com/api/memberships/") if response.status_code == 403: @@ -28,7 +36,7 @@ def get_user_clubs(user): return res_json -def get_club_info(user, club_code): +def get_club_info(user: "UserType", club_code: str) -> dict[str, Any]: """Returns club information based on club code""" response = authenticated_request(user, "GET", f"https://pennclubs.com/api/clubs/{club_code}/") if response.status_code == 403: @@ -37,7 +45,7 @@ def get_club_info(user, club_code): return {"name": res_json["name"], "image": res_json["image_url"], "club_code": club_code} -def get_user_populations(user): +def get_user_populations(user: "UserType") -> list[TargetPopulation]: """Returns the target populations that the user belongs to""" user_info = get_user_info(user) @@ -84,7 +92,7 @@ def get_user_populations(user): return [year, school, major, degree] -def check_targets(obj, user): +def check_targets(obj: Poll, user: "UserType") -> bool: """ Check if user aligns with target populations of poll or post """ @@ -104,7 +112,7 @@ def check_targets(obj, user): ) -def get_demographic_breakdown(poll_id): +def get_demographic_breakdown(poll_id: int) -> list[dict[str, Any]]: """Collects Poll statistics on school and graduation year demographics""" # passing in id is necessary because diff --git a/backend/portal/views.py b/backend/portal/views.py index 9b5d6504..61ac2413 100644 --- a/backend/portal/views.py +++ b/backend/portal/views.py @@ -1,7 +1,7 @@ from typing import Any, Dict, List, TypeAlias from django.contrib.auth import get_user_model -from django.db.models import Count, Q, QuerySet +from django.db.models import Count, Manager, Q, QuerySet from django.db.models.functions import Trunc from django.utils import timezone from rest_framework import generics, viewsets @@ -38,11 +38,11 @@ ) -PollQuerySet: TypeAlias = QuerySet[Poll] -PostQuerySet: TypeAlias = QuerySet[Post] -PollVoteQuerySet: TypeAlias = QuerySet[PollVote] +PollQuerySet: TypeAlias = QuerySet[Poll, Manager[Poll]] +PostQuerySet: TypeAlias = QuerySet[Post, Manager[Post]] +PollVoteQuerySet: TypeAlias = QuerySet[PollVote, Manager[PollVote]] ClubData: TypeAlias = List[Dict[str, Any]] -PollOptionQuerySet: TypeAlias = QuerySet[PollOption] +PollOptionQuerySet: TypeAlias = QuerySet[PollOption, Manager[PollOption]] TimeSeriesData: TypeAlias = Dict[str, Any] VoteStatistics: TypeAlias = Dict[str, Any] @@ -71,7 +71,7 @@ def get(self, request: Request) -> Response: return Response({"clubs": club_data}) -class TargetPopulations(generics.ListAPIView): +class TargetPopulations(generics.ListAPIView[TargetPopulation]): """List view to see which populations a poll can select""" permission_classes = [IsAuthenticated] @@ -79,7 +79,7 @@ class TargetPopulations(generics.ListAPIView): queryset = TargetPopulation.objects.all() -class Polls(viewsets.ModelViewSet): +class Polls(viewsets.ModelViewSet[Poll]): """ browse: returns a list of Polls that are valid and @@ -180,7 +180,7 @@ def option_view(self, request: Request, pk: int = None) -> Response: return Response(RetrievePollSerializer(Poll.objects.filter(id=pk).first(), many=False).data) -class PollOptions(viewsets.ModelViewSet): +class PollOptions(viewsets.ModelViewSet[PollOption]): """ create: Create a Poll Option. @@ -210,7 +210,7 @@ def get_queryset(self) -> PollOptionQuerySet: ) -class PollVotes(viewsets.ModelViewSet): +class PollVotes(viewsets.ModelViewSet[PollVote]): """ create: Create a Poll Vote. @@ -264,7 +264,7 @@ def get(self, request: Request, poll_id: int) -> Response: return Response(statistics) -class Posts(viewsets.ModelViewSet): +class Posts(viewsets.ModelViewSet[Post]): """ browse: returns a list of Posts that are targeted at the current user. @@ -315,7 +315,7 @@ def browse(self, request: Request) -> Response: # list of polls where user doesn't identify with # target populations - bad_posts = [] + bad_posts: List[int] = [] # commented out to make posts # if not request.user.is_superuser: diff --git a/backend/sublet/views.py b/backend/sublet/views.py index a4983520..3509a1aa 100644 --- a/backend/sublet/views.py +++ b/backend/sublet/views.py @@ -1,7 +1,7 @@ -from typing import TypeAlias +from typing import Any, TypeAlias from django.contrib.auth import get_user_model -from django.db.models import QuerySet, prefetch_related_objects +from django.db.models import Manager, QuerySet, prefetch_related_objects from django.utils import timezone from django.utils.dateparse import parse_date from rest_framework import exceptions, generics, mixins, status, viewsets @@ -30,11 +30,11 @@ ) -SubletQuerySet: TypeAlias = QuerySet[Sublet] -OfferQuerySet: TypeAlias = QuerySet[Offer] -ImageList: TypeAlias = QuerySet[SubletImage] -FavoriteQuerySet: TypeAlias = QuerySet[Sublet] -UserOfferQuerySet: TypeAlias = QuerySet[Offer] +SubletQuerySet: TypeAlias = QuerySet[Sublet, Manager[Sublet]] +OfferQuerySet: TypeAlias = QuerySet[Offer, Manager[Offer]] +ImageList: TypeAlias = QuerySet[SubletImage, Manager[SubletImage]] +FavoriteQuerySet: TypeAlias = QuerySet[Sublet, Manager[Sublet]] +UserOfferQuerySet: TypeAlias = QuerySet[Offer, Manager[Offer]] User = get_user_model() @@ -43,7 +43,7 @@ class Amenities(generics.ListAPIView): serializer_class = AmenitySerializer queryset = Amenity.objects.all() - def get(self, request: Request, *args, **kwargs) -> Response: + def get(self, request: Request, *args: Any, **kwargs: Any) -> Response: temp = super().get(request, *args, **kwargs).data return Response([a["name"] for a in temp]) @@ -89,7 +89,7 @@ def get_serializer_class(self): def get_queryset(self) -> SubletQuerySet: return Sublet.objects.all() - def create(self, request: Request, *args, **kwargs) -> Response: + def create(self, request: Request, *args: Any, **kwargs: Any) -> Response: serializer = self.get_serializer(data=request.data) serializer.is_valid(raise_exception=True) # Check if the data is valid instance = serializer.save() # Create the Sublet @@ -99,7 +99,7 @@ def create(self, request: Request, *args, **kwargs) -> Response: return Response(instance_serializer.data, status=status.HTTP_201_CREATED) - def update(self, request: Request, *args, **kwargs) -> Response: + def update(self, request: Request, *args: Any, **kwargs: Any) -> Response: partial = kwargs.pop("partial", False) instance = self.get_object() serializer = self.get_serializer(instance, data=request.data, partial=partial) @@ -135,7 +135,7 @@ def update(self, request: Request, *args, **kwargs) -> Response: # sublet.save() # return Response(serializer.data, status=status.HTTP_201_CREATED) - def list(self, request: Request, *args, **kwargs) -> Response: + def list(self, request: Request, *args: Any, **kwargs: Any) -> Response: """Returns a list of Sublets that match query parameters and user ownership.""" # Get query parameters from request (e.g., amenities, user_owned) params = request.query_params @@ -203,12 +203,12 @@ class CreateImages(generics.CreateAPIView): FormParser, ) - def get_queryset(self, *args, **kwargs) -> ImageList: + def get_queryset(self, *args: Any, **kwargs: Any) -> ImageList: sublet = get_object_or_404(Sublet, id=int(self.kwargs["sublet_id"])) return SubletImage.objects.filter(sublet=sublet) # takes an image multipart form data and creates a new image object - def post(self, request: Request, *args, **kwargs) -> Response: + def post(self, request: Request, *args: Any, **kwargs: Any) -> Response: images = request.data.getlist("images") sublet_id = int(self.kwargs["sublet_id"]) self.get_queryset() # check if sublet exists @@ -228,7 +228,7 @@ class DeleteImage(generics.DestroyAPIView): permission_classes = [SubletImageOwnerPermission | IsSuperUser] queryset = SubletImage.objects.all() - def destroy(self, request: Request, *args, **kwargs) -> Response: + def destroy(self, request: Request, *args: Any, **kwargs: Any) -> Response: queryset = self.get_queryset() filter = {"id": self.kwargs["image_id"]} obj = get_object_or_404(queryset, **filter) @@ -247,7 +247,7 @@ def get_queryset(self) -> FavoriteQuerySet: user = self.request.user return user.sublets_favorited - def create(self, request: Request, *args, **kwargs) -> Response: + def create(self, request: Request, *args: Any, **kwargs: Any) -> Response: sublet_id = int(self.kwargs["sublet_id"]) queryset = self.get_queryset() if queryset.filter(id=sublet_id).exists(): @@ -259,7 +259,7 @@ def create(self, request: Request, *args, **kwargs) -> Response: return Response(status=status.HTTP_201_CREATED) - def destroy(self, request: Request, *args, **kwargs) -> Response: + def destroy(self, request: Request, *args: Any, **kwargs: Any) -> Response: queryset = self.get_queryset() sublet = get_object_or_404(queryset, pk=int(self.kwargs["sublet_id"])) self.get_queryset().remove(sublet) @@ -286,7 +286,7 @@ def get_queryset(self) -> OfferQuerySet: "created_date" ) - def create(self, request: Request, *args, **kwargs) -> Response: + def create(self, request: Request, *args: Any, **kwargs: Any) -> Response: data = request.data request.POST._mutable = True if self.get_queryset().filter(user=self.request.user).exists(): @@ -301,16 +301,16 @@ def create(self, request: Request, *args, **kwargs) -> Response: return Response(serializer.data, status=status.HTTP_201_CREATED) - def destroy(self, request: Request, *args, **kwargs) -> Response: + def destroy(self, request: Request, *args: Any, **kwargs: Any) -> Response: queryset = self.get_queryset() filter = {"user": self.request.user.id, "sublet": int(self.kwargs["sublet_id"])} - obj = get_object_or_404(queryset, **filter) + obj: Offer = get_object_or_404(queryset, **filter) # checking permissions here is kind of redundant self.check_object_permissions(self.request, obj) self.perform_destroy(obj) return Response(status=status.HTTP_204_NO_CONTENT) - def list(self, request: Request, *args, **kwargs) -> Response: + def list(self, request: Request, *args: Any, **kwargs: Any) -> Response: sublet = get_object_or_404(Sublet, pk=int(self.kwargs["sublet_id"])) self.check_object_permissions(request, sublet) return super().list(request, *args, **kwargs) diff --git a/backend/user/management/commands/clear_cache.py b/backend/user/management/commands/clear_cache.py index cf6b9c73..b87d15c7 100644 --- a/backend/user/management/commands/clear_cache.py +++ b/backend/user/management/commands/clear_cache.py @@ -10,7 +10,7 @@ # https://github.com/pennlabs/penn-courses/blob/master/backend/review/management/commands/clearcache.py -def clear_cache(): +def clear_cache() -> int: # If we are not using redis as the cache backend, then we can delete everything from the cache. if ( settings.CACHES is None @@ -30,9 +30,9 @@ def clear_cache(): class Command(BaseCommand): - def handle(self, *args, **options): + def handle(self, *args, **options) -> None: root_logger = logging.getLogger("") root_logger.setLevel(logging.DEBUG) del_count = clear_cache() - print(f"{del_count if del_count >=0 else 'all'} cache entries removed.") + print(f"{del_count if del_count >= 0 else 'all'} cache entries removed.") diff --git a/backend/user/management/commands/profile_info.py b/backend/user/management/commands/profile_info.py index 52bc9460..667f4da2 100644 --- a/backend/user/management/commands/profile_info.py +++ b/backend/user/management/commands/profile_info.py @@ -1,3 +1,5 @@ +from argparse import ArgumentParser + from django.contrib.auth import get_user_model from django.core.management.base import BaseCommand @@ -10,7 +12,7 @@ class Command(BaseCommand): Shows all user information given a pennkey or an email. """ - def add_arguments(self, parser): + def add_arguments(self, parser: ArgumentParser) -> None: parser.add_argument("--pennkey", type=str, help="pennkey") parser.add_argument("--email", type=str, help="email") diff --git a/backend/user/management/commands/send_shadow_notifs.py b/backend/user/management/commands/send_shadow_notifs.py index 1d19664d..0b25157f 100644 --- a/backend/user/management/commands/send_shadow_notifs.py +++ b/backend/user/management/commands/send_shadow_notifs.py @@ -1,4 +1,6 @@ import json +from argparse import ArgumentParser +from typing import Any from django.core.management.base import BaseCommand @@ -23,7 +25,7 @@ class Command(BaseCommand): <> """ - def add_arguments(self, parser): + def add_arguments(self, parser: ArgumentParser) -> None: parser.add_argument("send_to_all", type=str, help="whether to send to all") parser.add_argument("message", type=str, help="JSON-formatted message to send") @@ -32,7 +34,7 @@ def add_arguments(self, parser): parser.add_argument("--delay", type=int, default=0) parser.add_argument("--is_dev", type=str, default="no") - def handle(self, *args, **kwargs): + def handle(self, *args: Any, **kwargs: Any) -> None: send_to_all = kwargs["send_to_all"].lower() == "yes" message = json.loads(kwargs["message"]) names = kwargs["users"] diff --git a/backend/user/notifications.py b/backend/user/notifications.py index 3cc73547..eacdea32 100644 --- a/backend/user/notifications.py +++ b/backend/user/notifications.py @@ -1,6 +1,7 @@ import collections import os import sys +from typing import Optional # Monkey Patch for apn2 errors, referenced from: @@ -30,7 +31,15 @@ Notification = collections.namedtuple("Notification", ["token", "payload"]) -def send_push_notifications(users, service, title, body, delay=0, is_dev=False, is_shadow=False): +def send_push_notifications( + users: Optional[list[str]], + service: Optional[str], + title: str, + body: str, + delay: int = 0, + is_dev: bool = False, + is_shadow: bool = False, +) -> tuple[list[str], list[str]]: """ Sends push notifications. @@ -61,7 +70,9 @@ def send_push_notifications(users, service, title, body, delay=0, is_dev=False, return success_users, failed_users -def get_tokens(users=None, service=None): +def get_tokens( + users: Optional[list[str]] = None, service: Optional[str] = None +) -> list[tuple[str, str]]: """Returns list of token objects (with username & token value) for specified users""" token_objs = NotificationToken.objects.select_related("user").filter( @@ -77,7 +88,9 @@ def get_tokens(users=None, service=None): @shared_task(name="notifications.send_immediate_notifications") -def send_immediate_notifications(tokens, title, body, category, is_dev, is_shadow): +def send_immediate_notifications( + tokens: list[str], title: str, body: str, category: str, is_dev: bool, is_shadow: bool +) -> None: client = get_client(is_dev) if is_shadow: payload = Payload( @@ -97,20 +110,28 @@ def send_immediate_notifications(tokens, title, body, category, is_dev, is_shado client.send_notification(tokens[0], payload, topic) -def send_delayed_notifications(tokens, title, body, category, is_dev, is_shadow, delay): +def send_delayed_notifications( + tokens: list[str], + title: str, + body: str, + category: str, + is_dev: bool, + is_shadow: bool, + delay: int, +) -> None: send_immediate_notifications.apply_async( (tokens, title, body, category, is_dev, is_shadow), countdown=delay ) -def get_auth_key_path(): +def get_auth_key_path() -> str: return os.environ.get( "IOS_KEY_PATH", # for dev purposes os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "ios_key.p8"), ) -def get_client(is_dev): +def get_client(is_dev: bool) -> APNsClient: """Creates and returns APNsClient based on iOS credentials""" auth_key_path = get_auth_key_path() diff --git a/backend/user/serializers.py b/backend/user/serializers.py index 94a17def..fce622da 100644 --- a/backend/user/serializers.py +++ b/backend/user/serializers.py @@ -1,15 +1,20 @@ +from typing import Any, TypeAlias + from django.contrib.auth import get_user_model from rest_framework import serializers from user.models import NotificationSetting, NotificationToken, Profile +ValidatedData: TypeAlias = dict[str, Any] + + class NotificationTokenSerializer(serializers.ModelSerializer): class Meta: model = NotificationToken fields = ("id", "kind", "token") - def create(self, validated_data): + def create(self, validated_data: ValidatedData) -> NotificationToken: validated_data["user"] = self.context["request"].user token_obj = NotificationToken.objects.filter(user=validated_data["user"]).first() if token_obj: @@ -22,7 +27,7 @@ class Meta: model = NotificationSetting fields = ("id", "service", "enabled") - def create(self, validated_data): + def create(self, validated_data: ValidatedData) -> NotificationSetting: validated_data["token"] = NotificationToken.objects.get(user=self.context["request"].user) setting = NotificationSetting.objects.filter( token=validated_data["token"], service=validated_data["service"] @@ -31,7 +36,9 @@ def create(self, validated_data): raise serializers.ValidationError(detail={"detail": "Setting already created."}) return super().create(validated_data) - def update(self, instance, validated_data): + def update( + self, instance: NotificationSetting, validated_data: ValidatedData + ) -> NotificationSetting: if instance.service != validated_data["service"]: raise serializers.ValidationError(detail={"detail": "Cannot change setting service."}) return super().update(instance, validated_data) diff --git a/backend/user/views.py b/backend/user/views.py index 48ea7e46..260d29d9 100644 --- a/backend/user/views.py +++ b/backend/user/views.py @@ -1,10 +1,14 @@ +from typing import TYPE_CHECKING, Any, Optional + from django.contrib.auth import get_user_model +from django.db.models import QuerySet from django.http import HttpResponseRedirect from django.shortcuts import get_object_or_404 from identity.permissions import B2BPermission from rest_framework import generics, viewsets from rest_framework.decorators import action from rest_framework.permissions import IsAuthenticated +from rest_framework.request import Request from rest_framework.response import Response from rest_framework.views import APIView @@ -17,6 +21,13 @@ ) +if TYPE_CHECKING: + from django.contrib.auth.models import AbstractUser + + UserType = AbstractUser +else: + UserType = Any + User = get_user_model() @@ -36,7 +47,7 @@ class UserView(generics.RetrieveUpdateAPIView): serializer_class = UserSerializer - def get_object(self): + def get_object(self) -> "UserType": return self.request.user @@ -49,7 +60,7 @@ class NotificationTokenView(viewsets.ModelViewSet): permission_classes = [IsAuthenticated] serializer_class = NotificationTokenSerializer - def get_queryset(self): + def get_queryset(self) -> QuerySet[NotificationToken]: return NotificationToken.objects.filter(user=self.request.user) @@ -68,16 +79,16 @@ class NotificationSettingView(viewsets.ModelViewSet): permission_classes = [B2BPermission("urn:pennlabs:*") | IsAuthenticated] serializer_class = NotificationSettingSerializer - def is_authorized(self, request): + def is_authorized(self, request: Request) -> bool: return request.user and request.user.is_authenticated - def get_queryset(self): + def get_queryset(self) -> QuerySet[NotificationSetting]: if self.is_authorized(self.request): return NotificationSetting.objects.filter(token__user=self.request.user) return NotificationSetting.objects.none() @action(detail=True, methods=["get"]) - def check(self, request, pk=None): + def check(self, request: Request, pk: Optional[str] = None) -> Response: """ Returns whether the user wants notification for specified service. :param pk: service name @@ -108,7 +119,7 @@ class NotificationAlertView(APIView): permission_classes = [B2BPermission("urn:pennlabs:*") | IsAuthenticated] - def post(self, request): + def post(self, request: Request) -> Response: users = ( [self.request.user.username] if request.user and request.user.is_authenticated @@ -138,7 +149,7 @@ class ClearCookiesView(APIView): Clears all cookies from the browser """ - def get(self, request): + def get(self, request: Request) -> Response: next_url = request.GET.get("next", None) response = ( HttpResponseRedirect(f"/api/accounts/login/?next={next_url}") diff --git a/backend/utils/r_request.py b/backend/utils/r_request.py index a0f1e9ee..3df7e360 100644 --- a/backend/utils/r_request.py +++ b/backend/utils/r_request.py @@ -1,7 +1,9 @@ from enum import Enum from json.decoder import JSONDecodeError +from typing import Any, Optional import requests +from requests import Response class Method(str, Enum): @@ -22,42 +24,42 @@ class RRequest: NUM_RETRIES = 2 - def __init__(self, num_retries=NUM_RETRIES): + def __init__(self, num_retries: int = NUM_RETRIES): self.num_retries = num_retries - def get(self, *args, **kwargs): + def get(self, *args: Any, **kwargs: Any) -> Response: return self.request(Method.GET, *args, **kwargs) - def post(self, *args, **kwargs): + def post(self, *args: Any, **kwargs: Any) -> Response: return self.request(Method.POST, *args, **kwargs) - def patch(self, *args, **kwargs): + def patch(self, *args: Any, **kwargs: Any) -> Response: return self.request(Method.PATCH, *args, **kwargs) - def put(self, *args, **kwargs): + def put(self, *args: Any, **kwargs: Any) -> Response: return self.request(Method.PUT, *args, **kwargs) - def delete(self, *args, **kwargs): + def delete(self, *args: Any, **kwargs: Any) -> Response: return self.request(Method.DELETE, *args, **kwargs) def request( self, - method, - url, - params=None, - data=None, - headers=None, - cookies=None, - files=None, - auth=None, - timeout=None, - allow_redirects=True, - proxies=None, - hooks=None, - stream=None, - verify=None, - cert=None, - json=None, + method: Method, + url: str, + params: Optional[dict] = None, + data: Optional[dict] = None, + headers: Optional[dict] = None, + cookies: Optional[dict] = None, + files: Optional[dict] = None, + auth: Optional[tuple[str, str]] = None, + timeout: Optional[int] = None, + allow_redirects: bool = True, + proxies: Optional[dict] = None, + hooks: Optional[dict] = None, + stream: Optional[bool] = None, + verify: Optional[bool] = None, + cert: Optional[str] = None, + json: Optional[dict] = None, ): response = self.__default_response() @@ -95,7 +97,7 @@ def request( return response - def __default_response(self): - response = requests.models.Response + def __default_response(self) -> requests.models.Response: + response = requests.models.Response() response.status_code = 400 return response From 43daeaa73f1262f77c9b3b100644fb671e66c6a8 Mon Sep 17 00:00:00 2001 From: Ashley Zhang <69987606+ashleyzhang01@users.noreply.github.com> Date: Wed, 13 Nov 2024 16:16:47 -0800 Subject: [PATCH 10/12] Add mypy (#322) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 🌷 add mypy * 🌌 fix mypy errors in portal * 👥user and sublet typing * 🍽️ fix dining typing * 🏘️ fix subletting tests typing * 📊 penndata typing errors * 📚 gsr booking type fixes * 🧎‍♀️OMFG NO WAY NO MORE TYPE ERRORS * 🧹 clean up types files * 💯 fix tests * 🌉 extend pre-commit rules --- .pre-commit-config.yaml | 27 +- backend/Pipfile | 2 + backend/Pipfile.lock | 397 ++++++++++-------- backend/dining/api_wrapper.py | 2 + backend/dining/migrations/0001_initial.py | 4 +- ...002_diningitem_diningstation_diningmenu.py | 4 +- backend/dining/migrations/0003_venue_name.py | 10 +- ...move_diningtransaction_profile_and_more.py | 25 +- ...gens_diningitem_nutrition_info_and_more.py | 4 +- ...006_remove_diningmenu_stations_and_more.py | 9 +- backend/dining/models.py | 44 +- backend/dining/views.py | 14 +- backend/gsr_booking/admin.py | 16 +- backend/gsr_booking/api_wrapper.py | 174 ++++---- .../management/commands/change_group.py | 3 +- .../management/commands/get_reservations.py | 3 +- .../management/commands/individual_usage.py | 3 +- .../gsr_booking/migrations/0001_initial.py | 18 +- .../0001_squashed_0011_merge_20200418_2009.py | 44 +- .../migrations/0002_auto_20210129_1527.py | 10 +- .../migrations/0004_alter_gsr_lid.py | 10 +- .../migrations/0005_reservation.py | 2 +- .../migrations/0005_usersearchindex.py | 8 +- .../migrations/0006_auto_20200207_2126.py | 4 +- .../migrations/0006_auto_20211024_1231.py | 14 +- .../migrations/0006_gsrbookingcredentials.py | 15 +- .../0007_delete_gsrbookingcredentials.py | 10 +- .../migrations/0008_auto_20200202_1218.py | 2 +- .../migrations/0008_auto_20211112_1657.py | 17 +- .../migrations/0009_auto_20200202_1232.py | 6 +- ...emove_groupmembership_username_and_more.py | 9 +- ...emove_gsrbooking_reminder_sent_and_more.py | 13 +- .../0011_alter_reservation_group.py | 6 +- .../gsr_booking/migrations/0012_gsr_in_use.py | 10 +- backend/gsr_booking/models.py | 118 +++--- backend/gsr_booking/serializers.py | 59 +-- backend/gsr_booking/urls.py | 4 +- backend/gsr_booking/views.py | 96 ++--- backend/laundry/api_wrapper.py | 2 +- .../migrations/0002_auto_20210321_1105.py | 6 +- backend/laundry/models.py | 26 +- backend/laundry/views.py | 36 +- backend/manage.py | 2 +- backend/penndata/admin.py | 6 +- .../commands/get_college_house_events.py | 2 +- .../commands/get_fitness_snapshot.py | 16 +- .../commands/get_penn_today_events.py | 109 ++--- .../management/commands/get_venture_events.py | 2 +- .../management/commands/get_wharton_events.py | 10 +- backend/penndata/migrations/0001_initial.py | 2 +- .../penndata/migrations/0002_homepageorder.py | 6 +- .../migrations/0003_analyticsevent.py | 2 +- .../migrations/0004_analyticsevent_data.py | 6 +- .../0005_fitnessroom_fitnesssnapshot.py | 4 +- .../0006_fitnesssnapshot_capacity.py | 10 +- .../migrations/0007_fitnessroom_image_url.py | 6 +- .../penndata/migrations/0008_calendarevent.py | 6 +- .../migrations/0009_auto_20240223_1820.py | 41 +- .../migrations/0010_auto_20240228_0150.py | 21 +- ...lter_event_event_type_alter_event_start.py | 8 +- .../migrations/0012_alter_event_event_type.py | 6 +- backend/penndata/models.py | 69 +-- backend/penndata/serializers.py | 7 +- backend/penndata/views.py | 73 ++-- backend/pennmobile/admin.py | 25 +- backend/pennmobile/celery.py | 3 +- backend/pennmobile/settings/base.py | 4 +- backend/pennmobile/settings/production.py | 2 +- backend/pennmobile/test_runner.py | 11 +- backend/pennmobile/urls.py | 14 +- backend/portal/admin.py | 32 +- backend/portal/logic.py | 57 ++- .../commands/load_target_populations.py | 11 +- .../management/commands/polls_populate.py | 75 +--- backend/portal/migrations/0001_initial.py | 4 +- .../migrations/0002_auto_20211003_2225.py | 13 +- .../0003_alter_targetpopulation_kind.py | 6 +- backend/portal/migrations/0004_post.py | 2 +- .../migrations/0005_auto_20211231_1558.py | 34 +- .../migrations/0006_auto_20220112_1529.py | 35 +- backend/portal/migrations/0007_post_status.py | 6 +- .../migrations/0008_alter_post_image_url.py | 6 +- .../0009_rename_image_url_post_image.py | 12 +- .../migrations/0010_remove_post_image.py | 11 +- backend/portal/migrations/0011_post_image.py | 6 +- .../migrations/0012_remove_post_image.py | 11 +- backend/portal/migrations/0013_post_image.py | 6 +- .../migrations/0014_alter_post_post_url.py | 6 +- .../migrations/0015_auto_20240226_2236.py | 18 +- backend/portal/models.py | 96 +++-- backend/portal/permissions.py | 65 +-- backend/portal/serializers.py | 87 ++-- backend/portal/views.py | 51 +-- backend/setup.cfg | 3 +- backend/sublet/admin.py | 6 +- backend/sublet/migrations/0001_initial.py | 8 +- .../migrations/0002_auto_20240209_1649.py | 19 +- .../migrations/0003_alter_sublet_baths.py | 6 +- .../0004_alter_sublet_external_link.py | 6 +- backend/sublet/models.py | 76 ++-- backend/sublet/permissions.py | 41 +- backend/sublet/serializers.py | 29 +- backend/sublet/views.py | 69 ++- backend/tests/dining/test_load_venues.py | 4 +- backend/tests/dining/test_views.py | 52 ++- backend/tests/gsr_booking/test_gsr_booking.py | 114 ++--- backend/tests/gsr_booking/test_gsr_views.py | 58 +-- backend/tests/gsr_booking/test_gsr_wrapper.py | 66 +-- backend/tests/laundry/test_api_wrapper.py | 12 +- backend/tests/laundry/test_commands.py | 10 +- backend/tests/laundry/test_models.py | 10 +- backend/tests/laundry/test_views.py | 43 +- backend/tests/penndata/test_views.py | 101 +++-- backend/tests/portal/test_permissions.py | 55 +-- backend/tests/portal/test_polls.py | 133 +++--- backend/tests/portal/test_posts.py | 65 +-- backend/tests/sublet/test_permissions.py | 71 ++-- backend/tests/sublet/test_sublets.py | 122 +++--- backend/tests/user/test_notifs.py | 107 ++--- backend/tests/user/test_user.py | 29 +- backend/tests/utils/test_email.py | 29 +- backend/tests/utils/test_r_request.py | 27 +- .../user/management/commands/clear_cache.py | 3 +- .../user/management/commands/profile_info.py | 3 +- .../0002_profile_laundry_preferences.py | 7 +- .../0003_profile_dining_preferences.py | 7 +- .../migrations/0004_auto_20210324_1851.py | 7 +- .../migrations/0005_auto_20211003_2240.py | 18 +- .../0007_alter_notificationsetting_service.py | 4 +- .../0008_remove_notificationtoken_dev.py | 11 +- .../0009_profile_fitness_preferences.py | 2 +- backend/user/models.py | 33 +- backend/user/notifications.py | 45 +- backend/user/serializers.py | 9 +- backend/user/views.py | 38 +- backend/utils/email.py | 37 +- backend/utils/r_request.py | 4 +- backend/utils/types.py | 75 ++++ 138 files changed, 2006 insertions(+), 2077 deletions(-) create mode 100644 backend/utils/types.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index aec50834..c6022dc8 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -3,9 +3,34 @@ repos: rev: v1.1-mobile-backend hooks: - id: black - args: [-l100] + args: [-l100, --config, backend/pyproject.toml] - id: isort args: [] - id: flake8 args: [--config, backend/setup.cfg] - id: detect-private-key + + - repo: https://github.com/pre-commit/mirrors-mypy + rev: v1.8.0 + hooks: + - id: mypy + additional_dependencies: + - types-requests + - types-python-dateutil + - django-stubs + - djangorestframework-stubs + - types-PyYAML + - types-redis + - types-pytz + files: ^backend/ + exclude: ^backend/.*/migrations/.*$ + args: [ + --ignore-missing-imports, + --disallow-untyped-defs, + --check-untyped-defs, + --warn-redundant-casts, + --no-implicit-optional, + --strict-optional, + --warn-unused-ignores, + --disallow-incomplete-defs, + ] diff --git a/backend/Pipfile b/backend/Pipfile index d2c29862..43a7aff5 100644 --- a/backend/Pipfile +++ b/backend/Pipfile @@ -48,6 +48,8 @@ webdriver-manager = "*" pre-commit = "*" alt-profanity-check = "*" inflection = "*" +types-redis = "*" +types-pytz = "*" [requires] python_version = "3.11" diff --git a/backend/Pipfile.lock b/backend/Pipfile.lock index e1ee3f2c..e1b32e1e 100644 --- a/backend/Pipfile.lock +++ b/backend/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "f89acb57e8e31f7e049ea1c699698737a30b50d260d20fea7f07d19bcb56c00d" + "sha256": "e8c3871357a67fa0783821e10e9633bd4abefb07550453f4c4fb6918ebfb8dfb" }, "pipfile-spec": 6, "requires": { @@ -109,76 +109,76 @@ }, "cffi": { "hashes": [ - "sha256:011aff3524d578a9412c8b3cfaa50f2c0bd78e03eb7af7aa5e0df59b158efb2f", - "sha256:0a048d4f6630113e54bb4b77e315e1ba32a5a31512c31a273807d0027a7e69ab", - "sha256:0bb15e7acf8ab35ca8b24b90af52c8b391690ef5c4aec3d31f38f0d37d2cc499", - "sha256:0d46ee4764b88b91f16661a8befc6bfb24806d885e27436fdc292ed7e6f6d058", - "sha256:0e60821d312f99d3e1569202518dddf10ae547e799d75aef3bca3a2d9e8ee693", - "sha256:0fdacad9e0d9fc23e519efd5ea24a70348305e8d7d85ecbb1a5fa66dc834e7fb", - "sha256:14b9cbc8f7ac98a739558eb86fabc283d4d564dafed50216e7f7ee62d0d25377", - "sha256:17c6d6d3260c7f2d94f657e6872591fe8733872a86ed1345bda872cfc8c74885", - "sha256:1a2ddbac59dc3716bc79f27906c010406155031a1c801410f1bafff17ea304d2", - "sha256:2404f3de742f47cb62d023f0ba7c5a916c9c653d5b368cc966382ae4e57da401", - "sha256:24658baf6224d8f280e827f0a50c46ad819ec8ba380a42448e24459daf809cf4", - "sha256:24aa705a5f5bd3a8bcfa4d123f03413de5d86e497435693b638cbffb7d5d8a1b", - "sha256:2770bb0d5e3cc0e31e7318db06efcbcdb7b31bcb1a70086d3177692a02256f59", - "sha256:331ad15c39c9fe9186ceaf87203a9ecf5ae0ba2538c9e898e3a6967e8ad3db6f", - "sha256:3aa9d43b02a0c681f0bfbc12d476d47b2b2b6a3f9287f11ee42989a268a1833c", - "sha256:41f4915e09218744d8bae14759f983e466ab69b178de38066f7579892ff2a555", - "sha256:4304d4416ff032ed50ad6bb87416d802e67139e31c0bde4628f36a47a3164bfa", - "sha256:435a22d00ec7d7ea533db494da8581b05977f9c37338c80bc86314bec2619424", - "sha256:45f7cd36186db767d803b1473b3c659d57a23b5fa491ad83c6d40f2af58e4dbb", - "sha256:48b389b1fd5144603d61d752afd7167dfd205973a43151ae5045b35793232aa2", - "sha256:4e67d26532bfd8b7f7c05d5a766d6f437b362c1bf203a3a5ce3593a645e870b8", - "sha256:516a405f174fd3b88829eabfe4bb296ac602d6a0f68e0d64d5ac9456194a5b7e", - "sha256:5ba5c243f4004c750836f81606a9fcb7841f8874ad8f3bf204ff5e56332b72b9", - "sha256:5bdc0f1f610d067c70aa3737ed06e2726fd9d6f7bfee4a351f4c40b6831f4e82", - "sha256:6107e445faf057c118d5050560695e46d272e5301feffda3c41849641222a828", - "sha256:6327b572f5770293fc062a7ec04160e89741e8552bf1c358d1a23eba68166759", - "sha256:669b29a9eca6146465cc574659058ed949748f0809a2582d1f1a324eb91054dc", - "sha256:6ce01337d23884b21c03869d2f68c5523d43174d4fc405490eb0091057943118", - "sha256:6d872186c1617d143969defeadac5a904e6e374183e07977eedef9c07c8953bf", - "sha256:6f76a90c345796c01d85e6332e81cab6d70de83b829cf1d9762d0a3da59c7932", - "sha256:70d2aa9fb00cf52034feac4b913181a6e10356019b18ef89bc7c12a283bf5f5a", - "sha256:7cbc78dc018596315d4e7841c8c3a7ae31cc4d638c9b627f87d52e8abaaf2d29", - "sha256:856bf0924d24e7f93b8aee12a3a1095c34085600aa805693fb7f5d1962393206", - "sha256:8a98748ed1a1df4ee1d6f927e151ed6c1a09d5ec21684de879c7ea6aa96f58f2", - "sha256:93a7350f6706b31f457c1457d3a3259ff9071a66f312ae64dc024f049055f72c", - "sha256:964823b2fc77b55355999ade496c54dde161c621cb1f6eac61dc30ed1b63cd4c", - "sha256:a003ac9edc22d99ae1286b0875c460351f4e101f8c9d9d2576e78d7e048f64e0", - "sha256:a0ce71725cacc9ebf839630772b07eeec220cbb5f03be1399e0457a1464f8e1a", - "sha256:a47eef975d2b8b721775a0fa286f50eab535b9d56c70a6e62842134cf7841195", - "sha256:a8b5b9712783415695663bd463990e2f00c6750562e6ad1d28e072a611c5f2a6", - "sha256:a9015f5b8af1bb6837a3fcb0cdf3b874fe3385ff6274e8b7925d81ccaec3c5c9", - "sha256:aec510255ce690d240f7cb23d7114f6b351c733a74c279a84def763660a2c3bc", - "sha256:b00e7bcd71caa0282cbe3c90966f738e2db91e64092a877c3ff7f19a1628fdcb", - "sha256:b50aaac7d05c2c26dfd50c3321199f019ba76bb650e346a6ef3616306eed67b0", - "sha256:b7b6ea9e36d32582cda3465f54c4b454f62f23cb083ebc7a94e2ca6ef011c3a7", - "sha256:bb9333f58fc3a2296fb1d54576138d4cf5d496a2cc118422bd77835e6ae0b9cb", - "sha256:c1c13185b90bbd3f8b5963cd8ce7ad4ff441924c31e23c975cb150e27c2bf67a", - "sha256:c3b8bd3133cd50f6b637bb4322822c94c5ce4bf0d724ed5ae70afce62187c492", - "sha256:c5d97162c196ce54af6700949ddf9409e9833ef1003b4741c2b39ef46f1d9720", - "sha256:c815270206f983309915a6844fe994b2fa47e5d05c4c4cef267c3b30e34dbe42", - "sha256:cab2eba3830bf4f6d91e2d6718e0e1c14a2f5ad1af68a89d24ace0c6b17cced7", - "sha256:d1df34588123fcc88c872f5acb6f74ae59e9d182a2707097f9e28275ec26a12d", - "sha256:d6bdcd415ba87846fd317bee0774e412e8792832e7805938987e4ede1d13046d", - "sha256:db9a30ec064129d605d0f1aedc93e00894b9334ec74ba9c6bdd08147434b33eb", - "sha256:dbc183e7bef690c9abe5ea67b7b60fdbca81aa8da43468287dae7b5c046107d4", - "sha256:dca802c8db0720ce1c49cce1149ff7b06e91ba15fa84b1d59144fef1a1bc7ac2", - "sha256:dec6b307ce928e8e112a6bb9921a1cb00a0e14979bf28b98e084a4b8a742bd9b", - "sha256:df8bb0010fdd0a743b7542589223a2816bdde4d94bb5ad67884348fa2c1c67e8", - "sha256:e4094c7b464cf0a858e75cd14b03509e84789abf7b79f8537e6a72152109c76e", - "sha256:e4760a68cab57bfaa628938e9c2971137e05ce48e762a9cb53b76c9b569f1204", - "sha256:eb09b82377233b902d4c3fbeeb7ad731cdab579c6c6fda1f763cd779139e47c3", - "sha256:eb862356ee9391dc5a0b3cbc00f416b48c1b9a52d252d898e5b7696a5f9fe150", - "sha256:ef9528915df81b8f4c7612b19b8628214c65c9b7f74db2e34a646a0a2a0da2d4", - "sha256:f3157624b7558b914cb039fd1af735e5e8049a87c817cc215109ad1c8779df76", - "sha256:f3e0992f23bbb0be00a921eae5363329253c3b86287db27092461c887b791e5e", - "sha256:f9338cc05451f1942d0d8203ec2c346c830f8e86469903d5126c1f0a13a2bcbb", - "sha256:ffef8fd58a36fb5f1196919638f73dd3ae0db1a878982b27a9a5a176ede4ba91" - ], - "markers": "platform_python_implementation != 'PyPy'", - "version": "==1.17.0" + "sha256:045d61c734659cc045141be4bae381a41d89b741f795af1dd018bfb532fd0df8", + "sha256:0984a4925a435b1da406122d4d7968dd861c1385afe3b45ba82b750f229811e2", + "sha256:0e2b1fac190ae3ebfe37b979cc1ce69c81f4e4fe5746bb401dca63a9062cdaf1", + "sha256:0f048dcf80db46f0098ccac01132761580d28e28bc0f78ae0d58048063317e15", + "sha256:1257bdabf294dceb59f5e70c64a3e2f462c30c7ad68092d01bbbfb1c16b1ba36", + "sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824", + "sha256:1d599671f396c4723d016dbddb72fe8e0397082b0a77a4fab8028923bec050e8", + "sha256:28b16024becceed8c6dfbc75629e27788d8a3f9030691a1dbf9821a128b22c36", + "sha256:2bb1a08b8008b281856e5971307cc386a8e9c5b625ac297e853d36da6efe9c17", + "sha256:30c5e0cb5ae493c04c8b42916e52ca38079f1b235c2f8ae5f4527b963c401caf", + "sha256:31000ec67d4221a71bd3f67df918b1f88f676f1c3b535a7eb473255fdc0b83fc", + "sha256:386c8bf53c502fff58903061338ce4f4950cbdcb23e2902d86c0f722b786bbe3", + "sha256:3edc8d958eb099c634dace3c7e16560ae474aa3803a5df240542b305d14e14ed", + "sha256:45398b671ac6d70e67da8e4224a065cec6a93541bb7aebe1b198a61b58c7b702", + "sha256:46bf43160c1a35f7ec506d254e5c890f3c03648a4dbac12d624e4490a7046cd1", + "sha256:4ceb10419a9adf4460ea14cfd6bc43d08701f0835e979bf821052f1805850fe8", + "sha256:51392eae71afec0d0c8fb1a53b204dbb3bcabcb3c9b807eedf3e1e6ccf2de903", + "sha256:5da5719280082ac6bd9aa7becb3938dc9f9cbd57fac7d2871717b1feb0902ab6", + "sha256:610faea79c43e44c71e1ec53a554553fa22321b65fae24889706c0a84d4ad86d", + "sha256:636062ea65bd0195bc012fea9321aca499c0504409f413dc88af450b57ffd03b", + "sha256:6883e737d7d9e4899a8a695e00ec36bd4e5e4f18fabe0aca0efe0a4b44cdb13e", + "sha256:6b8b4a92e1c65048ff98cfe1f735ef8f1ceb72e3d5f0c25fdb12087a23da22be", + "sha256:6f17be4345073b0a7b8ea599688f692ac3ef23ce28e5df79c04de519dbc4912c", + "sha256:706510fe141c86a69c8ddc029c7910003a17353970cff3b904ff0686a5927683", + "sha256:72e72408cad3d5419375fc87d289076ee319835bdfa2caad331e377589aebba9", + "sha256:733e99bc2df47476e3848417c5a4540522f234dfd4ef3ab7fafdf555b082ec0c", + "sha256:7596d6620d3fa590f677e9ee430df2958d2d6d6de2feeae5b20e82c00b76fbf8", + "sha256:78122be759c3f8a014ce010908ae03364d00a1f81ab5c7f4a7a5120607ea56e1", + "sha256:805b4371bf7197c329fcb3ead37e710d1bca9da5d583f5073b799d5c5bd1eee4", + "sha256:85a950a4ac9c359340d5963966e3e0a94a676bd6245a4b55bc43949eee26a655", + "sha256:8f2cdc858323644ab277e9bb925ad72ae0e67f69e804f4898c070998d50b1a67", + "sha256:9755e4345d1ec879e3849e62222a18c7174d65a6a92d5b346b1863912168b595", + "sha256:98e3969bcff97cae1b2def8ba499ea3d6f31ddfdb7635374834cf89a1a08ecf0", + "sha256:a08d7e755f8ed21095a310a693525137cfe756ce62d066e53f502a83dc550f65", + "sha256:a1ed2dd2972641495a3ec98445e09766f077aee98a1c896dcb4ad0d303628e41", + "sha256:a24ed04c8ffd54b0729c07cee15a81d964e6fee0e3d4d342a27b020d22959dc6", + "sha256:a45e3c6913c5b87b3ff120dcdc03f6131fa0065027d0ed7ee6190736a74cd401", + "sha256:a9b15d491f3ad5d692e11f6b71f7857e7835eb677955c00cc0aefcd0669adaf6", + "sha256:ad9413ccdeda48c5afdae7e4fa2192157e991ff761e7ab8fdd8926f40b160cc3", + "sha256:b2ab587605f4ba0bf81dc0cb08a41bd1c0a5906bd59243d56bad7668a6fc6c16", + "sha256:b62ce867176a75d03a665bad002af8e6d54644fad99a3c70905c543130e39d93", + "sha256:c03e868a0b3bc35839ba98e74211ed2b05d2119be4e8a0f224fba9384f1fe02e", + "sha256:c59d6e989d07460165cc5ad3c61f9fd8f1b4796eacbd81cee78957842b834af4", + "sha256:c7eac2ef9b63c79431bc4b25f1cd649d7f061a28808cbc6c47b534bd789ef964", + "sha256:c9c3d058ebabb74db66e431095118094d06abf53284d9c81f27300d0e0d8bc7c", + "sha256:ca74b8dbe6e8e8263c0ffd60277de77dcee6c837a3d0881d8c1ead7268c9e576", + "sha256:caaf0640ef5f5517f49bc275eca1406b0ffa6aa184892812030f04c2abf589a0", + "sha256:cdf5ce3acdfd1661132f2a9c19cac174758dc2352bfe37d98aa7512c6b7178b3", + "sha256:d016c76bdd850f3c626af19b0542c9677ba156e4ee4fccfdd7848803533ef662", + "sha256:d01b12eeeb4427d3110de311e1774046ad344f5b1a7403101878976ecd7a10f3", + "sha256:d63afe322132c194cf832bfec0dc69a99fb9bb6bbd550f161a49e9e855cc78ff", + "sha256:da95af8214998d77a98cc14e3a3bd00aa191526343078b530ceb0bd710fb48a5", + "sha256:dd398dbc6773384a17fe0d3e7eeb8d1a21c2200473ee6806bb5e6a8e62bb73dd", + "sha256:de2ea4b5833625383e464549fec1bc395c1bdeeb5f25c4a3a82b5a8c756ec22f", + "sha256:de55b766c7aa2e2a3092c51e0483d700341182f08e67c63630d5b6f200bb28e5", + "sha256:df8b1c11f177bc2313ec4b2d46baec87a5f3e71fc8b45dab2ee7cae86d9aba14", + "sha256:e03eab0a8677fa80d646b5ddece1cbeaf556c313dcfac435ba11f107ba117b5d", + "sha256:e221cf152cff04059d011ee126477f0d9588303eb57e88923578ace7baad17f9", + "sha256:e31ae45bc2e29f6b2abd0de1cc3b9d5205aa847cafaecb8af1476a609a2f6eb7", + "sha256:edae79245293e15384b51f88b00613ba9f7198016a5948b5dddf4917d4d26382", + "sha256:f1e22e8c4419538cb197e4dd60acc919d7696e5ef98ee4da4e01d3f8cfa4cc5a", + "sha256:f3a2b4222ce6b60e2e8b337bb9596923045681d71e5a082783484d845390938e", + "sha256:f6a16c31041f09ead72d69f583767292f750d24913dadacf5756b966aacb3f1a", + "sha256:f75c7ab1f9e4aca5414ed4d8e5c0e303a34f4421f8a0d47a4d019ceff0ab6af4", + "sha256:f79fc4fc25f1c8698ff97788206bb3c2598949bfe0fef03d299eb1b5356ada99", + "sha256:f7f5baafcc48261359e14bcd6d9bff6d4b28d9103847c9e136694cb0501aef87", + "sha256:fc48c783f9c87e60831201f2cce7f3b2e4846bf4d8728eabe54d60700b318a0b" + ], + "markers": "python_version >= '3.8'", + "version": "==1.17.1" }, "cfgv": { "hashes": [ @@ -317,36 +317,36 @@ }, "cryptography": { "hashes": [ - "sha256:0663585d02f76929792470451a5ba64424acc3cd5227b03921dab0e2f27b1709", - "sha256:08a24a7070b2b6804c1940ff0f910ff728932a9d0e80e7814234269f9d46d069", - "sha256:232ce02943a579095a339ac4b390fbbe97f5b5d5d107f8a08260ea2768be8cc2", - "sha256:2905ccf93a8a2a416f3ec01b1a7911c3fe4073ef35640e7ee5296754e30b762b", - "sha256:299d3da8e00b7e2b54bb02ef58d73cd5f55fb31f33ebbf33bd00d9aa6807df7e", - "sha256:2c6d112bf61c5ef44042c253e4859b3cbbb50df2f78fa8fae6747a7814484a70", - "sha256:31e44a986ceccec3d0498e16f3d27b2ee5fdf69ce2ab89b52eaad1d2f33d8778", - "sha256:3d9a1eca329405219b605fac09ecfc09ac09e595d6def650a437523fcd08dd22", - "sha256:3dcdedae5c7710b9f97ac6bba7e1052b95c7083c9d0e9df96e02a1932e777895", - "sha256:47ca71115e545954e6c1d207dd13461ab81f4eccfcb1345eac874828b5e3eaaf", - "sha256:4a997df8c1c2aae1e1e5ac49c2e4f610ad037fc5a3aadc7b64e39dea42249431", - "sha256:51956cf8730665e2bdf8ddb8da0056f699c1a5715648c1b0144670c1ba00b48f", - "sha256:5bcb8a5620008a8034d39bce21dc3e23735dfdb6a33a06974739bfa04f853947", - "sha256:64c3f16e2a4fc51c0d06af28441881f98c5d91009b8caaff40cf3548089e9c74", - "sha256:6e2b11c55d260d03a8cf29ac9b5e0608d35f08077d8c087be96287f43af3ccdc", - "sha256:7b3f5fe74a5ca32d4d0f302ffe6680fcc5c28f8ef0dc0ae8f40c0f3a1b4fca66", - "sha256:844b6d608374e7d08f4f6e6f9f7b951f9256db41421917dfb2d003dde4cd6b66", - "sha256:9a8d6802e0825767476f62aafed40532bd435e8a5f7d23bd8b4f5fd04cc80ecf", - "sha256:aae4d918f6b180a8ab8bf6511a419473d107df4dbb4225c7b48c5c9602c38c7f", - "sha256:ac1955ce000cb29ab40def14fd1bbfa7af2017cca696ee696925615cafd0dce5", - "sha256:b88075ada2d51aa9f18283532c9f60e72170041bba88d7f37e49cbb10275299e", - "sha256:cb013933d4c127349b3948aa8aaf2f12c0353ad0eccd715ca789c8a0f671646f", - "sha256:cc70b4b581f28d0a254d006f26949245e3657d40d8857066c2ae22a61222ef55", - "sha256:e9c5266c432a1e23738d178e51c2c7a5e2ddf790f248be939448c0ba2021f9d1", - "sha256:ea9e57f8ea880eeea38ab5abf9fbe39f923544d7884228ec67d666abd60f5a47", - "sha256:ee0c405832ade84d4de74b9029bedb7b31200600fa524d218fc29bfa371e97f5", - "sha256:fdcb265de28585de5b859ae13e3846a8e805268a823a12a4da2597f1f5afc9f0" + "sha256:0c580952eef9bf68c4747774cde7ec1d85a6e61de97281f2dba83c7d2c806362", + "sha256:0f996e7268af62598f2fc1204afa98a3b5712313a55c4c9d434aef49cadc91d4", + "sha256:1ec0bcf7e17c0c5669d881b1cd38c4972fade441b27bda1051665faaa89bdcaa", + "sha256:281c945d0e28c92ca5e5930664c1cefd85efe80e5c0d2bc58dd63383fda29f83", + "sha256:2ce6fae5bdad59577b44e4dfed356944fbf1d925269114c28be377692643b4ff", + "sha256:315b9001266a492a6ff443b61238f956b214dbec9910a081ba5b6646a055a805", + "sha256:443c4a81bb10daed9a8f334365fe52542771f25aedaf889fd323a853ce7377d6", + "sha256:4a02ded6cd4f0a5562a8887df8b3bd14e822a90f97ac5e544c162899bc467664", + "sha256:53a583b6637ab4c4e3591a15bc9db855b8d9dee9a669b550f311480acab6eb08", + "sha256:63efa177ff54aec6e1c0aefaa1a241232dcd37413835a9b674b6e3f0ae2bfd3e", + "sha256:74f57f24754fe349223792466a709f8e0c093205ff0dca557af51072ff47ab18", + "sha256:7e1ce50266f4f70bf41a2c6dc4358afadae90e2a1e5342d3c08883df1675374f", + "sha256:81ef806b1fef6b06dcebad789f988d3b37ccaee225695cf3e07648eee0fc6b73", + "sha256:846da004a5804145a5f441b8530b4bf35afbf7da70f82409f151695b127213d5", + "sha256:8ac43ae87929a5982f5948ceda07001ee5e83227fd69cf55b109144938d96984", + "sha256:9762ea51a8fc2a88b70cf2995e5675b38d93bf36bd67d91721c309df184f49bd", + "sha256:a2a431ee15799d6db9fe80c82b055bae5a752bef645bba795e8e52687c69efe3", + "sha256:bf7a1932ac4176486eab36a19ed4c0492da5d97123f1406cf15e41b05e787d2e", + "sha256:c2e6fc39c4ab499049df3bdf567f768a723a5e8464816e8f009f121a5a9f4405", + "sha256:cbeb489927bd7af4aa98d4b261af9a5bc025bd87f0e3547e11584be9e9427be2", + "sha256:d03b5621a135bffecad2c73e9f4deb1a0f977b9a8ffe6f8e002bf6c9d07b918c", + "sha256:d56e96520b1020449bbace2b78b603442e7e378a9b3bd68de65c782db1507995", + "sha256:df6b6c6d742395dd77a23ea3728ab62f98379eff8fb61be2744d4679ab678f73", + "sha256:e1be4655c7ef6e1bbe6b5d0403526601323420bcf414598955968c9ef3eb7d16", + "sha256:f18c716be16bc1fea8e95def49edf46b82fccaa88587a45f8dc0ff6ab5d8e0a7", + "sha256:f46304d6f0c6ab8e52770addfa2fc41e6629495548862279641972b6215451cd", + "sha256:f7b178f11ed3664fd0e995a47ed2b5ff0a12d893e41dd0494f406d1cf555cab7" ], "markers": "python_version >= '3.7'", - "version": "==43.0.0" + "version": "==43.0.3" }, "distlib": { "hashes": [ @@ -1118,6 +1118,48 @@ "markers": "python_version >= '3.7'", "version": "==0.11.1" }, + "types-cffi": { + "hashes": [ + "sha256:a363e5ea54a4eb6a4a105d800685fde596bc318089b025b27dee09849fe41ff0", + "sha256:b8b20d23a2b89cfed5f8c5bc53b0cb8677c3aac6d970dbc771e28b9c698f5dee" + ], + "markers": "python_version >= '3.8'", + "version": "==1.16.0.20240331" + }, + "types-pyopenssl": { + "hashes": [ + "sha256:47913b4678a01d879f503a12044468221ed8576263c1540dcb0484ca21b08c39", + "sha256:6a7a5d2ec042537934cfb4c9d4deb0e16c4c6250b09358df1f083682fe6fda54" + ], + "markers": "python_version >= '3.8'", + "version": "==24.1.0.20240722" + }, + "types-pytz": { + "hashes": [ + "sha256:3e22df1336c0c6ad1d29163c8fda82736909eb977281cb823c57f8bae07118b7", + "sha256:575dc38f385a922a212bac00a7d6d2e16e141132a3c955078f4a4fd13ed6cb44" + ], + "index": "pypi", + "markers": "python_version >= '3.8'", + "version": "==2024.2.0.20241003" + }, + "types-redis": { + "hashes": [ + "sha256:5f17d2b3f9091ab75384153bfa276619ffa1cf6a38da60e10d5e6749cc5b902e", + "sha256:ef5da68cb827e5f606c8f9c0b49eeee4c2669d6d97122f301d3a55dc6a63f6ed" + ], + "index": "pypi", + "markers": "python_version >= '3.8'", + "version": "==4.6.0.20241004" + }, + "types-setuptools": { + "hashes": [ + "sha256:78cb5fef4a6056d2f37114d27da90f4655a306e4e38042d7034a8a880bc3f5dd", + "sha256:f9e1ebd17a56f606e16395c4ee4efa1cdc394b9a2a0ee898a624058b4b62ef8f" + ], + "markers": "python_version >= '3.8'", + "version": "==75.3.0.20241112" + }, "typing-extensions": { "hashes": [ "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d", @@ -1265,89 +1307,78 @@ }, "coverage": { "hashes": [ - "sha256:06a737c882bd26d0d6ee7269b20b12f14a8704807a01056c80bb881a4b2ce6ca", - "sha256:07e2ca0ad381b91350c0ed49d52699b625aab2b44b65e1b4e02fa9df0e92ad2d", - "sha256:0c0420b573964c760df9e9e86d1a9a622d0d27f417e1a949a8a66dd7bcee7bc6", - "sha256:0dbde0f4aa9a16fa4d754356a8f2e36296ff4d83994b2c9d8398aa32f222f989", - "sha256:1125ca0e5fd475cbbba3bb67ae20bd2c23a98fac4e32412883f9bcbaa81c314c", - "sha256:13b0a73a0896988f053e4fbb7de6d93388e6dd292b0d87ee51d106f2c11b465b", - "sha256:166811d20dfea725e2e4baa71fffd6c968a958577848d2131f39b60043400223", - "sha256:170d444ab405852903b7d04ea9ae9b98f98ab6d7e63e1115e82620807519797f", - "sha256:1f4aa8219db826ce6be7099d559f8ec311549bfc4046f7f9fe9b5cea5c581c56", - "sha256:225667980479a17db1048cb2bf8bfb39b8e5be8f164b8f6628b64f78a72cf9d3", - "sha256:260933720fdcd75340e7dbe9060655aff3af1f0c5d20f46b57f262ab6c86a5e8", - "sha256:2bdb062ea438f22d99cba0d7829c2ef0af1d768d1e4a4f528087224c90b132cb", - "sha256:2c09f4ce52cb99dd7505cd0fc8e0e37c77b87f46bc9c1eb03fe3bc9991085388", - "sha256:3115a95daa9bdba70aea750db7b96b37259a81a709223c8448fa97727d546fe0", - "sha256:3e0cadcf6733c09154b461f1ca72d5416635e5e4ec4e536192180d34ec160f8a", - "sha256:3f1156e3e8f2872197af3840d8ad307a9dd18e615dc64d9ee41696f287c57ad8", - "sha256:4421712dbfc5562150f7554f13dde997a2e932a6b5f352edcce948a815efee6f", - "sha256:44df346d5215a8c0e360307d46ffaabe0f5d3502c8a1cefd700b34baf31d411a", - "sha256:502753043567491d3ff6d08629270127e0c31d4184c4c8d98f92c26f65019962", - "sha256:547f45fa1a93154bd82050a7f3cddbc1a7a4dd2a9bf5cb7d06f4ae29fe94eaf8", - "sha256:5621a9175cf9d0b0c84c2ef2b12e9f5f5071357c4d2ea6ca1cf01814f45d2391", - "sha256:609b06f178fe8e9f89ef676532760ec0b4deea15e9969bf754b37f7c40326dbc", - "sha256:645786266c8f18a931b65bfcefdbf6952dd0dea98feee39bd188607a9d307ed2", - "sha256:6878ef48d4227aace338d88c48738a4258213cd7b74fd9a3d4d7582bb1d8a155", - "sha256:6a89ecca80709d4076b95f89f308544ec8f7b4727e8a547913a35f16717856cb", - "sha256:6db04803b6c7291985a761004e9060b2bca08da6d04f26a7f2294b8623a0c1a0", - "sha256:6e2cd258d7d927d09493c8df1ce9174ad01b381d4729a9d8d4e38670ca24774c", - "sha256:6e81d7a3e58882450ec4186ca59a3f20a5d4440f25b1cff6f0902ad890e6748a", - "sha256:702855feff378050ae4f741045e19a32d57d19f3e0676d589df0575008ea5004", - "sha256:78b260de9790fd81e69401c2dc8b17da47c8038176a79092a89cb2b7d945d060", - "sha256:7bb65125fcbef8d989fa1dd0e8a060999497629ca5b0efbca209588a73356232", - "sha256:7dea0889685db8550f839fa202744652e87c60015029ce3f60e006f8c4462c93", - "sha256:8284cf8c0dd272a247bc154eb6c95548722dce90d098c17a883ed36e67cdb129", - "sha256:877abb17e6339d96bf08e7a622d05095e72b71f8afd8a9fefc82cf30ed944163", - "sha256:8929543a7192c13d177b770008bc4e8119f2e1f881d563fc6b6305d2d0ebe9de", - "sha256:8ae539519c4c040c5ffd0632784e21b2f03fc1340752af711f33e5be83a9d6c6", - "sha256:8f59d57baca39b32db42b83b2a7ba6f47ad9c394ec2076b084c3f029b7afca23", - "sha256:9054a0754de38d9dbd01a46621636689124d666bad1936d76c0341f7d71bf569", - "sha256:953510dfb7b12ab69d20135a0662397f077c59b1e6379a768e97c59d852ee51d", - "sha256:95cae0efeb032af8458fc27d191f85d1717b1d4e49f7cb226cf526ff28179778", - "sha256:9bc572be474cafb617672c43fe989d6e48d3c83af02ce8de73fff1c6bb3c198d", - "sha256:9c56863d44bd1c4fe2abb8a4d6f5371d197f1ac0ebdee542f07f35895fc07f36", - "sha256:9e0b2df163b8ed01d515807af24f63de04bebcecbd6c3bfeff88385789fdf75a", - "sha256:a09ece4a69cf399510c8ab25e0950d9cf2b42f7b3cb0374f95d2e2ff594478a6", - "sha256:a1ac0ae2b8bd743b88ed0502544847c3053d7171a3cff9228af618a068ed9c34", - "sha256:a318d68e92e80af8b00fa99609796fdbcdfef3629c77c6283566c6f02c6d6704", - "sha256:a4acd025ecc06185ba2b801f2de85546e0b8ac787cf9d3b06e7e2a69f925b106", - "sha256:a6d3adcf24b624a7b778533480e32434a39ad8fa30c315208f6d3e5542aeb6e9", - "sha256:a78d169acd38300060b28d600344a803628c3fd585c912cacc9ea8790fe96862", - "sha256:a95324a9de9650a729239daea117df21f4b9868ce32e63f8b650ebe6cef5595b", - "sha256:abd5fd0db5f4dc9289408aaf34908072f805ff7792632250dcb36dc591d24255", - "sha256:b06079abebbc0e89e6163b8e8f0e16270124c154dc6e4a47b413dd538859af16", - "sha256:b43c03669dc4618ec25270b06ecd3ee4fa94c7f9b3c14bae6571ca00ef98b0d3", - "sha256:b48f312cca9621272ae49008c7f613337c53fadca647d6384cc129d2996d1133", - "sha256:b5d7b556859dd85f3a541db6a4e0167b86e7273e1cdc973e5b175166bb634fdb", - "sha256:b9f222de8cded79c49bf184bdbc06630d4c58eec9459b939b4a690c82ed05657", - "sha256:c3c02d12f837d9683e5ab2f3d9844dc57655b92c74e286c262e0fc54213c216d", - "sha256:c44fee9975f04b33331cb8eb272827111efc8930cfd582e0320613263ca849ca", - "sha256:cf4b19715bccd7ee27b6b120e7e9dd56037b9c0681dcc1adc9ba9db3d417fa36", - "sha256:d0c212c49b6c10e6951362f7c6df3329f04c2b1c28499563d4035d964ab8e08c", - "sha256:d3296782ca4eab572a1a4eca686d8bfb00226300dcefdf43faa25b5242ab8a3e", - "sha256:d85f5e9a5f8b73e2350097c3756ef7e785f55bd71205defa0bfdaf96c31616ff", - "sha256:da511e6ad4f7323ee5702e6633085fb76c2f893aaf8ce4c51a0ba4fc07580ea7", - "sha256:e05882b70b87a18d937ca6768ff33cc3f72847cbc4de4491c8e73880766718e5", - "sha256:e61c0abb4c85b095a784ef23fdd4aede7a2628478e7baba7c5e3deba61070a02", - "sha256:e6a08c0be454c3b3beb105c0596ebdc2371fab6bb90c0c0297f4e58fd7e1012c", - "sha256:e9a6e0eb86070e8ccaedfbd9d38fec54864f3125ab95419970575b42af7541df", - "sha256:ed37bd3c3b063412f7620464a9ac1314d33100329f39799255fb8d3027da50d3", - "sha256:f1adfc8ac319e1a348af294106bc6a8458a0f1633cc62a1446aebc30c5fa186a", - "sha256:f5796e664fe802da4f57a168c85359a8fbf3eab5e55cd4e4569fbacecc903959", - "sha256:fc5a77d0c516700ebad189b587de289a20a78324bc54baee03dd486f0855d234", - "sha256:fd21f6ae3f08b41004dfb433fa895d858f3f5979e7762d052b12aef444e29afc" + "sha256:00a1d69c112ff5149cabe60d2e2ee948752c975d95f1e1096742e6077affd376", + "sha256:023bf8ee3ec6d35af9c1c6ccc1d18fa69afa1cb29eaac57cb064dbb262a517f9", + "sha256:0294ca37f1ba500667b1aef631e48d875ced93ad5e06fa665a3295bdd1d95111", + "sha256:06babbb8f4e74b063dbaeb74ad68dfce9186c595a15f11f5d5683f748fa1d172", + "sha256:0809082ee480bb8f7416507538243c8863ac74fd8a5d2485c46f0f7499f2b491", + "sha256:0b3fb02fe73bed561fa12d279a417b432e5b50fe03e8d663d61b3d5990f29546", + "sha256:0b58c672d14f16ed92a48db984612f5ce3836ae7d72cdd161001cc54512571f2", + "sha256:0bcd1069e710600e8e4cf27f65c90c7843fa8edfb4520fb0ccb88894cad08b11", + "sha256:1032e178b76a4e2b5b32e19d0fd0abbce4b58e77a1ca695820d10e491fa32b08", + "sha256:11a223a14e91a4693d2d0755c7a043db43d96a7450b4f356d506c2562c48642c", + "sha256:12394842a3a8affa3ba62b0d4ab7e9e210c5e366fbac3e8b2a68636fb19892c2", + "sha256:182e6cd5c040cec0a1c8d415a87b67ed01193ed9ad458ee427741c7d8513d963", + "sha256:1d5b8007f81b88696d06f7df0cb9af0d3b835fe0c8dbf489bad70b45f0e45613", + "sha256:1f76846299ba5c54d12c91d776d9605ae33f8ae2b9d1d3c3703cf2db1a67f2c0", + "sha256:27fb4a050aaf18772db513091c9c13f6cb94ed40eacdef8dad8411d92d9992db", + "sha256:29155cd511ee058e260db648b6182c419422a0d2e9a4fa44501898cf918866cf", + "sha256:29fc0f17b1d3fea332f8001d4558f8214af7f1d87a345f3a133c901d60347c73", + "sha256:2b6b4c83d8e8ea79f27ab80778c19bc037759aea298da4b56621f4474ffeb117", + "sha256:2fdef0d83a2d08d69b1f2210a93c416d54e14d9eb398f6ab2f0a209433db19e1", + "sha256:3c65d37f3a9ebb703e710befdc489a38683a5b152242664b973a7b7b22348a4e", + "sha256:4f704f0998911abf728a7783799444fcbbe8261c4a6c166f667937ae6a8aa522", + "sha256:51b44306032045b383a7a8a2c13878de375117946d68dcb54308111f39775a25", + "sha256:53d202fd109416ce011578f321460795abfe10bb901b883cafd9b3ef851bacfc", + "sha256:58809e238a8a12a625c70450b48e8767cff9eb67c62e6154a642b21ddf79baea", + "sha256:5915fcdec0e54ee229926868e9b08586376cae1f5faa9bbaf8faf3561b393d52", + "sha256:5beb1ee382ad32afe424097de57134175fea3faf847b9af002cc7895be4e2a5a", + "sha256:5f8ae553cba74085db385d489c7a792ad66f7f9ba2ee85bfa508aeb84cf0ba07", + "sha256:5fbd612f8a091954a0c8dd4c0b571b973487277d26476f8480bfa4b2a65b5d06", + "sha256:6bd818b7ea14bc6e1f06e241e8234508b21edf1b242d49831831a9450e2f35fa", + "sha256:6f01ba56b1c0e9d149f9ac85a2f999724895229eb36bd997b61e62999e9b0901", + "sha256:73d2b73584446e66ee633eaad1a56aad577c077f46c35ca3283cd687b7715b0b", + "sha256:7bb92c539a624cf86296dd0c68cd5cc286c9eef2d0c3b8b192b604ce9de20a17", + "sha256:8165b796df0bd42e10527a3f493c592ba494f16ef3c8b531288e3d0d72c1f6f0", + "sha256:862264b12ebb65ad8d863d51f17758b1684560b66ab02770d4f0baf2ff75da21", + "sha256:8902dd6a30173d4ef09954bfcb24b5d7b5190cf14a43170e386979651e09ba19", + "sha256:8cf717ee42012be8c0cb205dbbf18ffa9003c4cbf4ad078db47b95e10748eec5", + "sha256:8ed9281d1b52628e81393f5eaee24a45cbd64965f41857559c2b7ff19385df51", + "sha256:99b41d18e6b2a48ba949418db48159d7a2e81c5cc290fc934b7d2380515bd0e3", + "sha256:9cb7fa111d21a6b55cbf633039f7bc2749e74932e3aa7cb7333f675a58a58bf3", + "sha256:a181e99301a0ae128493a24cfe5cfb5b488c4e0bf2f8702091473d033494d04f", + "sha256:a413a096c4cbac202433c850ee43fa326d2e871b24554da8327b01632673a076", + "sha256:a6b1e54712ba3474f34b7ef7a41e65bd9037ad47916ccb1cc78769bae324c01a", + "sha256:ade3ca1e5f0ff46b678b66201f7ff477e8fa11fb537f3b55c3f0568fbfe6e718", + "sha256:b0ac3d42cb51c4b12df9c5f0dd2f13a4f24f01943627120ec4d293c9181219ba", + "sha256:b369ead6527d025a0fe7bd3864e46dbee3aa8f652d48df6174f8d0bac9e26e0e", + "sha256:b57b768feb866f44eeed9f46975f3d6406380275c5ddfe22f531a2bf187eda27", + "sha256:b8d3a03d9bfcaf5b0141d07a88456bb6a4c3ce55c080712fec8418ef3610230e", + "sha256:bc66f0bf1d7730a17430a50163bb264ba9ded56739112368ba985ddaa9c3bd09", + "sha256:bf20494da9653f6410213424f5f8ad0ed885e01f7e8e59811f572bdb20b8972e", + "sha256:c48167910a8f644671de9f2083a23630fbf7a1cb70ce939440cd3328e0919f70", + "sha256:c481b47f6b5845064c65a7bc78bc0860e635a9b055af0df46fdf1c58cebf8e8f", + "sha256:c7c8b95bf47db6d19096a5e052ffca0a05f335bc63cef281a6e8fe864d450a72", + "sha256:c9b8e184898ed014884ca84c70562b4a82cbc63b044d366fedc68bc2b2f3394a", + "sha256:cc8ff50b50ce532de2fa7a7daae9dd12f0a699bfcd47f20945364e5c31799fef", + "sha256:d541423cdd416b78626b55f123412fcf979d22a2c39fce251b350de38c15c15b", + "sha256:dab4d16dfef34b185032580e2f2f89253d302facba093d5fa9dbe04f569c4f4b", + "sha256:dacbc52de979f2823a819571f2e3a350a7e36b8cb7484cdb1e289bceaf35305f", + "sha256:df57bdbeffe694e7842092c5e2e0bc80fff7f43379d465f932ef36f027179806", + "sha256:ed8fe9189d2beb6edc14d3ad19800626e1d9f2d975e436f84e19efb7fa19469b", + "sha256:f3ddf056d3ebcf6ce47bdaf56142af51bb7fad09e4af310241e9db7a3a8022e1", + "sha256:f8fe4984b431f8621ca53d9380901f62bfb54ff759a1348cd140490ada7b693c", + "sha256:fe439416eb6380de434886b00c859304338f8b19f6f54811984f3420a2e03858" ], "index": "pypi", - "markers": "python_version >= '3.8'", - "version": "==7.6.1" + "markers": "python_version >= '3.9'", + "version": "==7.6.4" }, "django": { "hashes": [ "sha256:56ab63a105e8bb06ee67381d7b65fe6774f057e41a8bab06c8020c8882d8ecd4", "sha256:b5bb1d11b2518a5f91372a282f24662f58f66749666b0a286ab057029f728080" ], - "index": "pypi", "markers": "python_version >= '3.10'", "version": "==5.0.2" }, @@ -1571,11 +1602,11 @@ }, "packaging": { "hashes": [ - "sha256:026ed72c8ed3fcce5bf8950572258698927fd1dbda10a5e981cdf0ac37f4f002", - "sha256:5b8f2217dbdbd2f7f384c41c628544e6d52f2d0f53c6d0c3ea61aa5d1d7ff124" + "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759", + "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f" ], "markers": "python_version >= '3.8'", - "version": "==24.1" + "version": "==24.2" }, "pathspec": { "hashes": [ @@ -1587,11 +1618,11 @@ }, "platformdirs": { "hashes": [ - "sha256:2d7a1657e36a80ea911db832a8a6ece5ee53d8de21edd5cc5879af6530b1bfee", - "sha256:38b7b51f512eed9e84a22788b4bce1de17c0adb134d6becb09836e37d8654cd3" + "sha256:357fb2acbc885b0419afd3ce3ed34564c13c9b95c89360cd9563f73aa5e2b907", + "sha256:73e575e1408ab8103900836b97580d5307456908a03e92031bab39e4554cc3fb" ], "markers": "python_version >= '3.8'", - "version": "==4.2.2" + "version": "==4.3.6" }, "pluggy": { "hashes": [ @@ -1619,12 +1650,12 @@ }, "pytest": { "hashes": [ - "sha256:4ba08f9ae7dcf84ded419494d229b48d0903ea6407b030eaec46df5e6a73bba5", - "sha256:c132345d12ce551242c87269de812483f5bcc87cdbb4722e48487ba194f9fdce" + "sha256:70b98107bd648308a7952b06e6ca9a50bc660be218d53c257cc1fc94fda10181", + "sha256:a6853c7375b2663155079443d2e45de913a911a11d669df02a50814944db57b2" ], "index": "pypi", "markers": "python_version >= '3.8'", - "version": "==8.3.2" + "version": "==8.3.3" }, "pytoolconfig": { "extras": [ diff --git a/backend/dining/api_wrapper.py b/backend/dining/api_wrapper.py index 3c55cf18..7ba539d2 100644 --- a/backend/dining/api_wrapper.py +++ b/backend/dining/api_wrapper.py @@ -65,6 +65,8 @@ def get_venues(self) -> list[dict[str, Any]]: for key, value in venues.items(): # Cleaning up json response venue = Venue.objects.filter(venue_id=key).first() + if venue is None: + continue value["name"] = venue.name value["image"] = venue.image_url if venue else None diff --git a/backend/dining/migrations/0001_initial.py b/backend/dining/migrations/0001_initial.py index 51b3bc0f..5cc33ee2 100644 --- a/backend/dining/migrations/0001_initial.py +++ b/backend/dining/migrations/0001_initial.py @@ -9,9 +9,7 @@ class Migration(migrations.Migration): initial = True - dependencies = [ - ("user", "0002_profile_laundry_preferences"), - ] + dependencies = [("user", "0002_profile_laundry_preferences")] operations = [ migrations.CreateModel( diff --git a/backend/dining/migrations/0002_diningitem_diningstation_diningmenu.py b/backend/dining/migrations/0002_diningitem_diningstation_diningmenu.py index 0caaa2be..51874ad9 100644 --- a/backend/dining/migrations/0002_diningitem_diningstation_diningmenu.py +++ b/backend/dining/migrations/0002_diningitem_diningstation_diningmenu.py @@ -7,9 +7,7 @@ class Migration(migrations.Migration): - dependencies = [ - ("dining", "0001_initial"), - ] + dependencies = [("dining", "0001_initial")] operations = [ migrations.CreateModel( diff --git a/backend/dining/migrations/0003_venue_name.py b/backend/dining/migrations/0003_venue_name.py index 40dada7b..9eddb770 100644 --- a/backend/dining/migrations/0003_venue_name.py +++ b/backend/dining/migrations/0003_venue_name.py @@ -5,14 +5,10 @@ class Migration(migrations.Migration): - dependencies = [ - ("dining", "0002_diningitem_diningstation_diningmenu"), - ] + dependencies = [("dining", "0002_diningitem_diningstation_diningmenu")] operations = [ migrations.AddField( - model_name="venue", - name="name", - field=models.CharField(max_length=255, null=True), - ), + model_name="venue", name="name", field=models.CharField(max_length=255, null=True) + ) ] diff --git a/backend/dining/migrations/0004_remove_diningtransaction_profile_and_more.py b/backend/dining/migrations/0004_remove_diningtransaction_profile_and_more.py index a0f90009..db86346a 100644 --- a/backend/dining/migrations/0004_remove_diningtransaction_profile_and_more.py +++ b/backend/dining/migrations/0004_remove_diningtransaction_profile_and_more.py @@ -5,29 +5,16 @@ class Migration(migrations.Migration): - dependencies = [ - ("dining", "0003_venue_name"), - ] + dependencies = [("dining", "0003_venue_name")] operations = [ - migrations.RemoveField( - model_name="diningtransaction", - name="profile", - ), + migrations.RemoveField(model_name="diningtransaction", name="profile"), migrations.AlterField( - model_name="diningitem", - name="description", - field=models.CharField(max_length=1000), + model_name="diningitem", name="description", field=models.CharField(max_length=1000) ), migrations.AlterField( - model_name="diningitem", - name="ingredients", - field=models.CharField(max_length=1000), - ), - migrations.DeleteModel( - name="DiningBalance", - ), - migrations.DeleteModel( - name="DiningTransaction", + model_name="diningitem", name="ingredients", field=models.CharField(max_length=1000) ), + migrations.DeleteModel(name="DiningBalance"), + migrations.DeleteModel(name="DiningTransaction"), ] diff --git a/backend/dining/migrations/0005_diningitem_allergens_diningitem_nutrition_info_and_more.py b/backend/dining/migrations/0005_diningitem_allergens_diningitem_nutrition_info_and_more.py index 401ece69..f98c3631 100644 --- a/backend/dining/migrations/0005_diningitem_allergens_diningitem_nutrition_info_and_more.py +++ b/backend/dining/migrations/0005_diningitem_allergens_diningitem_nutrition_info_and_more.py @@ -6,9 +6,7 @@ class Migration(migrations.Migration): - dependencies = [ - ("dining", "0004_remove_diningtransaction_profile_and_more"), - ] + dependencies = [("dining", "0004_remove_diningtransaction_profile_and_more")] operations = [ migrations.AddField( diff --git a/backend/dining/migrations/0006_remove_diningmenu_stations_and_more.py b/backend/dining/migrations/0006_remove_diningmenu_stations_and_more.py index 35e63881..a6eec85d 100644 --- a/backend/dining/migrations/0006_remove_diningmenu_stations_and_more.py +++ b/backend/dining/migrations/0006_remove_diningmenu_stations_and_more.py @@ -6,15 +6,10 @@ class Migration(migrations.Migration): - dependencies = [ - ("dining", "0005_diningitem_allergens_diningitem_nutrition_info_and_more"), - ] + dependencies = [("dining", "0005_diningitem_allergens_diningitem_nutrition_info_and_more")] operations = [ - migrations.RemoveField( - model_name="diningmenu", - name="stations", - ), + migrations.RemoveField(model_name="diningmenu", name="stations"), migrations.AlterField( model_name="diningitem", name="allergens", diff --git a/backend/dining/models.py b/backend/dining/models.py index dbb5064a..ae5b7b96 100644 --- a/backend/dining/models.py +++ b/backend/dining/models.py @@ -3,37 +3,43 @@ class Venue(models.Model): - venue_id = models.IntegerField(primary_key=True) - name = models.CharField(max_length=255, null=True) - image_url = models.URLField() + venue_id: models.IntegerField = models.IntegerField(primary_key=True) + name: models.CharField = models.CharField(max_length=255, null=True) + image_url: models.URLField = models.URLField() - def __str__(self): + def __str__(self) -> str: return f"{self.name}-{str(self.venue_id)}" class DiningItem(models.Model): - item_id = models.IntegerField(primary_key=True) - name = models.CharField(max_length=255) - description = models.CharField(max_length=1000, blank=True) - ingredients = models.CharField(max_length=1000, blank=True) # comma separated list - allergens = models.CharField(max_length=1000, blank=True) # comma separated list - nutrition_info = models.CharField(max_length=1000, blank=True) # json string. + item_id: models.IntegerField = models.IntegerField(primary_key=True) + name: models.CharField = models.CharField(max_length=255) + description: models.CharField = models.CharField(max_length=1000, blank=True) + ingredients: models.CharField = models.CharField( + max_length=1000, blank=True + ) # comma separated list + allergens: models.CharField = models.CharField( + max_length=1000, blank=True + ) # comma separated list + nutrition_info: models.CharField = models.CharField(max_length=1000, blank=True) # json string. # Technically, postgres supports json fields but that involves local postgres # instead of sqlite AND we don't need to query on this field - def __str__(self): + def __str__(self) -> str: return f"{self.name}" class DiningStation(models.Model): - name = models.CharField(max_length=255) - items = models.ManyToManyField(DiningItem) - menu = models.ForeignKey("DiningMenu", on_delete=models.CASCADE, related_name="stations") + name: models.CharField = models.CharField(max_length=255) + items: models.ManyToManyField = models.ManyToManyField(DiningItem) + menu: models.ForeignKey = models.ForeignKey( + "DiningMenu", on_delete=models.CASCADE, related_name="stations" + ) class DiningMenu(models.Model): - venue = models.ForeignKey(Venue, on_delete=models.CASCADE) - date = models.DateField(default=timezone.now) - start_time = models.DateTimeField() - end_time = models.DateTimeField() - service = models.CharField(max_length=255) + venue: models.ForeignKey = models.ForeignKey(Venue, on_delete=models.CASCADE) + date: models.DateField = models.DateField(default=timezone.now) + start_time: models.DateTimeField = models.DateTimeField() + end_time: models.DateTimeField = models.DateTimeField() + service: models.CharField = models.CharField(max_length=255) diff --git a/backend/dining/views.py b/backend/dining/views.py index 8b1d1ff5..35c5240c 100644 --- a/backend/dining/views.py +++ b/backend/dining/views.py @@ -1,7 +1,7 @@ import datetime from django.core.cache import cache -from django.db.models import Count, QuerySet +from django.db.models import Count, Manager, QuerySet from django.shortcuts import get_object_or_404 from django.utils import timezone from django.utils.timezone import make_aware @@ -15,6 +15,7 @@ from dining.models import DiningMenu, Venue from dining.serializers import DiningMenuSerializer from utils.cache import Cache +from utils.types import get_user d = DiningAPIWrapper() @@ -40,7 +41,7 @@ class Menus(generics.ListAPIView): serializer_class = DiningMenuSerializer - def get_queryset(self) -> QuerySet[DiningMenu]: + def get_queryset(self) -> QuerySet[DiningMenu, Manager[DiningMenu]]: # TODO: We only have data for the next week, so we should 404 # if date_param is out of bounds if date_param := self.kwargs.get("date"): @@ -63,18 +64,19 @@ class Preferences(APIView): key = "dining_preferences:{user_id}" def get(self, request: Request) -> Response: - key = self.key.format(user_id=request.user.id) + key = self.key.format(user_id=get_user(request).id) cached_preferences = cache.get(key) if cached_preferences is None: - preferences = request.user.profile.dining_preferences + preferences = get_user(request).profile.dining_preferences # aggregates venues and puts it in form {"venue_id": x, "count": x} cached_preferences = preferences.values("venue_id").annotate(count=Count("venue_id")) cache.set(key, cached_preferences, Cache.MONTH) return Response({"preferences": cached_preferences}) def post(self, request: Request) -> Response: - key = self.key.format(user_id=request.user.id) - profile = request.user.profile + user = get_user(request) + key = self.key.format(user_id=user.id) + profile = user.profile preferences = profile.dining_preferences venue_ids = set(request.data["venues"]) diff --git a/backend/gsr_booking/admin.py b/backend/gsr_booking/admin.py index 5c1586d0..494f2886 100644 --- a/backend/gsr_booking/admin.py +++ b/backend/gsr_booking/admin.py @@ -1,8 +1,11 @@ +from typing import Any, cast + from django.contrib import admin -from django.db.models import QuerySet -from rest_framework.request import Request +from django.db.models import Manager, QuerySet +from django.http import HttpRequest from gsr_booking.models import GSR, Group, GroupMembership, GSRBooking, Reservation +from utils.types import UserType class GroupMembershipInline(admin.TabularInline): @@ -12,12 +15,13 @@ class GroupMembershipInline(admin.TabularInline): readonly_fields = ["name"] def name(self, obj: GroupMembership) -> str: - return obj.user.get_full_name() + user = cast(UserType, obj.user) + return str(user.get_full_name()) - def get_fields(self, request, obj=None) -> list[str]: + def get_fields(self, request: HttpRequest, obj: Any = None) -> list[str]: fields = super().get_fields(request, obj) to_remove = ["user", "name"] - return ["name"] + [f for f in fields if f not in to_remove] + return ["name"] + [str(f) for f in fields if f not in to_remove] class GroupAdmin(admin.ModelAdmin): @@ -33,7 +37,7 @@ class GroupMembershipAdmin(admin.ModelAdmin): class GSRAdmin(admin.ModelAdmin): - def get_queryset(self, request: Request) -> QuerySet[GSR]: + def get_queryset(self, request: HttpRequest) -> QuerySet[GSR, Manager[GSR]]: return GSR.all_objects.all() list_display = ["name", "kind", "lid", "gid", "in_use"] diff --git a/backend/gsr_booking/api_wrapper.py b/backend/gsr_booking/api_wrapper.py index f4f2b1e1..2f639fdc 100644 --- a/backend/gsr_booking/api_wrapper.py +++ b/backend/gsr_booking/api_wrapper.py @@ -2,7 +2,7 @@ from abc import ABC, abstractmethod from enum import Enum from random import randint -from typing import TYPE_CHECKING, Any +from typing import Any, Optional, TypedDict, cast import requests from bs4 import BeautifulSoup @@ -17,15 +17,9 @@ from gsr_booking.models import GSR, Group, GroupMembership, GSRBooking, Reservation from gsr_booking.serializers import GSRBookingSerializer, GSRSerializer from utils.errors import APIError +from utils.types import UserType -if TYPE_CHECKING: - from django.contrib.auth.models import AbstractUser - - UserType = AbstractUser -else: - UserType = Any - User = get_user_model() BASE_URL = "https://libcal.library.upenn.edu" @@ -46,23 +40,40 @@ class CreditType(Enum): ARB = "ARB" +class AvailabilityTime(TypedDict): + start_time: str + end_time: str + + +class RoomInfo(TypedDict): + room_name: str + id: int + availability: list[AvailabilityTime] + + +class AvailabilityResponse(TypedDict): + name: str + gid: int + rooms: list[RoomInfo] + + class AbstractBookingWrapper(ABC): @abstractmethod - def book_room(self, rid: int, start: str, end: str, user: "UserType") -> dict[str, Any]: + def book_room(self, rid: int, start: str, end: str, user: UserType) -> dict[str, Any]: raise NotImplementedError # pragma: no cover @abstractmethod - def cancel_room(self, booking_id: str, user: "UserType") -> dict[str, Any]: + def cancel_room(self, booking_id: str, user: UserType) -> dict[str, Any]: raise NotImplementedError # pragma: no cover @abstractmethod def get_availability( - self, lid: int, start: str | None, end: str | None, user: "UserType" - ) -> list[dict[str, Any]]: + self, lid: str | int, start: str | None, end: str | None, user: UserType + ) -> list[RoomInfo]: raise NotImplementedError # pragma: no cover @abstractmethod - def get_reservations(self, user: "UserType") -> list[dict[str, Any]]: + def get_reservations(self, user: UserType) -> list[dict[str, Any]]: raise NotImplementedError # pragma: no cover @@ -82,21 +93,16 @@ def request(self, *args: Any, **kwargs: Any) -> requests.Response: return response - def book_room(self, rid: int, start: str, end: str, user: "UserType") -> dict[str, Any]: + def book_room(self, rid: int, start: str, end: str, user: UserType) -> dict[str, Any]: """Books room if pennkey is valid""" - payload = { - "start": start, - "end": end, - "pennkey": user.username, - "room": rid, - } + payload = {"start": start, "end": end, "pennkey": user.username, "room": rid} url = f"{WHARTON_URL}{user.username}/student_reserve" response = self.request("POST", url, json=payload).json() if "error" in response: raise APIError("Wharton: " + response["error"]) return response - def cancel_room(self, booking_id: str, user: "UserType") -> dict[str, Any]: + def cancel_room(self, booking_id: str, user: UserType) -> dict[str, Any]: """Cancels reservation given booking id""" url = f"{WHARTON_URL}{user.username}/reservations/{booking_id}/cancel" response = self.request("DELETE", url).json() @@ -105,8 +111,8 @@ def cancel_room(self, booking_id: str, user: "UserType") -> dict[str, Any]: return response def get_availability( - self, lid: int, start: str | None, end: str | None, user: "UserType" - ) -> list[dict[str, Any]]: + self, lid: str | int, start: str | None, end: str | None, user: UserType + ) -> list[RoomInfo]: """Returns a list of rooms and their availabilities""" current_time = timezone.localtime() search_date = ( @@ -144,7 +150,7 @@ def get_availability( room["availability"] = valid_slots return rooms - def get_reservations(self, user: "UserType") -> list[dict[str, Any]]: + def get_reservations(self, user: UserType) -> list[dict[str, Any]]: url = f"{WHARTON_URL}{user.username}/reservations" bookings = self.request("GET", url).json()["bookings"] @@ -162,7 +168,7 @@ def get_reservations(self, user: "UserType") -> list[dict[str, Any]]: if datetime.datetime.strptime(booking["end"], "%Y-%m-%dT%H:%M:%S%z") >= now ] - def is_wharton(self, user: "UserType") -> bool | None: + def is_wharton(self, user: UserType) -> bool | None: url = f"{WHARTON_URL}{user.username}/privileges" try: response = self.request("GET", url) @@ -175,7 +181,7 @@ def is_wharton(self, user: "UserType") -> bool | None: class LibCalBookingWrapper(AbstractBookingWrapper): - def __init__(self): + def __init__(self) -> None: self.token = None self.expiration = timezone.localtime() @@ -213,7 +219,7 @@ def request(self, *args: Any, **kwargs: Any) -> requests.Response: except (ConnectTimeout, ReadTimeout, ConnectionError): raise APIError("LibCal: Connection timeout") - def book_room(self, rid: int, start: str, end: str, user: "UserType") -> dict[str, Any]: + def book_room(self, rid: int, start: str, end: str, user: UserType) -> dict[str, Any]: """ Books room if pennkey is valid @@ -258,10 +264,10 @@ def book_room(self, rid: int, start: str, end: str, user: "UserType") -> dict[st raise APIError("LibCal: " + res_json["error"]) return res_json - def get_reservations(self, user: "UserType") -> list[dict[str, Any]]: - pass + def get_reservations(self, user: UserType) -> list[dict[str, Any]]: + return [] - def cancel_room(self, booking_id: str, user: "UserType") -> dict[str, Any]: + def cancel_room(self, booking_id: str, user: UserType) -> dict[str, Any]: """Cancels room""" response = self.request("POST", f"{API_URL}/1.1/space/cancel/{booking_id}").json() if "error" in response[0]: @@ -269,8 +275,8 @@ def cancel_room(self, booking_id: str, user: "UserType") -> dict[str, Any]: return response def get_availability( - self, gid: int, start: str | None, end: str | None, user: "UserType" - ) -> list[dict[str, Any]]: + self, gid: str | int, start: str | None, end: str | None, user: UserType + ) -> list[RoomInfo]: """Returns a list of rooms and their availabilities""" # adjusts url based on start and end times @@ -295,7 +301,7 @@ def get_availability( if response.status_code != 200: raise APIError(f"GSR Reserve: Error {response.status_code} when reserving data") - rooms = [ + rooms: list[dict[str, Any]] = [ {"room_name": room["name"], "id": room["id"], "availability": room["availability"]} for room in response.json() if room["id"] not in ROOM_BLACKLIST @@ -313,7 +319,7 @@ def get_availability( or datetime.datetime.strptime(time["from"][:-6], "%Y-%m-%dT%H:%M:%S") >= start_datetime ] - return rooms + return cast(list[RoomInfo], rooms) def get_affiliation(self, email: str) -> str: """Gets school from email""" @@ -328,13 +334,15 @@ def get_affiliation(self, email: str) -> str: class BookingHandler: - def __init__(self, WBW=None, LBW=None): + def __init__( + self, + WBW: Optional[AbstractBookingWrapper] = None, + LBW: Optional[AbstractBookingWrapper] = None, + ) -> None: self.WBW = WBW or WhartonBookingWrapper() self.LBW = LBW or LibCalBookingWrapper() - def format_members( - self, members: QuerySet[GroupMembership] - ) -> list[tuple["UserType", datetime.timedelta]]: + def format_members(self, members: QuerySet) -> list[tuple[UserType, datetime.timedelta]]: PREFIX = "user__" return [ ( @@ -348,7 +356,7 @@ def format_members( def get_wharton_members( self, group: Group, gsr_id: int - ) -> list[tuple["UserType", datetime.timedelta]]: + ) -> list[tuple[UserType, datetime.timedelta]]: now = timezone.localtime() ninty_min = datetime.timedelta(minutes=90) zero_min = datetime.timedelta(minutes=0) @@ -359,15 +367,18 @@ def get_wharton_members( .values("user") .annotate( credits=ninty_min - - Coalesce( - Sum( - F("user__gsrbooking__end") - F("user__gsrbooking__start"), - filter=Q(user__gsrbooking__gsr__gid=gsr_id) - & Q(user__gsrbooking__is_cancelled=False) - & Q(user__gsrbooking__end__gte=now), + - cast( + datetime.timedelta, + Coalesce( + Sum( + F("user__gsrbooking__end") - F("user__gsrbooking__start"), + filter=Q(user__gsrbooking__gsr__gid=gsr_id) + & Q(user__gsrbooking__is_cancelled=False) + & Q(user__gsrbooking__end__gte=now), + ), + zero_min, ), - zero_min, - ) + ), ) .filter(Q(credits__gt=zero_min)) .values("user__id", "user__username", "credits") @@ -375,7 +386,7 @@ def get_wharton_members( ) return self.format_members(ret) - def get_libcal_members(self, group: Group) -> list[tuple["UserType", datetime.timedelta]]: + def get_libcal_members(self, group: Group) -> list[tuple[UserType, datetime.timedelta]]: day_start = timezone.localtime().replace(hour=0, minute=0, second=0, microsecond=0) day_end = day_start + datetime.timedelta(days=1) two_hours = datetime.timedelta(hours=2) @@ -387,15 +398,18 @@ def get_libcal_members(self, group: Group) -> list[tuple["UserType", datetime.ti .values("user") .annotate( credits=two_hours - - Coalesce( - Sum( - F("user__gsrbooking__end") - F("user__gsrbooking__start"), - filter=Q(user__gsrbooking__gsr__kind=GSR.KIND_LIBCAL) - & Q(user__gsrbooking__is_cancelled=False) - & Q(user__gsrbooking__start__gte=day_start) - & Q(user__gsrbooking__end__lte=day_end), + - cast( + datetime.timedelta, + Coalesce( + Sum( + F("user__gsrbooking__end") - F("user__gsrbooking__start"), + filter=Q(user__gsrbooking__gsr__kind=GSR.KIND_LIBCAL) + & Q(user__gsrbooking__is_cancelled=False) + & Q(user__gsrbooking__start__gte=day_start) + & Q(user__gsrbooking__end__lte=day_end), + ), + zero_min, ), - zero_min, ) ) .filter(Q(credits__gt=zero_min)) @@ -418,14 +432,14 @@ def book_room( room_name: str, start: str, end: str, - user: "UserType", - group: Group | None = None, + user: UserType, + group: Optional[Group] = None, ) -> Reservation: # NOTE when booking with a group, we are only querying our db for existing bookings, # so users in a group who book through wharton may screw up the query gsr = get_object_or_404(GSR, gid=gid) - start = datetime.datetime.strptime(start, "%Y-%m-%dT%H:%M:%S%z") - end = datetime.datetime.strptime(end, "%Y-%m-%dT%H:%M:%S%z") + start_dt = datetime.datetime.strptime(start, "%Y-%m-%dT%H:%M:%S%z") + end_dt = datetime.datetime.strptime(end, "%Y-%m-%dT%H:%M:%S%z") book_func = self.WBW.book_room if gsr.kind == GSR.KIND_WHARTON else self.LBW.book_room members = ( @@ -442,15 +456,17 @@ def book_room( [time_available for _, time_available in members], datetime.timedelta(minutes=0) ) - if (end - start) >= total_time_available: + if (end_dt - start_dt) >= total_time_available: raise APIError("Error: Not enough credits") - reservation = Reservation.objects.create(start=start, end=end, creator=user, group=group) + reservation = Reservation.objects.create( + start=start_dt, end=end_dt, creator=user, group=group + ) - curr_start = start + curr_start = start_dt try: for curr_user, time_available in members: - curr_end = curr_start + min(time_available, end - curr_start) + curr_end = curr_start + min(time_available, end_dt - curr_start) booking_id = book_func( rid, @@ -470,17 +486,17 @@ def book_room( booking.reservation = reservation booking.save() - if (curr_start := curr_end) >= end: + if (curr_start := curr_end) >= end_dt: break except APIError as e: raise APIError( - f"{str(e)}. Was only able to book {start.strftime('%H:%M')}" + f"{str(e)}. Was only able to book {start_dt.strftime('%H:%M')}" f" - {curr_start.strftime('%H:%M')}" ) return reservation - def cancel_room(self, booking_id: str, user: "UserType") -> None: + def cancel_room(self, booking_id: str, user: UserType) -> None | APIError: if ( gsr_booking := GSRBooking.objects.filter(booking_id=booking_id) .prefetch_related(Prefetch("reservation__gsrbooking_set"), Prefetch("gsr")) @@ -504,20 +520,20 @@ def cancel_room(self, booking_id: str, user: "UserType") -> None: for service in [self.WBW, self.LBW]: try: service.cancel_room(booking_id, user) - return + return None except APIError: - pass - raise APIError("Error: Unknown booking id") + raise APIError("Error: Unknown booking id") + return None def get_availability( self, - lid: int, - gid: int, + lid: str | int, + gid: str | int, start: str | None, end: str | None, - user: "UserType", - group: Group | None = None, - ) -> list[dict[str, Any]]: + user: UserType, + group: Optional[Group] = None, + ) -> AvailabilityResponse: gsr = get_object_or_404(GSR, gid=gid) # select a random user from the group if booking wharton gsr @@ -535,7 +551,7 @@ def get_availability( return {"name": gsr.name, "gid": gsr.gid, "rooms": rooms} def get_reservations( - self, user: "UserType", group: Group | None = None + self, user: UserType, group: Optional[Group] = None ) -> list[dict[str, Any]]: q = Q(user=user) | Q(reservation__creator=user) if group else Q(user=user) bookings = GSRBooking.objects.filter( @@ -543,16 +559,16 @@ def get_reservations( ).prefetch_related(Prefetch("reservation")) if group: - ret = [] + ret: list[dict[str, Any]] = [] for booking in bookings: - data = GSRBookingSerializer(booking).data + data = cast(dict[str, Any], GSRBookingSerializer(booking).data) if booking.reservation.creator == user: data["room_name"] = f"[Me] {data['room_name']}" else: data["room_name"] = f"[{group.name}] {data['room_name']}" ret.append(data) else: - ret = GSRBookingSerializer(bookings, many=True).data + ret = cast(list[dict[str, Any]], GSRBookingSerializer(bookings, many=True).data) # deal with bookings made directly through wharton (not us) try: diff --git a/backend/gsr_booking/management/commands/change_group.py b/backend/gsr_booking/management/commands/change_group.py index b2832087..28eeb7f2 100644 --- a/backend/gsr_booking/management/commands/change_group.py +++ b/backend/gsr_booking/management/commands/change_group.py @@ -1,3 +1,4 @@ +from argparse import ArgumentParser from typing import Any from django.contrib.auth import get_user_model @@ -15,7 +16,7 @@ class Command(BaseCommand): Adds/remove users to a group. """ - def add_arguments(self, parser) -> None: + def add_arguments(self, parser: ArgumentParser) -> None: parser.add_argument("usernames", type=str, help="list of pennkeys") parser.add_argument("group", type=str, help="group name") parser.add_argument("mode", type=str, help="mode of operation (add/remove/reset)") diff --git a/backend/gsr_booking/management/commands/get_reservations.py b/backend/gsr_booking/management/commands/get_reservations.py index 609478da..9e644b0e 100644 --- a/backend/gsr_booking/management/commands/get_reservations.py +++ b/backend/gsr_booking/management/commands/get_reservations.py @@ -1,3 +1,4 @@ +from argparse import ArgumentParser from datetime import datetime from typing import Any @@ -28,7 +29,7 @@ class Command(BaseCommand): Note: --start/--end and --current are mutually exclusive """ - def add_arguments(self, parser) -> None: + def add_arguments(self, parser: ArgumentParser) -> None: # optional flags parser.add_argument("--group", type=str, default=None) parser.add_argument("--start", type=str, default=None) diff --git a/backend/gsr_booking/management/commands/individual_usage.py b/backend/gsr_booking/management/commands/individual_usage.py index 6b27fbaf..80dbf32c 100644 --- a/backend/gsr_booking/management/commands/individual_usage.py +++ b/backend/gsr_booking/management/commands/individual_usage.py @@ -1,3 +1,4 @@ +from argparse import ArgumentParser from typing import Any from django.core.management.base import BaseCommand @@ -9,7 +10,7 @@ class Command(BaseCommand): help = "Provides usage stats for a given user." - def add_arguments(self, parser) -> None: + def add_arguments(self, parser: ArgumentParser) -> None: parser.add_argument("pennkey", type=str, help="Pennkey of user to check") def handle(self, *args: Any, **kwargs: Any) -> None: diff --git a/backend/gsr_booking/migrations/0001_initial.py b/backend/gsr_booking/migrations/0001_initial.py index 7ff4fead..d3b8d594 100644 --- a/backend/gsr_booking/migrations/0001_initial.py +++ b/backend/gsr_booking/migrations/0001_initial.py @@ -18,10 +18,7 @@ class Migration(migrations.Migration): ( "id", models.AutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" ), ), ("name", models.CharField(max_length=255)), @@ -36,22 +33,15 @@ class Migration(migrations.Migration): ( "id", models.AutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" ), ), ("accepted", models.BooleanField(default=False)), - ( - "type", - models.CharField(choices=[("A", "Admin"), ("M", "M")], max_length=10), - ), + ("type", models.CharField(choices=[("A", "Admin"), ("M", "M")], max_length=10)), ( "group", models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - to="gsr_booking.Group", + on_delete=django.db.models.deletion.CASCADE, to="gsr_booking.Group" ), ), ( diff --git a/backend/gsr_booking/migrations/0001_squashed_0011_merge_20200418_2009.py b/backend/gsr_booking/migrations/0001_squashed_0011_merge_20200418_2009.py index a6f36cba..c3fa6e9c 100644 --- a/backend/gsr_booking/migrations/0001_squashed_0011_merge_20200418_2009.py +++ b/backend/gsr_booking/migrations/0001_squashed_0011_merge_20200418_2009.py @@ -34,10 +34,7 @@ class Migration(migrations.Migration): ( "id", models.AutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" ), ), ("name", models.CharField(max_length=255)), @@ -52,22 +49,15 @@ class Migration(migrations.Migration): ( "id", models.AutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" ), ), ("accepted", models.BooleanField(default=False)), - ( - "type", - models.CharField(choices=[("A", "Admin"), ("M", "M")], max_length=10), - ), + ("type", models.CharField(choices=[("A", "Admin"), ("M", "M")], max_length=10)), ( "group", models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - to="gsr_booking.Group", + on_delete=django.db.models.deletion.CASCADE, to="gsr_booking.Group" ), ), ( @@ -82,10 +72,7 @@ class Migration(migrations.Migration): ), ("notifications", models.BooleanField(default=True)), ("pennkey_allow", models.BooleanField(default=False)), - ( - "username", - models.CharField(blank=True, default=None, max_length=127, null=True), - ), + ("username", models.CharField(blank=True, default=None, max_length=127, null=True)), ], options={"verbose_name": "Group Membership"}, ), @@ -114,10 +101,7 @@ class Migration(migrations.Migration): ( "id", models.AutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" ), ), ("full_name", models.CharField(db_index=True, max_length=255)), @@ -125,8 +109,7 @@ class Migration(migrations.Migration): ( "user", models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - to=settings.AUTH_USER_MODEL, + on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL ), ), ], @@ -137,10 +120,7 @@ class Migration(migrations.Migration): ( "id", models.AutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" ), ), ( @@ -156,17 +136,13 @@ class Migration(migrations.Migration): ( "email", models.CharField( - max_length=255, - null=True, - unique=True, - verbose_name="school email", + max_length=255, null=True, unique=True, verbose_name="school email" ), ), ( "user", models.OneToOneField( - on_delete=django.db.models.deletion.CASCADE, - to=settings.AUTH_USER_MODEL, + on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL ), ), ( diff --git a/backend/gsr_booking/migrations/0002_auto_20210129_1527.py b/backend/gsr_booking/migrations/0002_auto_20210129_1527.py index 0411d13d..68848799 100644 --- a/backend/gsr_booking/migrations/0002_auto_20210129_1527.py +++ b/backend/gsr_booking/migrations/0002_auto_20210129_1527.py @@ -13,14 +13,8 @@ class Migration(migrations.Migration): ] operations = [ - migrations.RemoveField( - model_name="gsrbookingcredentials", - name="email", - ), - migrations.RemoveField( - model_name="gsrbookingcredentials", - name="id", - ), + migrations.RemoveField(model_name="gsrbookingcredentials", name="email"), + migrations.RemoveField(model_name="gsrbookingcredentials", name="id"), migrations.AlterField( model_name="gsrbookingcredentials", name="date_updated", diff --git a/backend/gsr_booking/migrations/0004_alter_gsr_lid.py b/backend/gsr_booking/migrations/0004_alter_gsr_lid.py index b9955207..84bf6c65 100644 --- a/backend/gsr_booking/migrations/0004_alter_gsr_lid.py +++ b/backend/gsr_booking/migrations/0004_alter_gsr_lid.py @@ -5,14 +5,8 @@ class Migration(migrations.Migration): - dependencies = [ - ("gsr_booking", "0003_gsr_gsrbooking"), - ] + dependencies = [("gsr_booking", "0003_gsr_gsrbooking")] operations = [ - migrations.AlterField( - model_name="gsr", - name="lid", - field=models.CharField(max_length=255), - ), + migrations.AlterField(model_name="gsr", name="lid", field=models.CharField(max_length=255)) ] diff --git a/backend/gsr_booking/migrations/0005_reservation.py b/backend/gsr_booking/migrations/0005_reservation.py index 14928fbc..b6b71649 100644 --- a/backend/gsr_booking/migrations/0005_reservation.py +++ b/backend/gsr_booking/migrations/0005_reservation.py @@ -44,5 +44,5 @@ class Migration(migrations.Migration): ), ), ], - ), + ) ] diff --git a/backend/gsr_booking/migrations/0005_usersearchindex.py b/backend/gsr_booking/migrations/0005_usersearchindex.py index 61345e3b..a64bc4c6 100644 --- a/backend/gsr_booking/migrations/0005_usersearchindex.py +++ b/backend/gsr_booking/migrations/0005_usersearchindex.py @@ -19,10 +19,7 @@ class Migration(migrations.Migration): ( "id", models.AutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" ), ), ("full_name", models.CharField(db_index=True, max_length=255)), @@ -30,8 +27,7 @@ class Migration(migrations.Migration): ( "user", models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - to=settings.AUTH_USER_MODEL, + on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL ), ), ], diff --git a/backend/gsr_booking/migrations/0006_auto_20200207_2126.py b/backend/gsr_booking/migrations/0006_auto_20200207_2126.py index 378edc5b..69704727 100644 --- a/backend/gsr_booking/migrations/0006_auto_20200207_2126.py +++ b/backend/gsr_booking/migrations/0006_auto_20200207_2126.py @@ -6,9 +6,7 @@ class Migration(migrations.Migration): - dependencies = [ - ("gsr_booking", "0005_usersearchindex"), - ] + dependencies = [("gsr_booking", "0005_usersearchindex")] operations = [ migrations.AlterField( diff --git a/backend/gsr_booking/migrations/0006_auto_20211024_1231.py b/backend/gsr_booking/migrations/0006_auto_20211024_1231.py index 05297a76..6c636569 100644 --- a/backend/gsr_booking/migrations/0006_auto_20211024_1231.py +++ b/backend/gsr_booking/migrations/0006_auto_20211024_1231.py @@ -8,21 +8,13 @@ def create_single_user_group(apps, schema_editor): Group = apps.get_model("gsr_booking", "Group") GroupMembership = apps.get_model("gsr_booking", "GroupMembership") for user in User.objects.all(): - group, created = Group.objects.get_or_create( - owner=user, - name="Me", - color="#14f7d1", - ) + group, created = Group.objects.get_or_create(owner=user, name="Me", color="#14f7d1") if created: GroupMembership.objects.get_or_create(group=group, user=user, type="A", accepted=True) class Migration(migrations.Migration): - dependencies = [ - ("gsr_booking", "0005_reservation"), - ] + dependencies = [("gsr_booking", "0005_reservation")] - operations = [ - migrations.RunPython(create_single_user_group), - ] + operations = [migrations.RunPython(create_single_user_group)] diff --git a/backend/gsr_booking/migrations/0006_gsrbookingcredentials.py b/backend/gsr_booking/migrations/0006_gsrbookingcredentials.py index da854a7d..9214c372 100644 --- a/backend/gsr_booking/migrations/0006_gsrbookingcredentials.py +++ b/backend/gsr_booking/migrations/0006_gsrbookingcredentials.py @@ -19,10 +19,7 @@ class Migration(migrations.Migration): ( "id", models.AutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" ), ), ( @@ -42,19 +39,15 @@ class Migration(migrations.Migration): ( "email", models.CharField( - max_length=255, - null=True, - unique=True, - verbose_name="school email", + max_length=255, null=True, unique=True, verbose_name="school email" ), ), ( "user", models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - to=settings.AUTH_USER_MODEL, + on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL ), ), ], - ), + ) ] diff --git a/backend/gsr_booking/migrations/0007_delete_gsrbookingcredentials.py b/backend/gsr_booking/migrations/0007_delete_gsrbookingcredentials.py index 4a089e57..af5a2a01 100644 --- a/backend/gsr_booking/migrations/0007_delete_gsrbookingcredentials.py +++ b/backend/gsr_booking/migrations/0007_delete_gsrbookingcredentials.py @@ -5,12 +5,6 @@ class Migration(migrations.Migration): - dependencies = [ - ("gsr_booking", "0006_auto_20211024_1231"), - ] + dependencies = [("gsr_booking", "0006_auto_20211024_1231")] - operations = [ - migrations.DeleteModel( - name="GSRBookingCredentials", - ), - ] + operations = [migrations.DeleteModel(name="GSRBookingCredentials")] diff --git a/backend/gsr_booking/migrations/0008_auto_20200202_1218.py b/backend/gsr_booking/migrations/0008_auto_20200202_1218.py index 74069e66..239eed6d 100644 --- a/backend/gsr_booking/migrations/0008_auto_20200202_1218.py +++ b/backend/gsr_booking/migrations/0008_auto_20200202_1218.py @@ -19,5 +19,5 @@ class Migration(migrations.Migration): field=models.OneToOneField( on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL ), - ), + ) ] diff --git a/backend/gsr_booking/migrations/0008_auto_20211112_1657.py b/backend/gsr_booking/migrations/0008_auto_20211112_1657.py index bedd8c7d..5a9ea923 100644 --- a/backend/gsr_booking/migrations/0008_auto_20211112_1657.py +++ b/backend/gsr_booking/migrations/0008_auto_20211112_1657.py @@ -30,15 +30,10 @@ def create_reservation_for_booking(apps, schema_editor): class Migration(migrations.Migration): - dependencies = [ - ("gsr_booking", "0007_delete_gsrbookingcredentials"), - ] + dependencies = [("gsr_booking", "0007_delete_gsrbookingcredentials")] operations = [ - migrations.RemoveField( - model_name="reservation", - name="gsr", - ), + migrations.RemoveField(model_name="reservation", name="gsr"), migrations.AddField( model_name="gsrbooking", name="reservation", @@ -47,12 +42,8 @@ class Migration(migrations.Migration): ), ), migrations.AddField( - model_name="reservation", - name="is_cancelled", - field=models.BooleanField(default=False), - ), - migrations.DeleteModel( - name="UserSearchIndex", + model_name="reservation", name="is_cancelled", field=models.BooleanField(default=False) ), + migrations.DeleteModel(name="UserSearchIndex"), migrations.RunPython(create_reservation_for_booking), ] diff --git a/backend/gsr_booking/migrations/0009_auto_20200202_1232.py b/backend/gsr_booking/migrations/0009_auto_20200202_1232.py index d45a3235..d6c9519e 100644 --- a/backend/gsr_booking/migrations/0009_auto_20200202_1232.py +++ b/backend/gsr_booking/migrations/0009_auto_20200202_1232.py @@ -5,14 +5,12 @@ class Migration(migrations.Migration): - dependencies = [ - ("gsr_booking", "0008_auto_20200202_1218"), - ] + dependencies = [("gsr_booking", "0008_auto_20200202_1218")] operations = [ migrations.AlterField( model_name="gsrbookingcredentials", name="expiration_date", field=models.DateTimeField(null=True, verbose_name="session ID expiration date"), - ), + ) ] diff --git a/backend/gsr_booking/migrations/0009_remove_groupmembership_username_and_more.py b/backend/gsr_booking/migrations/0009_remove_groupmembership_username_and_more.py index 9a9e70c0..f7f6d857 100644 --- a/backend/gsr_booking/migrations/0009_remove_groupmembership_username_and_more.py +++ b/backend/gsr_booking/migrations/0009_remove_groupmembership_username_and_more.py @@ -5,15 +5,10 @@ class Migration(migrations.Migration): - dependencies = [ - ("gsr_booking", "0008_auto_20211112_1657"), - ] + dependencies = [("gsr_booking", "0008_auto_20211112_1657")] operations = [ - migrations.RemoveField( - model_name="groupmembership", - name="username", - ), + migrations.RemoveField(model_name="groupmembership", name="username"), migrations.AddField( model_name="groupmembership", name="is_wharton", diff --git a/backend/gsr_booking/migrations/0010_remove_gsrbooking_reminder_sent_and_more.py b/backend/gsr_booking/migrations/0010_remove_gsrbooking_reminder_sent_and_more.py index 2ef9af6e..96ee1c7f 100644 --- a/backend/gsr_booking/migrations/0010_remove_gsrbooking_reminder_sent_and_more.py +++ b/backend/gsr_booking/migrations/0010_remove_gsrbooking_reminder_sent_and_more.py @@ -5,18 +5,11 @@ class Migration(migrations.Migration): - dependencies = [ - ("gsr_booking", "0009_remove_groupmembership_username_and_more"), - ] + dependencies = [("gsr_booking", "0009_remove_groupmembership_username_and_more")] operations = [ - migrations.RemoveField( - model_name="gsrbooking", - name="reminder_sent", - ), + migrations.RemoveField(model_name="gsrbooking", name="reminder_sent"), migrations.AddField( - model_name="reservation", - name="reminder_sent", - field=models.BooleanField(default=False), + model_name="reservation", name="reminder_sent", field=models.BooleanField(default=False) ), ] diff --git a/backend/gsr_booking/migrations/0011_alter_reservation_group.py b/backend/gsr_booking/migrations/0011_alter_reservation_group.py index df639bad..9b7422a7 100644 --- a/backend/gsr_booking/migrations/0011_alter_reservation_group.py +++ b/backend/gsr_booking/migrations/0011_alter_reservation_group.py @@ -6,9 +6,7 @@ class Migration(migrations.Migration): - dependencies = [ - ("gsr_booking", "0010_remove_gsrbooking_reminder_sent_and_more"), - ] + dependencies = [("gsr_booking", "0010_remove_gsrbooking_reminder_sent_and_more")] operations = [ migrations.AlterField( @@ -20,5 +18,5 @@ class Migration(migrations.Migration): on_delete=django.db.models.deletion.CASCADE, to="gsr_booking.group", ), - ), + ) ] diff --git a/backend/gsr_booking/migrations/0012_gsr_in_use.py b/backend/gsr_booking/migrations/0012_gsr_in_use.py index d1e3879f..522505b9 100644 --- a/backend/gsr_booking/migrations/0012_gsr_in_use.py +++ b/backend/gsr_booking/migrations/0012_gsr_in_use.py @@ -5,14 +5,10 @@ class Migration(migrations.Migration): - dependencies = [ - ("gsr_booking", "0011_alter_reservation_group"), - ] + dependencies = [("gsr_booking", "0011_alter_reservation_group")] operations = [ migrations.AddField( - model_name="gsr", - name="in_use", - field=models.BooleanField(default=True), - ), + model_name="gsr", name="in_use", field=models.BooleanField(default=True) + ) ] diff --git a/backend/gsr_booking/models.py b/backend/gsr_booking/models.py index 003832c3..e2ef5b8f 100644 --- a/backend/gsr_booking/models.py +++ b/backend/gsr_booking/models.py @@ -1,17 +1,12 @@ -from typing import TYPE_CHECKING, Any +from typing import Any from django.contrib.auth import get_user_model from django.db import models from django.db.models import QuerySet from django.utils import timezone +from utils.types import UserType -if TYPE_CHECKING: - from django.contrib.auth.models import AbstractUser - - UserType = AbstractUser -else: - UserType = Any User = get_user_model() @@ -19,54 +14,65 @@ class GroupMembership(models.Model): # INVARIANT: either user or username should always be set. if user is not None, then the # username should the be username of the associated user. - user = models.ForeignKey( + user: models.ForeignKey = models.ForeignKey( User, on_delete=models.CASCADE, related_name="memberships", blank=True, null=True ) - group = models.ForeignKey("Group", on_delete=models.CASCADE, related_name="memberships") + group: models.ForeignKey = models.ForeignKey( + "Group", on_delete=models.CASCADE, related_name="memberships" + ) + group_id: int # When accepted is False, this is a request, otherwise this is an active membership. - accepted = models.BooleanField(default=False) + accepted: models.BooleanField = models.BooleanField(default=False) ADMIN = "A" MEMBER = "M" - type = models.CharField(max_length=10, choices=[(ADMIN, "Admin"), (MEMBER, "Member")]) + type: models.CharField = models.CharField( + max_length=10, choices=[(ADMIN, "Admin"), (MEMBER, "Member")] + ) - pennkey_allow = models.BooleanField(default=False) + pennkey_allow: models.BooleanField = models.BooleanField(default=False) - notifications = models.BooleanField(default=True) + notifications: models.BooleanField = models.BooleanField(default=True) - is_wharton = models.BooleanField(blank=True, null=True, default=None) + is_wharton: models.BooleanField = models.BooleanField(blank=True, null=True, default=None) @property - def is_invite(self): + def is_invite(self) -> bool: return not self.accepted - def __str__(self): + def __str__(self) -> str: return f"{self.user}<->{self.group}" - def save(self, *args, **kwargs): + def save(self, *args: Any, **kwargs: Any) -> None: # determines whether user is wharton or not if self.is_wharton is None: self.is_wharton = self.check_wharton() super().save(*args, **kwargs) - def check_wharton(self): - return WhartonGSRBooker.is_wharton(self.user) + def check_wharton(self) -> bool: + if check := WhartonGSRBooker.is_wharton(self.user): + return check + return False class Meta: verbose_name = "Group Membership" class Group(models.Model): - owner = models.ForeignKey(User, on_delete=models.CASCADE) - members = models.ManyToManyField(User, through=GroupMembership, related_name="booking_groups") + id: int + owner: models.ForeignKey = models.ForeignKey(User, on_delete=models.CASCADE) + members: models.ManyToManyField = models.ManyToManyField( + User, through=GroupMembership, related_name="booking_groups" + ) + memberships: QuerySet - name = models.CharField(max_length=255) - color = models.CharField(max_length=255) + name: models.CharField = models.CharField(max_length=255) + color: models.CharField = models.CharField(max_length=255) - created_at = models.DateTimeField(auto_now_add=True) - updated_at = models.DateTimeField(auto_now=True) + created_at: models.DateTimeField = models.DateTimeField(auto_now_add=True) + updated_at: models.DateTimeField = models.DateTimeField(auto_now=True) ADMIN = "A" MEMBER = "M" @@ -74,15 +80,15 @@ class Group(models.Model): def __str__(self) -> str: return f"{self.name}-{self.pk}" - def has_member(self, user: "UserType") -> bool: + def has_member(self, user: UserType) -> bool: memberships = GroupMembership.objects.filter(group=self, user=user) return memberships.all().exists() - def has_admin(self, user: "UserType") -> bool: + def has_admin(self, user: UserType) -> bool: memberships = GroupMembership.objects.filter(group=self, accepted=True) return memberships.all().filter(type="A").filter(user=user).exists() - def get_pennkey_active_members(self): + def get_pennkey_active_members(self) -> list[UserType]: memberships = GroupMembership.objects.filter(group=self, accepted=True) pennkey_active_members_list = memberships.all().filter(pennkey_allow=True).all() return [member for member in pennkey_active_members_list] @@ -95,7 +101,7 @@ def save(self, *args: Any, **kwargs: Any) -> None: class GSRManager(models.Manager): - def get_queryset(self) -> QuerySet[Any]: + def get_queryset(self) -> QuerySet: return super().get_queryset().filter(in_use=True) @@ -105,41 +111,51 @@ class GSR(models.Model): KIND_LIBCAL = "LIBCAL" KIND_OPTIONS = ((KIND_WHARTON, "Wharton"), (KIND_LIBCAL, "Libcal")) - kind = models.CharField(max_length=7, choices=KIND_OPTIONS, default=KIND_LIBCAL) - lid = models.CharField(max_length=255) - gid = models.IntegerField(null=True) - name = models.CharField(max_length=255) - image_url = models.URLField() + kind: models.CharField = models.CharField( + max_length=7, choices=KIND_OPTIONS, default=KIND_LIBCAL + ) + id: int + lid: models.CharField = models.CharField(max_length=255) + gid: models.IntegerField = models.IntegerField(null=True) + name: models.CharField = models.CharField(max_length=255) + image_url: models.URLField = models.URLField() - in_use = models.BooleanField(default=True) + in_use: models.BooleanField = models.BooleanField(default=True) objects = GSRManager() - all_objects = models.Manager() # for admin page + all_objects: models.Manager = models.Manager() # for admin page def __str__(self) -> str: return f"{self.name}: {self.lid}-{self.gid}" class Reservation(models.Model): - start = models.DateTimeField(default=timezone.now) - end = models.DateTimeField(default=timezone.now) - creator = models.ForeignKey(User, on_delete=models.CASCADE) - group = models.ForeignKey(Group, on_delete=models.CASCADE, null=True, blank=True) - is_cancelled = models.BooleanField(default=False) - reminder_sent = models.BooleanField(default=False) + id: int + start: models.DateTimeField = models.DateTimeField(default=timezone.now) + end: models.DateTimeField = models.DateTimeField(default=timezone.now) + creator: models.ForeignKey = models.ForeignKey(User, on_delete=models.CASCADE) + group: models.ForeignKey = models.ForeignKey( + Group, on_delete=models.CASCADE, null=True, blank=True + ) + is_cancelled: models.BooleanField = models.BooleanField(default=False) + reminder_sent: models.BooleanField = models.BooleanField(default=False) + + gsrbooking_set: QuerySet class GSRBooking(models.Model): # TODO: change to non-null after reservations are created for current bookings - reservation = models.ForeignKey(Reservation, on_delete=models.CASCADE, null=True) - user = models.ForeignKey(User, on_delete=models.CASCADE, null=True) - booking_id = models.CharField(max_length=255, null=True, blank=True) - gsr = models.ForeignKey(GSR, on_delete=models.CASCADE) - room_id = models.IntegerField() - room_name = models.CharField(max_length=255) - start = models.DateTimeField(default=timezone.now) - end = models.DateTimeField(default=timezone.now) - is_cancelled = models.BooleanField(default=False) + reservation: models.ForeignKey = models.ForeignKey( + Reservation, on_delete=models.CASCADE, null=True + ) + user: models.ForeignKey = models.ForeignKey(User, on_delete=models.CASCADE, null=True) + booking_id: models.CharField = models.CharField(max_length=255, null=True, blank=True) + gsr: models.ForeignKey = models.ForeignKey(GSR, on_delete=models.CASCADE) + room_id: models.IntegerField = models.IntegerField() + room_name: models.CharField = models.CharField(max_length=255) + start: models.DateTimeField = models.DateTimeField(default=timezone.now) + end: models.DateTimeField = models.DateTimeField(default=timezone.now) + is_cancelled: models.BooleanField = models.BooleanField(default=False) def __str__(self) -> str: return f"{self.user} - {self.gsr.name} - {self.start} - {self.end}" diff --git a/backend/gsr_booking/serializers.py b/backend/gsr_booking/serializers.py index da1139a7..206ab816 100644 --- a/backend/gsr_booking/serializers.py +++ b/backend/gsr_booking/serializers.py @@ -1,4 +1,4 @@ -from typing import TYPE_CHECKING, Any, TypeAlias +from typing import Any, TypeAlias from django.contrib.auth import get_user_model from rest_framework import serializers @@ -6,13 +6,6 @@ from gsr_booking.models import GSR, Group, GroupMembership, GSRBooking -if TYPE_CHECKING: - from django.contrib.auth.models import AbstractUser - - UserType = AbstractUser -else: - UserType = Any - ValidatedData: TypeAlias = dict[str, Any] User = get_user_model() @@ -30,32 +23,21 @@ def get_is_wharton(self, obj: ValidatedData) -> bool: return obj["lid"] == 1 -class MiniUserSerializer(serializers.ModelSerializer): - class Meta: - model = User - fields = ["username", "first_name", "last_name"] - - class GroupMembershipSerializer(serializers.ModelSerializer): - user = MiniUserSerializer(read_only=True) - group = serializers.SlugRelatedField(slug_field="name", queryset=Group.objects.all()) - color = serializers.SlugRelatedField(slug_field="color", read_only=True, source="group") + group: serializers.SlugRelatedField = serializers.SlugRelatedField( + slug_field="name", queryset=Group.objects.all() + ) + color: serializers.SlugRelatedField = serializers.SlugRelatedField( + slug_field="color", read_only=True, source="group" + ) class Meta: model = GroupMembership - fields = [ - "user", - "group", - "type", - "pennkey_allow", - "notifications", - "id", - "color", - ] + fields = ["group", "type", "pennkey_allow", "notifications", "id", "color"] class GroupSerializer(serializers.ModelSerializer): - owner = serializers.SlugRelatedField( + owner: serializers.SlugRelatedField = serializers.SlugRelatedField( slug_field="username", queryset=User.objects.all(), required=False ) memberships = GroupMembershipSerializer(many=True, read_only=True) @@ -83,29 +65,6 @@ def to_internal_value(self, data: ValidatedData) -> None: return None # TODO: If you want to update based on BookingField, implement this. -class UserSerializer(serializers.ModelSerializer): - booking_groups = serializers.SerializerMethodField() - - def get_booking_groups(self, obj: "UserType") -> list[dict[str, Any]]: - result = [] - for membership in GroupMembership.objects.filter(accepted=True, user=obj): - result.append( - { - "name": membership.group.name, - "id": membership.group.id, - "color": membership.group.color, - "pennkey_allow": membership.pennkey_allow, - "notifications": membership.notifications, - } - ) - - return result - - class Meta: - model = User - fields = ["username", "booking_groups"] - - class GSRSerializer(serializers.ModelSerializer): class Meta: model = GSR diff --git a/backend/gsr_booking/urls.py b/backend/gsr_booking/urls.py index 7f6e2b01..a54a1e12 100644 --- a/backend/gsr_booking/urls.py +++ b/backend/gsr_booking/urls.py @@ -10,16 +10,16 @@ GroupMembershipViewSet, GroupViewSet, Locations, + MyMembershipViewSet, RecentGSRs, ReservationsView, - UserViewSet, ) from utils.cache import Cache router = routers.DefaultRouter() -router.register(r"users", UserViewSet) +router.register(r"mymemberships", MyMembershipViewSet, "mymemberships") router.register(r"membership", GroupMembershipViewSet) router.register(r"groups", GroupViewSet) diff --git a/backend/gsr_booking/views.py b/backend/gsr_booking/views.py index bf82d6c8..f5f2df5b 100644 --- a/backend/gsr_booking/views.py +++ b/backend/gsr_booking/views.py @@ -1,6 +1,5 @@ -from typing import TYPE_CHECKING, Any, Optional, cast +from typing import Optional -from django.contrib.auth import get_user_model from django.db.models import Manager, Prefetch, Q, QuerySet from django.http import HttpResponseForbidden from django.shortcuts import get_object_or_404 @@ -14,74 +13,32 @@ from gsr_booking.api_wrapper import GSRBooker, WhartonGSRBooker from gsr_booking.models import GSR, Group, GroupMembership, GSRBooking -from gsr_booking.serializers import ( - GroupMembershipSerializer, - GroupSerializer, - GSRSerializer, - UserSerializer, -) +from gsr_booking.serializers import GroupMembershipSerializer, GroupSerializer, GSRSerializer from pennmobile.analytics import Metric, record_analytics from utils.errors import APIError +from utils.types import get_user -if TYPE_CHECKING: - from django.contrib.auth.models import AbstractUser - - UserType = AbstractUser -else: - UserType = Any - -User = get_user_model() - - -# TODO: user model doesn't have a `booking_groups` attribute, so placing Any type for now -class UserViewSet(viewsets.ReadOnlyModelViewSet["UserType"]): - """ - Can specify `me` instead of the `username` to retrieve details on the current user. - """ - - queryset = User.objects.all().prefetch_related( - Prefetch("booking_groups", Group.objects.filter(memberships__accepted=True)) - ) +class MyMembershipViewSet(viewsets.ReadOnlyModelViewSet): permission_classes = [IsAuthenticated] - serializer_class = UserSerializer - lookup_field = "username" - filter_backends = [DjangoFilterBackend] - filterset_fields = ["username", "first_name", "last_name"] - - def get_object(self) -> "UserType": - lookup_url_kwarg = self.lookup_url_kwarg or self.lookup_field - param = self.kwargs[lookup_url_kwarg] - if param == "me": - return cast("UserType", self.request.user) - return super().get_object() - - def get_queryset(self) -> QuerySet["UserType", Manager["UserType"]]: - if not self.request.user.is_authenticated: - return User.objects.none() - - user: Any = self.request.user - queryset = User.objects.all() - queryset = queryset.prefetch_related( - Prefetch( - "memberships", - GroupMembership.objects.filter(group__in=user.booking_groups.all(), accepted=True), - ) - ) - return queryset + serializer_class = GroupMembershipSerializer + + def get_queryset(self) -> QuerySet[GroupMembership, Manager[GroupMembership]]: + return GroupMembership.objects.filter(user=self.request.user, accepted=True) - @action(detail=True, methods=["get"]) - def invites(self, request: Request, username: Optional[str] = None) -> Response: + @action(detail=False, methods=["get"]) + def invites(self, request: Request) -> Response: """ Retrieve all invites for a given user. """ - user = get_object_or_404(User, username=username) - request_user: Any = self.request.user + request_user = get_user(self.request) return Response( GroupMembershipSerializer( GroupMembership.objects.filter( - user=user, accepted=False, group__in=request_user.booking_groups.all() + user=get_user(request), + accepted=False, + group__in=request_user.booking_groups.all(), ), many=True, ).data @@ -96,7 +53,7 @@ class GroupMembershipViewSet(viewsets.ModelViewSet[GroupMembership]): serializer_class = GroupMembershipSerializer def get_queryset(self) -> QuerySet[GroupMembership, Manager[GroupMembership]]: - user: Any = self.request.user + user = get_user(self.request) if not user.is_authenticated: return GroupMembership.objects.none() @@ -118,7 +75,7 @@ def invite(self, request: Request) -> Response | HttpResponseForbidden: group = get_object_or_404(Group, pk=group_id) # don't invite when user already in group - if group.has_member(cast(Any, request.user)): + if group.has_member(get_user(request)): return HttpResponseForbidden() return Response({"message": "invite(s) sent."}) @@ -128,7 +85,7 @@ def accept( self, request: Request, pk: Optional[int] = None ) -> Response | HttpResponseForbidden: membership = get_object_or_404(GroupMembership, pk=pk, accepted=False) - user = cast("UserType", request.user) + user = get_user(request) if membership.user is None or membership.user != user: return HttpResponseForbidden() @@ -150,7 +107,7 @@ def decline( self, request: Request, pk: Optional[int] = None ) -> Response | HttpResponseForbidden: membership = get_object_or_404(GroupMembership, pk=pk, accepted=False) - if membership.user is None or membership.user != cast("UserType", request.user): + if membership.user is None or membership.user != get_user(request): return HttpResponseForbidden() if not membership.is_invite: return Response({"message": "cannot decline an invite that has been accepted."}, 400) @@ -170,7 +127,7 @@ class GroupViewSet(viewsets.ModelViewSet[Group]): permission_classes = [IsAuthenticated] def get_queryset(self) -> QuerySet[Group, Manager[Group]]: - user = cast("UserType", self.request.user) + user = get_user(self.request) if not user.is_authenticated: return Group.objects.none() return ( @@ -197,9 +154,8 @@ class RecentGSRs(generics.ListAPIView[GSR]): permission_classes = [IsAuthenticated] def get_queryset(self) -> QuerySet[GSR, Manager[GSR]]: - user = cast("UserType", self.request.user) return GSR.objects.filter( - id__in=GSRBooking.objects.filter(user=user, is_cancelled=False) + id__in=GSRBooking.objects.filter(user=get_user(self.request), is_cancelled=False) .distinct() .order_by("-end")[:2] .values_list("gsr", flat=True) @@ -211,7 +167,7 @@ class CheckWharton(APIView): permission_classes = [IsAuthenticated] def get(self, request: Request) -> Response: - user: Any = request.user + user = get_user(request) return Response( { "is_wharton": user.booking_groups.filter(name="Penn Labs").exists() @@ -231,13 +187,13 @@ class Availability(APIView): permission_classes = [IsAuthenticated] - def get(self, request: Request, lid: int, gid: int) -> Response: + def get(self, request: Request, lid: int, gid: str) -> Response: start = request.GET.get("start", None) end = request.GET.get("end", None) try: - user: Any = request.user + user = get_user(request) return Response( GSRBooker.get_availability( lid, @@ -263,7 +219,7 @@ def post(self, request: Request) -> Response: gid = request.data["gid"] room_id = request.data["id"] room_name = request.data["room_name"] - user: Any = request.user + user = get_user(request) try: GSRBooker.book_room( @@ -292,7 +248,7 @@ class CancelRoom(APIView): def post(self, request: Request) -> Response: booking_id = request.data["booking_id"] - user: Any = request.user + user = get_user(request) try: GSRBooker.cancel_room(booking_id, user) return Response({"detail": "success"}) @@ -308,7 +264,7 @@ class ReservationsView(APIView): permission_classes = [IsAuthenticated] def get(self, request: Request) -> Response: - user: Any = request.user + user = get_user(request) return Response( GSRBooker.get_reservations(user, user.booking_groups.filter(name="Penn Labs").first()) ) diff --git a/backend/laundry/api_wrapper.py b/backend/laundry/api_wrapper.py index 938e9a18..16321d9f 100644 --- a/backend/laundry/api_wrapper.py +++ b/backend/laundry/api_wrapper.py @@ -48,7 +48,7 @@ def parse_a_hall(hall_link: str) -> dict[str, Any]: washers = {"open": 0, "running": 0, "out_of_order": 0, "offline": 0, "time_remaining": []} dryers = {"open": 0, "running": 0, "out_of_order": 0, "offline": 0, "time_remaining": []} - detailed = [] + detailed: list[dict[str, Any]] = [] try: page = requests.get( diff --git a/backend/laundry/migrations/0002_auto_20210321_1105.py b/backend/laundry/migrations/0002_auto_20210321_1105.py index eed312d7..9497a9d9 100644 --- a/backend/laundry/migrations/0002_auto_20210321_1105.py +++ b/backend/laundry/migrations/0002_auto_20210321_1105.py @@ -6,14 +6,12 @@ class Migration(migrations.Migration): - dependencies = [ - ("laundry", "0001_initial"), - ] + dependencies = [("laundry", "0001_initial")] operations = [ migrations.AlterField( model_name="laundrysnapshot", name="date", field=models.DateTimeField(default=django.utils.timezone.now), - ), + ) ] diff --git a/backend/laundry/models.py b/backend/laundry/models.py index 4af9bd2a..148936e3 100644 --- a/backend/laundry/models.py +++ b/backend/laundry/models.py @@ -5,25 +5,25 @@ class LaundryRoom(models.Model): - hall_id = models.IntegerField(default=0) - name = models.CharField(max_length=255) - location = models.CharField(max_length=255) - total_washers = models.IntegerField(default=0) - total_dryers = models.IntegerField(default=0) + hall_id: models.IntegerField = models.IntegerField(default=0) + name: models.CharField = models.CharField(max_length=255) + location: models.CharField = models.CharField(max_length=255) + total_washers: models.IntegerField = models.IntegerField(default=0) + total_dryers: models.IntegerField = models.IntegerField(default=0) # Each Laundry Room has a UUID that we need to # access Penn API laundry data - uuid = models.UUIDField(default=uuid.uuid4) + uuid: models.UUIDField = models.UUIDField(default=uuid.uuid4) - def __str__(self): + def __str__(self) -> str: return f"Hall No. {self.hall_id} | {self.name}" class LaundrySnapshot(models.Model): - room = models.ForeignKey(LaundryRoom, on_delete=models.CASCADE, null=True) - date = models.DateTimeField(default=timezone.now) - available_washers = models.IntegerField() - available_dryers = models.IntegerField() + room: models.ForeignKey = models.ForeignKey(LaundryRoom, on_delete=models.CASCADE, null=True) + date: models.DateTimeField = models.DateTimeField(default=timezone.now) + available_washers: models.IntegerField = models.IntegerField() + available_dryers: models.IntegerField = models.IntegerField() - def __str__(self): - return f"Hall No. {self.room.hall_id} | {self.date.date()}" + def __str__(self) -> str: + return f"Hall No. {self.room.hall_id} | {self.date.date()}" # ignore: type[attr-defined] diff --git a/backend/laundry/views.py b/backend/laundry/views.py index 85d8698c..eccf04a1 100644 --- a/backend/laundry/views.py +++ b/backend/laundry/views.py @@ -1,8 +1,9 @@ import calendar import datetime +from typing import Any, Optional, cast from django.core.cache import cache -from django.db.models import Q, QuerySet +from django.db.models import Manager, Q, QuerySet from django.shortcuts import get_object_or_404 from django.utils import timezone from requests.exceptions import HTTPError @@ -16,6 +17,7 @@ from laundry.serializers import LaundryRoomSerializer from pennmobile.analytics import Metric, record_analytics from utils.cache import Cache +from utils.types import get_user class Ids(APIView): @@ -46,7 +48,7 @@ class MultipleHallInfo(APIView): def get(self, request: Request, hall_ids: str) -> Response: halls = [int(x) for x in hall_ids.split(",")] - output = {"rooms": []} + output: dict[str, Any] = {"rooms": []} for hall_id in halls: hall_data = hall_status(get_object_or_404(LaundryRoom, hall_id=hall_id)) @@ -54,7 +56,7 @@ def get(self, request: Request, hall_ids: str) -> Response: hall_data["usage_data"] = HallUsage.compute_usage(hall_id) output["rooms"].append(hall_data) - record_analytics(Metric.LAUNDRY_VIEWED, request.user.username) + record_analytics(Metric.LAUNDRY_VIEWED, get_user(request).username) return Response(output) @@ -64,10 +66,16 @@ class HallUsage(APIView): GET: returns usage data for dryers and washers of a particular hall """ - def safe_division(a: int | None, b: int | None) -> float | None: - return round(a / float(b), 3) if b > 0 else 0 + @staticmethod + def safe_division(a: Optional[int] = None, b: Optional[int] = None) -> float | None: + if a is None or b is None or b <= 0: + return 0.0 + return round(a / float(b), 3) - def get_snapshot_info(hall_id: int) -> tuple[LaundryRoom, QuerySet[LaundrySnapshot]]: + @staticmethod + def get_snapshot_info( + hall_id: int, + ) -> tuple[LaundryRoom, QuerySet[LaundrySnapshot, Manager[LaundrySnapshot]]]: # filters for LaundrySnapshots within timeframe room = get_object_or_404(LaundryRoom, hall_id=hall_id) @@ -84,7 +92,8 @@ def get_snapshot_info(hall_id: int) -> tuple[LaundryRoom, QuerySet[LaundrySnapsh snapshots = LaundrySnapshot.objects.filter(filter).order_by("-date") return (room, snapshots) - def compute_usage(hall_id: int) -> Response: + @staticmethod + def compute_usage(hall_id: int) -> dict[str, Any] | Response: try: (room, snapshots) = HallUsage.get_snapshot_info(hall_id) except ValueError: @@ -97,7 +106,8 @@ def compute_usage(hall_id: int) -> Response: min_date = timezone.localtime() max_date = timezone.localtime() - datetime.timedelta(days=30) - for snapshot in snapshots: + for snapshot_obj in snapshots.iterator(): + snapshot = cast(LaundrySnapshot, snapshot_obj) date = snapshot.date.astimezone() min_date = min(min_date, date) max_date = max(max_date, date) @@ -152,18 +162,20 @@ class Preferences(APIView): key = "laundry_preferences:{user_id}" def get(self, request: Request) -> Response: - key = self.key.format(user_id=request.user.id) + user = get_user(request) + key = self.key.format(user_id=user.id) cached_preferences = cache.get(key) if cached_preferences is None: - preferences = request.user.profile.laundry_preferences.all() + preferences = user.profile.laundry_preferences.all() cached_preferences = preferences.values_list("hall_id", flat=True) cache.set(key, cached_preferences, Cache.MONTH) return Response({"rooms": cached_preferences}) def post(self, request: Request) -> Response: - key = self.key.format(user_id=request.user.id) - profile = request.user.profile + user = get_user(request) + key = self.key.format(user_id=user.id) + profile = user.profile preferences = profile.laundry_preferences if "rooms" not in request.data: return Response({"success": False, "error": "No rooms provided"}, status=400) diff --git a/backend/manage.py b/backend/manage.py index 4d8cecf0..8b4e3280 100755 --- a/backend/manage.py +++ b/backend/manage.py @@ -4,7 +4,7 @@ import sys -def main(): +def main() -> None: os.environ.setdefault("DJANGO_SETTINGS_MODULE", "pennmobile.settings.development") try: from django.core.management import execute_from_command_line diff --git a/backend/penndata/admin.py b/backend/penndata/admin.py index 2ba02fc3..4219fcf1 100644 --- a/backend/penndata/admin.py +++ b/backend/penndata/admin.py @@ -1,6 +1,6 @@ from django.contrib import admin -from django.utils.html import escape, mark_safe -from django.utils.safestring import SafeText +from django.utils.html import escape +from django.utils.safestring import SafeText, mark_safe from penndata.models import ( AnalyticsEvent, @@ -16,7 +16,7 @@ class FitnessRoomAdmin(admin.ModelAdmin): def image_tag(self, instance: FitnessRoom) -> SafeText: return mark_safe('' % escape(instance.image_url)) - image_tag.short_description = "Fitness Room Image" + image_tag.short_description = "Fitness Room Image" # type: ignore[attr-defined] readonly_fields = ("image_tag",) diff --git a/backend/penndata/management/commands/get_college_house_events.py b/backend/penndata/management/commands/get_college_house_events.py index 7773f91b..b0c2aaba 100644 --- a/backend/penndata/management/commands/get_college_house_events.py +++ b/backend/penndata/management/commands/get_college_house_events.py @@ -51,7 +51,7 @@ def scrape_details(self, event_url: str) -> tuple[ resp = requests.get(event_url) except ConnectionError: print("Error:", ConnectionError) - return None + return None, None, None, None, None soup = BeautifulSoup(resp.text, "html.parser") location = ( diff --git a/backend/penndata/management/commands/get_fitness_snapshot.py b/backend/penndata/management/commands/get_fitness_snapshot.py index 09036cc0..a684c8cd 100644 --- a/backend/penndata/management/commands/get_fitness_snapshot.py +++ b/backend/penndata/management/commands/get_fitness_snapshot.py @@ -1,5 +1,5 @@ import datetime -from typing import Any +from typing import Any, Optional import requests from bs4 import BeautifulSoup @@ -14,7 +14,7 @@ def cap_string(s: str) -> str: return " ".join([word[0].upper() + word[1:] for word in s.split()]) -def get_usages() -> tuple[dict[str, dict[str, int | float]], datetime.datetime]: +def get_usages() -> tuple[Optional[dict[str, dict[str, int | float]]], datetime.datetime]: # count/capacities default to 0 since spreadsheet number appears blank if no one there locations = [ @@ -28,7 +28,9 @@ def get_usages() -> tuple[dict[str, dict[str, int | float]], datetime.datetime]: "Pool-Shallow", "Pool-Deep", ] - usages = {location: {"count": 0, "capacity": 0} for location in locations} + usages: dict[str, dict[str, int | float]] = { + location: {"count": 0, "capacity": 0} for location in locations + } date = timezone.localtime() # default if can't get date from spreadsheet @@ -42,12 +44,12 @@ def get_usages() -> tuple[dict[str, dict[str, int | float]], datetime.datetime]: ) ) except ConnectionError: - return None + return None, date html = resp.content.decode("utf8") soup = BeautifulSoup(html, "html5lib") if not (embedded_spreadsheet := soup.find("tbody")): - return None + return None, date table_rows = embedded_spreadsheet.findChildren("tr") for i, row in enumerate(table_rows): @@ -77,6 +79,10 @@ def handle(self, *args: Any, **kwargs: Any) -> None: self.stdout.write("FitnessSnapshots already exist for this date!") return + if not usage_by_location: + self.stdout.write("Failed to get usages from spreadsheet!") + return + FitnessSnapshot.objects.bulk_create( [ FitnessSnapshot( diff --git a/backend/penndata/management/commands/get_penn_today_events.py b/backend/penndata/management/commands/get_penn_today_events.py index 8076bd1d..3f88259f 100644 --- a/backend/penndata/management/commands/get_penn_today_events.py +++ b/backend/penndata/management/commands/get_penn_today_events.py @@ -2,7 +2,7 @@ from typing import Any, Optional from urllib.parse import urljoin -from bs4 import BeautifulSoup +from bs4 import BeautifulSoup, Tag from django.core.management.base import BaseCommand from django.utils import timezone from selenium import webdriver @@ -55,60 +55,24 @@ def handle(self, *args: Any, **kwargs: Any) -> None: "p", class_="tease__meta--sm", string=lambda x: "Through" in str(x) ) - if start_date_str == "02/29" or start_date_str == "2/29": - # If it's February 29th - start_date = datetime.datetime.strptime("02/28", "%m/%d").replace(year=current_year) - if start_date.month < current_month: - # If scraped month is before current month, increment year - start_date = start_date.replace(year=current_year + 1) - start_date = start_date + datetime.timedelta(days=1) - else: - start_date = datetime.datetime.strptime(start_date_str, "%m/%d").replace( - year=current_year - ) - if start_date.month < current_month: - # If scraped month is before current month, increment year - start_date = start_date.replace(year=current_year + 1) - print(start_date_str) - if ALL_DAY in start_time_str.lower(): - start_time = datetime.time(0, 0) - else: - start_time = datetime.datetime.strptime(start_time_str, "%I:%M%p").time() - start_date = datetime.datetime.combine(start_date, start_time) + start_date = self._parse_start_date(start_date_str, current_month, current_year) + start_time = self._parse_start_time(start_time_str) + start_datetime = datetime.datetime.combine(start_date, start_time) - if start_date > now + datetime.timedelta(days=31): + if start_datetime > now + datetime.timedelta(days=31): continue event_url = urljoin(PENN_TODAY_WEBSITE, article.find("a", class_="tease__link")["href"]) - - end_time = self.get_end_time(event_url) - if end_time is not None: - if end_date_elem: # end date and end time - end_date_str = end_date_elem.text.strip().split(" ")[-1] - end_date = datetime.datetime.strptime(end_date_str, "%m/%d/%Y") - end_time = datetime.datetime.strptime(end_time, "%I:%M %p").time() - end_date = datetime.datetime.combine(end_date, end_time) - else: # no end date but end time - end_time = datetime.datetime.strptime(end_time, "%I:%M %p").time() - end_date = datetime.datetime.combine(start_date, end_time) - else: - end_of_day = datetime.time(23, 59, 59) - if end_date_elem: # end date but no end time - end_date_str = end_date_elem.text.strip().split(" ")[-1] - end_date = datetime.datetime.combine( - datetime.datetime.strptime(end_date_str, "%m/%d/%Y"), end_of_day - ) - - else: # no end date or end time - end_date = datetime.datetime.combine(start_date, end_of_day) + end_time_str = self.get_end_time(event_url) + end_datetime = self._calculate_end_datetime(end_time_str, end_date_elem, start_datetime) Event.objects.update_or_create( name=name, defaults={ "event_type": Event.TYPE_PENN_TODAY, "image_url": None, - "start": timezone.make_aware(start_date), - "end": timezone.make_aware(end_date), + "start": timezone.make_aware(start_datetime), + "end": timezone.make_aware(end_datetime), "location": location, "website": event_url, "description": description, @@ -118,7 +82,7 @@ def handle(self, *args: Any, **kwargs: Any) -> None: self.stdout.write("Uploaded Penn Today Events!") - def connect_and_parse_html(self, event_url: str, condition: EC) -> Optional[str]: + def connect_and_parse_html(self, event_url: str, condition: EC) -> Optional[BeautifulSoup]: try: options = Options() options.add_argument("--headless") @@ -143,9 +107,14 @@ def get_end_time(self, event_url: str) -> Optional[str]: event_url, EC.presence_of_element_located((By.CLASS_NAME, "event__topper-content")) ) - end_time_range_str = ( - end_time_soup.find("p", class_="event__meta event__time").text.strip().replace(".", "") - ) + if not end_time_soup: + return None + + time_element = end_time_soup.find("p", class_="event__meta event__time") + if not time_element: + return None + + end_time_range_str = time_element.text.strip().replace(".", "") if ( not end_time_range_str @@ -155,3 +124,45 @@ def get_end_time(self, event_url: str) -> Optional[str]: return None # No end time if the event is all day return times[1] + + def _parse_start_date( + self, date_str: str, current_month: int, current_year: int + ) -> datetime.date: + if date_str in ("02/29", "2/29"): + start_date = datetime.datetime.strptime("02/28", "%m/%d").replace(year=current_year) + if start_date.month < current_month: + start_date = start_date.replace(year=current_year + 1) + return (start_date + datetime.timedelta(days=1)).date() + + start_date = datetime.datetime.strptime(date_str, "%m/%d").replace(year=current_year) + if start_date.month < current_month: + start_date = start_date.replace(year=current_year + 1) + return start_date.date() + + def _parse_start_time(self, time_str: str) -> datetime.time: + if ALL_DAY in time_str.lower(): + return datetime.time(0, 0) + return datetime.datetime.strptime(time_str, "%I:%M%p").time() + + def _calculate_end_datetime( + self, + end_time_str: Optional[str], + end_date_elem: Optional[Tag], + start_datetime: datetime.datetime, + ) -> datetime.datetime: + end_of_day = datetime.time(23, 59, 59) + + if end_time_str: + end_time = datetime.datetime.strptime(end_time_str, "%I:%M %p").time() + if end_date_elem: + end_date_str = end_date_elem.text.strip().split(" ")[-1] + end_date = datetime.datetime.strptime(end_date_str, "%m/%d/%Y").date() + return datetime.datetime.combine(end_date, end_time) + return datetime.datetime.combine(start_datetime.date(), end_time) + + if end_date_elem: + end_date_str = end_date_elem.text.strip().split(" ")[-1] + end_date = datetime.datetime.strptime(end_date_str, "%m/%d/%Y").date() + return datetime.datetime.combine(end_date, end_of_day) + + return datetime.datetime.combine(start_datetime.date(), end_of_day) diff --git a/backend/penndata/management/commands/get_venture_events.py b/backend/penndata/management/commands/get_venture_events.py index 78cdf00d..ef9e852a 100644 --- a/backend/penndata/management/commands/get_venture_events.py +++ b/backend/penndata/management/commands/get_venture_events.py @@ -77,7 +77,7 @@ def handle(self, *args: Any, **kwargs: Any) -> None: ) # events are ordered from future to past, so break once we find a past event - if event_start_datetime < now.replace(tzinfo=None): + if event_start_datetime and event_start_datetime < now.replace(tzinfo=None): break if title := event.find("div", class_="PromoSearchResultEvent-title"): diff --git a/backend/penndata/management/commands/get_wharton_events.py b/backend/penndata/management/commands/get_wharton_events.py index 360cbb5e..81a46f0d 100644 --- a/backend/penndata/management/commands/get_wharton_events.py +++ b/backend/penndata/management/commands/get_wharton_events.py @@ -1,6 +1,6 @@ import datetime import re -from typing import Any +from typing import Any, Optional import pytz import requests @@ -36,8 +36,12 @@ def handle(self, *args: Any, **kwargs: Any) -> None: match = re.match(r"(\w+\s+\d+) \| (\d{1,2}:\d{2} [AP]M) - (\d{1,2}:\d{2} [AP]M)", info) if match: _, start_time, end_time = match.groups() - start_time_obj = datetime.datetime.strptime(start_time, "%I:%M %p") - end_time_obj = datetime.datetime.strptime(end_time, "%I:%M %p") + start_time_obj: Optional[datetime.datetime] = datetime.datetime.strptime( + start_time, "%I:%M %p" + ) + end_time_obj: Optional[datetime.datetime] = datetime.datetime.strptime( + end_time, "%I:%M %p" + ) else: # event has start and end times on different dates match = re.match( diff --git a/backend/penndata/migrations/0001_initial.py b/backend/penndata/migrations/0001_initial.py index 97305072..c2551529 100644 --- a/backend/penndata/migrations/0001_initial.py +++ b/backend/penndata/migrations/0001_initial.py @@ -29,5 +29,5 @@ class Migration(migrations.Migration): ("website", models.URLField(max_length=255, null=True)), ("facebook", models.URLField(max_length=255, null=True)), ], - ), + ) ] diff --git a/backend/penndata/migrations/0002_homepageorder.py b/backend/penndata/migrations/0002_homepageorder.py index 639435e2..83abb355 100644 --- a/backend/penndata/migrations/0002_homepageorder.py +++ b/backend/penndata/migrations/0002_homepageorder.py @@ -5,9 +5,7 @@ class Migration(migrations.Migration): - dependencies = [ - ("penndata", "0001_initial"), - ] + dependencies = [("penndata", "0001_initial")] operations = [ migrations.CreateModel( @@ -22,5 +20,5 @@ class Migration(migrations.Migration): ("cell", models.CharField(max_length=255)), ("rank", models.IntegerField()), ], - ), + ) ] diff --git a/backend/penndata/migrations/0003_analyticsevent.py b/backend/penndata/migrations/0003_analyticsevent.py index 7ba82e52..ec59efa7 100644 --- a/backend/penndata/migrations/0003_analyticsevent.py +++ b/backend/penndata/migrations/0003_analyticsevent.py @@ -47,5 +47,5 @@ class Migration(migrations.Migration): ), ), ], - ), + ) ] diff --git a/backend/penndata/migrations/0004_analyticsevent_data.py b/backend/penndata/migrations/0004_analyticsevent_data.py index dc3aa7cd..04cc6ba5 100644 --- a/backend/penndata/migrations/0004_analyticsevent_data.py +++ b/backend/penndata/migrations/0004_analyticsevent_data.py @@ -5,14 +5,12 @@ class Migration(migrations.Migration): - dependencies = [ - ("penndata", "0003_analyticsevent"), - ] + dependencies = [("penndata", "0003_analyticsevent")] operations = [ migrations.AddField( model_name="analyticsevent", name="data", field=models.CharField(max_length=255, null=True), - ), + ) ] diff --git a/backend/penndata/migrations/0005_fitnessroom_fitnesssnapshot.py b/backend/penndata/migrations/0005_fitnessroom_fitnesssnapshot.py index d671cc13..a5f8118f 100644 --- a/backend/penndata/migrations/0005_fitnessroom_fitnesssnapshot.py +++ b/backend/penndata/migrations/0005_fitnessroom_fitnesssnapshot.py @@ -7,9 +7,7 @@ class Migration(migrations.Migration): - dependencies = [ - ("penndata", "0004_analyticsevent_data"), - ] + dependencies = [("penndata", "0004_analyticsevent_data")] operations = [ migrations.CreateModel( diff --git a/backend/penndata/migrations/0006_fitnesssnapshot_capacity.py b/backend/penndata/migrations/0006_fitnesssnapshot_capacity.py index fa144d61..ce1e12f0 100644 --- a/backend/penndata/migrations/0006_fitnesssnapshot_capacity.py +++ b/backend/penndata/migrations/0006_fitnesssnapshot_capacity.py @@ -5,14 +5,10 @@ class Migration(migrations.Migration): - dependencies = [ - ("penndata", "0005_fitnessroom_fitnesssnapshot"), - ] + dependencies = [("penndata", "0005_fitnessroom_fitnesssnapshot")] operations = [ migrations.AddField( - model_name="fitnesssnapshot", - name="capacity", - field=models.FloatField(null=True), - ), + model_name="fitnesssnapshot", name="capacity", field=models.FloatField(null=True) + ) ] diff --git a/backend/penndata/migrations/0007_fitnessroom_image_url.py b/backend/penndata/migrations/0007_fitnessroom_image_url.py index 44f89470..3874cff3 100644 --- a/backend/penndata/migrations/0007_fitnessroom_image_url.py +++ b/backend/penndata/migrations/0007_fitnessroom_image_url.py @@ -5,9 +5,7 @@ class Migration(migrations.Migration): - dependencies = [ - ("penndata", "0006_fitnesssnapshot_capacity"), - ] + dependencies = [("penndata", "0006_fitnesssnapshot_capacity")] operations = [ migrations.AddField( @@ -15,5 +13,5 @@ class Migration(migrations.Migration): name="image_url", field=models.URLField(default=""), preserve_default=False, - ), + ) ] diff --git a/backend/penndata/migrations/0008_calendarevent.py b/backend/penndata/migrations/0008_calendarevent.py index e067e9bb..0d13f565 100644 --- a/backend/penndata/migrations/0008_calendarevent.py +++ b/backend/penndata/migrations/0008_calendarevent.py @@ -5,9 +5,7 @@ class Migration(migrations.Migration): - dependencies = [ - ("penndata", "0007_fitnessroom_image_url"), - ] + dependencies = [("penndata", "0007_fitnessroom_image_url")] operations = [ migrations.CreateModel( @@ -23,5 +21,5 @@ class Migration(migrations.Migration): ("date", models.CharField(blank=True, max_length=50, null=True)), ("date_obj", models.DateTimeField(blank=True, null=True)), ], - ), + ) ] diff --git a/backend/penndata/migrations/0009_auto_20240223_1820.py b/backend/penndata/migrations/0009_auto_20240223_1820.py index c615f12f..8327da1f 100644 --- a/backend/penndata/migrations/0009_auto_20240223_1820.py +++ b/backend/penndata/migrations/0009_auto_20240223_1820.py @@ -5,48 +5,25 @@ class Migration(migrations.Migration): - dependencies = [ - ("penndata", "0008_calendarevent"), - ] + dependencies = [("penndata", "0008_calendarevent")] operations = [ - migrations.RenameField( - model_name="event", - old_name="start_time", - new_name="start", - ), - migrations.RemoveField( - model_name="event", - name="end_time", - ), - migrations.AddField( - model_name="event", - name="end", - field=models.DateTimeField(null=True), - ), + migrations.RenameField(model_name="event", old_name="start_time", new_name="start"), + migrations.RemoveField(model_name="event", name="end_time"), + migrations.AddField(model_name="event", name="end", field=models.DateTimeField(null=True)), migrations.AddField( - model_name="event", - name="location", - field=models.CharField(max_length=255, null=True), + model_name="event", name="location", field=models.CharField(max_length=255, null=True) ), migrations.AlterField( - model_name="event", - name="description", - field=models.TextField(null=True), + model_name="event", name="description", field=models.TextField(null=True) ), migrations.AlterField( - model_name="event", - name="email", - field=models.CharField(max_length=255, null=True), + model_name="event", name="email", field=models.CharField(max_length=255, null=True) ), migrations.AlterField( - model_name="event", - name="event_type", - field=models.CharField(max_length=255, null=True), + model_name="event", name="event_type", field=models.CharField(max_length=255, null=True) ), migrations.AlterField( - model_name="event", - name="image_url", - field=models.URLField(null=True), + model_name="event", name="image_url", field=models.URLField(null=True) ), ] diff --git a/backend/penndata/migrations/0010_auto_20240228_0150.py b/backend/penndata/migrations/0010_auto_20240228_0150.py index 9e756298..9cf74601 100644 --- a/backend/penndata/migrations/0010_auto_20240228_0150.py +++ b/backend/penndata/migrations/0010_auto_20240228_0150.py @@ -5,19 +5,12 @@ class Migration(migrations.Migration): - dependencies = [ - ("penndata", "0009_auto_20240223_1820"), - ] + dependencies = [("penndata", "0009_auto_20240223_1820")] operations = [ - migrations.RemoveField( - model_name="event", - name="facebook", - ), + migrations.RemoveField(model_name="event", name="facebook"), migrations.AlterField( - model_name="event", - name="description", - field=models.TextField(blank=True, null=True), + model_name="event", name="description", field=models.TextField(blank=True, null=True) ), migrations.AlterField( model_name="event", @@ -25,9 +18,7 @@ class Migration(migrations.Migration): field=models.CharField(blank=True, max_length=255, null=True), ), migrations.AlterField( - model_name="event", - name="end", - field=models.DateTimeField(blank=True, null=True), + model_name="event", name="end", field=models.DateTimeField(blank=True, null=True) ), migrations.AlterField( model_name="event", @@ -35,9 +26,7 @@ class Migration(migrations.Migration): field=models.CharField(blank=True, max_length=255, null=True), ), migrations.AlterField( - model_name="event", - name="image_url", - field=models.URLField(blank=True, null=True), + model_name="event", name="image_url", field=models.URLField(blank=True, null=True) ), migrations.AlterField( model_name="event", diff --git a/backend/penndata/migrations/0011_alter_event_event_type_alter_event_start.py b/backend/penndata/migrations/0011_alter_event_event_type_alter_event_start.py index e9e00be9..105b4335 100644 --- a/backend/penndata/migrations/0011_alter_event_event_type_alter_event_start.py +++ b/backend/penndata/migrations/0011_alter_event_event_type_alter_event_start.py @@ -5,9 +5,7 @@ class Migration(migrations.Migration): - dependencies = [ - ("penndata", "0010_auto_20240228_0150"), - ] + dependencies = [("penndata", "0010_auto_20240228_0150")] operations = [ migrations.AlterField( @@ -41,8 +39,6 @@ class Migration(migrations.Migration): ), ), migrations.AlterField( - model_name="event", - name="start", - field=models.DateTimeField(blank=True, null=True), + model_name="event", name="start", field=models.DateTimeField(blank=True, null=True) ), ] diff --git a/backend/penndata/migrations/0012_alter_event_event_type.py b/backend/penndata/migrations/0012_alter_event_event_type.py index a211bce8..f0c97461 100644 --- a/backend/penndata/migrations/0012_alter_event_event_type.py +++ b/backend/penndata/migrations/0012_alter_event_event_type.py @@ -5,9 +5,7 @@ class Migration(migrations.Migration): - dependencies = [ - ("penndata", "0011_alter_event_event_type_alter_event_start"), - ] + dependencies = [("penndata", "0011_alter_event_event_type_alter_event_start")] operations = [ migrations.AlterField( @@ -40,5 +38,5 @@ class Migration(migrations.Migration): max_length=63, null=True, ), - ), + ) ] diff --git a/backend/penndata/models.py b/backend/penndata/models.py index f1d9122e..9d9b41c3 100644 --- a/backend/penndata/models.py +++ b/backend/penndata/models.py @@ -51,65 +51,66 @@ class Event(models.Model): (TYPE_STOUFFER_COLLEGE_HOUSE, "Stouffer College House"), ) - event_type = models.CharField( + event_type: models.CharField = models.CharField( max_length=63, choices=TYPE_CHOICES, default=None, null=True, blank=True ) - name = models.CharField(max_length=255) - description = models.TextField(null=True, blank=True) - image_url = models.URLField(null=True, blank=True) - start = models.DateTimeField(null=True, blank=True) - end = models.DateTimeField(null=True, blank=True) - location = models.CharField(max_length=255, null=True, blank=True) - email = models.CharField(max_length=255, null=True, blank=True) - website = models.URLField(max_length=255, null=True, blank=True) + name: models.CharField = models.CharField(max_length=255) + description: models.TextField = models.TextField(null=True, blank=True) + image_url: models.URLField = models.URLField(null=True, blank=True) + start: models.DateTimeField = models.DateTimeField(null=True, blank=True) + end: models.DateTimeField = models.DateTimeField(null=True, blank=True) + location: models.CharField = models.CharField(max_length=255, null=True, blank=True) + email: models.CharField = models.CharField(max_length=255, null=True, blank=True) + website: models.URLField = models.URLField(max_length=255, null=True, blank=True) class HomePageOrder(models.Model): - cell = models.CharField(max_length=255) - rank = models.IntegerField() + cell: models.CharField = models.CharField(max_length=255) + rank: models.IntegerField = models.IntegerField() - def __str__(self): + def __str__(self) -> str: return self.cell class FitnessRoom(models.Model): - name = models.CharField(max_length=255) - image_url = models.URLField() + id: int + name: models.CharField = models.CharField(max_length=255) + image_url: models.URLField = models.URLField() - def __str__(self): + def __str__(self) -> str: return str(self.name) class FitnessSnapshot(models.Model): - room = models.ForeignKey(FitnessRoom, on_delete=models.CASCADE, null=True) - date = models.DateTimeField(default=timezone.now) - count = models.IntegerField() - capacity = models.FloatField(null=True) + room: models.ForeignKey = models.ForeignKey(FitnessRoom, on_delete=models.CASCADE, null=True) + date: models.DateTimeField = models.DateTimeField(default=timezone.now) + count: models.IntegerField = models.IntegerField() + capacity: models.FloatField = models.FloatField(null=True) - def __str__(self): + def __str__(self) -> str: return f"Room Name: {self.room.name} | {self.date.date()}" class AnalyticsEvent(models.Model): - user = models.ForeignKey(User, on_delete=models.CASCADE) - created_at = models.DateTimeField(default=timezone.now) - cell_type = models.CharField(max_length=255) - index = models.IntegerField(default=0) - post = models.ForeignKey(Post, on_delete=models.CASCADE, null=True) - poll = models.ForeignKey(Poll, on_delete=models.CASCADE, null=True) - is_interaction = models.BooleanField(default=False) - data = models.CharField(max_length=255, null=True) - - def __str__(self): + user: models.ForeignKey = models.ForeignKey(User, on_delete=models.CASCADE) + created_at: models.DateTimeField = models.DateTimeField(default=timezone.now) + cell_type: models.CharField = models.CharField(max_length=255) + index: models.IntegerField = models.IntegerField(default=0) + post: models.ForeignKey = models.ForeignKey(Post, on_delete=models.CASCADE, null=True) + poll: models.ForeignKey = models.ForeignKey(Poll, on_delete=models.CASCADE, null=True) + is_interaction: models.BooleanField = models.BooleanField(default=False) + data: models.CharField = models.CharField(max_length=255, null=True) + + def __str__(self) -> str: return f"{self.cell_type}-{self.user.username}" class CalendarEvent(models.Model): - event = models.CharField(max_length=255) - date = models.CharField(max_length=50, null=True, blank=True) + event: models.CharField = models.CharField(max_length=255) + date: models.CharField = models.CharField(max_length=50, null=True, blank=True) # NOTE: This is bad practice, though is necessary for the time being # since frontends use the string date field - date_obj = models.DateTimeField(null=True, blank=True) + date_obj: models.DateTimeField = models.DateTimeField(null=True, blank=True) - def __str__(self): + def __str__(self) -> str: return f"{self.date}-{self.event}" diff --git a/backend/penndata/serializers.py b/backend/penndata/serializers.py index 51933e26..9dca83c0 100644 --- a/backend/penndata/serializers.py +++ b/backend/penndata/serializers.py @@ -1,4 +1,4 @@ -from typing import Any, TypeAlias +from typing import Any from rest_framework import serializers @@ -12,9 +12,6 @@ ) -ValidatedData: TypeAlias = dict[str, Any] - - class EventSerializer(serializers.ModelSerializer): class Meta: model = Event @@ -63,7 +60,7 @@ class Meta: model = AnalyticsEvent fields = ("created_at", "cell_type", "index", "post", "poll", "is_interaction") - def create(self, validated_data: ValidatedData) -> AnalyticsEvent: + def create(self, validated_data: dict[str, Any]) -> AnalyticsEvent: validated_data["user"] = self.context["request"].user if validated_data["poll"] and validated_data["post"]: raise serializers.ValidationError( diff --git a/backend/penndata/views.py b/backend/penndata/views.py index 8b722f68..b8a70d96 100644 --- a/backend/penndata/views.py +++ b/backend/penndata/views.py @@ -1,6 +1,6 @@ import datetime from datetime import timedelta -from typing import Any, TypeAlias +from typing import Any, Optional, Sequence, TypeAlias, cast import requests from bs4 import BeautifulSoup @@ -29,9 +29,9 @@ FitnessRoomSerializer, HomePageOrderSerializer, ) +from utils.types import get_user -ValidatedData: TypeAlias = dict[str, Any] CalendarEventList: TypeAlias = QuerySet[CalendarEvent, Manager[CalendarEvent]] EventList: TypeAlias = QuerySet[Event, Manager[Event]] HomePageOrderList: TypeAlias = QuerySet[HomePageOrder, Manager[HomePageOrder]] @@ -43,7 +43,7 @@ class News(APIView): """ def get_article(self) -> dict[str, Any] | None: - article = {"source": "The Daily Pennsylvanian"} + article: dict[str, Any] = {"source": "The Daily Pennsylvanian"} try: headers = { "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_5) AppleWebKit/537.36 " @@ -161,7 +161,7 @@ class HomePage(APIView): class Cell: def __init__( - self, myType: str, myInfo: ValidatedData | None = None, myWeight: int = 0 + self, myType: str, myInfo: Optional[dict[str, Any]] = None, myWeight: int = 0 ) -> None: self.type = myType self.info = myInfo @@ -174,7 +174,7 @@ def get(self, request: Request) -> Response: # NOTE: accept arguments: ?version= - profile = request.user.profile + profile = get_user(request).profile # TODO: add user's GSR reservations to Response # TODO: add user's courses to Response @@ -216,7 +216,7 @@ def get(self, request: Request) -> Response: # cells.append(self.Cell("calendar", {"calendar": Calendar.get_calendar(self)}, 40)) # adds front page article of DP - cells.append(self.Cell("news", {"article": News.get_article(self)}, 50)) + cells.append(self.Cell("news", {"article": News().get_article()}, 50)) # sorts by cell weight cells.sort(key=lambda x: x.weight, reverse=True) @@ -243,7 +243,7 @@ class FitnessRoomView(generics.ListAPIView): } def get(self, request: Request) -> Response: - response = super().get(self, request) + response = super().get(request) # also add last_updated and open/close times to each room in response for room in response.data: ss = FitnessSnapshot.objects.filter(room__id=room["id"]).order_by("-date").first() @@ -263,17 +263,17 @@ def get(self, request: Request) -> Response: class FitnessUsage(APIView): - def safe_add(self, a: int | None, b: int | None) -> int | None: + def safe_add(self, a: Optional[float] = None, b: Optional[float] = None) -> Optional[float]: return None if a is None and b is None else (a or 0) + (b or 0) def linear_interpolate( self, - before_val: int | None, - after_val: int | None, + before_val: int | float, + after_val: int | float, before_date: datetime.datetime, current_date: datetime.datetime, after_date: datetime.datetime, - ) -> int | None: + ) -> int | float: return ( before_val + (after_val - before_val) @@ -283,7 +283,7 @@ def linear_interpolate( def get_usage_on_date( self, room: FitnessRoom, date: datetime.date, field: str - ) -> list[int | None]: + ) -> Sequence[Optional[int | float]]: """ Returns the number of people in the fitness center on a given date per hour """ @@ -298,7 +298,7 @@ def get_usage_on_date( snapshots = FitnessSnapshot.objects.filter(room=room, date__date=date) # For usage, None represents no data - usage = [0] * 24 + usage: list[Optional[float]] = [0] * 24 for hour in range(open, close + 1): # consider the :30 mark of each hour hour_date = timezone.make_aware(datetime.datetime.combine(date, datetime.time(hour))) @@ -307,8 +307,8 @@ def get_usage_on_date( before = snapshots.filter(date__lte=hour_date).order_by("-date").first() after = snapshots.filter(date__gte=hour_date).order_by("date").first() - before_date, before_val = getattr(before, "date", None), getattr(before, field, None) - after_date, after_val = getattr(after, "date", None), getattr(after, field, None) + before_date, before_val = getattr(before, "date", None), getattr(before, field, 0) + after_date, after_val = getattr(after, "date", None), getattr(after, field, 0) # This condition should only activate during morning times if before is None: @@ -322,7 +322,7 @@ def get_usage_on_date( if date == timezone.localtime().date(): # Set value to None if the last retrieved data was # over 2 hours old to avoid extrapolation - if hour_date - datetime.timedelta(hours=1) > before_date: + if before_date and hour_date - datetime.timedelta(hours=1) > before_date: for i in range(hour, 24): usage[i] = None break @@ -334,21 +334,29 @@ def get_usage_on_date( + datetime.timedelta(minutes=30), 0, ) - - usage[hour] = ( - self.linear_interpolate(before_val, after_val, before_date, hour_date, after_date) - if before_date != after_date - else after_val - ) + if before_date and after_date: + usage[hour] = ( + self.linear_interpolate( + before_val, + after_val, + cast(datetime.datetime, before_date), + hour_date, + cast(datetime.datetime, after_date), + ) + if before_date != after_date + else (after_val) + ) if all(amt == 0 for amt in usage): # location probably closed - don't count in aggregate return [None] * 24 - return usage + return cast(Sequence[Optional[int | float]], usage) def get_usage( self, room: FitnessRoom, date: datetime.date, num_samples: int, group_by: str, field: str - ) -> tuple[list[int | None], datetime.date, datetime.date]: + ) -> tuple[list[Optional[int | float]], datetime.date, datetime.date]: unit = 1 if group_by == "day" else 7 # skip by 1 or 7 days - usage_aggs = [(None, 0)] * 24 # (sum, count) for each hour + usage_aggs: list[tuple[Optional[float], int]] = [ + (None, 0) + ] * 24 # (sum, count) for each hour min_date = timezone.localtime().date() max_date = date - datetime.timedelta(days=unit * (num_samples - 1)) @@ -361,10 +369,10 @@ def get_usage( for (sum, count), val in zip(usage_aggs, usage) ] # update min and max date if any data was logged - if any(usage): + if any(u is not None for u in usage): min_date = min(min_date, curr) max_date = max(max_date, curr) - ret = [(sum / count) if count else None for (sum, count) in usage_aggs] + ret = [(sum / count if count and sum is not None else None) for (sum, count) in usage_aggs] return ret, min_date, max_date def get(self, request: Request, room_id: int) -> Response: @@ -397,6 +405,7 @@ def get(self, request: Request, room_id: int) -> Response: usage_per_hour, min_date, max_date = self.get_usage( room, date, num_samples, group_by, field ) + return Response( { "room_name": room.name, @@ -419,7 +428,7 @@ class FitnessPreferences(APIView): def get(self, request: Request) -> Response: - preferences = request.user.profile.fitness_preferences.all() + preferences = get_user(request).profile.fitness_preferences.all() # returns all ids in a person's preferences return Response({"rooms": preferences.values_list("id", flat=True)}) @@ -429,7 +438,7 @@ def post(self, request: Request) -> Response: if "rooms" not in request.data: return Response({"success": False, "error": "No rooms provided"}) - profile = request.user.profile + profile = get_user(request).profile ids = request.data["rooms"] @@ -454,14 +463,14 @@ class UniqueCounterView(APIView): permission_classes = [IsAuthenticated] def get(self, request: Request) -> Response: - query = dict() + query: dict[str, Any] = {} if "post_id" in request.query_params: query["post__id"] = request.query_params["post_id"] if "poll_id" in request.query_params: query["poll__id"] = request.query_params["poll_id"] if len(query) != 1: return Response({"detail": "require 1 id out of post_id or poll_id"}, status=400) - query["is_interaction"] = ( - request.query_params.get("is_interaction", "false").lower() == "true" + query["is_interaction"] = bool( + str(request.query_params.get("is_interaction", "false")).lower() == "true" ) return Response({"count": AnalyticsEvent.objects.filter(**query).count()}) diff --git a/backend/pennmobile/admin.py b/backend/pennmobile/admin.py index 2da5a734..45aa6711 100644 --- a/backend/pennmobile/admin.py +++ b/backend/pennmobile/admin.py @@ -1,21 +1,31 @@ # CUSTOM ADMIN SETTUP FOR PENN MOBILE -from typing import Any, Dict, Optional, Type, TypeAlias +from typing import Any, Dict, Optional, Type, TypeAlias, cast from django.contrib import admin, messages from django.contrib.admin.apps import AdminConfig from django.db.models import Model from django.http import HttpRequest +from django.template.response import TemplateResponse from django.urls import reverse from django.utils.html import format_html -ModelType: TypeAlias = Type[Model] AdminContext: TypeAlias = Dict[str, Any] MessageText: TypeAlias = str -def add_post_poll_message(request: HttpRequest, model: ModelType) -> None: - if (count := model.objects.filter(model.ACTION_REQUIRED_CONDITION).count()) > 0: +def add_post_poll_message(request: HttpRequest, model: Type[Model]) -> None: + from portal.models import Poll, Post + + model_obj: Poll | Post + if model == Poll: + model_obj = cast(Poll, model) + elif model == Post: + model_obj = cast(Post, model) + else: + raise ValueError(f"Invalid model: {model}") + + if (count := model_obj.objects.filter(model_obj.ACTION_REQUIRED_CONDITION).count()) > 0: link = reverse(f"admin:{model._meta.app_label}_{model._meta.model_name}_changelist") messages.info( request, @@ -30,7 +40,9 @@ def add_post_poll_message(request: HttpRequest, model: ModelType) -> None: class CustomAdminSite(admin.AdminSite): site_header = "Penn Mobile Backend Admin" - def index(self, request: HttpRequest, extra_context: Optional[AdminContext] = None) -> Any: + def index( + self, request: HttpRequest, extra_context: Optional[AdminContext] = None + ) -> TemplateResponse: from portal.models import Poll, Post add_post_poll_message(request, Post) @@ -43,4 +55,5 @@ class PennMobileAdminConfig(AdminConfig): default_site = "pennmobile.admin.CustomAdminSite" -admin.AdminSite = CustomAdminSite # anything else that overrides default admin should override ours +# anything else that overrides default admin should override ours +admin.site = CustomAdminSite() diff --git a/backend/pennmobile/celery.py b/backend/pennmobile/celery.py index 7b81bab0..18ddcd1d 100644 --- a/backend/pennmobile/celery.py +++ b/backend/pennmobile/celery.py @@ -1,4 +1,5 @@ import os +from typing import Any from celery import Celery @@ -21,5 +22,5 @@ @app.task(bind=True) -def debug_task(self) -> None: +def debug_task(self: Any) -> None: print(f"Request: {self.request!r}") diff --git a/backend/pennmobile/settings/base.py b/backend/pennmobile/settings/base.py index cf285a89..1cc083bd 100644 --- a/backend/pennmobile/settings/base.py +++ b/backend/pennmobile/settings/base.py @@ -89,7 +89,7 @@ # Database # https://docs.djangoproject.com/en/2.1/ref/settings/#databases DATABASES = { - "default": dj_database_url.config(default="sqlite:///" + os.path.join(BASE_DIR, "db.sqlite3")), + "default": dj_database_url.config(default="sqlite:///" + os.path.join(BASE_DIR, "db.sqlite3")) } DEFAULT_AUTO_FIELD = "django.db.models.AutoField" @@ -148,7 +148,7 @@ "rest_framework.authentication.SessionAuthentication", "rest_framework.authentication.BasicAuthentication", "accounts.authentication.PlatformAuthentication", - ], + ] } # Redis for Celery & Caching diff --git a/backend/pennmobile/settings/production.py b/backend/pennmobile/settings/production.py index 066012aa..87996116 100644 --- a/backend/pennmobile/settings/production.py +++ b/backend/pennmobile/settings/production.py @@ -17,7 +17,7 @@ ALLOWED_HOSTS = DOMAINS # Make sure SECRET_KEY is set to a secret in production -SECRET_KEY = os.environ.get("SECRET_KEY", None) +SECRET_KEY = os.environ.get("SECRET_KEY", "") # Sentry settings SENTRY_URL = os.environ.get("SENTRY_URL", "") diff --git a/backend/pennmobile/test_runner.py b/backend/pennmobile/test_runner.py index 55937401..cd4cd55d 100644 --- a/backend/pennmobile/test_runner.py +++ b/backend/pennmobile/test_runner.py @@ -1,31 +1,32 @@ +from typing import Any from unittest import mock from django.test.runner import DiscoverRunner from xmlrunner.extra.djangotestrunner import XMLTestRunner -def check_wharton(*args) -> bool: +def check_wharton(*args: Any) -> bool: return False class MockLabsAnalytics: - def __init__(self): + def __init__(self) -> None: pass - def submit(self, txn): + def submit(self, txn: Any) -> None: pass class MobileTestCIRunner(XMLTestRunner): @mock.patch("analytics.analytics.LabsAnalytics", MockLabsAnalytics) @mock.patch("gsr_booking.models.GroupMembership.check_wharton", check_wharton) - def run_tests(self, test_labels, **kwargs) -> None: + def run_tests(self, test_labels: list[str], **kwargs: Any) -> int: return super().run_tests(test_labels, **kwargs) class MobileTestLocalRunner(DiscoverRunner): @mock.patch("analytics.analytics.LabsAnalytics", MockLabsAnalytics) @mock.patch("gsr_booking.models.GroupMembership.check_wharton", check_wharton) - def run_tests(self, test_labels, **kwargs) -> None: + def run_tests(self, test_labels: list[str], **kwargs: Any) -> int: return super().run_tests(test_labels, **kwargs) diff --git a/backend/pennmobile/urls.py b/backend/pennmobile/urls.py index 5e94960e..1ea3b6d2 100644 --- a/backend/pennmobile/urls.py +++ b/backend/pennmobile/urls.py @@ -1,11 +1,15 @@ +from typing import List, Union + from django.conf import settings from django.contrib import admin -from django.urls import include, path +from django.urls import URLPattern, URLResolver, include, path from django.views.generic import TemplateView from rest_framework.schemas import get_schema_view -urlpatterns = [ +URLPatternList = List[Union[URLPattern, URLResolver]] + +urlpatterns: URLPatternList = [ path("gsr/", include("gsr_booking.urls")), path("portal/", include("portal.urls")), path("admin/", admin.site.urls), @@ -29,7 +33,7 @@ path("sublet/", include("sublet.urls")), ] -urlpatterns = [ +urlpatterns: URLPatternList = [ # type: ignore[no-redef] path("api/", include(urlpatterns)), path("", include((urlpatterns, "apex"))), ] @@ -37,4 +41,6 @@ if settings.DEBUG: import debug_toolbar - urlpatterns = [path("__debug__/", include(debug_toolbar.urls))] + urlpatterns + urlpatterns: URLPatternList = [ # type: ignore[no-redef] + path("__debug__/", include(debug_toolbar.urls)) + ] + urlpatterns diff --git a/backend/portal/admin.py b/backend/portal/admin.py index a92c248b..a39cfb05 100644 --- a/backend/portal/admin.py +++ b/backend/portal/admin.py @@ -1,51 +1,57 @@ +from typing import Any + from django.contrib import admin -from django.utils.html import escape, mark_safe +from django.db.models import QuerySet +from django.utils.html import escape +from django.utils.safestring import SafeString, mark_safe from portal.models import Content, Poll, PollOption, PollVote, Post, TargetPopulation class ContentAdmin(admin.ModelAdmin): @admin.action(description="Set status to Approved") - def action_approved(modeladmin, request, queryset): + def action_approved(modeladmin: Any, request: Any, queryset: QuerySet) -> None: queryset.update(status=Content.STATUS_APPROVED) @admin.action(description="Set status to Draft") - def action_draft(modeladmin, request, queryset): + def action_draft(modeladmin: Any, request: Any, queryset: QuerySet) -> None: queryset.update(status=Content.STATUS_DRAFT) @admin.action(description="Set status to Revision") - def action_revision(modeladmin, request, queryset): + def action_revision(modeladmin: Any, request: Any, queryset: QuerySet) -> None: queryset.update(status=Content.STATUS_REVISION) actions = [action_approved, action_draft, action_revision] - def get_queryset(self, request): + def get_queryset(self, request: Any) -> QuerySet: queryset = super().get_queryset(request) return queryset.annotate(ar=Content.ACTION_REQUIRED_CONDITION).order_by( "-ar", "-created_date" ) - def ar(self, obj): - return obj.ar + # Using any for the ar property since it comes from a queryset annotation + def ar(self, obj: Any) -> bool: + return bool(obj.ar) - ar.boolean = True + ar.boolean = True # type: ignore[attr-defined] class PostAdmin(ContentAdmin): - def image_tag(instance, height): + @staticmethod + def image_tag(instance: Post, height: int) -> SafeString: return mark_safe( f'' % escape(instance.image and instance.image.url) ) - def small_image(self, instance): + def small_image(self, instance: Post) -> SafeString: return PostAdmin.image_tag(instance, 100) - small_image.short_description = "Post Image" + small_image.short_description = "Post Image" # type: ignore[attr-defined] - def large_image(self, instance): + def large_image(self, instance: Post) -> SafeString: return PostAdmin.image_tag(instance, 300) - large_image.short_description = "Post Image" + large_image.short_description = "Post Image" # type: ignore[attr-defined] readonly_fields = ("large_image",) list_display = ( diff --git a/backend/portal/logic.py b/backend/portal/logic.py index 8234a246..bd6d546b 100644 --- a/backend/portal/logic.py +++ b/backend/portal/logic.py @@ -1,25 +1,15 @@ import json from collections import defaultdict -from typing import TYPE_CHECKING, Any +from typing import Any, Optional from accounts.ipc import authenticated_request -from django.contrib.auth import get_user_model from rest_framework.exceptions import PermissionDenied from portal.models import Poll, PollOption, PollVote, TargetPopulation +from utils.types import UserType -if TYPE_CHECKING: - from django.contrib.auth.models import AbstractUser - - UserType = AbstractUser -else: - UserType = Any - -User = get_user_model() - - -def get_user_info(user: "UserType") -> dict[str, Any]: +def get_user_info(user: UserType) -> dict[str, Any]: """Returns Platform user information""" response = authenticated_request(user, "GET", "https://platform.pennlabs.org/accounts/me/") if response.status_code == 403: @@ -27,7 +17,7 @@ def get_user_info(user: "UserType") -> dict[str, Any]: return json.loads(response.content) -def get_user_clubs(user: "UserType") -> list[dict[str, Any]]: +def get_user_clubs(user: UserType) -> list[dict[str, Any]]: """Returns list of clubs that user is a member of""" response = authenticated_request(user, "GET", "https://pennclubs.com/api/memberships/") if response.status_code == 403: @@ -36,7 +26,7 @@ def get_user_clubs(user: "UserType") -> list[dict[str, Any]]: return res_json -def get_club_info(user: "UserType", club_code: str) -> dict[str, Any]: +def get_club_info(user: UserType, club_code: str) -> dict[str, Any]: """Returns club information based on club code""" response = authenticated_request(user, "GET", f"https://pennclubs.com/api/clubs/{club_code}/") if response.status_code == 403: @@ -45,12 +35,12 @@ def get_club_info(user: "UserType", club_code: str) -> dict[str, Any]: return {"name": res_json["name"], "image": res_json["image_url"], "club_code": club_code} -def get_user_populations(user: "UserType") -> list[TargetPopulation]: +def get_user_populations(user: UserType) -> list[list[TargetPopulation]]: """Returns the target populations that the user belongs to""" user_info = get_user_info(user) - year = ( + year: list[TargetPopulation] = ( [ TargetPopulation.objects.get( kind=TargetPopulation.KIND_YEAR, population=user_info["student"]["graduation_year"] @@ -60,7 +50,7 @@ def get_user_populations(user: "UserType") -> list[TargetPopulation]: else [] ) - school = ( + school: list[TargetPopulation] = ( [ TargetPopulation.objects.get(kind=TargetPopulation.KIND_SCHOOL, population=x["name"]) for x in user_info["student"]["school"] @@ -69,7 +59,7 @@ def get_user_populations(user: "UserType") -> list[TargetPopulation]: else [] ) - major = ( + major: list[TargetPopulation] = ( [ TargetPopulation.objects.get(kind=TargetPopulation.KIND_MAJOR, population=x["name"]) for x in user_info["student"]["major"] @@ -78,7 +68,7 @@ def get_user_populations(user: "UserType") -> list[TargetPopulation]: else [] ) - degree = ( + degree: list[TargetPopulation] = ( [ TargetPopulation.objects.get( kind=TargetPopulation.KIND_DEGREE, population=x["degree_type"] @@ -92,29 +82,30 @@ def get_user_populations(user: "UserType") -> list[TargetPopulation]: return [year, school, major, degree] -def check_targets(obj: Poll, user: "UserType") -> bool: +def check_targets(obj: Poll, user: UserType) -> bool: """ Check if user aligns with target populations of poll or post """ - populations = get_user_populations(user) + population_groups = get_user_populations(user) - year = set(obj.target_populations.filter(kind=TargetPopulation.KIND_YEAR)) - school = set(obj.target_populations.filter(kind=TargetPopulation.KIND_SCHOOL)) - major = set(obj.target_populations.filter(kind=TargetPopulation.KIND_MAJOR)) - degree = set(obj.target_populations.filter(kind=TargetPopulation.KIND_DEGREE)) + year_targets = set(obj.target_populations.filter(kind=TargetPopulation.KIND_YEAR)) + school_targets = set(obj.target_populations.filter(kind=TargetPopulation.KIND_SCHOOL)) + major_targets = set(obj.target_populations.filter(kind=TargetPopulation.KIND_MAJOR)) + degree_targets = set(obj.target_populations.filter(kind=TargetPopulation.KIND_DEGREE)) - return ( - set(populations[0]).issubset(year) - and set(populations[1]).issubset(school) - and set(populations[2]).issubset(major) - and set(populations[3]).issubset(degree) + return all( + set(group).issubset(targets) + for group, targets in zip( + population_groups, [year_targets, school_targets, major_targets, degree_targets] + ) ) -def get_demographic_breakdown(poll_id: int) -> list[dict[str, Any]]: +def get_demographic_breakdown(poll_id: Optional[int] = None) -> list[dict[str, Any]]: """Collects Poll statistics on school and graduation year demographics""" - + if poll_id is None: + raise ValueError("poll_id is required") # passing in id is necessary because # poll info is already serialized poll = Poll.objects.get(id=poll_id) diff --git a/backend/portal/management/commands/load_target_populations.py b/backend/portal/management/commands/load_target_populations.py index 82ecf247..6244e73a 100644 --- a/backend/portal/management/commands/load_target_populations.py +++ b/backend/portal/management/commands/load_target_populations.py @@ -1,3 +1,6 @@ +from argparse import ArgumentParser +from typing import Any, Optional + import requests from django.core.management.base import BaseCommand from django.utils import timezone @@ -6,7 +9,7 @@ class Command(BaseCommand): - def add_arguments(self, parser): + def add_arguments(self, parser: ArgumentParser) -> None: parser.add_argument( "--years", type=str, @@ -14,7 +17,7 @@ def add_arguments(self, parser): "This is only used for testing currently.", ) - def handle(self, *args, **kwargs): + def handle(self, *args: Any, **kwargs: Any) -> None: # loads majors, years, schools, and degrees onto TargetPopulations # runs get_or_create to ensure no duplicates majors = requests.get("https://platform.pennlabs.org/accounts/majors/").json() @@ -36,10 +39,10 @@ def handle(self, *args, **kwargs): TargetPopulation.objects.get_or_create(kind=TargetPopulation.KIND_YEAR, population=year) self.stdout.write("Uploaded Target Populations!") - def get_degrees(self): + def get_degrees(self) -> list[str]: return ["BACHELORS", "MASTERS", "PHD", "PROFESSIONAL"] - def get_years(self, years): + def get_years(self, years: Optional[str] = None) -> list[int]: # creates new class year in August in preparation for upcoming school year if years is None: return ( diff --git a/backend/portal/management/commands/polls_populate.py b/backend/portal/management/commands/polls_populate.py index 1414ab37..e5939f87 100644 --- a/backend/portal/management/commands/polls_populate.py +++ b/backend/portal/management/commands/polls_populate.py @@ -1,19 +1,29 @@ import datetime +from typing import Any -from django.contrib.auth import get_user_model from django.core.management import call_command from django.core.management.base import BaseCommand from django.utils import timezone from portal.models import Poll, PollOption, PollVote, TargetPopulation from user.models import Profile - - -User = get_user_model() +from utils.types import DjangoUserModel, UserType class Command(BaseCommand): - def handle(self, *args, **kwargs): + def _create_user( + self, username: str, email: str, password: str, graduation_date: datetime.date + ) -> UserType: + """Helper to create a user with profile""" + if not DjangoUserModel.objects.filter(username=username).exists(): + user = DjangoUserModel.objects.create_user(username, email, password) + profile = Profile.objects.get(user=user) + setattr(profile, "expected_graduation", graduation_date) + profile.save() + return user + return DjangoUserModel.objects.get(username=username) + + def handle(self, *args: Any, **kwargs: Any) -> None: # Define graduation years df_2022 = datetime.date(2022, 5, 15) @@ -22,55 +32,12 @@ def handle(self, *args, **kwargs): df_2025 = datetime.date(2025, 5, 17) # Create users and set graduation years - if not User.objects.filter(username="user1").first(): - user1 = User.objects.create_user("user1", "user@seas.upenn.edu", "user") - user1_profile = Profile.objects.get(user=user1) - user1_profile.expected_graduation = df_2022 - user1_profile.save() - else: - user1 = User.objects.get(username="user1") - - if not User.objects.filter(username="user2").first(): - user2 = User.objects.create_user("user2", "user2@seas.upenn.edu", "user2") - user2_profile = Profile.objects.get(user=user2) - user2_profile.expected_graduation = df_2023 - user2_profile.save() - else: - user2 = User.objects.get(username="user2") - - if not User.objects.filter(username="user3").first(): - user3 = User.objects.create_user("user3", "user3@seas.upenn.edu", "user3") - user3_profile = Profile.objects.get(user=user3) - user3_profile.expected_graduation = df_2024 - user3_profile.save() - else: - user3 = User.objects.get(username="user3") - - if not User.objects.filter(username="user_cas").first(): - user_cas = User.objects.create_user("user_cas", "user@sas.upenn.edu", "user_cas") - user_cas_profile = Profile.objects.get(user=user_cas) - user_cas_profile.expected_graduation = df_2025 - user_cas_profile.save() - else: - user_cas = User.objects.get(username="user_cas") - - if not User.objects.filter(username="user_wh").first(): - user_wh = User.objects.create_user("user_wh", "user@wharton.upenn.edu", "user_wh") - user_wh_profile = Profile.objects.get(user=user_wh) - user_wh_profile.expected_graduation = df_2024 - user_wh_profile.save() - else: - user_wh = User.objects.get(username="user_wh") - - if not User.objects.filter(username="user_nursing").first(): - user_nursing = User.objects.create_user( - "user_nursing", "user@nursing.upenn.edu", "user_nursing" - ) - user_nursing_profile = Profile.objects.get(user=user_nursing) - user_nursing_profile.expected_graduation = df_2023 - user_nursing_profile.save() - else: - user_nursing = User.objects.get(username="user_nursing") + self._create_user("user1", "user@seas.upenn.edu", "user", df_2022) + self._create_user("user2", "user2@seas.upenn.edu", "user2", df_2023) + self._create_user("user3", "user3@seas.upenn.edu", "user3", df_2024) + self._create_user("user_cas", "user@sas.upenn.edu", "user_cas", df_2025) + self._create_user("user_wh", "user@wharton.upenn.edu", "user_wh", df_2024) + self._create_user("user_nursing", "user@nursing.upenn.edu", "user_nursing", df_2023) # Create target populations call_command("load_target_populations", "--years", "2022, 2023, 2024, 2025") diff --git a/backend/portal/migrations/0001_initial.py b/backend/portal/migrations/0001_initial.py index c7ed66f3..ec955f2c 100644 --- a/backend/portal/migrations/0001_initial.py +++ b/backend/portal/migrations/0001_initial.py @@ -10,9 +10,7 @@ class Migration(migrations.Migration): initial = True - dependencies = [ - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ] + dependencies = [migrations.swappable_dependency(settings.AUTH_USER_MODEL)] operations = [ migrations.CreateModel( diff --git a/backend/portal/migrations/0002_auto_20211003_2225.py b/backend/portal/migrations/0002_auto_20211003_2225.py index 06a6a1b6..33fa66d9 100644 --- a/backend/portal/migrations/0002_auto_20211003_2225.py +++ b/backend/portal/migrations/0002_auto_20211003_2225.py @@ -6,24 +6,17 @@ class Migration(migrations.Migration): - dependencies = [ - ("portal", "0001_initial"), - ] + dependencies = [("portal", "0001_initial")] operations = [ - migrations.RemoveField( - model_name="poll", - name="image_url", - ), + migrations.RemoveField(model_name="poll", name="image_url"), migrations.AddField( model_name="poll", name="start_date", field=models.DateTimeField(default=django.utils.timezone.now), ), migrations.AddField( - model_name="polloption", - name="vote_count", - field=models.IntegerField(default=0), + model_name="polloption", name="vote_count", field=models.IntegerField(default=0) ), migrations.AddField( model_name="targetpopulation", diff --git a/backend/portal/migrations/0003_alter_targetpopulation_kind.py b/backend/portal/migrations/0003_alter_targetpopulation_kind.py index 6d89a215..2868d863 100644 --- a/backend/portal/migrations/0003_alter_targetpopulation_kind.py +++ b/backend/portal/migrations/0003_alter_targetpopulation_kind.py @@ -5,9 +5,7 @@ class Migration(migrations.Migration): - dependencies = [ - ("portal", "0002_auto_20211003_2225"), - ] + dependencies = [("portal", "0002_auto_20211003_2225")] operations = [ migrations.AlterField( @@ -23,5 +21,5 @@ class Migration(migrations.Migration): default="SCHOOL", max_length=10, ), - ), + ) ] diff --git a/backend/portal/migrations/0004_post.py b/backend/portal/migrations/0004_post.py index c80fd9dd..cdeb467a 100644 --- a/backend/portal/migrations/0004_post.py +++ b/backend/portal/migrations/0004_post.py @@ -45,5 +45,5 @@ class Migration(migrations.Migration): ), ), ], - ), + ) ] diff --git a/backend/portal/migrations/0005_auto_20211231_1558.py b/backend/portal/migrations/0005_auto_20211231_1558.py index 2f35eba8..6df6356a 100644 --- a/backend/portal/migrations/0005_auto_20211231_1558.py +++ b/backend/portal/migrations/0005_auto_20211231_1558.py @@ -6,36 +6,16 @@ class Migration(migrations.Migration): - dependencies = [ - ("portal", "0004_post"), - ] + dependencies = [("portal", "0004_post")] operations = [ - migrations.RenameField( - model_name="poll", - old_name="user_comment", - new_name="club_comment", - ), - migrations.RemoveField( - model_name="poll", - name="approved", - ), - migrations.RemoveField( - model_name="poll", - name="source", - ), - migrations.RemoveField( - model_name="poll", - name="user", - ), - migrations.RemoveField( - model_name="pollvote", - name="user", - ), + migrations.RenameField(model_name="poll", old_name="user_comment", new_name="club_comment"), + migrations.RemoveField(model_name="poll", name="approved"), + migrations.RemoveField(model_name="poll", name="source"), + migrations.RemoveField(model_name="poll", name="user"), + migrations.RemoveField(model_name="pollvote", name="user"), migrations.AddField( - model_name="poll", - name="club_code", - field=models.CharField(blank=True, max_length=255), + model_name="poll", name="club_code", field=models.CharField(blank=True, max_length=255) ), migrations.AddField( model_name="poll", diff --git a/backend/portal/migrations/0006_auto_20220112_1529.py b/backend/portal/migrations/0006_auto_20220112_1529.py index 3dc42228..44bfd28c 100644 --- a/backend/portal/migrations/0006_auto_20220112_1529.py +++ b/backend/portal/migrations/0006_auto_20220112_1529.py @@ -5,36 +5,15 @@ class Migration(migrations.Migration): - dependencies = [ - ("portal", "0005_auto_20211231_1558"), - ] + dependencies = [("portal", "0005_auto_20211231_1558")] operations = [ - migrations.RenameField( - model_name="post", - old_name="user_comment", - new_name="club_comment", - ), - migrations.RenameField( - model_name="post", - old_name="created_at", - new_name="created_date", - ), - migrations.RemoveField( - model_name="post", - name="approved", - ), - migrations.RemoveField( - model_name="post", - name="source", - ), - migrations.RemoveField( - model_name="post", - name="user", - ), + migrations.RenameField(model_name="post", old_name="user_comment", new_name="club_comment"), + migrations.RenameField(model_name="post", old_name="created_at", new_name="created_date"), + migrations.RemoveField(model_name="post", name="approved"), + migrations.RemoveField(model_name="post", name="source"), + migrations.RemoveField(model_name="post", name="user"), migrations.AddField( - model_name="post", - name="club_code", - field=models.CharField(blank=True, max_length=255), + model_name="post", name="club_code", field=models.CharField(blank=True, max_length=255) ), ] diff --git a/backend/portal/migrations/0007_post_status.py b/backend/portal/migrations/0007_post_status.py index ae126767..7cfba2bf 100644 --- a/backend/portal/migrations/0007_post_status.py +++ b/backend/portal/migrations/0007_post_status.py @@ -5,9 +5,7 @@ class Migration(migrations.Migration): - dependencies = [ - ("portal", "0006_auto_20220112_1529"), - ] + dependencies = [("portal", "0006_auto_20220112_1529")] operations = [ migrations.AddField( @@ -18,5 +16,5 @@ class Migration(migrations.Migration): default="DRAFT", max_length=30, ), - ), + ) ] diff --git a/backend/portal/migrations/0008_alter_post_image_url.py b/backend/portal/migrations/0008_alter_post_image_url.py index 2249e1d0..92ba5a3a 100644 --- a/backend/portal/migrations/0008_alter_post_image_url.py +++ b/backend/portal/migrations/0008_alter_post_image_url.py @@ -5,14 +5,12 @@ class Migration(migrations.Migration): - dependencies = [ - ("portal", "0007_post_status"), - ] + dependencies = [("portal", "0007_post_status")] operations = [ migrations.AlterField( model_name="post", name="image_url", field=models.ImageField(blank=True, null=True, upload_to="portal/images"), - ), + ) ] diff --git a/backend/portal/migrations/0009_rename_image_url_post_image.py b/backend/portal/migrations/0009_rename_image_url_post_image.py index 64028b4f..cbfca6f1 100644 --- a/backend/portal/migrations/0009_rename_image_url_post_image.py +++ b/backend/portal/migrations/0009_rename_image_url_post_image.py @@ -5,14 +5,6 @@ class Migration(migrations.Migration): - dependencies = [ - ("portal", "0008_alter_post_image_url"), - ] + dependencies = [("portal", "0008_alter_post_image_url")] - operations = [ - migrations.RenameField( - model_name="post", - old_name="image_url", - new_name="image", - ), - ] + operations = [migrations.RenameField(model_name="post", old_name="image_url", new_name="image")] diff --git a/backend/portal/migrations/0010_remove_post_image.py b/backend/portal/migrations/0010_remove_post_image.py index ede76232..22759b5c 100644 --- a/backend/portal/migrations/0010_remove_post_image.py +++ b/backend/portal/migrations/0010_remove_post_image.py @@ -5,13 +5,6 @@ class Migration(migrations.Migration): - dependencies = [ - ("portal", "0009_rename_image_url_post_image"), - ] + dependencies = [("portal", "0009_rename_image_url_post_image")] - operations = [ - migrations.RemoveField( - model_name="post", - name="image", - ), - ] + operations = [migrations.RemoveField(model_name="post", name="image")] diff --git a/backend/portal/migrations/0011_post_image.py b/backend/portal/migrations/0011_post_image.py index b7a03d12..b2d9b737 100644 --- a/backend/portal/migrations/0011_post_image.py +++ b/backend/portal/migrations/0011_post_image.py @@ -5,14 +5,12 @@ class Migration(migrations.Migration): - dependencies = [ - ("portal", "0010_remove_post_image"), - ] + dependencies = [("portal", "0010_remove_post_image")] operations = [ migrations.AddField( model_name="post", name="image", field=models.ImageField(blank=True, null=True, upload_to="portal/images"), - ), + ) ] diff --git a/backend/portal/migrations/0012_remove_post_image.py b/backend/portal/migrations/0012_remove_post_image.py index 2ae2a531..32a1013b 100644 --- a/backend/portal/migrations/0012_remove_post_image.py +++ b/backend/portal/migrations/0012_remove_post_image.py @@ -5,13 +5,6 @@ class Migration(migrations.Migration): - dependencies = [ - ("portal", "0011_post_image"), - ] + dependencies = [("portal", "0011_post_image")] - operations = [ - migrations.RemoveField( - model_name="post", - name="image", - ), - ] + operations = [migrations.RemoveField(model_name="post", name="image")] diff --git a/backend/portal/migrations/0013_post_image.py b/backend/portal/migrations/0013_post_image.py index 44a7ac6c..cf67dfee 100644 --- a/backend/portal/migrations/0013_post_image.py +++ b/backend/portal/migrations/0013_post_image.py @@ -5,14 +5,12 @@ class Migration(migrations.Migration): - dependencies = [ - ("portal", "0012_remove_post_image"), - ] + dependencies = [("portal", "0012_remove_post_image")] operations = [ migrations.AddField( model_name="post", name="image", field=models.ImageField(blank=True, null=True, upload_to="portal/images"), - ), + ) ] diff --git a/backend/portal/migrations/0014_alter_post_post_url.py b/backend/portal/migrations/0014_alter_post_post_url.py index b2690d59..bd0df256 100644 --- a/backend/portal/migrations/0014_alter_post_post_url.py +++ b/backend/portal/migrations/0014_alter_post_post_url.py @@ -5,14 +5,12 @@ class Migration(migrations.Migration): - dependencies = [ - ("portal", "0013_post_image"), - ] + dependencies = [("portal", "0013_post_image")] operations = [ migrations.AlterField( model_name="post", name="post_url", field=models.CharField(blank=True, max_length=255, null=True), - ), + ) ] diff --git a/backend/portal/migrations/0015_auto_20240226_2236.py b/backend/portal/migrations/0015_auto_20240226_2236.py index 7a9c7108..722d7083 100644 --- a/backend/portal/migrations/0015_auto_20240226_2236.py +++ b/backend/portal/migrations/0015_auto_20240226_2236.py @@ -5,24 +5,14 @@ class Migration(migrations.Migration): - dependencies = [ - ("portal", "0014_alter_post_post_url"), - ] + dependencies = [("portal", "0014_alter_post_post_url")] operations = [ migrations.AddField( - model_name="poll", - name="priority", - field=models.IntegerField(default=0), + model_name="poll", name="priority", field=models.IntegerField(default=0) ), migrations.AddField( - model_name="post", - name="priority", - field=models.IntegerField(default=0), - ), - migrations.AlterField( - model_name="poll", - name="expire_date", - field=models.DateTimeField(), + model_name="post", name="priority", field=models.IntegerField(default=0) ), + migrations.AlterField(model_name="poll", name="expire_date", field=models.DateTimeField()), ] diff --git a/backend/portal/models.py b/backend/portal/models.py index b15aeee1..6e11f0a9 100644 --- a/backend/portal/models.py +++ b/backend/portal/models.py @@ -1,3 +1,5 @@ +from typing import Any + from django.contrib.auth import get_user_model from django.db import models from django.db.models import Q @@ -6,9 +8,6 @@ from utils.email import get_backend_manager_emails, send_automated_email -User = get_user_model() - - class TargetPopulation(models.Model): KIND_SCHOOL = "SCHOOL" KIND_YEAR = "YEAR" @@ -21,10 +20,13 @@ class TargetPopulation(models.Model): (KIND_DEGREE, "Degree"), ) - kind = models.CharField(max_length=10, choices=KIND_OPTIONS, default=KIND_SCHOOL) - population = models.CharField(max_length=255) + id: int + kind: models.CharField = models.CharField( + max_length=10, choices=KIND_OPTIONS, default=KIND_SCHOOL + ) + population: models.CharField = models.CharField(max_length=255) - def __str__(self): + def __str__(self) -> str: return self.population @@ -41,34 +43,46 @@ class Content(models.Model): ACTION_REQUIRED_CONDITION = Q(expire_date__gt=timezone.now()) & Q(status=STATUS_DRAFT) - club_code = models.CharField(max_length=255, blank=True) - created_date = models.DateTimeField(default=timezone.now) - start_date = models.DateTimeField(default=timezone.now) - expire_date = models.DateTimeField() - status = models.CharField(max_length=30, choices=STATUS_OPTIONS, default=STATUS_DRAFT) - club_comment = models.CharField(max_length=255, null=True, blank=True) - admin_comment = models.CharField(max_length=255, null=True, blank=True) - target_populations = models.ManyToManyField(TargetPopulation, blank=True) - priority = models.IntegerField(default=0) - creator = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True) + id: int + club_code: models.CharField = models.CharField(max_length=255, blank=True) + created_date: models.DateTimeField = models.DateTimeField(default=timezone.now) + start_date: models.DateTimeField = models.DateTimeField(default=timezone.now) + expire_date: models.DateTimeField = models.DateTimeField() + status: models.CharField = models.CharField( + max_length=30, choices=STATUS_OPTIONS, default=STATUS_DRAFT + ) + club_comment: models.CharField = models.CharField(max_length=255, null=True, blank=True) + admin_comment: models.CharField = models.CharField(max_length=255, null=True, blank=True) + target_populations: models.ManyToManyField = models.ManyToManyField( + TargetPopulation, blank=True + ) + priority: models.IntegerField = models.IntegerField(default=0) + creator: models.ForeignKey = models.ForeignKey( + get_user_model(), on_delete=models.SET_NULL, null=True, blank=True + ) class Meta: abstract = True - def _get_email_subject(self): - return f"[Portal] {self.__class__._meta.model_name.capitalize()} #{self.id}" + def _get_email_subject(self) -> str: + model_name = ( + self.__class__._meta.model_name.capitalize() + if self.__class__._meta.model_name is not None + else "" + ) + return f"[Portal] {model_name} #{self.id}" - def _on_create(self): + def _on_create(self) -> None: send_automated_email.delay_on_commit( self._get_email_subject(), get_backend_manager_emails(), ( - f"A new {self.__class__._meta.model_name} for {self.club_code}" + f"A new {self.__class__._meta.model_name} for {self.club_code} " f"has been created by {self.creator}." ), ) - def _on_status_change(self): + def _on_status_change(self) -> None: if email := getattr(self.creator, "email", None): send_automated_email.delay_on_commit( self._get_email_subject(), @@ -82,7 +96,7 @@ def _on_status_change(self): ), ) - def save(self, *args, **kwargs): + def save(self, *args: Any, **kwargs: Any) -> None: prev = self.__class__.objects.filter(id=self.id).first() super().save(*args, **kwargs) if prev is None: @@ -93,35 +107,39 @@ def save(self, *args, **kwargs): class Poll(Content): - question = models.CharField(max_length=255) - multiselect = models.BooleanField(default=False) + question: models.CharField = models.CharField(max_length=255) + multiselect: models.BooleanField = models.BooleanField(default=False) - def __str__(self): + def __str__(self) -> str: return self.question class PollOption(models.Model): - poll = models.ForeignKey(Poll, on_delete=models.CASCADE) - choice = models.CharField(max_length=255) - vote_count = models.IntegerField(default=0) + id: int + poll: models.ForeignKey = models.ForeignKey(Poll, on_delete=models.CASCADE) + choice: models.CharField = models.CharField(max_length=255) + vote_count: models.IntegerField = models.IntegerField(default=0) - def __str__(self): + def __str__(self) -> str: return f"{self.poll.id} - Option - {self.choice}" class PollVote(models.Model): - id_hash = models.CharField(max_length=255, blank=True) - poll = models.ForeignKey(Poll, on_delete=models.CASCADE) - poll_options = models.ManyToManyField(PollOption) - created_date = models.DateTimeField(default=timezone.now) - target_populations = models.ManyToManyField(TargetPopulation, blank=True) + id: int + id_hash: models.CharField = models.CharField(max_length=255, blank=True) + poll: models.ForeignKey = models.ForeignKey(Poll, on_delete=models.CASCADE) + poll_options: models.ManyToManyField = models.ManyToManyField(PollOption) + created_date: models.DateTimeField = models.DateTimeField(default=timezone.now) + target_populations: models.ManyToManyField = models.ManyToManyField( + TargetPopulation, blank=True + ) class Post(Content): - title = models.CharField(max_length=255) - subtitle = models.CharField(max_length=255) - post_url = models.CharField(max_length=255, null=True, blank=True) - image = models.ImageField(upload_to="portal/images", null=True, blank=True) + title: models.CharField = models.CharField(max_length=255) + subtitle: models.CharField = models.CharField(max_length=255) + post_url: models.CharField = models.CharField(max_length=255, null=True, blank=True) + image: models.ImageField = models.ImageField(upload_to="portal/images", null=True, blank=True) - def __str__(self): + def __str__(self) -> str: return self.title diff --git a/backend/portal/permissions.py b/backend/portal/permissions.py index adc319ba..c8b09364 100644 --- a/backend/portal/permissions.py +++ b/backend/portal/permissions.py @@ -1,7 +1,11 @@ +from typing import Any, cast + from rest_framework import permissions +from rest_framework.request import Request from portal.logic import get_user_clubs -from portal.models import Poll +from portal.models import Poll, PollOption +from utils.types import get_auth_user class IsSuperUser(permissions.BasePermission): @@ -9,66 +13,79 @@ class IsSuperUser(permissions.BasePermission): Grants permission if the current user is a superuser. """ - def has_object_permission(self, request, view, obj): - return request.user.is_superuser + def has_object_permission(self, request: Request, view: Any, obj: Any) -> bool: + return get_auth_user(request).is_superuser - def has_permission(self, request, view): - return request.user.is_superuser + def has_permission(self, request: Request, view: Any) -> bool: + return get_auth_user(request).is_superuser class PollOwnerPermission(permissions.BasePermission): """Permission that checks authentication and only permits owner to update/destroy objects""" - def has_object_permission(self, request, view, obj): + def _get_club_code(self, obj: Any) -> str: + """Helper to get club_code from either Poll or PollOption object""" + if isinstance(obj, Poll): + return obj.club_code + elif isinstance(obj, PollOption): + poll = cast(Poll, obj.poll) + return poll.club_code + raise ValueError(f"Unexpected object type: {type(obj)}") + + def has_object_permission(self, request: Request, view: Any, obj: Any) -> bool: # only creator can edit + user = get_auth_user(request) if view.action in ["partial_update", "update", "destroy"]: - return obj.club_code in [x["club"]["code"] for x in get_user_clubs(request.user)] - return request.user.is_authenticated + club_code = self._get_club_code(obj) + return club_code in [x["club"]["code"] for x in get_user_clubs(user)] + return user.is_authenticated - def has_permission(self, request, view): - return request.user.is_authenticated + def has_permission(self, request: Request, view: Any) -> bool: + return get_auth_user(request).is_authenticated class OptionOwnerPermission(permissions.BasePermission): """Permission that checks authentication and only permits owner of Poll to update corresponding Option objects""" - def has_object_permission(self, request, view, obj): + def has_object_permission(self, request: Request, view: Any, obj: Any) -> bool: # only creator can edit + user = get_auth_user(request) if view.action in ["partial_update", "update", "destroy"]: - return obj.poll.club_code in [x["club"]["code"] for x in get_user_clubs(request.user)] + return obj.poll.club_code in [x["club"]["code"] for x in get_user_clubs(user)] return True - def has_permission(self, request, view): + def has_permission(self, request: Request, view: Any) -> bool: # only creator of poll can create poll option + user = get_auth_user(request) if view.action == "create" and request.data: poll = Poll.objects.get(id=request.data["poll"]) - return poll.club_code in [x["club"]["code"] for x in get_user_clubs(request.user)] - return request.user.is_authenticated + return poll.club_code in [x["club"]["code"] for x in get_user_clubs(user)] + return user.is_authenticated class TimeSeriesPermission(permissions.BasePermission): """Permission that checks for Time Series access (only creator of Poll and admins)""" - def has_permission(self, request, view): - poll = Poll.objects.filter(id=view.kwargs["poll_id"]) + def has_permission(self, request: Request, view: Any) -> bool: + poll = Poll.objects.filter(id=view.kwargs["poll_id"]).first() + user = get_auth_user(request) # checks if poll exists - if poll.exists(): + if poll is not None: # only poll creator and admin can access - return poll.first().club_code in [ - x["club"]["code"] for x in get_user_clubs(request.user) - ] + return poll.club_code in [x["club"]["code"] for x in get_user_clubs(user)] return False class PostOwnerPermission(permissions.BasePermission): """checks authentication and only permits owner to update/destroy posts""" - def has_object_permission(self, request, view, obj): + def has_object_permission(self, request: Request, view: Any, obj: Any) -> bool: # only creator can edit + user = get_auth_user(request) if view.action in ["partial_update", "update", "destroy"]: - return obj.club_code in [x["club"]["code"] for x in get_user_clubs(request.user)] + return obj.club_code in [x["club"]["code"] for x in get_user_clubs(user)] return True - def has_permission(self, request, view): + def has_permission(self, request: Request, view: Any) -> bool: return request.user.is_authenticated diff --git a/backend/portal/serializers.py b/backend/portal/serializers.py index d9a65e44..b384d90e 100644 --- a/backend/portal/serializers.py +++ b/backend/portal/serializers.py @@ -1,5 +1,6 @@ -from typing import Any, Dict, TypeAlias +from typing import Any, ClassVar, Type, cast +from django.db.models import Model from django.http.request import QueryDict from rest_framework import serializers @@ -7,10 +8,6 @@ from portal.models import Content, Poll, PollOption, PollVote, Post, TargetPopulation -ClubCode: TypeAlias = str -ValidationData: TypeAlias = Dict[str, Any] - - class TargetPopulationSerializer(serializers.ModelSerializer): class Meta: model = TargetPopulation @@ -19,7 +16,8 @@ class Meta: class ContentSerializer(serializers.ModelSerializer): class Meta: - fields = ( + model: ClassVar[Type[Model]] + fields: tuple[str, ...] = ( "id", "club_code", "created_date", @@ -30,10 +28,10 @@ class Meta: "status", "target_populations", ) - read_only_fields = ("id", "created_date") + read_only_fields: tuple[str, ...] = ("id", "created_date") abstract = True - def _auto_add_target_population(self, validated_data: ValidationData) -> None: + def _auto_add_target_population(self, validated_data: dict[str, Any]) -> None: # auto add all target populations of a kind if not specified if target_populations := validated_data.get("target_populations"): auto_add_kind = [ @@ -47,15 +45,19 @@ def _auto_add_target_population(self, validated_data: ValidationData) -> None: else: validated_data["target_populations"] = list(TargetPopulation.objects.all()) - def create(self, validated_data: ValidationData) -> Poll: - club_code: ClubCode = validated_data["club_code"] + def create(self, validated_data: dict[str, Any]) -> Poll: + club_code: str = validated_data["club_code"] user = self.context["request"].user # ensures user is part of club if not any([x["club"]["code"] == club_code for x in get_user_clubs(user)]): + model_name = ( + self.Meta.model._meta.model_name.capitalize() + if self.Meta.model._meta.model_name is not None + else "content" + ) raise serializers.ValidationError( detail={ - "detail": "You do not have access to create a " - + f"{self.Meta.model._meta.model_name.capitalize()} under this club." + "detail": f"You do not have access to create a {model_name} under this club." } ) @@ -69,7 +71,7 @@ def create(self, validated_data: ValidationData) -> Poll: return super().create(validated_data) - def update(self, instance: Content, validated_data: ValidationData) -> Content: + def update(self, instance: Content, validated_data: dict[str, Any]) -> Content: # if Content is updated, then approve should be false if not self.context["request"].user.is_superuser: validated_data["status"] = Content.STATUS_DRAFT @@ -82,25 +84,16 @@ def update(self, instance: Content, validated_data: ValidationData) -> Content: class PollSerializer(ContentSerializer): class Meta(ContentSerializer.Meta): model = Poll - fields = ( - *ContentSerializer.Meta.fields, - "question", - "multiselect", - ) + fields: tuple[str, ...] = (*ContentSerializer.Meta.fields, "question", "multiselect") class PollOptionSerializer(serializers.ModelSerializer): class Meta: model = PollOption - fields = ( - "id", - "poll", - "choice", - "vote_count", - ) - read_only_fields = ("id", "vote_count") + fields: tuple[str, ...] = ("id", "poll", "choice", "vote_count") + read_only_fields: tuple[str, ...] = ("id", "vote_count") - def create(self, validated_data: ValidationData) -> PollOption: + def create(self, validated_data: dict[str, Any]) -> PollOption: poll_options_count = PollOption.objects.filter(poll=validated_data["poll"]).count() if poll_options_count >= 5: raise serializers.ValidationError( @@ -108,10 +101,11 @@ def create(self, validated_data: ValidationData) -> PollOption: ) return super().create(validated_data) - def update(self, instance, validated_data): + def update(self, instance: PollOption, validated_data: dict[str, Any]) -> PollOption: # if Poll Option is updated, then corresponding Poll approval should be false - instance.poll.status = Poll.STATUS_DRAFT - instance.poll.save() + poll = cast(Poll, instance.poll) + poll.status = Poll.STATUS_DRAFT + poll.save() return super().update(instance, validated_data) @@ -122,7 +116,7 @@ class RetrievePollSerializer(serializers.ModelSerializer): class Meta: model = Poll - fields = ( + fields: tuple[str, ...] = ( "id", "club_code", "question", @@ -140,13 +134,10 @@ class Meta: class PollVoteSerializer(serializers.ModelSerializer): class Meta: model = PollVote - fields = ("id", "id_hash", "poll_options", "created_date") - read_only_fields = ( - "id", - "created_date", - ) + fields: tuple[str, ...] = ("id", "id_hash", "poll_options", "created_date") + read_only_fields: tuple[str, ...] = ("id", "created_date") - def create(self, validated_data: ValidationData) -> PollVote: + def create(self, validated_data: dict[str, Any]) -> PollVote: options = validated_data["poll_options"] id_hash = validated_data["id_hash"] @@ -201,11 +192,8 @@ class RetrievePollVoteSerializer(serializers.ModelSerializer): class Meta: model = PollVote - fields = ("id", "id_hash", "poll", "poll_options", "created_date") - read_only_fields = ( - "id", - "created_date", - ) + fields: tuple[str, ...] = ("id", "id_hash", "poll", "poll_options", "created_date") + read_only_fields: tuple[str, ...] = ("id", "created_date") class PostSerializer(ContentSerializer): @@ -229,7 +217,7 @@ def get_image_url(self, obj: Post) -> str | None: class Meta(ContentSerializer.Meta): model = Post - fields = ( + fields: tuple[str, ...] = ( *ContentSerializer.Meta.fields, "title", "subtitle", @@ -240,12 +228,13 @@ class Meta(ContentSerializer.Meta): def is_valid(self, *args: Any, **kwargs: Any) -> bool: if isinstance(self.initial_data, QueryDict): - self.initial_data = self.initial_data.dict() - self.initial_data["target_populations"] = list( - ( - map(int, self.initial_data["target_populations"].split(",")) - if "target_populations" in self.initial_data + data = self.initial_data.dict() + target_populations = data.get("target_populations", "") + if isinstance(target_populations, str): + data["target_populations"] = ( + list(map(int, target_populations.split(","))) + if target_populations.strip() else [] - ), - ) + ) + self.initial_data = data return super().is_valid(*args, **kwargs) diff --git a/backend/portal/views.py b/backend/portal/views.py index 61ac2413..b87b058c 100644 --- a/backend/portal/views.py +++ b/backend/portal/views.py @@ -1,6 +1,5 @@ -from typing import Any, Dict, List, TypeAlias +from typing import Any, List, Optional, TypeAlias -from django.contrib.auth import get_user_model from django.db.models import Count, Manager, Q, QuerySet from django.db.models.functions import Trunc from django.utils import timezone @@ -36,17 +35,13 @@ RetrievePollVoteSerializer, TargetPopulationSerializer, ) +from utils.types import AuthRequest, get_auth_user PollQuerySet: TypeAlias = QuerySet[Poll, Manager[Poll]] PostQuerySet: TypeAlias = QuerySet[Post, Manager[Post]] PollVoteQuerySet: TypeAlias = QuerySet[PollVote, Manager[PollVote]] -ClubData: TypeAlias = List[Dict[str, Any]] PollOptionQuerySet: TypeAlias = QuerySet[PollOption, Manager[PollOption]] -TimeSeriesData: TypeAlias = Dict[str, Any] -VoteStatistics: TypeAlias = Dict[str, Any] - -User = get_user_model() class UserInfo(APIView): @@ -54,7 +49,7 @@ class UserInfo(APIView): permission_classes = [IsAuthenticated] - def get(self, request: Request) -> Response: + def get(self, request: AuthRequest) -> Response: return Response({"user": get_user_info(request.user)}) @@ -63,8 +58,8 @@ class UserClubs(APIView): permission_classes = [IsAuthenticated] - def get(self, request: Request) -> Response: - club_data: ClubData = [ + def get(self, request: AuthRequest) -> Response: + club_data = [ get_club_info(request.user, club["club"]["code"]) for club in get_user_clubs(request.user) ] @@ -104,21 +99,23 @@ class Polls(viewsets.ModelViewSet[Poll]): def get_queryset(self) -> PollQuerySet: # all polls if superuser, polls corresponding to club for regular user + user = get_auth_user(self.request) return ( Poll.objects.all() - if self.request.user.is_superuser + if user.is_superuser else Poll.objects.filter( - club_code__in=[x["club"]["code"] for x in get_user_clubs(self.request.user)] + club_code__in=[x["club"]["code"] for x in get_user_clubs(user)] ) ) @action(detail=False, methods=["post"]) - def browse(self, request: Request) -> Response: + def browse(self, request: AuthRequest) -> Response: """Returns list of all possible polls user can answer but has yet to For admins, returns list of all polls they have not voted for and have yet to expire """ id_hash = request.data["id_hash"] + user = get_auth_user(request) # unvoted polls in draft/approaved mode for superuser # unvoted and approved polls within time frame for regular user @@ -128,7 +125,7 @@ def browse(self, request: Request) -> Response: Q(status=Poll.STATUS_DRAFT) | Q(status=Poll.STATUS_APPROVED), expire_date__gte=timezone.localtime(), ) - if request.user.is_superuser + if user.is_superuser else Poll.objects.filter( ~Q(id__in=PollVote.objects.filter(id_hash=id_hash).values_list("poll_id")), status=Poll.STATUS_APPROVED, @@ -140,9 +137,9 @@ def browse(self, request: Request) -> Response: # list of polls where user doesn't identify with # target populations bad_polls = [] - if not request.user.is_superuser: + if not user.is_superuser: for unfiltered_poll in unfiltered_polls: - if not check_targets(unfiltered_poll, request.user): + if not check_targets(unfiltered_poll, user): bad_polls.append(unfiltered_poll.id) # excludes the bad polls @@ -175,7 +172,7 @@ def review(self, request: Request) -> Response: ) @action(detail=True, methods=["get"]) - def option_view(self, request: Request, pk: int = None) -> Response: + def option_view(self, request: Request, pk: Optional[int] = None) -> Response: """Returns information on specific poll, including options and vote counts""" return Response(RetrievePollSerializer(Poll.objects.filter(id=pk).first(), many=False).data) @@ -199,12 +196,13 @@ class PollOptions(viewsets.ModelViewSet[PollOption]): def get_queryset(self) -> PollOptionQuerySet: # if user is admin, they can update anything # if user is not admin, they can only update their own options + user = get_auth_user(self.request) return ( PollOption.objects.all() - if self.request.user.is_superuser + if user.is_superuser else PollOption.objects.filter( poll__in=Poll.objects.filter( - club_code__in=[x["club"]["code"] for x in get_user_clubs(self.request.user)] + club_code__in=[x["club"]["code"] for x in get_user_clubs(user)] ) ) ) @@ -239,7 +237,8 @@ def all(self, request: Request) -> Response: return Response(RetrievePollVoteSerializer(poll_votes, many=True).data) def create(self, request: Request, *args: Any, **kwargs: Any) -> Response: - record_analytics(Metric.PORTAL_POLL_VOTED, request.user.username) + user = get_auth_user(request) + record_analytics(Metric.PORTAL_POLL_VOTED, user.username) return super().create(request, *args, **kwargs) @@ -257,7 +256,7 @@ def get(self, request: Request, poll_id: int) -> Response: .order_by("date") ) - statistics: VoteStatistics = { + statistics: dict[str, Any] = { "time_series": time_series, "poll_statistics": get_demographic_breakdown(poll_id), } @@ -286,26 +285,28 @@ class Posts(viewsets.ModelViewSet[Post]): serializer_class = PostSerializer def get_queryset(self) -> PostQuerySet: + user = get_auth_user(self.request) return ( Post.objects.all() - if self.request.user.is_superuser + if user.is_superuser else Post.objects.filter( - club_code__in=[x["club"]["code"] for x in get_user_clubs(self.request.user)] + club_code__in=[x["club"]["code"] for x in get_user_clubs(user)] ) ) @action(detail=False, methods=["get"]) - def browse(self, request: Request) -> Response: + def browse(self, request: AuthRequest) -> Response: """ Returns a list of all posts that are targeted at the current user For admins, returns list of posts that they have not approved and have yet to expire """ + user = get_auth_user(request) unfiltered_posts = ( Post.objects.filter( Q(status=Post.STATUS_DRAFT) | Q(status=Post.STATUS_APPROVED), expire_date__gte=timezone.localtime(), ) - if request.user.is_superuser + if user.is_superuser else Post.objects.filter( status=Post.STATUS_APPROVED, start_date__lte=timezone.localtime(), diff --git a/backend/setup.cfg b/backend/setup.cfg index 278639ed..afca0145 100644 --- a/backend/setup.cfg +++ b/backend/setup.cfg @@ -2,7 +2,8 @@ max-line-length = 100 exclude = .venv, migrations inline-quotes = double -ignore = E203, W503 +ignore = E203, W503, E704 +extend-select = F841, F401 [isort] default_section = THIRDPARTY diff --git a/backend/sublet/admin.py b/backend/sublet/admin.py index 0dbdd521..8e7fa78a 100644 --- a/backend/sublet/admin.py +++ b/backend/sublet/admin.py @@ -1,15 +1,15 @@ from django.contrib import admin -from django.utils.html import mark_safe +from django.utils.safestring import SafeText, mark_safe from sublet.models import Amenity, Offer, Sublet, SubletImage class SubletAdmin(admin.ModelAdmin): - def image_tag(self, instance): + def image_tag(self, instance: Sublet) -> SafeText: images = ['' for image in instance.images.all()] return mark_safe("
".join(images)) - image_tag.short_description = "Sublet Images" + image_tag.short_description = "Sublet Images" # type: ignore[attr-defined] readonly_fields = ("image_tag",) diff --git a/backend/sublet/migrations/0001_initial.py b/backend/sublet/migrations/0001_initial.py index 5245debc..7e7f7948 100644 --- a/backend/sublet/migrations/0001_initial.py +++ b/backend/sublet/migrations/0001_initial.py @@ -10,16 +10,12 @@ class Migration(migrations.Migration): initial = True - dependencies = [ - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ] + dependencies = [migrations.swappable_dependency(settings.AUTH_USER_MODEL)] operations = [ migrations.CreateModel( name="Amenity", - fields=[ - ("name", models.CharField(max_length=255, primary_key=True, serialize=False)), - ], + fields=[("name", models.CharField(max_length=255, primary_key=True, serialize=False))], ), migrations.CreateModel( name="Offer", diff --git a/backend/sublet/migrations/0002_auto_20240209_1649.py b/backend/sublet/migrations/0002_auto_20240209_1649.py index 35943aab..85d431b6 100644 --- a/backend/sublet/migrations/0002_auto_20240209_1649.py +++ b/backend/sublet/migrations/0002_auto_20240209_1649.py @@ -5,23 +5,12 @@ class Migration(migrations.Migration): - dependencies = [ - ("sublet", "0001_initial"), - ] + dependencies = [("sublet", "0001_initial")] operations = [ - migrations.RenameField( - model_name="sublet", - old_name="max_price", - new_name="price", - ), - migrations.RemoveField( - model_name="sublet", - name="min_price", - ), + migrations.RenameField(model_name="sublet", old_name="max_price", new_name="price"), + migrations.RemoveField(model_name="sublet", name="min_price"), migrations.AddField( - model_name="sublet", - name="negotiable", - field=models.BooleanField(default=True), + model_name="sublet", name="negotiable", field=models.BooleanField(default=True) ), ] diff --git a/backend/sublet/migrations/0003_alter_sublet_baths.py b/backend/sublet/migrations/0003_alter_sublet_baths.py index 7f8b65b9..bc0e7280 100644 --- a/backend/sublet/migrations/0003_alter_sublet_baths.py +++ b/backend/sublet/migrations/0003_alter_sublet_baths.py @@ -5,14 +5,12 @@ class Migration(migrations.Migration): - dependencies = [ - ("sublet", "0002_auto_20240209_1649"), - ] + dependencies = [("sublet", "0002_auto_20240209_1649")] operations = [ migrations.AlterField( model_name="sublet", name="baths", field=models.DecimalField(blank=True, decimal_places=1, max_digits=3, null=True), - ), + ) ] diff --git a/backend/sublet/migrations/0004_alter_sublet_external_link.py b/backend/sublet/migrations/0004_alter_sublet_external_link.py index 5e01a6ce..01a30b39 100644 --- a/backend/sublet/migrations/0004_alter_sublet_external_link.py +++ b/backend/sublet/migrations/0004_alter_sublet_external_link.py @@ -5,14 +5,12 @@ class Migration(migrations.Migration): - dependencies = [ - ("sublet", "0003_alter_sublet_baths"), - ] + dependencies = [("sublet", "0003_alter_sublet_baths")] operations = [ migrations.AlterField( model_name="sublet", name="external_link", field=models.URLField(blank=True, max_length=255, null=True), - ), + ) ] diff --git a/backend/sublet/models.py b/backend/sublet/models.py index e391053a..4447b9d4 100644 --- a/backend/sublet/models.py +++ b/backend/sublet/models.py @@ -1,5 +1,6 @@ from django.contrib.auth import get_user_model from django.db import models +from django.db.models import QuerySet from phonenumber_field.modelfields import PhoneNumberField @@ -10,49 +11,64 @@ class Offer(models.Model): class Meta: constraints = [models.UniqueConstraint(fields=["user", "sublet"], name="unique_offer")] - user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="offers_made") - sublet = models.ForeignKey("Sublet", on_delete=models.CASCADE, related_name="offers") - email = models.EmailField(max_length=255, null=True, blank=True) - phone_number = PhoneNumberField(null=True, blank=True) - message = models.CharField(max_length=255, blank=True) - created_date = models.DateTimeField(auto_now_add=True) + id: int + user: models.ForeignKey = models.ForeignKey( + User, on_delete=models.CASCADE, related_name="offers_made" + ) + sublet: models.ForeignKey = models.ForeignKey( + "Sublet", on_delete=models.CASCADE, related_name="offers" + ) + email: models.EmailField = models.EmailField(max_length=255, null=True, blank=True) + phone_number: PhoneNumberField = PhoneNumberField(null=True, blank=True) + message: models.CharField = models.CharField(max_length=255, blank=True) + created_date: models.DateTimeField = models.DateTimeField(auto_now_add=True) - def __str__(self): + def __str__(self) -> str: return f"Offer for {self.sublet} made by {self.user}" class Amenity(models.Model): - name = models.CharField(max_length=255, primary_key=True) + name: models.CharField = models.CharField(max_length=255, primary_key=True) - def __str__(self): + def __str__(self) -> str: return self.name class Sublet(models.Model): - subletter = models.ForeignKey(User, on_delete=models.CASCADE) - sublettees = models.ManyToManyField( + id: int + subletter: models.ForeignKey = models.ForeignKey(User, on_delete=models.CASCADE) + sublettees: models.ManyToManyField = models.ManyToManyField( User, through=Offer, related_name="sublets_offered", blank=True ) - favorites = models.ManyToManyField(User, related_name="sublets_favorited", blank=True) - amenities = models.ManyToManyField(Amenity, blank=True) - - title = models.CharField(max_length=255) - address = models.CharField(max_length=255, null=True, blank=True) - beds = models.IntegerField(null=True, blank=True) - baths = models.DecimalField(max_digits=3, decimal_places=1, null=True, blank=True) - description = models.TextField(null=True, blank=True) - external_link = models.URLField(max_length=255, null=True, blank=True) - price = models.IntegerField() - negotiable = models.BooleanField(default=True) - created_at = models.DateTimeField(auto_now_add=True) - expires_at = models.DateTimeField() - start_date = models.DateField() - end_date = models.DateField() - - def __str__(self): + favorites: models.ManyToManyField = models.ManyToManyField( + User, related_name="sublets_favorited", blank=True + ) + amenities: models.ManyToManyField = models.ManyToManyField(Amenity, blank=True) + + title: models.CharField = models.CharField(max_length=255) + address: models.CharField = models.CharField(max_length=255, null=True, blank=True) + beds: models.IntegerField = models.IntegerField(null=True, blank=True) + baths: models.DecimalField = models.DecimalField( + max_digits=3, decimal_places=1, null=True, blank=True + ) + description: models.TextField = models.TextField(null=True, blank=True) + external_link: models.URLField = models.URLField(max_length=255, null=True, blank=True) + price: models.IntegerField = models.IntegerField() + negotiable: models.BooleanField = models.BooleanField(default=True) + created_at: models.DateTimeField = models.DateTimeField(auto_now_add=True) + expires_at: models.DateTimeField = models.DateTimeField() + start_date: models.DateField = models.DateField() + end_date: models.DateField = models.DateField() + + images: QuerySet + + def __str__(self) -> str: return f"{self.title} by {self.subletter}" class SubletImage(models.Model): - sublet = models.ForeignKey(Sublet, on_delete=models.CASCADE, related_name="images") - image = models.ImageField(upload_to="sublet/images") + id: int + sublet: models.ForeignKey = models.ForeignKey( + Sublet, on_delete=models.CASCADE, related_name="images" + ) + image: models.ImageField = models.ImageField(upload_to="sublet/images") diff --git a/backend/sublet/permissions.py b/backend/sublet/permissions.py index c1aeb314..69a5f3de 100644 --- a/backend/sublet/permissions.py +++ b/backend/sublet/permissions.py @@ -1,4 +1,9 @@ +from typing import Any + from rest_framework import permissions +from rest_framework.request import Request + +from utils.types import get_auth_user class IsSuperUser(permissions.BasePermission): @@ -6,11 +11,11 @@ class IsSuperUser(permissions.BasePermission): Grants permission if the current user is a superuser. """ - def has_object_permission(self, request, view, obj): - return request.user.is_superuser + def has_object_permission(self, request: Request, view: Any, obj: Any) -> bool: + return get_auth_user(request).is_superuser - def has_permission(self, request, view): - return request.user.is_superuser + def has_permission(self, request: Request, view: Any) -> bool: + return get_auth_user(request).is_superuser class SubletOwnerPermission(permissions.BasePermission): @@ -18,14 +23,14 @@ class SubletOwnerPermission(permissions.BasePermission): Custom permission to allow the owner of a Sublet to edit or delete it. """ - def has_permission(self, request, view): - return request.user.is_authenticated + def has_permission(self, request: Request, view: Any) -> bool: + return get_auth_user(request).is_authenticated - def has_object_permission(self, request, view, obj): + def has_object_permission(self, request: Request, view: Any, obj: Any) -> bool: # Check if the user is the owner of the Sublet. if request.method in permissions.SAFE_METHODS: return True - return obj.subletter == request.user + return obj.subletter == get_auth_user(request) class SubletImageOwnerPermission(permissions.BasePermission): @@ -33,12 +38,14 @@ class SubletImageOwnerPermission(permissions.BasePermission): Custom permission to allow the owner of a SubletImage to edit or delete it. """ - def has_permission(self, request, view): - return request.user.is_authenticated + def has_permission(self, request: Request, view: Any) -> bool: + return get_auth_user(request).is_authenticated - def has_object_permission(self, request, view, obj): + def has_object_permission(self, request: Request, view: Any, obj: Any) -> bool: # Check if the user is the owner of the Sublet. - return request.method in permissions.SAFE_METHODS or obj.sublet.subletter == request.user + return request.method in permissions.SAFE_METHODS or obj.sublet.subletter == get_auth_user( + request + ) class OfferOwnerPermission(permissions.BasePermission): @@ -46,12 +53,12 @@ class OfferOwnerPermission(permissions.BasePermission): Custom permission to allow owner of an offer to delete it. """ - def has_permission(self, request, view): - return request.user.is_authenticated + def has_permission(self, request: Request, view: Any) -> bool: + return get_auth_user(request).is_authenticated - def has_object_permission(self, request, view, obj): + def has_object_permission(self, request: Request, view: Any, obj: Any) -> bool: if request.method in permissions.SAFE_METHODS: # Check if the user owns the sublet when getting list - return obj.subletter == request.user + return obj.subletter == get_auth_user(request) # This is redundant, here for safety - return obj.user == request.user + return obj.user == get_auth_user(request) diff --git a/backend/sublet/serializers.py b/backend/sublet/serializers.py index 5622ae07..e211b675 100644 --- a/backend/sublet/serializers.py +++ b/backend/sublet/serializers.py @@ -1,12 +1,13 @@ -from datetime import datetime from typing import Any, Optional, cast from phonenumber_field.serializerfields import PhoneNumberField from profanity_check import predict from rest_framework import serializers +from rest_framework.relations import PrimaryKeyRelatedField from rest_framework.request import Request from sublet.models import Amenity, Offer, Sublet, SubletImage +from utils.types import get_auth_user class BaseModelSerializer(serializers.ModelSerializer): @@ -15,7 +16,7 @@ def get_request(self) -> Request: class AmenitySerializer(BaseModelSerializer): - name: str = serializers.CharField(max_length=255) + name = serializers.CharField(max_length=255) class Meta: model = Amenity @@ -23,10 +24,10 @@ class Meta: class OfferSerializer(BaseModelSerializer): - phone_number: str = PhoneNumberField() - email: Optional[str] = serializers.EmailField(allow_null=True) - message: str = serializers.CharField(max_length=255) - created_date: datetime = serializers.DateTimeField(read_only=True) + phone_number = PhoneNumberField() + email = serializers.EmailField(allow_null=True) + message = serializers.CharField(max_length=255) + created_date = serializers.DateTimeField(read_only=True) class Meta: model = Offer @@ -51,7 +52,7 @@ class Meta: class SubletImageURLSerializer(BaseModelSerializer): image_url = serializers.SerializerMethodField("get_image_url") - def get_image_url(self, obj) -> Optional[str]: + def get_image_url(self, obj: SubletImage) -> Optional[str]: if not obj.image: return None @@ -71,7 +72,7 @@ class Meta: class SubletSerializer(BaseModelSerializer): # amenities = AmenitySerializer(many=True, required=False) # images = SubletImageURLSerializer(many=True, required=False) - amenities: list[Amenity] = serializers.PrimaryKeyRelatedField( + amenities: PrimaryKeyRelatedField = serializers.PrimaryKeyRelatedField( many=True, queryset=Amenity.objects.all(), required=False ) @@ -126,7 +127,7 @@ def create(self, validated_data: dict[str, Any]) -> Sublet: # delete_images is a list of image ids to delete def update(self, instance: Sublet, validated_data: dict[str, Any]) -> Sublet: # Check if the user is the subletter before allowing the update - user = self.get_request().user + user = get_auth_user(self.get_request()) if user == instance.subletter or user.is_superuser: instance = super().update(instance, validated_data) instance.save() @@ -135,7 +136,7 @@ def update(self, instance: Sublet, validated_data: dict[str, Any]) -> Sublet: def destroy(self, instance: Sublet) -> None: # Check if the user is the subletter before allowing the delete - user = self.get_request().user + user = get_auth_user(self.get_request()) if user == instance.subletter or user.is_superuser: instance.delete() else: @@ -143,10 +144,10 @@ def destroy(self, instance: Sublet) -> None: class SubletSerializerRead(BaseModelSerializer): - amenities: list[Amenity] = serializers.PrimaryKeyRelatedField( + amenities: PrimaryKeyRelatedField = serializers.PrimaryKeyRelatedField( many=True, queryset=Amenity.objects.all(), required=False ) - images: list[SubletImage] = SubletImageURLSerializer(many=True, required=False) + images = SubletImageURLSerializer(many=True, required=False) class Meta: model = Sublet @@ -178,10 +179,10 @@ def to_representation(self, instance: Sublet) -> dict[str, Any]: # simple sublet serializer for use when pulling all serializers/etc class SubletSerializerSimple(BaseModelSerializer): - amenities: list[Amenity] = serializers.PrimaryKeyRelatedField( + amenities: PrimaryKeyRelatedField = serializers.PrimaryKeyRelatedField( many=True, queryset=Amenity.objects.all(), required=False ) - images: list[SubletImage] = SubletImageURLSerializer(many=True, required=False) + images = SubletImageURLSerializer(many=True, required=False) class Meta: model = Sublet diff --git a/backend/sublet/views.py b/backend/sublet/views.py index 3509a1aa..c749514c 100644 --- a/backend/sublet/views.py +++ b/backend/sublet/views.py @@ -1,7 +1,7 @@ -from typing import Any, TypeAlias +from typing import Any, Type, TypeAlias, cast -from django.contrib.auth import get_user_model from django.db.models import Manager, QuerySet, prefetch_related_objects +from django.http import QueryDict from django.utils import timezone from django.utils.dateparse import parse_date from rest_framework import exceptions, generics, mixins, status, viewsets @@ -21,6 +21,7 @@ ) from sublet.serializers import ( AmenitySerializer, + BaseModelSerializer, OfferSerializer, SubletImageSerializer, SubletImageURLSerializer, @@ -28,16 +29,15 @@ SubletSerializerRead, SubletSerializerSimple, ) +from utils.types import get_user SubletQuerySet: TypeAlias = QuerySet[Sublet, Manager[Sublet]] OfferQuerySet: TypeAlias = QuerySet[Offer, Manager[Offer]] +AmenityQuerySet: TypeAlias = QuerySet[Amenity, Manager[Amenity]] ImageList: TypeAlias = QuerySet[SubletImage, Manager[SubletImage]] -FavoriteQuerySet: TypeAlias = QuerySet[Sublet, Manager[Sublet]] UserOfferQuerySet: TypeAlias = QuerySet[Offer, Manager[Offer]] -User = get_user_model() - class Amenities(generics.ListAPIView): serializer_class = AmenitySerializer @@ -52,9 +52,8 @@ class UserFavorites(generics.ListAPIView): serializer_class = SubletSerializerSimple permission_classes = [IsAuthenticated] - def get_queryset(self) -> FavoriteQuerySet: - user = self.request.user - return user.sublets_favorited + def get_queryset(self) -> SubletQuerySet: + return get_user(self.request).sublets_favorited class UserOffers(generics.ListAPIView): @@ -62,8 +61,7 @@ class UserOffers(generics.ListAPIView): permission_classes = [IsAuthenticated] def get_queryset(self) -> UserOfferQuerySet: - user = self.request.user - return Offer.objects.filter(user=user) + return Offer.objects.filter(user=get_user(self.request)) class Properties(viewsets.ModelViewSet): @@ -83,7 +81,7 @@ class Properties(viewsets.ModelViewSet): permission_classes = [SubletOwnerPermission | IsSuperUser] - def get_serializer_class(self): + def get_serializer_class(self) -> Type[BaseModelSerializer]: return SubletSerializerRead if self.action == "retrieve" else SubletSerializer def get_queryset(self) -> SubletQuerySet: @@ -95,7 +93,7 @@ def create(self, request: Request, *args: Any, **kwargs: Any) -> Response: instance = serializer.save() # Create the Sublet instance_serializer = SubletSerializerRead(instance=instance, context={"request": request}) - record_analytics(Metric.SUBLET_CREATED, request.user.username) + record_analytics(Metric.SUBLET_CREATED, get_user(request).username) return Response(instance_serializer.data, status=status.HTTP_201_CREATED) @@ -108,7 +106,7 @@ def update(self, request: Request, *args: Any, **kwargs: Any) -> Response: queryset = self.filter_queryset(self.get_queryset()) # no clue what this does but I copied it from the DRF source code - if queryset._prefetch_related_lookups: + if hasattr(queryset, "_prefetch_related_lookups"): # If 'prefetch_related' has been applied to a queryset, we need to # forcibly invalidate the prefetch cache on the instance, # and then re-prefetch related objects @@ -142,7 +140,7 @@ def list(self, request: Request, *args: Any, **kwargs: Any) -> Response: queryset: SubletQuerySet = self.get_queryset() if params.get("subletter", "false").lower() == "true": - queryset = queryset.filter(subletter=request.user) + queryset = queryset.filter(subletter=get_user(request)) else: queryset = queryset.filter(expires_at__gte=timezone.now()) @@ -188,7 +186,7 @@ def list(self, request: Request, *args: Any, **kwargs: Any) -> Response: for amenity in amenities: queryset = queryset.filter(amenities__name=amenity) - record_analytics(Metric.SUBLET_BROWSE, request.user.username) + record_analytics(Metric.SUBLET_BROWSE, get_user(request).username) serializer = SubletSerializerSimple(queryset, many=True) return Response(serializer.data) @@ -198,10 +196,7 @@ class CreateImages(generics.CreateAPIView): serializer_class = SubletImageSerializer http_method_names = ["post"] permission_classes = [SubletImageOwnerPermission | IsSuperUser] - parser_classes = ( - MultiPartParser, - FormParser, - ) + parser_classes = (MultiPartParser, FormParser) def get_queryset(self, *args: Any, **kwargs: Any) -> ImageList: sublet = get_object_or_404(Sublet, id=int(self.kwargs["sublet_id"])) @@ -209,7 +204,8 @@ def get_queryset(self, *args: Any, **kwargs: Any) -> ImageList: # takes an image multipart form data and creates a new image object def post(self, request: Request, *args: Any, **kwargs: Any) -> Response: - images = request.data.getlist("images") + data = cast(QueryDict, request.data) + images = data.getlist("images") sublet_id = int(self.kwargs["sublet_id"]) self.get_queryset() # check if sublet exists img_serializers = [] @@ -218,8 +214,10 @@ def post(self, request: Request, *args: Any, **kwargs: Any) -> Response: img_serializer.is_valid(raise_exception=True) img_serializers.append(img_serializer) instances = [img_serializer.save() for img_serializer in img_serializers] - data = [SubletImageURLSerializer(instance=instance).data for instance in instances] - return Response(data, status=status.HTTP_201_CREATED) + serialized_data = [ + SubletImageURLSerializer(instance=instance).data for instance in instances + ] + return Response(serialized_data, status=status.HTTP_201_CREATED) class DeleteImage(generics.DestroyAPIView): @@ -231,7 +229,7 @@ class DeleteImage(generics.DestroyAPIView): def destroy(self, request: Request, *args: Any, **kwargs: Any) -> Response: queryset = self.get_queryset() filter = {"id": self.kwargs["image_id"]} - obj = get_object_or_404(queryset, **filter) + obj: SubletImage = get_object_or_404(queryset, **filter) # checking permissions here is kind of redundant self.check_object_permissions(self.request, obj) self.perform_destroy(obj) @@ -243,8 +241,8 @@ class Favorites(mixins.DestroyModelMixin, mixins.CreateModelMixin, viewsets.Gene http_method_names = ["post", "delete"] permission_classes = [IsAuthenticated | IsSuperUser] - def get_queryset(self) -> FavoriteQuerySet: - user = self.request.user + def get_queryset(self) -> SubletQuerySet: + user = get_user(self.request) return user.sublets_favorited def create(self, request: Request, *args: Any, **kwargs: Any) -> Response: @@ -253,16 +251,16 @@ def create(self, request: Request, *args: Any, **kwargs: Any) -> Response: if queryset.filter(id=sublet_id).exists(): raise exceptions.NotAcceptable("Favorite already exists") sublet = get_object_or_404(Sublet, id=sublet_id) - self.get_queryset().add(sublet) + get_user(self.request).sublets_favorited.add(sublet) - record_analytics(Metric.SUBLET_FAVORITED, request.user.username) + record_analytics(Metric.SUBLET_FAVORITED, get_user(request).username) return Response(status=status.HTTP_201_CREATED) def destroy(self, request: Request, *args: Any, **kwargs: Any) -> Response: - queryset = self.get_queryset() - sublet = get_object_or_404(queryset, pk=int(self.kwargs["sublet_id"])) - self.get_queryset().remove(sublet) + queryset = cast(QuerySet, self.get_queryset()) + sublet: Sublet = get_object_or_404(queryset, pk=int(self.kwargs["sublet_id"])) + get_user(self.request).sublets_favorited.remove(sublet) return Response(status=status.HTTP_204_NO_CONTENT) @@ -288,22 +286,23 @@ def get_queryset(self) -> OfferQuerySet: def create(self, request: Request, *args: Any, **kwargs: Any) -> Response: data = request.data - request.POST._mutable = True - if self.get_queryset().filter(user=self.request.user).exists(): + if isinstance(data, QueryDict): + data._mutable = True + if self.get_queryset().filter(user=get_user(self.request)).exists(): raise exceptions.NotAcceptable("Offer already exists") data["sublet"] = int(self.kwargs["sublet_id"]) - data["user"] = self.request.user.id + data["user"] = get_user(self.request).id serializer = self.get_serializer(data=request.data) serializer.is_valid(raise_exception=True) serializer.save() - record_analytics(Metric.SUBLET_OFFER, request.user.username) + record_analytics(Metric.SUBLET_OFFER, get_user(request).username) return Response(serializer.data, status=status.HTTP_201_CREATED) def destroy(self, request: Request, *args: Any, **kwargs: Any) -> Response: queryset = self.get_queryset() - filter = {"user": self.request.user.id, "sublet": int(self.kwargs["sublet_id"])} + filter = {"user": get_user(self.request).id, "sublet": int(self.kwargs["sublet_id"])} obj: Offer = get_object_or_404(queryset, **filter) # checking permissions here is kind of redundant self.check_object_permissions(self.request, obj) diff --git a/backend/tests/dining/test_load_venues.py b/backend/tests/dining/test_load_venues.py index 596027ab..91b66586 100644 --- a/backend/tests/dining/test_load_venues.py +++ b/backend/tests/dining/test_load_venues.py @@ -7,13 +7,13 @@ class TestLoadVenues(TestCase): - def test(self): + def test(self) -> None: out = StringIO() call_command("load_venues", stdout=out) self.assertEqual(len(Venue.objects.all()), 16) - list_of_ids = [] + list_of_ids: list[int] = [] for venue in Venue.objects.all(): self.assertNotIn(venue.venue_id, list_of_ids) list_of_ids.append(venue.venue_id) diff --git a/backend/tests/dining/test_views.py b/backend/tests/dining/test_views.py index 2ab0c474..82d18049 100644 --- a/backend/tests/dining/test_views.py +++ b/backend/tests/dining/test_views.py @@ -1,8 +1,8 @@ import datetime import json +from typing import Any from unittest import mock -from django.contrib.auth import get_user_model from django.core.management import call_command from django.test import TestCase from django.urls import reverse @@ -10,18 +10,16 @@ from dining.api_wrapper import APIError, DiningAPIWrapper from dining.models import DiningMenu, Venue +from utils.types import DjangoUserModel, UserType -User = get_user_model() - - -def mock_dining_requests(url, *args, **kwargs): +def mock_dining_requests(url: str, *args: Any, **kwargs: Any) -> Any: class Mock: - def __init__(self, json_data, status_code): + def __init__(self, json_data: dict, status_code: int) -> None: self.json_data = json_data self.status_code = status_code - def json(self): + def json(self) -> dict: return self.json_data if "token" in url: @@ -37,23 +35,23 @@ def json(self): return Mock(json.load(data), 200) -def mock_request_raise_error(*args, **kwargs): +def mock_request_raise_error(*args: Any, **kwargs: Any) -> None: raise ConnectionError -def mock_request_post_error(*args, **kwargs): +def mock_request_post_error(*args: Any, **kwargs: Any) -> Any: class Mock: - def json(self): + def json(self) -> dict: return {"error": None} return Mock() class TestTokenAndRequest(TestCase): - def setUp(self): + def setUp(self) -> None: self.wrapper = DiningAPIWrapper() - def test_expired_token(self): + def test_expired_token(self) -> None: self.wrapper.expiration += datetime.timedelta(days=1) prev_token = self.wrapper.token prev_expiration = self.wrapper.expiration @@ -65,19 +63,19 @@ def test_expired_token(self): self.assertEqual(prev_expiration, self.wrapper.expiration) @mock.patch("requests.post", mock_request_post_error) - def test_update_token_error(self): + def test_update_token_error(self) -> None: with self.assertRaises(APIError): self.wrapper.update_token() @mock.patch("requests.post", mock_dining_requests) @mock.patch("requests.request", lambda **kwargs: None) - def test_request_headers_update(self): + def test_request_headers_update(self) -> None: res = self.wrapper.request(headers=dict()) self.assertIsNone(res) @mock.patch("requests.post", mock_dining_requests) @mock.patch("requests.request", mock_request_raise_error) - def test_request_api_error(self): + def test_request_api_error(self) -> None: with self.assertRaises(APIError): self.wrapper.request() @@ -85,10 +83,10 @@ def test_request_api_error(self): @mock.patch("requests.post", mock_dining_requests) @mock.patch("requests.request", mock_dining_requests) class TestVenues(TestCase): - def setUp(self): + def setUp(self) -> None: call_command("load_venues") - def test_get(self): + def test_get(self) -> None: response = self.client.get(reverse("venues")) for entry in response.json(): self.assertIn("name", entry) @@ -104,7 +102,7 @@ def test_get(self): class TestMenus(TestCase): @mock.patch("requests.post", mock_dining_requests) @mock.patch("requests.request", mock_dining_requests) - def setUp(self): + def setUp(self) -> None: Venue.objects.create( venue_id=593, name="1920 Commons", @@ -112,7 +110,7 @@ def setUp(self): ) call_command("load_next_menu") - def try_structure(self, data): + def try_structure(self, data: list[dict]) -> None: for entry in data: self.assertIn("venue", entry) self.assertIn("date", entry) @@ -131,16 +129,16 @@ def try_structure(self, data): self.assertIn("allergens", item) self.assertIn("nutrition_info", item) - def test_get_default(self): + def test_get_default(self) -> None: response = self.client.get(reverse("menus")) self.try_structure(response.json()) - def test_get_date(self): + def test_get_date(self) -> None: response = self.client.get("/dining/menus/2022-10-04/") self.try_structure(response.json()) @mock.patch("requests.request", mock_dining_requests) - def test_skip_venue(self): + def test_skip_venue(self) -> None: Venue.objects.all().delete() Venue.objects.create(venue_id=747, name="Skip", image_url="URL") wrapper = DiningAPIWrapper() @@ -149,11 +147,11 @@ def test_skip_venue(self): class TestPreferences(TestCase): - def setUp(self): + def setUp(self) -> None: call_command("load_venues") - self.client = APIClient() + self.client: APIClient = APIClient() - self.test_user = User.objects.create_user("user", "user@a.com", "user") + self.test_user: UserType = DjangoUserModel.objects.create_user("user", "user@a.com", "user") preference = self.test_user.profile.dining_preferences preference.add(Venue.objects.get(venue_id=593)) @@ -163,7 +161,7 @@ def setUp(self): preference.add(Venue.objects.get(venue_id=636)) preference.add(Venue.objects.get(venue_id=637)) - def test_get(self): + def test_get(self) -> None: self.client.force_authenticate(user=self.test_user) response = self.client.get(reverse("dining-preferences")) @@ -177,7 +175,7 @@ def test_get(self): else: self.assertEqual(item["count"], 1) - def test_post(self): + def test_post(self) -> None: self.client.force_authenticate(user=self.test_user) self.client.post( reverse("dining-preferences"), diff --git a/backend/tests/gsr_booking/test_gsr_booking.py b/backend/tests/gsr_booking/test_gsr_booking.py index 1559dbbd..89b0bdb0 100644 --- a/backend/tests/gsr_booking/test_gsr_booking.py +++ b/backend/tests/gsr_booking/test_gsr_booking.py @@ -1,89 +1,87 @@ -from django.contrib.auth import get_user_model from django.test import TestCase from rest_framework.test import APIClient from gsr_booking.models import Group, GroupMembership +from utils.types import DjangoUserModel, UserType -User = get_user_model() - - -class UserViewTestCase(TestCase): - def setUp(self): - self.user1 = User.objects.create_user( +class MyMembershipViewTestCase(TestCase): + def setUp(self) -> None: + self.user1: UserType = DjangoUserModel.objects.create_user( username="user1", password="password", first_name="user", last_name="one" ) - self.user2 = User.objects.create_user( + self.user2: UserType = DjangoUserModel.objects.create_user( username="user2", password="password", first_name="user", last_name="two" ) - self.group = Group.objects.create(owner=self.user1, name="g1", color="blue") - self.group.members.add(self.user1) - memship = self.group.memberships.all()[0] - memship.accepted = True - memship.save() + Group.objects.create( + owner=self.user1, name="g1", color="blue" + ) # creating group also adds user + group2 = Group.objects.create(owner=self.user2, name="g2", color="blue") + GroupMembership.objects.create(user=self.user1, group=group2, accepted=True) + group3 = Group.objects.create(owner=self.user2, name="g3", color="blue") + GroupMembership.objects.create(user=self.user1, group=group3) self.client = APIClient() self.client.login(username="user1", password="password") - def test_user_list(self): - response = self.client.get("/gsr/users/") + def test_user_memberships(self) -> None: + response = self.client.get("/gsr/mymemberships/") self.assertTrue(200, response.status_code) - self.assertEqual(2, len(response.data)) + self.assertEqual(2, len(response.data)) # type: ignore[attr-defined] - def test_user_detail_in_group(self): - response = self.client.get("/gsr/users/user1/") + def test_user_invites(self) -> None: + response = self.client.get("/gsr/mymemberships/invites/") self.assertTrue(200, response.status_code) - self.assertEqual(2, len(response.data["booking_groups"])) - - def test_me_user_detail_in_group(self): - response = self.client.get("/gsr/users/me/") - self.assertTrue(200, response.status_code) - self.assertEqual(2, len(response.data["booking_groups"])) + self.assertEqual(1, len(response.data)) # type: ignore[attr-defined] class MembershipViewTestCase(TestCase): - def setUp(self): - self.user1 = User.objects.create_user(username="user1", password="password") - self.user2 = User.objects.create_user(username="user2", password="password") + def setUp(self) -> None: + self.user1: UserType = DjangoUserModel.objects.create_user( + username="user1", password="password" + ) + self.user2: UserType = DjangoUserModel.objects.create_user( + username="user2", password="password" + ) self.group = Group.objects.create(owner=self.user1, name="g1", color="blue") self.group2 = Group.objects.create(owner=self.user2, name="g2", color="white") - self.client = APIClient() + self.client: APIClient = APIClient() self.client.login(username="user1", password="password") - def test_invite_single(self): + def test_invite_single(self) -> None: self.client.login(username="user2", password="password") response = self.client.post( "/gsr/membership/invite/", {"user": "user2", "group": self.group.pk} ) self.assertEqual(200, response.status_code) - def test_bulk_invite(self): - User.objects.create_user(username="user3", password="password") + def test_bulk_invite(self) -> None: + DjangoUserModel.objects.create_user(username="user3", password="password") self.client.login(username="user2", password="password") response = self.client.post( "/gsr/membership/invite/", {"user": "user2,user3", "group": self.group.pk} ) self.assertEqual(200, response.status_code) - def test_invite_no_permission(self): + def test_invite_no_permission(self) -> None: self.client.login(username="user2", password="password") response = self.client.post( "/gsr/membership/invite/", {"user": "user2", "group": self.group.pk} ) self.assertEqual(200, response.status_code) - def test_invite_logged_out_fails(self): + def test_invite_logged_out_fails(self) -> None: self.client.logout() response = self.client.post( "/gsr/membership/invite/", {"user": "user2", "group": self.group.pk} ) self.assertEqual(403, response.status_code) - def test_invite_bad_group_fails(self): + def test_invite_bad_group_fails(self) -> None: response = self.client.post("/gsr/membership/invite/", {"user": "user2", "group": 300}) self.assertEqual(404, response.status_code) - def test_duplicate_invite_fails(self): + def test_duplicate_invite_fails(self) -> None: GroupMembership.objects.create(user=self.user2, group=self.group, accepted=False) self.client.force_authenticate(user=self.user2) response = self.client.post( @@ -91,7 +89,7 @@ def test_duplicate_invite_fails(self): ) self.assertEqual(403, response.status_code) - def test_already_member_invite_fails(self): + def test_already_member_invite_fails(self) -> None: GroupMembership.objects.create(user=self.user2, group=self.group, accepted=True) self.client.force_authenticate(user=self.user2) response = self.client.post( @@ -99,44 +97,44 @@ def test_already_member_invite_fails(self): ) self.assertEqual(403, response.status_code) - def test_accept_invite(self): + def test_accept_invite(self) -> None: mem = GroupMembership.objects.create(user=self.user1, group=self.group2, accepted=False) response = self.client.post(f"/gsr/membership/{mem.pk}/accept/") self.assertEqual(200, response.status_code) self.assertTrue(GroupMembership.objects.get(pk=mem.pk).accepted) - def test_wrong_user_accept_invite_fails(self): - user3 = User.objects.create_user(username="user3", password="password") + def test_wrong_user_accept_invite_fails(self) -> None: + user3: UserType = DjangoUserModel.objects.create_user(username="user3", password="password") mem = GroupMembership.objects.create(user=user3, group=self.group2, accepted=False) response = self.client.post(f"/gsr/membership/{mem.pk}/accept/") self.assertEqual(403, response.status_code) self.assertFalse(GroupMembership.objects.get(pk=mem.pk).accepted) - def test_accept_invite_again_fails(self): + def test_accept_invite_again_fails(self) -> None: mem = GroupMembership.objects.create(user=self.user1, group=self.group2, accepted=True) response = self.client.post(f"/gsr/membership/{mem.pk}/accept/") self.assertEqual(404, response.status_code) - def test_decline_invite(self): + def test_decline_invite(self) -> None: mem = GroupMembership.objects.create(user=self.user1, group=self.group2, accepted=False) response = self.client.post(f"/gsr/membership/{mem.pk}/decline/") self.assertEqual(200, response.status_code) self.assertFalse(GroupMembership.objects.filter(pk=mem.pk).exists()) - def test_wrong_user_decline_invite_fails(self): - user3 = User.objects.create_user(username="user3", password="password") + def test_wrong_user_decline_invite_fails(self) -> None: + user3: UserType = DjangoUserModel.objects.create_user(username="user3", password="password") mem = GroupMembership.objects.create(user=user3, group=self.group2, accepted=False) response = self.client.post(f"/gsr/membership/{mem.pk}/decline/") self.assertEqual(403, response.status_code) self.assertTrue(GroupMembership.objects.filter(pk=mem.pk).exists()) self.assertFalse(GroupMembership.objects.get(pk=mem.pk).accepted) - def test_decline_invite_again_fails(self): + def test_decline_invite_again_fails(self) -> None: mem = GroupMembership.objects.create(user=self.user1, group=self.group2, accepted=True) response = self.client.post(f"/gsr/membership/{mem.pk}/decline/") self.assertEqual(404, response.status_code) - def test_promote_to_admin(self): + def test_promote_to_admin(self) -> None: GroupMembership.objects.create(user=self.user1, group=self.group, accepted=True, type="A") mem = GroupMembership.objects.create( user=self.user2, group=self.group, accepted=True, type="M" @@ -148,35 +146,39 @@ def test_promote_to_admin(self): class GroupTestCase(TestCase): - def setUp(self): - self.user1 = User.objects.create_user(username="user1", password="password") - self.user2 = User.objects.create_user(username="user2", password="password") + def setUp(self) -> None: + self.user1: UserType = DjangoUserModel.objects.create_user( + username="user1", password="password" + ) + self.user2: UserType = DjangoUserModel.objects.create_user( + username="user2", password="password" + ) self.group = Group.objects.create(owner=self.user1, name="g1", color="blue") self.group2 = Group.objects.create(owner=self.user2, name="g2", color="white") - self.client = APIClient() + self.client: APIClient = APIClient() self.client.login(username="user1", password="password") - def test_get_groups(self): + def test_get_groups(self) -> None: response = self.client.get("/gsr/groups/") self.assertEqual(200, response.status_code) - self.assertEqual(2, len(response.data)) + self.assertEqual(1, len(response.data)) - def test_get_groups_includes_invites(self): + def test_get_groups_includes_invites(self) -> None: GroupMembership.objects.create(user=self.user1, group=self.group2, accepted=False) response = self.client.get(f"/gsr/groups/{self.group2.pk}/") self.assertEqual(200, response.status_code) - def test_get_group_not_involved_fails(self): + def test_get_group_not_involved_fails(self) -> None: response = self.client.get(f"/gsr/groups/{self.group2.pk}/") self.assertEqual(404, response.status_code) - def test_make_group(self): + def test_make_group(self) -> None: response = self.client.post("/gsr/groups/", {"name": "gx", "color": "blue"}) self.assertEqual(201, response.status_code, response.data) - self.assertEqual(5, Group.objects.count()) + self.assertEqual(3, Group.objects.count()) self.assertEqual("user1", Group.objects.get(name="gx").owner.username) - def test_only_accepted_memberships(self): + def test_only_accepted_memberships(self) -> None: gm = GroupMembership.objects.create(user=self.user2, group=self.group, accepted=False) response = self.client.get(f"/gsr/groups/{self.group.pk}/") self.assertEqual(200, response.status_code) diff --git a/backend/tests/gsr_booking/test_gsr_views.py b/backend/tests/gsr_booking/test_gsr_views.py index c749d2b9..3dcbb772 100644 --- a/backend/tests/gsr_booking/test_gsr_views.py +++ b/backend/tests/gsr_booking/test_gsr_views.py @@ -1,56 +1,56 @@ import json +from typing import Any from unittest import mock -from django.contrib.auth import get_user_model from django.core.management import call_command from django.test import TestCase from django.urls import reverse from rest_framework.test import APIClient from gsr_booking.models import GSR, Group, GSRBooking +from utils.types import DjangoUserModel, UserType -User = get_user_model() - - -def is_wharton_false(*args): +def is_wharton_false(*args: Any) -> bool: return False -def is_wharton_true(*args): +def is_wharton_true(*args: Any) -> bool: return True -def libcal_availability(*args): +def libcal_availability(*args: Any) -> list[dict]: with open("tests/gsr_booking/views_libcal_availability.json") as data: return json.load(data) -def wharton_availability(*args): +def wharton_availability(*args: Any) -> list[dict]: with open("tests/gsr_booking/views_wharton_availability.json") as data: return json.load(data) -def book_cancel_room(*args): +def book_cancel_room(*args: Any) -> None: pass -def reservations(*args): +def reservations(*args: Any) -> list[dict]: with open("tests/gsr_booking/views_reservations.json") as data: return json.load(data) class TestGSRs(TestCase): - def setUp(self): + def setUp(self) -> None: call_command("load_gsrs") - self.user = User.objects.create_user("user", "user@seas.upenn.edu", "user") - self.client = APIClient() + self.user: UserType = DjangoUserModel.objects.create_user( + "user", "user@seas.upenn.edu", "user" + ) + self.client: APIClient = APIClient() self.client.force_authenticate(user=self.user) - test_user = User.objects.create_user("user1", "user") + test_user = DjangoUserModel.objects.create_user("user1", "user") Group.objects.create(owner=test_user, name="Penn Labs", color="blue") - def test_get_location(self): + def test_get_location(self) -> None: response = self.client.get(reverse("locations")) res_json = json.loads(response.content) for entry in res_json: @@ -63,16 +63,18 @@ def test_get_location(self): class TestGSRFunctions(TestCase): - def setUp(self): + def setUp(self) -> None: call_command("load_gsrs") - self.user = User.objects.create_user("user", "user@sas.upenn.edu", "user") - self.client = APIClient() + self.user: UserType = DjangoUserModel.objects.create_user( + "user", "user@sas.upenn.edu", "user" + ) + self.client: APIClient = APIClient() self.client.force_authenticate(user=self.user) - test_user = User.objects.create_user("user1", "user") + test_user = DjangoUserModel.objects.create_user("user1", "user") Group.objects.create(owner=test_user, name="Penn Labs", color="blue") - def test_recent(self): + def test_recent(self) -> None: gsrs = list(GSR.objects.all()) GSRBooking.objects.create(user=self.user, room_id=1, room_name="Room 1", gsr=gsrs[0]) GSRBooking.objects.create(user=self.user, room_id=3, room_name="Room 3", gsr=gsrs[0]) @@ -93,21 +95,21 @@ def test_recent(self): self.assertNotEqual(res_json[0]["id"], res_json[1]["id"]) @mock.patch("gsr_booking.api_wrapper.WhartonBookingWrapper.is_wharton", is_wharton_false) - def test_get_wharton_false(self): + def test_get_wharton_false(self) -> None: response = self.client.get(reverse("is-wharton")) res_json = json.loads(response.content) self.assertEqual(1, len(res_json)) self.assertFalse(res_json["is_wharton"]) @mock.patch("gsr_booking.api_wrapper.WhartonBookingWrapper.is_wharton", is_wharton_true) - def test_get_wharton_true(self): + def test_get_wharton_true(self) -> None: response = self.client.get(reverse("is-wharton")) res_json = json.loads(response.content) self.assertEqual(1, len(res_json)) self.assertTrue(res_json["is_wharton"]) @mock.patch("gsr_booking.api_wrapper.BookingHandler.get_availability", libcal_availability) - def test_availability_libcal(self): + def test_availability_libcal(self) -> None: response = self.client.get(reverse("availability", args=["1086", "1889"])) res_json = json.loads(response.content) self.assertEqual(3, len(res_json)) @@ -121,7 +123,7 @@ def test_availability_libcal(self): self.assertIn("availability", room) @mock.patch("gsr_booking.api_wrapper.BookingHandler.get_availability", wharton_availability) - def test_availability_wharton(self): + def test_availability_wharton(self) -> None: response = self.client.get(reverse("availability", args=["JMHH", "1"])) res_json = json.loads(response.content) self.assertEqual(3, len(res_json)) @@ -135,7 +137,7 @@ def test_availability_wharton(self): self.assertIn("availability", room) @mock.patch("gsr_booking.api_wrapper.BookingHandler.book_room", book_cancel_room) - def test_book_libcal(self): + def test_book_libcal(self) -> None: payload = { "start_time": "2021-11-21T18:30:00-05:00", "end_time": "2021-11-21T19:00:00-05:00", @@ -151,7 +153,7 @@ def test_book_libcal(self): self.assertEqual("success", res_json["detail"]) @mock.patch("gsr_booking.api_wrapper.BookingHandler.book_room", book_cancel_room) - def test_book_wharton(self): + def test_book_wharton(self) -> None: payload = { "start_time": "2021-11-21T18:30:00-05:00", "end_time": "2021-11-21T19:00:00-05:00", @@ -167,7 +169,7 @@ def test_book_wharton(self): self.assertEqual("success", res_json["detail"]) @mock.patch("gsr_booking.api_wrapper.BookingHandler.cancel_room", book_cancel_room) - def test_cancel_room(self): + def test_cancel_room(self) -> None: payload = {"booking_id": "booking id"} response = self.client.post( reverse("cancel"), json.dumps(payload), content_type="application/json" @@ -177,7 +179,7 @@ def test_cancel_room(self): self.assertEqual("success", res_json["detail"]) @mock.patch("gsr_booking.api_wrapper.BookingHandler.get_reservations", reservations) - def test_reservations(self): + def test_reservations(self) -> None: response = self.client.get(reverse("reservations")) res_json = json.loads(response.content) self.assertEqual(6, len(res_json)) diff --git a/backend/tests/gsr_booking/test_gsr_wrapper.py b/backend/tests/gsr_booking/test_gsr_wrapper.py index aa214b95..23a307c3 100644 --- a/backend/tests/gsr_booking/test_gsr_wrapper.py +++ b/backend/tests/gsr_booking/test_gsr_wrapper.py @@ -1,8 +1,8 @@ import json from datetime import timedelta +from typing import Any, cast from unittest import mock -from django.contrib.auth import get_user_model from django.core.management import call_command from django.test import TestCase from django.utils import timezone @@ -10,19 +10,17 @@ from gsr_booking.api_wrapper import APIError, GSRBooker, WhartonGSRBooker from gsr_booking.models import GSR, Group, GroupMembership, GSRBooking, Reservation +from utils.types import DjangoUserModel, UserType -User = get_user_model() - - -def mock_requests_get(obj, *args, **kwargs): +def mock_requests_get(obj: Any, *args: Any, **kwargs: Any) -> Any: class Mock: - def __init__(self, json_data, status_code): + def __init__(self, json_data: dict, status_code: int) -> None: self.json_data = json_data self.status_code = status_code self.ok = True - def json(self): + def json(self) -> dict: return self.json_data url = args[1] @@ -52,22 +50,24 @@ def json(self): class TestBookingWrapper(TestCase): - def setUp(self): + def setUp(self) -> None: call_command("load_gsrs") - self.user = User.objects.create_user("user", "user@seas.upenn.edu", "user") - self.group_user = User.objects.create_user( + self.user: UserType = DjangoUserModel.objects.create_user( + "user", "user@seas.upenn.edu", "user" + ) + self.group_user: UserType = DjangoUserModel.objects.create_user( "grou_user", "group_user@seas.upenn.edu", "group_user" ) - self.client = APIClient() + self.client: APIClient = APIClient() self.client.force_authenticate(user=self.user) self.group = Group.objects.create(owner=self.group_user, name="Penn Labs", color="blue") @mock.patch("gsr_booking.api_wrapper.WhartonBookingWrapper.request", mock_requests_get) - def test_is_wharton(self): + def test_is_wharton(self) -> None: self.assertFalse(WhartonGSRBooker.is_wharton(self.user)) @mock.patch("gsr_booking.api_wrapper.WhartonBookingWrapper.request", mock_requests_get) - def test_wharton_availability(self): + def test_wharton_availability(self) -> None: availability = GSRBooker.get_availability("JMHH", 1, "2021-01-07", "2022-01-08", self.user) self.assertIn("name", availability) self.assertIn("gid", availability) @@ -78,26 +78,28 @@ def test_wharton_availability(self): # @mock.patch("gsr_booking.api_wrapper.WhartonBookingWrapper.check_credits", mock_check_credits) @mock.patch("gsr_booking.api_wrapper.WhartonBookingWrapper.request", mock_requests_get) - def test_book_wharton(self): + def test_book_wharton(self) -> None: book_wharton = GSRBooker.book_room( 1, 94, "241", "2021-12-05T16:00:00-05:00", "2021-12-05T16:30:00-05:00", self.user ) - self.assertEquals("241", book_wharton.gsrbooking_set.first().room_name) + first_booking = book_wharton.gsrbooking_set.first() + assert first_booking is not None + self.assertEquals("241", first_booking.room_name) @mock.patch("gsr_booking.api_wrapper.WhartonBookingWrapper.request", mock_requests_get) - def test_wharton_reservations(self): + def test_wharton_reservations(self) -> None: reservations = WhartonGSRBooker.get_reservations(self.user) self.assertTrue(isinstance(reservations, list)) self.assertIn("booking_id", reservations[0]) self.assertIn("gid", reservations[0]) @mock.patch("gsr_booking.api_wrapper.WhartonBookingWrapper.request", mock_requests_get) - def test_cancel_wharton(self): + def test_cancel_wharton(self) -> None: cancel = GSRBooker.cancel_room("987654", self.user) self.assertIsNone(cancel) @mock.patch("gsr_booking.api_wrapper.LibCalBookingWrapper.request", mock_requests_get) - def test_libcal_availability(self): + def test_libcal_availability(self) -> None: availability = GSRBooker.get_availability( "1086", 1889, "2021-01-07", "2022-01-08", self.user ) @@ -109,7 +111,7 @@ def test_libcal_availability(self): self.assertIn("availability", availability["rooms"][0]) @mock.patch("gsr_booking.api_wrapper.LibCalBookingWrapper.request", mock_requests_get) - def test_book_libcal(self): + def test_book_libcal(self) -> None: book_libcal = GSRBooker.book_room( 1889, 7192, @@ -118,19 +120,21 @@ def test_book_libcal(self): "2021-12-05T16:30:00-05:00", self.user, ) - self.assertEquals("VP WIC Booth 01", book_libcal.gsrbooking_set.first().room_name) + first_booking = book_libcal.gsrbooking_set.first() + assert first_booking is not None + self.assertEquals("VP WIC Booth 01", first_booking.room_name) @mock.patch( "gsr_booking.api_wrapper.WhartonBookingWrapper.request", mock_requests_get ) # purposefully wharton request here - def test_libcal_reservations(self): + def test_libcal_reservations(self) -> None: reservations = GSRBooker.get_reservations(self.user) self.assertTrue(isinstance(reservations, list)) self.assertIn("booking_id", reservations[0]) self.assertIn("gsr", reservations[0]) @mock.patch("gsr_booking.api_wrapper.LibCalBookingWrapper.request", mock_requests_get) - def test_cancel_libcal(self): + def test_cancel_libcal(self) -> None: group = Group.objects.create(owner=self.user) reservation = Reservation.objects.create(creator=self.user, group=group) GSRBooking.objects.create( @@ -145,10 +149,11 @@ def test_cancel_libcal(self): self.assertIsNone(cancel) @mock.patch("gsr_booking.api_wrapper.WhartonBookingWrapper.request", mock_requests_get) - def test_group_book_wharton(self): + def test_group_book_wharton(self) -> None: # make sure group_user is treated as a wharton user so they # are returned in list of wharton users in gb.book_room membership1 = GroupMembership.objects.filter(group=self.group).first() + assert membership1 is not None membership1.is_wharton = True membership1.save() @@ -180,7 +185,7 @@ def test_group_book_wharton(self): self.assertIsNotNone(Reservation.objects.get(pk=reservation.id)) @mock.patch("gsr_booking.api_wrapper.LibCalBookingWrapper.request", mock_requests_get) - def test_group_book_libcal(self): + def test_group_book_libcal(self) -> None: # add user to the group GroupMembership.objects.create(user=self.user, group=self.group, accepted=True) @@ -211,7 +216,9 @@ def test_group_book_libcal(self): self.assertEqual(len(res), 1) self.assertEqual(res[0]["room_name"], "[Me] VP WIC Booth 01") - credit_owner = reservation.gsrbooking_set.first().user + first_booking = reservation.gsrbooking_set.first() + assert first_booking is not None + credit_owner = first_booking.user res = GSRBooker.get_reservations(credit_owner, self.group) self.assertEqual(len(res), 1) self.assertEqual( @@ -220,7 +227,7 @@ def test_group_book_libcal(self): ) @mock.patch("gsr_booking.api_wrapper.WhartonBookingWrapper.request", mock_requests_get) - def test_group_wharton_availability(self): + def test_group_wharton_availability(self) -> None: with self.assertRaises(APIError): GSRBooker.get_availability( "JMHH", 1, "2021-01-07", "2022-01-08", self.group_user, self.group @@ -234,6 +241,7 @@ def test_group_wharton_availability(self): self.assertIn("name", availability) self.assertIn("gid", availability) self.assertIn("rooms", availability) - self.assertIn("room_name", availability["rooms"][0]) - self.assertIn("id", availability["rooms"][0]) - self.assertIn("availability", availability["rooms"][0]) + room_info = cast(dict[str, Any], availability["rooms"][0]) + self.assertIn("room_name", room_info) + self.assertIn("id", room_info) + self.assertIn("availability", room_info) diff --git a/backend/tests/laundry/test_api_wrapper.py b/backend/tests/laundry/test_api_wrapper.py index dccca9ea..a5749879 100644 --- a/backend/tests/laundry/test_api_wrapper.py +++ b/backend/tests/laundry/test_api_wrapper.py @@ -13,7 +13,7 @@ @mock.patch("requests.get", fakeLaundryGet) class TestAllStatus(TestCase): - def setUp(self): + def setUp(self) -> None: LaundryRoom.objects.get_or_create( hall_id=0, name="Bishop White", location="Quad", total_washers=9, total_dryers=9 ) @@ -27,7 +27,7 @@ def setUp(self): hall_id=3, name="Craig", location="Quad", total_washers=3, total_dryers=3 ) - def test_all_status(self): + def test_all_status(self) -> None: data = all_status() @@ -56,7 +56,7 @@ def test_all_status(self): @mock.patch("requests.get", fakeLaundryGet) class TestHallStatus(TestCase): - def setUp(self): + def setUp(self) -> None: LaundryRoom.objects.get_or_create( hall_id=0, name="Bishop White", location="Quad", total_washers=9, total_dryers=9 ) @@ -70,7 +70,7 @@ def setUp(self): hall_id=3, name="Craig", location="Quad", total_washers=3, total_dryers=3 ) - def test_all_status(self): + def test_all_status(self) -> None: for room in LaundryRoom.objects.all(): @@ -88,7 +88,7 @@ def test_all_status(self): @mock.patch("requests.get", fakeLaundryGet) class TestSaveData(TestCase): - def setUp(self): + def setUp(self) -> None: LaundryRoom.objects.get_or_create( hall_id=0, name="Bishop White", location="Quad", total_washers=9, total_dryers=9 ) @@ -102,7 +102,7 @@ def setUp(self): hall_id=3, name="Craig", location="Quad", total_washers=3, total_dryers=3 ) - def test_save_data(self): + def test_save_data(self) -> None: self.assertEqual(LaundrySnapshot.objects.all().count(), 0) diff --git a/backend/tests/laundry/test_commands.py b/backend/tests/laundry/test_commands.py index 79028394..998a3758 100644 --- a/backend/tests/laundry/test_commands.py +++ b/backend/tests/laundry/test_commands.py @@ -1,7 +1,9 @@ import csv from io import StringIO +from typing import Any from unittest import mock +import requests from django.conf import settings from django.core.management import call_command from django.test import TestCase @@ -9,7 +11,7 @@ from laundry.models import LaundryRoom, LaundrySnapshot -def fakeLaundryGet(url, *args, **kwargs): +def fakeLaundryGet(url: str, *args: Any, **kwargs: Any) -> requests.models.Response: if settings.LAUNDRY_URL in url: with open("tests/laundry/laundry_snapshot.html", "rb") as f: m = mock.MagicMock(content=f.read()) @@ -20,7 +22,7 @@ def fakeLaundryGet(url, *args, **kwargs): @mock.patch("requests.get", fakeLaundryGet) class TestGetSnapshot(TestCase): - def setUp(self): + def setUp(self) -> None: # populates database with LaundryRooms LaundryRoom.objects.get_or_create( hall_id=0, name="Bishop White", location="Quad", total_washers=9, total_dryers=9 @@ -35,7 +37,7 @@ def setUp(self): hall_id=3, name="Craig", location="Quad", total_washers=3, total_dryers=3 ) - def test_db_populate(self): + def test_db_populate(self) -> None: out = StringIO() call_command("get_snapshot", stdout=out) @@ -48,7 +50,7 @@ def test_db_populate(self): @mock.patch("requests.get", fakeLaundryGet) class TestLaundryRoomMigration(TestCase): - def test_db_populate(self): + def test_db_populate(self) -> None: out = StringIO() call_command("load_laundry_rooms", stdout=out) diff --git a/backend/tests/laundry/test_models.py b/backend/tests/laundry/test_models.py index 5dbd8fbc..bf2fa08b 100644 --- a/backend/tests/laundry/test_models.py +++ b/backend/tests/laundry/test_models.py @@ -4,7 +4,7 @@ class LaundrySnapshotTestCase(TestCase): - def setUp(self): + def setUp(self) -> None: # populates database with LaundryRooms LaundryRoom.objects.get_or_create( hall_id=0, name="Bishop White", location="Quad", total_washers=9, total_dryers=9 @@ -23,15 +23,15 @@ def setUp(self): room=self.laundry_room, available_washers=10, available_dryers=10 ) - def test_str(self): + def test_str(self) -> None: self.assertEqual( str(self.snapshot), - f"Hall No. {self.snapshot.room.hall_id} | {self.snapshot.date.date()}", + f"Hall No. {self.snapshot.room.hall_id} | {self.snapshot.date.date()}", # ignore: type ) class LaundryRoomTestCase(TestCase): - def setUp(self): + def setUp(self) -> None: # populates database with LaundryRooms LaundryRoom.objects.get_or_create( hall_id=0, name="Bishop White", location="Quad", total_washers=9, total_dryers=9 @@ -47,5 +47,5 @@ def setUp(self): ) self.room = LaundryRoom.objects.create(hall_id=1, name="test hall", location="location") - def test_str(self): + def test_str(self) -> None: self.assertEqual(str(self.room), f"Hall No. {self.room.hall_id} | {self.room.name}") diff --git a/backend/tests/laundry/test_views.py b/backend/tests/laundry/test_views.py index d0dae554..78249de9 100644 --- a/backend/tests/laundry/test_views.py +++ b/backend/tests/laundry/test_views.py @@ -1,7 +1,6 @@ import json from unittest import mock -from django.contrib.auth import get_user_model from django.test import TestCase from django.urls import reverse from django.utils import timezone @@ -9,14 +8,12 @@ from laundry.models import LaundryRoom, LaundrySnapshot from tests.laundry.test_commands import fakeLaundryGet - - -User = get_user_model() +from utils.types import DjangoUserModel, UserType @mock.patch("requests.get", fakeLaundryGet) class HallIdViewTestCase(TestCase): - def setUp(self): + def setUp(self) -> None: LaundryRoom.objects.get_or_create( hall_id=0, name="Bishop White", location="Quad", total_washers=9, total_dryers=9 ) @@ -29,9 +26,9 @@ def setUp(self): LaundryRoom.objects.get_or_create( hall_id=3, name="Craig", location="Quad", total_washers=3, total_dryers=3 ) - self.client = APIClient() + self.client: APIClient = APIClient() - def test_response(self): + def test_response(self) -> None: response = self.client.get(reverse("hall-ids")) res_json = json.loads(response.content) for hall in res_json: @@ -44,7 +41,7 @@ def test_response(self): @mock.patch("requests.get", fakeLaundryGet) class HallInfoViewTestCase(TestCase): - def setUp(self): + def setUp(self) -> None: LaundryRoom.objects.get_or_create( hall_id=0, name="Bishop White", location="Quad", total_washers=9, total_dryers=9 ) @@ -58,9 +55,9 @@ def setUp(self): hall_id=3, name="Craig", location="Quad", total_washers=3, total_dryers=3 ) self.laundry_room = LaundryRoom.objects.get(hall_id=0, name="Bishop White", location="Quad") - self.client = APIClient() + self.client: APIClient = APIClient() - def test_response(self): + def test_response(self) -> None: response = self.client.get(reverse("hall-info", args=[self.laundry_room.hall_id])) res_json = json.loads(response.content) if response.status_code == 200: @@ -69,14 +66,14 @@ def test_response(self): elif response.status_code == 503: self.assertEqual("The laundry api is currently unavailable.", res_json["error"]) - def test_hall_error(self): + def test_hall_error(self) -> None: response = self.client.get(reverse("hall-info", args=[1000000])) self.assertEqual(404, response.status_code) @mock.patch("requests.get", fakeLaundryGet) class MultipleHallInfoViewTestCase(TestCase): - def setUp(self): + def setUp(self) -> None: LaundryRoom.objects.get_or_create( hall_id=0, name="Bishop White", location="Quad", total_washers=9, total_dryers=9 ) @@ -90,9 +87,9 @@ def setUp(self): hall_id=3, name="Craig", location="Quad", total_washers=3, total_dryers=3 ) self.laundry_room = LaundryRoom.objects.get(hall_id=0, name="Bishop White", location="Quad") - self.client = APIClient() + self.client: APIClient = APIClient() - def test_response(self): + def test_response(self) -> None: response = self.client.get(reverse("multiple-hall-info", args=["0,1,2,3"])) res_json = json.loads(response.content) if response.status_code == 200: @@ -111,14 +108,14 @@ def test_response(self): elif response.status_code == 503: self.assertEqual("The laundry api is currently unavailable.", res_json["error"]) - def test_hall_error(self): + def test_hall_error(self) -> None: response = self.client.get(reverse("multiple-hall-info", args=["1000000"])) self.assertEqual(404, response.status_code) @mock.patch("requests.get", fakeLaundryGet) class HallUsageViewTestCase(TestCase): - def setUp(self): + def setUp(self) -> None: LaundryRoom.objects.get_or_create( hall_id=0, name="Bishop White", location="Quad", total_washers=9, total_dryers=9 ) @@ -135,9 +132,9 @@ def setUp(self): self.snapshot = LaundrySnapshot.objects.create( room=self.laundry_room, available_washers=5, available_dryers=10 ) - self.client = APIClient() + self.client: APIClient = APIClient() - def test_response(self): + def test_response(self) -> None: response = self.client.get(reverse("hall-usage", args=[self.laundry_room.hall_id])) res_json = json.loads(response.content) @@ -150,7 +147,7 @@ def test_response(self): @mock.patch("requests.get", fakeLaundryGet) class PreferencesTestCase(TestCase): - def setUp(self): + def setUp(self) -> None: LaundryRoom.objects.get_or_create( hall_id=0, name="Bishop White", location="Quad", total_washers=9, total_dryers=9 ) @@ -163,22 +160,22 @@ def setUp(self): LaundryRoom.objects.get_or_create( hall_id=3, name="Craig", location="Quad", total_washers=3, total_dryers=3 ) - self.client = APIClient() - self.test_user = User.objects.create_user("user", "user@a.com", "user") + self.client: APIClient = APIClient() + self.test_user: UserType = DjangoUserModel.objects.create_user("user", "user@a.com", "user") self.laundry_room = LaundryRoom.objects.get(hall_id=0, name="Bishop White", location="Quad") self.other_laundry_room = LaundryRoom.objects.get( hall_id=1, name="Chestnut Butcher", location="Quad" ) self.test_user.profile.laundry_preferences.add(self.laundry_room) - def test_get(self): + def test_get(self) -> None: self.client.force_authenticate(user=self.test_user) response = self.client.get(reverse("preferences")) res_json = json.loads(response.content) self.assertIn(self.laundry_room.hall_id, res_json["rooms"]) - def test_post(self): + def test_post(self) -> None: self.client.force_authenticate(user=self.test_user) self.client.post(reverse("preferences"), {"rooms": [self.other_laundry_room.hall_id]}) diff --git a/backend/tests/penndata/test_views.py b/backend/tests/penndata/test_views.py index fea551a7..67795636 100644 --- a/backend/tests/penndata/test_views.py +++ b/backend/tests/penndata/test_views.py @@ -1,8 +1,8 @@ import datetime import json +from typing import Any from unittest import mock -from django.contrib.auth import get_user_model from django.core.management import call_command from django.test import TestCase from django.urls import reverse @@ -13,9 +13,10 @@ from laundry.models import LaundryRoom from penndata.models import AnalyticsEvent, Event, FitnessRoom, FitnessSnapshot from portal.models import Poll, Post +from utils.types import DjangoUserModel, UserType -def fakeFitnessGet(url, *args, **kwargs): +def fakeFitnessGet(url: str, *args: Any, **kwargs: Any) -> mock.MagicMock: if "docs.google.com/spreadsheets/" in url: with open("tests/penndata/fitness_snapshot.html", "rb") as f: m = mock.MagicMock(content=f.read()) @@ -24,11 +25,8 @@ def fakeFitnessGet(url, *args, **kwargs): raise NotImplementedError -User = get_user_model() - - class TestNews(TestCase): - def test_response(self): + def test_response(self) -> None: response = self.client.get(reverse("news")) res_json = json.loads(response.content) self.assertEqual(len(res_json), 6) @@ -40,10 +38,10 @@ def test_response(self): class TestCalender(TestCase): - def setUp(self): + def setUp(self) -> None: call_command("get_calendar") - def test_response(self): + def test_response(self) -> None: response = self.client.get(reverse("calendar")) res_json = json.loads(response.content) @@ -54,8 +52,8 @@ def test_response(self): class TestEvent(TestCase): - def setUp(self): - self.client = APIClient() + def setUp(self) -> None: + self.client: APIClient = APIClient() self.event1 = Event.objects.create( event_type="type1", name="Event 1", @@ -75,7 +73,7 @@ def setUp(self): website="https://pennlabs.org/", ) - def test_get_all_events(self): + def test_get_all_events(self) -> None: """Test GET request to retrieve all events (no type)""" url = reverse("events") response = self.client.get(url) @@ -83,7 +81,7 @@ def test_get_all_events(self): res_json = json.loads(response.content) self.assertEqual(len(events), len(res_json)) - def test_get_events_by_type(self): + def test_get_events_by_type(self) -> None: """Test GET request to retrieve events by type""" url = reverse("events-type", kwargs={"type": "type1"}) response = self.client.get(url) @@ -95,13 +93,13 @@ def test_get_events_by_type(self): class TestHomePage(TestCase): - def setUp(self): + def setUp(self) -> None: call_command("load_venues") call_command("load_laundry_rooms") - self.client = APIClient() - self.test_user = User.objects.create_user("user", "user@a.com", "user") + self.client: APIClient = APIClient() + self.test_user: UserType = DjangoUserModel.objects.create_user("user", "user@a.com", "user") - def test_first_response(self): + def test_first_response(self) -> None: self.client.force_authenticate(user=self.test_user) response = self.client.get(reverse("homepage")) res_json = json.loads(response.content)["cells"] @@ -138,10 +136,10 @@ def test_first_response(self): class TestGetRecentFitness(TestCase): - def setUp(self): + def setUp(self) -> None: call_command("load_fitness_rooms") - self.client = APIClient() - self.test_user = User.objects.create_user("user", "user@a.com", "user") + self.client: APIClient = APIClient() + self.test_user: UserType = DjangoUserModel.objects.create_user("user", "user@a.com", "user") self.client.force_authenticate(user=self.test_user) self.fitness_room = FitnessRoom.objects.first() @@ -156,13 +154,14 @@ def setUp(self): room=self.fitness_room, date=self.new_time, count=self.new_count ) - def test_get_recent(self): + def test_get_recent(self) -> None: response = self.client.get(reverse("fitness")) res_json = json.loads(response.content) for room_obj in res_json: room_obj.pop("open") room_obj.pop("close") + assert self.fitness_room is not None expected = [ { "id": room.id, @@ -196,10 +195,10 @@ def test_get_recent(self): @mock.patch("requests.get", fakeFitnessGet) class TestGetFitnessSnapshot(TestCase): - def setUp(self): + def setUp(self) -> None: call_command("load_fitness_rooms") - def test_get_fitness_snapshot(self): + def test_get_fitness_snapshot(self) -> None: self.assertEqual(FitnessSnapshot.objects.all().count(), 0) call_command("get_fitness_snapshot") @@ -219,7 +218,7 @@ def test_get_fitness_snapshot(self): class TestFitnessUsage(TestCase): - def load_snapshots_1(self, date): + def load_snapshots_1(self, date: datetime.datetime) -> None: # 6:00, 0 FitnessSnapshot.objects.create( room=self.room, date=date + datetime.timedelta(hours=6), count=0, capacity=0.0 @@ -254,7 +253,7 @@ def load_snapshots_1(self, date): capacity=0.0, ) - def load_snapshots_2(self, date): + def load_snapshots_2(self, date: datetime.datetime) -> None: # 7:30, 3 FitnessSnapshot.objects.create( room=self.room, @@ -270,12 +269,12 @@ def load_snapshots_2(self, date): capacity=93.0, ) - def setUp(self): - self.client = APIClient() + def setUp(self) -> None: + self.client: APIClient = APIClient() self.date = timezone.make_aware(datetime.datetime(2023, 1, 19, 0, 0, 0)) self.room = FitnessRoom.objects.create(name="test") - def test_get_fitness_usage_1(self): + def test_get_fitness_usage_1(self) -> None: self.load_snapshots_1(self.date) response = self.client.get( reverse("fitness-usage", args=[self.room.id]), @@ -319,7 +318,7 @@ def test_get_fitness_usage_1(self): self.assertEqual(res_json, expected) - def test_get_fitness_usage_2(self): + def test_get_fitness_usage_2(self) -> None: self.load_snapshots_2(self.date) response = self.client.get( reverse("fitness-usage", args=[self.room.id]), @@ -362,14 +361,14 @@ def test_get_fitness_usage_2(self): self.assertEqual(res_json, expected) - def test_get_usage_2_samples_week(self): + def test_get_usage_2_samples_week(self) -> None: self.load_snapshots_1(self.date) self.load_snapshots_2(self.date - datetime.timedelta(days=7)) response = self.client.get( reverse("fitness-usage", args=[self.room.id]), { "date": (self.date).strftime("%Y-%m-%d"), - "num_samples": 2, + "num_samples": "2", "group_by": "week", "field": "capacity", }, @@ -438,12 +437,12 @@ def test_get_usage_2_samples_week(self): self.assertEqual(res_json, expected) - def test_get_usage_2_samples_day(self): + def test_get_usage_2_samples_day(self) -> None: self.load_snapshots_2(self.date) self.load_snapshots_1(self.date - datetime.timedelta(days=1)) response = self.client.get( reverse("fitness-usage", args=[self.room.id]), - {"date": (self.date).strftime("%Y-%m-%d"), "num_samples": 2}, + {"date": (self.date).strftime("%Y-%m-%d"), "num_samples": "2"}, ) res_json = json.loads(response.content) @@ -510,7 +509,7 @@ def test_get_usage_2_samples_day(self): self.assertEqual(res_json, expected) - def test_day_closed(self): + def test_day_closed(self) -> None: self.load_snapshots_1(self.date - datetime.timedelta(days=1)) response = self.client.get( reverse("fitness-usage", args=[self.room.id]), @@ -565,7 +564,7 @@ def test_day_closed(self): } self.assertEqual(res_json, expected) - def test_get_fitness_usage_error(self): + def test_get_fitness_usage_error(self) -> None: response = self.client.get(reverse("fitness-usage", args=[self.room.id + 1])) self.assertEqual(response.status_code, 404) @@ -576,25 +575,25 @@ def test_get_fitness_usage_error(self): @mock.patch("requests.get", fakeFitnessGet) class FitnessPreferencesTestCase(TestCase): - def setUp(self): + def setUp(self) -> None: FitnessRoom.objects.get_or_create(id=0, name="1st Floor Fitness") FitnessRoom.objects.get_or_create(id=1, name="MPR") FitnessRoom.objects.get_or_create(id=2, name="Pool-Deep") FitnessRoom.objects.get_or_create(id=3, name="4th Floor Fitness") - self.client = APIClient() - self.test_user = User.objects.create_user("user", "user@a.com", "user") + self.client: APIClient = APIClient() + self.test_user: UserType = DjangoUserModel.objects.create_user("user", "user@a.com", "user") self.fitness_room = FitnessRoom.objects.get(id=0, name="1st Floor Fitness") self.other_fitness_room = FitnessRoom.objects.get(id=1, name="MPR") self.test_user.profile.fitness_preferences.add(self.fitness_room) - def test_get(self): + def test_get(self) -> None: self.client.force_authenticate(user=self.test_user) response = self.client.get(reverse("fitness-preferences")) res_json = json.loads(response.content) self.assertIn(self.fitness_room.id, res_json["rooms"]) - def test_post(self): + def test_post(self) -> None: self.client.force_authenticate(user=self.test_user) self.client.post(reverse("fitness-preferences"), {"rooms": [self.other_fitness_room.id]}) @@ -605,12 +604,12 @@ def test_post(self): class TestAnalytics(TestCase): - def setUp(self): - self.client = APIClient() - self.test_user = User.objects.create_user("user", "user@a.com", "user") + def setUp(self) -> None: + self.client: APIClient = APIClient() + self.test_user: UserType = DjangoUserModel.objects.create_user("user", "user@a.com", "user") self.client.force_authenticate(user=self.test_user) - def test_create_regular_analytics(self): + def test_create_regular_analytics(self) -> None: payload = { "cell_type": "dining", "index": 0, @@ -624,7 +623,7 @@ def test_create_regular_analytics(self): self.assertIsNone(res_json["post"]) self.assertIsNone(res_json["poll"]) - def test_create_poll_analytics(self): + def test_create_poll_analytics(self) -> None: poll = Poll.objects.create( club_code="pennlabs", question="hello?", @@ -645,7 +644,7 @@ def test_create_poll_analytics(self): self.assertIsNone(res_json["post"]) self.assertTrue(res_json["is_interaction"]) - def test_create_post_analytics(self): + def test_create_post_analytics(self) -> None: post = Post.objects.create( club_code="notpennlabs", title="Test title 2", @@ -667,7 +666,7 @@ def test_create_post_analytics(self): self.assertIsNotNone(res_json["post"]) self.assertFalse(res_json["is_interaction"]) - def test_fail_post_poll_analytics(self): + def test_fail_post_poll_analytics(self) -> None: poll = Poll.objects.create( club_code="pennlabs", question="hello?", @@ -693,12 +692,12 @@ def test_fail_post_poll_analytics(self): class TestUniqueCounterView(TestCase): - def setUp(self): - self.client = APIClient() - self.test_user = User.objects.create_user("user", "user@a.com", "user") + def setUp(self) -> None: + self.client: APIClient = APIClient() + self.test_user: UserType = DjangoUserModel.objects.create_user("user", "user@a.com", "user") self.client.force_authenticate(user=self.test_user) - def test_get_unique_counter(self): + def test_get_unique_counter(self) -> None: post = Post.objects.create( club_code="pennlabs", title="Test title", @@ -728,6 +727,6 @@ def test_get_unique_counter(self): self.assertEqual(response.status_code, 200) self.assertEqual(response.json()["count"], 0) - def test_get_unique_counter_no_id(self): + def test_get_unique_counter_no_id(self) -> None: response = self.client.get(reverse("eventcounter")) self.assertEqual(response.status_code, 400) diff --git a/backend/tests/portal/test_permissions.py b/backend/tests/portal/test_permissions.py index a3081b76..b0bee144 100644 --- a/backend/tests/portal/test_permissions.py +++ b/backend/tests/portal/test_permissions.py @@ -1,8 +1,8 @@ import datetime import json +from typing import Any from unittest import mock -from django.contrib.auth import get_user_model from django.core.management import call_command from django.test import TestCase from django.urls import reverse @@ -10,55 +10,60 @@ from rest_framework.test import APIClient from portal.models import Poll, PollOption, PollVote +from utils.types import DjangoUserModel, UserType -User = get_user_model() - - -def mock_get_user_clubs(*args, **kwargs): +def mock_get_user_clubs(*args: Any, **kwargs: Any) -> list[dict[str, Any]]: with open("tests/portal/get_user_clubs.json") as data: return json.load(data) class PollPermissions(TestCase): - def setUp(self): + def setUp(self) -> None: call_command("load_target_populations", "--years", "2022, 2023, 2024, 2025") - self.client = APIClient() - self.admin = User.objects.create_superuser("admin@example.com", "admin", "admin") - self.user1 = User.objects.create_user("user1", "user@seas.upenn.edu", "user") - self.user2 = User.objects.create_user("user2", "user@seas.upenn.edu", "user") + self.client: APIClient = APIClient() + self.admin: UserType = DjangoUserModel.objects.create_superuser( + "admin@example.com", "admin", "admin" + ) + self.user1: UserType = DjangoUserModel.objects.create_user( + "user1", "user@seas.upenn.edu", "user" + ) + self.user2: UserType = DjangoUserModel.objects.create_user( + "user2", "user@seas.upenn.edu", "user" + ) - self.poll_1 = Poll.objects.create( + self.poll_1: Poll = Poll.objects.create( club_code="pennlabs", question="poll question 1", expire_date=timezone.now() + datetime.timedelta(days=1), status=Poll.STATUS_APPROVED, ) - self.poll_option_1 = PollOption.objects.create(poll=self.poll_1, choice="hello!") - self.poll_option_2 = PollOption.objects.create(poll=self.poll_1, choice="hello!!!!") - self.poll_option_3 = PollOption.objects.create(poll=self.poll_1, choice="hello!!!!!!!") + self.poll_option_1: PollOption = PollOption.objects.create( + poll=self.poll_1, choice="hello!" + ) + self.poll_option_2: PollOption = PollOption.objects.create( + poll=self.poll_1, choice="hello!!!!" + ) + self.poll_option_3: PollOption = PollOption.objects.create( + poll=self.poll_1, choice="hello!!!!!!!" + ) - self.poll_2 = Poll.objects.create( + self.poll_2: Poll = Poll.objects.create( club_code="pennlabs", question="poll question 2", expire_date=timezone.now() + datetime.timedelta(days=1), status=Poll.STATUS_APPROVED, ) - self.poll_vote = PollVote.objects.create(id_hash="2", poll=self.poll_1) + self.poll_vote: PollVote = PollVote.objects.create(id_hash="2", poll=self.poll_1) self.poll_vote.poll_options.add(self.poll_option_1) @mock.patch("portal.permissions.get_user_clubs", mock_get_user_clubs) - def test_authentication(self): + def test_authentication(self) -> None: # asserts that anonymous users cannot access any route - list_urls = [ - "poll-list", - "polloption-list", - "pollvote-list", - "target-populations", - ] + list_urls = ["poll-list", "polloption-list", "pollvote-list", "target-populations"] for url in list_urls: response_1 = self.client.get(reverse(f"portal:{url}")) self.assertEqual(response_1.status_code, 403) @@ -70,7 +75,7 @@ def test_authentication(self): @mock.patch("portal.permissions.get_user_clubs", mock_get_user_clubs) @mock.patch("portal.views.get_user_clubs", mock_get_user_clubs) - def test_update_poll(self): + def test_update_poll(self) -> None: # users in same club can edit self.client.force_authenticate(user=self.user2) payload_1 = {"status": Poll.STATUS_REVISION} @@ -90,7 +95,7 @@ def test_update_poll(self): @mock.patch("portal.permissions.get_user_clubs", mock_get_user_clubs) @mock.patch("portal.views.get_user_clubs", mock_get_user_clubs) - def test_create_update_options(self): + def test_create_update_options(self) -> None: # users in same club can edit poll option self.client.force_authenticate(user=self.user2) payload_1 = {"poll": self.poll_1.id, "choice": "hello"} diff --git a/backend/tests/portal/test_polls.py b/backend/tests/portal/test_polls.py index 1002d803..5d67be25 100644 --- a/backend/tests/portal/test_polls.py +++ b/backend/tests/portal/test_polls.py @@ -1,8 +1,8 @@ import datetime import json +from typing import Any from unittest import mock -from django.contrib.auth import get_user_model from django.core.management import call_command from django.test import TestCase from django.utils import timezone @@ -10,27 +10,25 @@ from portal.models import Poll, PollOption, PollVote, TargetPopulation from utils.email import get_backend_manager_emails +from utils.types import DjangoUserModel, UserType -User = get_user_model() - - -def mock_get_user_clubs(*args, **kwargs): +def mock_get_user_clubs(*args: Any, **kwargs: Any) -> list[dict[str, Any]]: with open("tests/portal/get_user_clubs.json") as data: return json.load(data) -def mock_get_user_info(*args, **kwargs): +def mock_get_user_info(*args: Any, **kwargs: Any) -> dict[str, Any]: with open("tests/portal/get_user_info.json") as data: return json.load(data) -def mock_get_null_user_info(*args, **kwargs): +def mock_get_null_user_info(*args: Any, **kwargs: Any) -> dict[str, Any]: with open("tests/portal/get_null_user_info.json") as data: return json.load(data) -def mock_get_club_info(*args, **kwargs): +def mock_get_club_info(*args: Any, **kwargs: Any) -> dict[str, Any]: with open("tests/portal/get_club_info.json") as data: return json.load(data) @@ -38,22 +36,28 @@ def mock_get_club_info(*args, **kwargs): class TestUserClubs(TestCase): """Test User and Club information""" - def setUp(self): - self.client = APIClient() - self.test_user = User.objects.create_user("user", "user@seas.upenn.edu", "user") + def setUp(self) -> None: + self.client: APIClient = APIClient() + self.test_user: UserType = DjangoUserModel.objects.create_user( + "user", "user@seas.upenn.edu", "user" + ) self.client.force_authenticate(user=self.test_user) @mock.patch("portal.views.get_user_info", mock_get_user_info) - def test_user_info(self): + def test_user_info(self) -> None: response = self.client.get("/portal/user/") res_json = json.loads(response.content) + assert isinstance(res_json, dict) + assert isinstance(res_json["user"], dict) self.assertEqual(12345678, res_json["user"]["pennid"]) @mock.patch("portal.views.get_club_info", mock_get_club_info) @mock.patch("portal.views.get_user_clubs", mock_get_user_clubs) - def test_user_clubs(self): + def test_user_clubs(self) -> None: response = self.client.get("/portal/clubs/") res_json = json.loads(response.content) + assert isinstance(res_json, dict) + assert isinstance(res_json["clubs"], list) self.assertEqual("pennlabs", res_json["clubs"][0]["code"]) @@ -61,17 +65,21 @@ class TestPolls(TestCase): """Tests Create/Update/Retrieve for Polls and Poll Options""" @mock.patch("portal.serializers.get_user_clubs", mock_get_user_clubs) - def setUp(self): + def setUp(self) -> None: call_command("load_target_populations", "--years", "2022, 2023, 2024, 2025") - self.target_id = TargetPopulation.objects.get(population="2024").id - year = TargetPopulation.objects.get(population="2024").id + target = TargetPopulation.objects.get(population="2024") + self.target_id = target.id + year = target.id major = TargetPopulation.objects.get(population="Computer Science, BSE").id school = TargetPopulation.objects.get( population="School of Engineering and Applied Science" ).id degree = TargetPopulation.objects.get(population="BACHELORS").id - self.client = APIClient() - self.test_user = User.objects.create_user("user", "user@seas.upenn.edu", "user") + + self.client: APIClient = APIClient() + self.test_user: UserType = DjangoUserModel.objects.create_user( + "user", "user@seas.upenn.edu", "user" + ) self.client.force_authenticate(user=self.test_user) # creates an approved poll to work with payload = { @@ -98,10 +106,10 @@ def setUp(self): poll.save() poll_1 = Poll.objects.get(question="How is your day") - self.id = poll_1.id + self.poll_id = poll_1.id @mock.patch("portal.serializers.get_user_clubs", mock_get_user_clubs) - def test_create_poll(self): + def test_create_poll(self) -> None: # creates an unapproved poll payload = { "club_code": "pennlabs", @@ -119,19 +127,17 @@ def test_create_poll(self): @mock.patch("portal.views.get_user_clubs", mock_get_user_clubs) @mock.patch("portal.permissions.get_user_clubs", mock_get_user_clubs) - def test_update_poll(self): - payload = { - "question": "New question", - } - response = self.client.patch(f"/portal/polls/{self.id}/", payload) + def test_update_poll(self) -> None: + payload = {"question": "New question"} + response = self.client.patch(f"/portal/polls/{self.poll_id}/", payload) res_json = json.loads(response.content) # asserts that the update worked - self.assertEqual(self.id, res_json["id"]) - self.assertEqual("New question", Poll.objects.get(id=self.id).question) + self.assertEqual(self.poll_id, res_json["id"]) + self.assertEqual("New question", Poll.objects.get(id=self.poll_id).question) @mock.patch("portal.serializers.get_user_clubs", mock_get_user_clubs) @mock.patch("portal.logic.get_user_info", mock_get_user_info) - def test_browse(self): + def test_browse(self) -> None: payload = { "club_code": "pennlabs", "question": "How is this question? 2", @@ -148,7 +154,7 @@ def test_browse(self): @mock.patch("portal.serializers.get_user_clubs", mock_get_user_clubs) @mock.patch("portal.logic.get_user_info", mock_get_null_user_info) - def test_null_user_info_browse(self): + def test_null_user_info_browse(self) -> None: # Asserts that a user with empty user info can access all available polls response = self.client.post("/portal/polls/browse/", {"id_hash": 1}) res_json = json.loads(response.content) @@ -158,38 +164,38 @@ def test_null_user_info_browse(self): @mock.patch("portal.serializers.get_user_clubs", mock_get_user_clubs) @mock.patch("portal.permissions.get_user_clubs", mock_get_user_clubs) @mock.patch("portal.logic.get_user_info", mock_get_user_info) - def test_create_option(self): - payload_1 = {"poll": self.id, "choice": "yes!"} - payload_2 = {"poll": self.id, "choice": "no!"} + def test_create_option(self) -> None: + payload_1 = {"poll": self.poll_id, "choice": "yes!"} + payload_2 = {"poll": self.poll_id, "choice": "no!"} self.client.post("/portal/options/", payload_1) self.client.post("/portal/options/", payload_2) self.assertEqual(2, PollOption.objects.all().count()) # asserts options were created and were placed to right poll for poll_option in PollOption.objects.all(): - self.assertEqual(Poll.objects.get(id=self.id), poll_option.poll) + self.assertEqual(Poll.objects.get(id=self.poll_id), poll_option.poll) response = self.client.post("/portal/polls/browse/", {"id_hash": 1}) res_json = json.loads(response.content) self.assertEqual(2, len(res_json[0]["options"])) @mock.patch("portal.permissions.get_user_clubs", mock_get_user_clubs) @mock.patch("portal.views.get_user_clubs", mock_get_user_clubs) - def test_update_option(self): - payload_1 = {"poll": self.id, "choice": "yes!"} + def test_update_option(self) -> None: + payload_1 = {"poll": self.poll_id, "choice": "yes!"} response = self.client.post("/portal/options/", payload_1) res_json = json.loads(response.content) self.assertEqual("yes!", PollOption.objects.get(id=res_json["id"]).choice) - payload_2 = {"poll": self.id, "choice": "no!"} + payload_2 = {"poll": self.poll_id, "choice": "no!"} # checks that poll's option was changed self.client.patch(f'/portal/options/{res_json["id"]}/', payload_2) self.assertEqual("no!", PollOption.objects.get(id=res_json["id"]).choice) - def test_review_poll(self): + def test_review_poll(self) -> None: Poll.objects.create( club_code="pennlabs", question="hello?", expire_date=timezone.now() + datetime.timedelta(days=3), ) - admin = User.objects.create_superuser("admin@example.com", "admin", "admin") + admin = DjangoUserModel.objects.create_superuser("admin@example.com", "admin", "admin") self.client.force_authenticate(user=admin) response = self.client.get("/portal/polls/review/") res_json = json.loads(response.content) @@ -200,12 +206,12 @@ def test_review_poll(self): @mock.patch("portal.serializers.get_user_clubs", mock_get_user_clubs) @mock.patch("portal.permissions.get_user_clubs", mock_get_user_clubs) @mock.patch("portal.logic.get_user_info", mock_get_user_info) - def test_more_than_five_options(self): - payload_1 = {"poll": self.id, "choice": "1"} - payload_2 = {"poll": self.id, "choice": "2"} - payload_3 = {"poll": self.id, "choice": "3"} - payload_4 = {"poll": self.id, "choice": "4"} - payload_5 = {"poll": self.id, "choice": "5"} + def test_more_than_five_options(self) -> None: + payload_1 = {"poll": self.poll_id, "choice": "1"} + payload_2 = {"poll": self.poll_id, "choice": "2"} + payload_3 = {"poll": self.poll_id, "choice": "3"} + payload_4 = {"poll": self.poll_id, "choice": "4"} + payload_5 = {"poll": self.poll_id, "choice": "5"} self.client.post("/portal/options/", payload_1) self.client.post("/portal/options/", payload_2) self.client.post("/portal/options/", payload_3) @@ -214,17 +220,17 @@ def test_more_than_five_options(self): self.assertEqual(5, PollOption.objects.all().count()) # asserts options were created and were placed to right poll for poll_option in PollOption.objects.all(): - self.assertEqual(Poll.objects.get(id=self.id), poll_option.poll) + self.assertEqual(Poll.objects.get(id=self.poll_id), poll_option.poll) response = self.client.post("/portal/polls/browse/", {"id_hash": 1}) res_json = json.loads(response.content) self.assertEqual(5, len(res_json[0]["options"])) # adding more than 5 options to same poll should not be allowed - payload_6 = {"poll": self.id, "choice": "6"} + payload_6 = {"poll": self.poll_id, "choice": "6"} response = self.client.post("/portal/options/", payload_6) self.assertEqual(5, PollOption.objects.all().count()) - def test_option_vote_view(self): - response = self.client.get(f"/portal/polls/{self.id}/option_view/") + def test_option_vote_view(self) -> None: + response = self.client.get(f"/portal/polls/{self.poll_id}/option_view/") res_json = json.loads(response.content) self.assertEqual("pennlabs", res_json["club_code"]) # test that options key is in response @@ -233,7 +239,7 @@ def test_option_vote_view(self): @mock.patch("portal.serializers.get_user_clubs", mock_get_user_clubs) @mock.patch("portal.permissions.get_user_clubs", mock_get_user_clubs) @mock.patch("utils.email.send_automated_email.delay_on_commit") - def test_send_email_on_create(self, mock_send_email): + def test_send_email_on_create(self, mock_send_email: mock.Mock) -> None: payload = { "club_code": "pennlabs", "question": "How is this question? 2", @@ -249,7 +255,7 @@ def test_send_email_on_create(self, mock_send_email): @mock.patch("portal.serializers.get_user_clubs", mock_get_user_clubs) @mock.patch("portal.permissions.get_user_clubs", mock_get_user_clubs) @mock.patch("utils.email.send_automated_email.delay_on_commit") - def test_send_email_on_status_change(self, mock_send_email): + def test_send_email_on_status_change(self, mock_send_email: mock.Mock) -> None: payload = { "club_code": "pennlabs", "question": "How is this question? 2", @@ -262,6 +268,7 @@ def test_send_email_on_status_change(self, mock_send_email): mock_send_email.assert_called_once() poll = Poll.objects.last() + assert poll is not None poll.status = Poll.STATUS_REVISION poll.save() @@ -272,12 +279,14 @@ def test_send_email_on_status_change(self, mock_send_email): class TestPollVotes(TestCase): """Tests Create/Update Polls and History""" - def setUp(self): + def setUp(self) -> None: call_command("load_target_populations", "--years", "2022, 2023, 2024, 2025") self.target_id = TargetPopulation.objects.get(population="2024").id - self.client = APIClient() - self.test_user = User.objects.create_user("user", "user@seas.upenn.edu", "user") + self.client: APIClient = APIClient() + self.test_user: UserType = DjangoUserModel.objects.create_user( + "user", "user@seas.upenn.edu", "user" + ) self.client.force_authenticate(user=self.test_user) # creates 4 polls, each with 3 options @@ -331,27 +340,29 @@ def setUp(self): PollOption.objects.create(poll=p4, choice="choice 12") @mock.patch("portal.logic.get_user_info", mock_get_user_info) - def test_create_vote(self): + def test_create_vote(self) -> None: payload_1 = {"id_hash": 1, "poll_options": [self.p1_op1_id]} response = self.client.post("/portal/votes/", payload_1) res_json = json.loads(response.content) + assert isinstance(res_json, dict) # tests that voting works self.assertIn(self.p1_op1_id, res_json["poll_options"]) + vote = PollVote.objects.first() + assert vote is not None self.assertEqual(1, PollVote.objects.all().count()) - self.assertEqual("1", PollVote.objects.all().first().id_hash) + self.assertEqual("1", vote.id_hash) self.assertIn( - TargetPopulation.objects.get(id=self.target_id), - PollVote.objects.all().first().target_populations.all(), + TargetPopulation.objects.get(id=self.target_id), vote.target_populations.all() ) - def test_recent_poll_empty(self): + def test_recent_poll_empty(self) -> None: response = self.client.post("/portal/votes/recent/", {"id_hash": 1}) res_json = json.loads(response.content) self.assertIsNone(res_json["created_date"]) self.assertIsNone(res_json["poll"]["created_date"]) @mock.patch("portal.logic.get_user_info", mock_get_user_info) - def test_recent_poll(self): + def test_recent_poll(self) -> None: # answer poll payload_1 = {"id_hash": 1, "poll_options": [self.p1_op1_id]} self.client.post("/portal/votes/", payload_1) @@ -368,7 +379,7 @@ def test_recent_poll(self): self.assertEquals(self.p4_id, res_json2["poll"]["id"]) @mock.patch("portal.logic.get_user_info", mock_get_user_info) - def test_all_votes(self): + def test_all_votes(self) -> None: payload_1 = {"id_hash": 1, "poll_options": [self.p1_op1_id]} self.client.post("/portal/votes/", payload_1) payload_2 = {"id_hash": 1, "poll_options": [self.p4_op1_id]} @@ -382,7 +393,7 @@ def test_all_votes(self): @mock.patch("portal.logic.get_user_info", mock_get_user_info) @mock.patch("portal.permissions.get_user_clubs", mock_get_user_clubs) - def test_demographic_breakdown(self): + def test_demographic_breakdown(self) -> None: # plugging in votes for breakdown payload_1 = {"id_hash": 1, "poll_options": [self.p1_op1_id]} self.client.post("/portal/votes/", payload_1) diff --git a/backend/tests/portal/test_posts.py b/backend/tests/portal/test_posts.py index 4a2b5bae..8b1cf513 100644 --- a/backend/tests/portal/test_posts.py +++ b/backend/tests/portal/test_posts.py @@ -1,8 +1,8 @@ import datetime import json +from typing import Any, cast from unittest import mock -from django.contrib.auth import get_user_model from django.core.management import call_command from django.test import TestCase from django.utils import timezone @@ -10,26 +10,24 @@ from portal.models import Post, TargetPopulation from utils.email import get_backend_manager_emails +from utils.types import DjangoUserModel, UserType -User = get_user_model() - - -def mock_get_user_clubs(*args, **kwargs): +def mock_get_user_clubs(*args: Any, **kwargs: Any) -> list[dict]: with open("tests/portal/get_user_clubs.json") as data: return json.load(data) -def mock_get_no_clubs(*args, **kwargs): +def mock_get_no_clubs(*args: Any, **kwargs: Any) -> list[dict]: return [] -def mock_get_user_info(*args, **kwargs): +def mock_get_user_info(*args: Any, **kwargs: Any) -> list[dict]: with open("tests/portal/get_user_info.json") as data: return json.load(data) -def mock_get_club_info(*args, **kwargs): +def mock_get_club_info(*args: Any, **kwargs: Any) -> list[dict]: with open("tests/portal/get_club_info.json") as data: return json.load(data) @@ -38,11 +36,13 @@ class TestPosts(TestCase): """Tests Created/Update/Retrieve for Posts""" @mock.patch("portal.serializers.get_user_clubs", mock_get_user_clubs) - def setUp(self): + def setUp(self) -> None: call_command("load_target_populations", "--years", "2022, 2023, 2024, 2025") - self.target_id = TargetPopulation.objects.get(population="2024").id - self.client = APIClient() - self.test_user = User.objects.create_user("user", "user@seas.upenn.edu", "user") + self.target_id: int = TargetPopulation.objects.get(population="2024").id + self.client: APIClient = APIClient() + self.test_user: UserType = DjangoUserModel.objects.create_user( + "user", "user@seas.upenn.edu", "user" + ) self.client.force_authenticate(user=self.test_user) payload = { @@ -57,12 +57,13 @@ def setUp(self): } self.client.post("/portal/posts/", payload) post_1 = Post.objects.all().first() + assert post_1 is not None post_1.status = Post.STATUS_APPROVED post_1.save() - self.id = post_1.id + self.post_id = post_1.id @mock.patch("portal.serializers.get_user_clubs", mock_get_user_clubs) - def test_create_post(self): + def test_create_post(self) -> None: # Creates an unapproved post payload = { "club_code": "pennlabs", @@ -81,7 +82,7 @@ def test_create_post(self): self.assertEqual(None, Post.objects.get(id=res_json["id"]).admin_comment) @mock.patch("portal.serializers.get_user_clubs", mock_get_no_clubs) - def test_fail_post(self): + def test_fail_post(self) -> None: # Creates an unapproved post payload = { "club_code": "pennlabs", @@ -101,29 +102,31 @@ def test_fail_post(self): @mock.patch("portal.views.get_user_clubs", mock_get_user_clubs) @mock.patch("portal.permissions.get_user_clubs", mock_get_user_clubs) - def test_update_post(self): + def test_update_post(self) -> None: payload = {"title": "New Test Title 3"} - response = self.client.patch(f"/portal/posts/{self.id}/", payload) + response = self.client.patch(f"/portal/posts/{self.post_id}/", payload) res_json = json.loads(response.content) - self.assertEqual(self.id, res_json["id"]) - self.assertEqual("New Test Title 3", Post.objects.get(id=self.id).title) + self.assertEqual(self.post_id, res_json["id"]) + self.assertEqual("New Test Title 3", Post.objects.get(id=self.post_id).title) # since the user is not an admin, approved should be set to false after update self.assertEqual(Post.STATUS_DRAFT, res_json["status"]) @mock.patch("portal.views.get_user_clubs", mock_get_user_clubs) @mock.patch("portal.permissions.get_user_clubs", mock_get_user_clubs) - def test_update_post_admin(self): - admin = User.objects.create_superuser("admin@upenn.edu", "admin", "admin") + def test_update_post_admin(self) -> None: + admin: UserType = DjangoUserModel.objects.create_superuser( + "admin@upenn.edu", "admin", "admin" + ) self.client.force_authenticate(user=admin) payload = {"title": "New Test Title 3"} - response = self.client.patch(f"/portal/posts/{self.id}/", payload) + response = self.client.patch(f"/portal/posts/{self.post_id}/", payload) res_json = json.loads(response.content) - self.assertEqual(self.id, res_json["id"]) + self.assertEqual(self.post_id, res_json["id"]) self.assertEqual(Post.STATUS_APPROVED, res_json["status"]) @mock.patch("portal.serializers.get_user_clubs", mock_get_user_clubs) @mock.patch("portal.logic.get_user_info", mock_get_user_info) - def test_browse(self): + def test_browse(self) -> None: payload = { "club_code": "pennlabs", "title": "Test Title 2", @@ -139,7 +142,7 @@ def test_browse(self): self.assertEqual(1, len(res_json)) self.assertEqual(2, Post.objects.all().count()) - def test_review_post_no_admin_comment(self): + def test_review_post_no_admin_comment(self) -> None: # No admin comment Post.objects.create( club_code="notpennlabs", @@ -147,7 +150,9 @@ def test_review_post_no_admin_comment(self): subtitle="Test subtitle 2", expire_date=timezone.localtime() + datetime.timedelta(days=1), ) - admin = User.objects.create_superuser("admin@upenn.edu", "admin", "admin") + admin: UserType = DjangoUserModel.objects.create_superuser( + "admin@upenn.edu", "admin", "admin" + ) self.client.force_authenticate(user=admin) response = self.client.get("/portal/posts/review/") res_json = json.loads(response.content) @@ -158,7 +163,7 @@ def test_review_post_no_admin_comment(self): @mock.patch("portal.serializers.get_user_clubs", mock_get_user_clubs) @mock.patch("portal.permissions.get_user_clubs", mock_get_user_clubs) @mock.patch("utils.email.send_automated_email.delay_on_commit") - def test_send_email_on_create(self, mock_send_email): + def test_send_email_on_create(self, mock_send_email: mock.Mock) -> None: payload = { "club_code": "pennlabs", "title": "Test Title 2", @@ -175,7 +180,7 @@ def test_send_email_on_create(self, mock_send_email): @mock.patch("portal.serializers.get_user_clubs", mock_get_user_clubs) @mock.patch("portal.permissions.get_user_clubs", mock_get_user_clubs) @mock.patch("utils.email.send_automated_email.delay_on_commit") - def test_send_email_on_status_change(self, mock_send_email): + def test_send_email_on_status_change(self, mock_send_email: mock.Mock) -> None: payload = { "club_code": "pennlabs", "title": "Test Title 2", @@ -190,8 +195,10 @@ def test_send_email_on_status_change(self, mock_send_email): mock_send_email.assert_called_once() post = Post.objects.last() + assert post is not None + creator = cast(UserType, post.creator) post.status = Post.STATUS_APPROVED post.save() self.assertEqual(mock_send_email.call_count, 2) - self.assertEqual(mock_send_email.call_args[0][1], [post.creator.email]) + self.assertEqual(mock_send_email.call_args[0][1], [creator.email]) diff --git a/backend/tests/sublet/test_permissions.py b/backend/tests/sublet/test_permissions.py index 39a26c5b..3be61b79 100644 --- a/backend/tests/sublet/test_permissions.py +++ b/backend/tests/sublet/test_permissions.py @@ -1,13 +1,10 @@ import json -from django.contrib.auth import get_user_model from django.test import TestCase from rest_framework.test import APIClient from sublet.models import Amenity, Sublet - - -User = get_user_model() +from utils.types import DjangoUserModel, UserType class SubletPermissions(TestCase): @@ -16,28 +13,34 @@ class SubletPermissions(TestCase): class OfferPermissions(TestCase): - def setUp(self): - self.client = APIClient() - self.admin = User.objects.create_superuser("admin", "admin@example.com", "admin") - self.user1 = User.objects.create_user("user1", "user1@seas.upenn.edu", "user1") - self.user2 = User.objects.create_user("user2", "user2@seas.upenn.edu", "user2") + def setUp(self) -> None: + self.client: APIClient = APIClient() + self.admin: UserType = DjangoUserModel.objects.create_superuser( + "admin", "admin@example.com", "admin" + ) + self.user1: UserType = DjangoUserModel.objects.create_user( + "user1", "user1@seas.upenn.edu", "user1" + ) + self.user2: UserType = DjangoUserModel.objects.create_user( + "user2", "user2@seas.upenn.edu", "user2" + ) for i in range(1, 6): Amenity.objects.create(name=f"Amenity{str(i)}") # TODO: Add amenities with open("tests/sublet/mock_sublets.json") as data: - data = json.load(data) - self.sublet1 = Sublet.objects.create(subletter=self.admin, **data[0]) - self.sublet2 = Sublet.objects.create(subletter=self.user1, **data[0]) - self.sublet3 = Sublet.objects.create(subletter=self.user2, **data[1]) + mock_data = json.load(data) + self.sublet1 = Sublet.objects.create(subletter=self.admin, **mock_data[0]) + self.sublet2 = Sublet.objects.create(subletter=self.user1, **mock_data[0]) + self.sublet3 = Sublet.objects.create(subletter=self.user2, **mock_data[1]) - def test_authentication(self): + def test_authentication(self) -> None: prop_url = f"/sublet/properties/{str(self.sublet1.id)}/offers/" self.assertEqual(self.client.get(prop_url).status_code, 403) self.assertEqual(self.client.post(prop_url).status_code, 403) self.assertEqual(self.client.delete(prop_url).status_code, 403) self.assertEqual(self.client.get("/sublet/offers/").status_code, 403) - def create_create_offer(self): + def test_create_offer(self) -> None: prop_url = f"/sublet/properties/{str(self.sublet1.id)}/offers/" payload = { "email": "offer@seas.upenn.edu", @@ -50,7 +53,7 @@ def create_create_offer(self): self.client.force_authenticate(user=u) self.assertEqual(self.client.post(prop_url, payload).status_code, 201) - def test_delete_offer(self): + def test_delete_offer(self) -> None: prop_url = f"/sublet/properties/{str(self.sublet1.id)}/offers/" payload = { "email": "offer@seas.upenn.edu", @@ -64,7 +67,7 @@ def test_delete_offer(self): self.client.post(prop_url, payload) self.assertEqual(self.client.delete(prop_url).status_code, 204) - def test_get_offers_property(self): + def test_get_offers_property(self) -> None: prop_url = f"/sublet/properties/{str(self.sublet2.id)}/offers/" payload = { "email": "offer@seas.upenn.edu", @@ -81,40 +84,46 @@ def test_get_offers_property(self): self.client.force_authenticate(user=u) self.assertEqual(self.client.get(prop_url).status_code, c) - def test_get_offers_user(self): + def test_get_offers_user(self) -> None: self.client.force_authenticate(user=self.user1) self.assertEqual(self.client.get("/sublet/offers/").status_code, 200) class FavoritePermissions(TestCase): - def setUp(self): - self.client = APIClient() - self.admin = User.objects.create_superuser("admin", "admin@example.com", "admin") - self.user1 = User.objects.create_user("user1", "user1@seas.upenn.edu", "user1") - self.user2 = User.objects.create_user("user2", "user2@seas.upenn.edu", "user2") + def setUp(self) -> None: + self.client: APIClient = APIClient() + self.admin: UserType = DjangoUserModel.objects.create_superuser( + "admin", "admin@example.com", "admin" + ) + self.user1: UserType = DjangoUserModel.objects.create_user( + "user1", "user1@seas.upenn.edu", "user1" + ) + self.user2: UserType = DjangoUserModel.objects.create_user( + "user2", "user2@seas.upenn.edu", "user2" + ) for i in range(1, 6): Amenity.objects.create(name=f"Amenity{str(i)}") # TODO: Add amenities with open("tests/sublet/mock_sublets.json") as data: - data = json.load(data) - self.sublet1 = Sublet.objects.create(subletter=self.admin, **data[0]) - self.sublet2 = Sublet.objects.create(subletter=self.user1, **data[0]) - self.sublet3 = Sublet.objects.create(subletter=self.user2, **data[1]) + mock_data = json.load(data) + self.sublet1 = Sublet.objects.create(subletter=self.admin, **mock_data[0]) + self.sublet2 = Sublet.objects.create(subletter=self.user1, **mock_data[0]) + self.sublet3 = Sublet.objects.create(subletter=self.user2, **mock_data[1]) - def test_authentication(self): + def test_authentication(self) -> None: prop_url = f"/sublet/properties/{str(self.sublet1.id)}/favorites/" self.assertEqual(self.client.post(prop_url).status_code, 403) self.assertEqual(self.client.delete(prop_url).status_code, 403) self.assertEqual(self.client.get("/sublet/favorites/").status_code, 403) - def test_create_favorite(self): + def test_create_favorite(self) -> None: prop_url = f"/sublet/properties/{str(self.sublet1.id)}/favorites/" users = [self.admin, self.user1] for u in users: self.client.force_authenticate(user=u) self.assertEqual(self.client.post(prop_url).status_code, 201) - def test_delete_favorite(self): + def test_delete_favorite(self) -> None: prop_url = f"/sublet/properties/{str(self.sublet1.id)}/favorites/" users = [self.admin, self.user1] for u in users: @@ -122,6 +131,6 @@ def test_delete_favorite(self): self.client.post(prop_url) self.assertEqual(self.client.delete(prop_url).status_code, 204) - def test_get_favorites_user(self): + def test_get_favorites_user(self) -> None: self.client.force_authenticate(user=self.user1) self.assertEqual(self.client.get("/sublet/favorites/").status_code, 200) diff --git a/backend/tests/sublet/test_sublets.py b/backend/tests/sublet/test_sublets.py index 793fc5f9..a10757e5 100644 --- a/backend/tests/sublet/test_sublets.py +++ b/backend/tests/sublet/test_sublets.py @@ -1,40 +1,45 @@ import json +from typing import cast from unittest.mock import MagicMock -from django.contrib.auth import get_user_model from django.core.files.storage import Storage +from django.forms import ImageField from django.test import TestCase from rest_framework.test import APIClient from sublet.models import Amenity, Offer, Sublet, SubletImage - - -User = get_user_model() +from utils.types import DjangoUserModel, UserType class TestSublets(TestCase): """Tests Create/Update/Retrieve/List for sublets""" - def setUp(self): - self.user = User.objects.create_user("user", "user@seas.upenn.edu", "user") - self.client = APIClient() + def setUp(self) -> None: + self.user: UserType = DjangoUserModel.objects.create_user( + "user", "user@seas.upenn.edu", "user" + ) + self.client: APIClient = APIClient() self.client.force_authenticate(user=self.user) - test_user = User.objects.create_user("user1", "user1@seas.upenn.edu", "user1") + test_user: UserType = DjangoUserModel.objects.create_user( + "user1", "user1@seas.upenn.edu", "user1" + ) for i in range(1, 6): Amenity.objects.create(name=f"Amenity{str(i)}") with open("tests/sublet/mock_sublets.json") as data: - data = json.load(data) - self.test_sublet1 = Sublet.objects.create(subletter=self.user, **data[0]) - self.test_sublet2 = Sublet.objects.create(subletter=test_user, **data[1]) + mock_data = json.load(data) + self.test_sublet1 = Sublet.objects.create(subletter=self.user, **mock_data[0]) + self.test_sublet2 = Sublet.objects.create(subletter=test_user, **mock_data[1]) storage_mock = MagicMock(spec=Storage, name="StorageMock") storage_mock.generate_filename = lambda filename: filename storage_mock.save = MagicMock(side_effect=lambda name, *args, **kwargs: name) storage_mock.url = MagicMock(name="url") storage_mock.url.return_value = "http://penn-mobile.com/mock-image.png" - SubletImage._meta.get_field("image").storage = storage_mock - def test_create_sublet(self): + image_field = cast(ImageField, SubletImage._meta.get_field("image")) + image_field.storage = storage_mock # type: ignore + + def test_create_sublet(self) -> None: # Create a new sublet using the serializer payload = { "title": "Test Sublet1", @@ -66,13 +71,14 @@ def test_create_sublet(self): "end_date", "amenities", ] - [self.assertEqual(payload[key], res_json[key]) for key in match_keys] + for key in match_keys: + self.assertEqual(payload[key], res_json[key]) self.assertIn("id", res_json) self.assertEqual(self.user.id, res_json["subletter"]) self.assertEqual(2, len(res_json["amenities"])) self.assertIn("images", res_json) - def test_create_sublet_with_profanity(self): + def test_create_sublet_with_profanity(self) -> None: # Payload with profanity in the title and description payload_with_profanity = { "title": "fuck", @@ -101,7 +107,7 @@ def test_create_sublet_with_profanity(self): res_json["description"][0], "The description contains inappropriate language." ) - def test_update_sublet(self): + def test_update_sublet(self) -> None: # Create a sublet to be updated payload = { "title": "Test Sublet2", @@ -124,12 +130,14 @@ def test_update_sublet(self): response = self.client.patch(f"/sublet/properties/{str(old_id)}/", data) res_json = json.loads(response.content) self.assertEqual(3, res_json["beds"]) - self.assertEqual(old_id, Sublet.objects.all().last().id) - self.assertEqual("New Title", Sublet.objects.get(id=old_id).title) + last_sublet = Sublet.objects.all().last() + assert last_sublet is not None + self.assertEqual(old_id, last_sublet.id) + self.assertEqual("New Title", last_sublet.title) self.assertEqual("New Title", res_json["title"]) self.assertEqual(1, len(res_json["amenities"])) - def test_browse_sublets(self): + def test_browse_sublets(self) -> None: response = self.client.get("/sublet/properties/") res_json = json.loads(response.content) first_length = len(res_json) @@ -158,7 +166,7 @@ def test_browse_sublets(self): self.assertEqual(sublet.beds, 2) self.assertEqual(sublet.baths, 1) - def test_browse_filtered(self): + def test_browse_filtered(self) -> None: payload = { "title": "Test Sublet2", "address": "1234 Test Street", @@ -175,12 +183,12 @@ def test_browse_filtered(self): } response = self.client.post("/sublet/properties/", payload) old_id = json.loads(response.content)["id"] - payload = { + filter_payload = { "title": "Sublet2", - "max_price": 999, - "min_price": 499, + "max_price": "999", + "min_price": "499", } - response = self.client.get("/sublet/properties/", payload) + response = self.client.get("/sublet/properties/", filter_payload) res_json = json.loads(response.content) sublet = res_json[0] self.assertEqual(1, len(res_json)) @@ -208,7 +216,7 @@ def test_browse_filtered(self): res_json = json.loads(response.content) self.assertEqual(old_length, len(res_json)) - def test_browse_sublet(self): + def test_browse_sublet(self) -> None: # browse single sublet by id payload = { "title": "Test Sublet2", @@ -234,19 +242,19 @@ def test_browse_sublet(self): self.assertEqual(res_json["baths"], "1.5") self.assertEqual(res_json["amenities"], ["Amenity1", "Amenity2"]) - def test_delete_sublet(self): + def test_delete_sublet(self) -> None: sublets_count = Sublet.objects.all().count() self.client.delete(f"/sublet/properties/{str(self.test_sublet1.id)}/") self.assertEqual(sublets_count - 1, Sublet.objects.all().count()) self.assertFalse(Sublet.objects.filter(id=1).exists()) - def test_amenities(self): + def test_amenities(self) -> None: response = self.client.get("/sublet/amenities/") res_json = json.loads(response.content) for i in range(1, 6): self.assertIn(f"Amenity{i}", res_json) - def test_create_image(self): + def test_create_image(self) -> None: with open("tests/sublet/mock_image.jpg", "rb") as image: response = self.client.post( f"/sublet/properties/{str(self.test_sublet1.id)}/images/", {"images": image} @@ -254,9 +262,11 @@ def test_create_image(self): self.assertEqual(response.status_code, 201) images = Sublet.objects.get(id=self.test_sublet1.id).images.all() self.assertTrue(images.exists()) - self.assertEqual(self.test_sublet1.id, images.first().sublet.id) + first_image = images.first() + assert first_image is not None + self.assertEqual(self.test_sublet1.id, first_image.sublet.id) - def test_create_delete_images(self): + def test_create_delete_images(self) -> None: with open("tests/sublet/mock_image.jpg", "rb") as image: with open("tests/sublet/mock_image.jpg", "rb") as image2: response = self.client.post( @@ -266,10 +276,12 @@ def test_create_delete_images(self): ) self.assertEqual(response.status_code, 201) images = Sublet.objects.get(id=self.test_sublet1.id).images.all() - image_id1 = images.first().id + first_image = images.first() + assert first_image is not None + image_id1 = first_image.id self.assertTrue(images.exists()) self.assertEqual(2, images.count()) - self.assertEqual(self.test_sublet1.id, images.first().sublet.id) + self.assertEqual(self.test_sublet1.id, first_image.sublet.id) response = self.client.delete(f"/sublet/properties/images/{image_id1}/") self.assertEqual(response.status_code, 204) self.assertFalse(SubletImage.objects.filter(id=image_id1).exists()) @@ -279,20 +291,22 @@ def test_create_delete_images(self): class TestOffers(TestCase): """Tests Create/Delete/List for offers""" - def setUp(self): - self.user = User.objects.create_user("user", "user@seas.upenn.edu", "user") - self.client = APIClient() + def setUp(self) -> None: + self.user: UserType = DjangoUserModel.objects.create_user( + "user", "user@seas.upenn.edu", "user" + ) + self.client: APIClient = APIClient() self.client.force_authenticate(user=self.user) - self.test_user = User.objects.create_user("user1", "user") + self.test_user: UserType = DjangoUserModel.objects.create_user("user1", "user") for i in range(1, 6): Amenity.objects.create(name=f"Amenity{str(i)}") # TODO: Not sure how to add these amenities to the sublets, but not important for now with open("tests/sublet/mock_sublets.json") as data: - data = json.load(data) - self.first_sublet = Sublet.objects.create(subletter=self.user, **data[0]) - self.second_sublet = Sublet.objects.create(subletter=self.test_user, **data[1]) + mock_data = json.load(data) + self.first_sublet = Sublet.objects.create(subletter=self.user, **mock_data[0]) + self.second_sublet = Sublet.objects.create(subletter=self.test_user, **mock_data[1]) - def test_create_offer(self): + def test_create_offer(self) -> None: prop_url = f"/sublet/properties/{str(self.second_sublet.id)}/offers/" payload = { "email": "offer@seas.upenn.edu", @@ -317,7 +331,7 @@ def test_create_offer(self): self.assertIsNotNone(offer.id) self.assertIsNotNone(offer.created_date) - def test_delete_offer(self): + def test_delete_offer(self) -> None: prop_url1 = f"/sublet/properties/{str(self.first_sublet.id)}/offers/" prop_url2 = f"/sublet/properties/{str(self.second_sublet.id)}/offers/" payload = { @@ -334,7 +348,7 @@ def test_delete_offer(self): self.client.delete(prop_url2) self.assertFalse(Offer.objects.filter(user=self.user, sublet=self.second_sublet).exists()) - def test_get_offers_property(self): + def test_get_offers_property(self) -> None: response = self.client.get("/sublet/offers/") res_json = json.loads(response.content) self.assertEqual(0, len(res_json)) @@ -376,7 +390,7 @@ def test_get_offers_property(self): self.assertIsNotNone(offer["id"]) self.assertIsNotNone(offer["created_date"]) - def test_get_offer_user(self): + def test_get_offer_user(self) -> None: response = self.client.get("/sublet/offers/") res_json = json.loads(response.content) self.assertEqual(0, len(res_json)) @@ -420,20 +434,22 @@ def test_get_offer_user(self): class TestFavorites(TestCase): """Tests Create/Delete/List for favorites""" - def setUp(self): - self.user = User.objects.create_user("user", "user@seas.upenn.edu", "user") - self.client = APIClient() + def setUp(self) -> None: + self.user: UserType = DjangoUserModel.objects.create_user( + "user", "user@seas.upenn.edu", "user" + ) + self.client: APIClient = APIClient() self.client.force_authenticate(user=self.user) - test_user = User.objects.create_user("user1", "user") + test_user: UserType = DjangoUserModel.objects.create_user("user1", "user") for i in range(1, 6): Amenity.objects.create(name=f"Amenity{str(i)}") # TODO: Not sure how to add these amenities to the sublets, but not important for now with open("tests/sublet/mock_sublets.json") as data: - data = json.load(data) - self.first_sublet = Sublet.objects.create(subletter=self.user, **data[0]) - self.second_sublet = Sublet.objects.create(subletter=test_user, **data[1]) + mock_data = json.load(data) + self.first_sublet = Sublet.objects.create(subletter=self.user, **mock_data[0]) + self.second_sublet = Sublet.objects.create(subletter=test_user, **mock_data[1]) - def test_create_favorite(self): + def test_create_favorite(self) -> None: prop_url1 = f"/sublet/properties/{str(self.first_sublet.id)}/favorites/" prop_url2 = f"/sublet/properties/{str(self.second_sublet.id)}/favorites/" self.client.post(prop_url2) @@ -444,7 +460,7 @@ def test_create_favorite(self): self.assertTrue(self.user.sublets_favorited.filter(pk=self.first_sublet.id).exists()) self.assertEqual(self.client.post(prop_url1).status_code, 406) - def test_delete_favorite(self): + def test_delete_favorite(self) -> None: self.client.post(f"/sublet/properties/{str(self.second_sublet.id)}/favorites/") self.client.post(f"/sublet/properties/{str(self.first_sublet.id)}/favorites/") self.client.delete(f"/sublet/properties/{str(self.first_sublet.id)}/favorites/") @@ -460,7 +476,7 @@ def test_delete_favorite(self): self.assertFalse(self.user.sublets_favorited.filter(pk=self.second_sublet.id).exists()) self.assertFalse(self.user.sublets_favorited.filter(pk=self.first_sublet.id).exists()) - def test_get_favorite_user(self): + def test_get_favorite_user(self) -> None: response = self.client.get("/sublet/favorites/") res_json = json.loads(response.content) self.assertEqual(len(res_json), 0) diff --git a/backend/tests/user/test_notifs.py b/backend/tests/user/test_notifs.py index 512b271a..e860ba8d 100644 --- a/backend/tests/user/test_notifs.py +++ b/backend/tests/user/test_notifs.py @@ -1,27 +1,25 @@ import datetime import json +from typing import Any from unittest import mock -from django.contrib.auth import get_user_model from django.core.management import call_command from django.test import TestCase from django.utils import timezone from identity.identity import attest, container, get_platform_jwks from rest_framework.test import APIClient -from gsr_booking.models import GSR, Group, GSRBooking, Reservation +from gsr_booking.models import GSR, GSRBooking, Reservation from user.models import NotificationSetting, NotificationToken +from utils.types import DjangoUserModel, UserType -User = get_user_model() - - -def initialize_b2b(): +def initialize_b2b() -> None: get_platform_jwks() attest() -def get_b2b_client(): +def get_b2b_client() -> APIClient: client = APIClient() client.logout() client.credentials(HTTP_AUTHORIZATION="Bearer " + container.access_jwt.serialize()) @@ -29,33 +27,37 @@ def get_b2b_client(): class MockAPNsClient: - def send_notification(self, token, payload, topic): + def send_notification(self, token: str, payload: dict, topic: str) -> None: del token, payload, topic pass - def send_notification_batch(self, notifications, topic): + def send_notification_batch(self, notifications: list[dict], topic: str) -> None: del notifications, topic pass -def mock_client(is_dev): +def mock_client(is_dev: bool) -> MockAPNsClient: return MockAPNsClient() class TestNotificationToken(TestCase): """Tests for CRUD Notification Tokens""" - def setUp(self): - self.client = APIClient() - self.test_user = User.objects.create_user("user", "user@seas.upenn.edu", "user") + def setUp(self) -> None: + self.client: APIClient = APIClient() + self.test_user: UserType = DjangoUserModel.objects.create_user( + "user", "user@seas.upenn.edu", "user" + ) self.client.force_authenticate(user=self.test_user) - def test_post_save(self): + def test_post_save(self) -> None: # asserts that post save hook in creating tokens works correctly self.assertEqual(1, NotificationToken.objects.all().count()) - self.assertEqual(self.test_user, NotificationToken.objects.all().first().user) + notification_token = NotificationToken.objects.all().first() + assert notification_token is not None + self.assertEqual(self.test_user, notification_token.user) - def test_create_update_token(self): + def test_create_update_token(self) -> None: NotificationToken.objects.all().delete() # test that creating token returns correct response @@ -74,13 +76,13 @@ def test_create_update_token(self): self.assertEqual("newtoken", res_json["token"]) self.assertEqual(1, NotificationToken.objects.all().count()) - def test_create_token_again_fail(self): + def test_create_token_again_fail(self) -> None: # test that creating token returns correct response payload = {"kind": "IOS", "token": "test123"} response = self.client.post("/user/notifications/tokens/", payload) self.assertEqual(response.status_code, 400) - def test_get_token(self): + def test_get_token(self) -> None: NotificationToken.objects.all().delete() # create token @@ -99,13 +101,15 @@ def test_get_token(self): class TestNotificationSetting(TestCase): """Tests for CRUD Notification Settings""" - def setUp(self): - self.client = APIClient() - self.test_user = User.objects.create_user("user", "user@seas.upenn.edu", "user") + def setUp(self) -> None: + self.client: APIClient = APIClient() + self.test_user: UserType = DjangoUserModel.objects.create_user( + "user", "user@seas.upenn.edu", "user" + ) self.client.force_authenticate(user=self.test_user) initialize_b2b() - def test_get_settings(self): + def test_get_settings(self) -> None: # test that settings visible via GET response = self.client.get("/user/notifications/settings/") res_json = json.loads(response.content) @@ -113,9 +117,9 @@ def test_get_settings(self): for setting in res_json: self.assertFalse(setting["enabled"]) - def test_invalid_settings_update(self): + def test_invalid_settings_update(self) -> None: NotificationToken.objects.all().delete() - payload = {"kind": "IOS", "token": "test123"} + payload: dict[str, Any] = {"kind": "IOS", "token": "test123"} response = self.client.post("/user/notifications/tokens/", payload) res_json = json.loads(response.content) @@ -128,13 +132,13 @@ def test_invalid_settings_update(self): self.assertEqual(res_json["service"], "PENN_MOBILE") self.assertTrue(res_json["enabled"]) - def test_valid_settings_update(self): + def test_valid_settings_update(self) -> None: NotificationToken.objects.all().delete() response = self.client.get("/user/notifications/settings/PENN_MOBILE/check/") res_json = json.loads(response.content) self.assertFalse(res_json["enabled"]) - payload = {"kind": "IOS", "token": "test123"} + payload: dict[str, Any] = {"kind": "IOS", "token": "test123"} response = self.client.post("/user/notifications/tokens/", payload) res_json = json.loads(response.content) @@ -145,7 +149,7 @@ def test_valid_settings_update(self): response = self.client.patch(f"/user/notifications/settings/{settings_id}/", payload) self.assertEqual(response.status_code, 400) - def test_create_update_check_settings(self): + def test_create_update_check_settings(self) -> None: # test that invalid settings are rejected NotificationSetting.objects.filter(service="PENN_MOBILE").delete() payload = {"service": "Penn Mobile", "enabled": True} @@ -181,7 +185,7 @@ def test_create_update_check_settings(self): res_json = json.loads(response.content) self.assertTrue(res_json["enabled"]) - def test_check_fail(self): + def test_check_fail(self) -> None: # since invalid setting, should return error response = self.client.get("/user/notifications/settings/PENN_MOBIL/check/") self.assertEqual(response.status_code, 400) @@ -205,7 +209,7 @@ def test_check_fail(self): # self.assertEqual(res_json["service"], "PENN_MOBILE") # self.assertFalse(res_json["enabled"]) - def test_b2b_auth_fails(self): + def test_b2b_auth_fails(self) -> None: self.client.logout() response = self.client.get("/user/notifications/settings/PENN_MOBILE/check/?pennkey=user") self.assertEqual(response.status_code, 403) @@ -214,18 +218,22 @@ def test_b2b_auth_fails(self): class TestNotificationAlert(TestCase): """Tests for sending Notification Alerts""" - def setUp(self): - self.client = APIClient() + def setUp(self) -> None: + self.client: APIClient = APIClient() # create user1 - self.test_user = User.objects.create_user("user", "user@seas.upenn.edu", "user") + self.test_user: UserType = DjangoUserModel.objects.create_user( + "user", "user@seas.upenn.edu", "user" + ) self.client.force_authenticate(user=self.test_user) token_obj = NotificationToken.objects.get(user=self.test_user) token_obj.token = "test123" token_obj.save() # create user2 - self.test_user = User.objects.create_user("user2", "user2@seas.upenn.edu", "user2") + self.test_user = DjangoUserModel.objects.create_user( + "user2", "user2@seas.upenn.edu", "user2" + ) self.client.force_authenticate(user=self.test_user) token_obj = NotificationToken.objects.get(user=self.test_user) token_obj.token = "test234" @@ -235,7 +243,7 @@ def setUp(self): setting.save() # create user3 - user3 = User.objects.create_user("user3", "user3@seas.upenn.edu", "user3") + user3 = DjangoUserModel.objects.create_user("user3", "user3@seas.upenn.edu", "user3") token_obj = NotificationToken.objects.get(user=user3) token_obj.token = "test234" token_obj.save() @@ -246,7 +254,7 @@ def setUp(self): initialize_b2b() @mock.patch("user.notifications.get_client", mock_client) - def test_failed_notif(self): + def test_failed_notif(self) -> None: # missing title payload = {"body": ":D", "service": "PENN_MOBILE"} response = self.client.post("/user/notifications/alerts/", payload) @@ -262,7 +270,7 @@ def test_failed_notif(self): self.assertEqual(response.status_code, 400) @mock.patch("user.notifications.get_client", mock_client) - def test_single_notif(self): + def test_single_notif(self) -> None: # test notif fail when setting is false payload = {"title": "Test", "body": ":D", "service": "OHQ"} response = self.client.post("/user/notifications/alerts/", payload) @@ -278,7 +286,7 @@ def test_single_notif(self): self.assertEqual(0, len(res_json["failed_users"])) @mock.patch("user.notifications.get_client", mock_client) - def test_batch_notif(self): + def test_batch_notif(self) -> None: # update all settings to be enabled NotificationSetting.objects.all().update(enabled=True) @@ -319,10 +327,12 @@ def test_batch_notif(self): class TestSendGSRReminders(TestCase): """Test Sending GSR Reminders""" - def setUp(self): + def setUp(self) -> None: call_command("load_gsrs") - self.client = APIClient() - self.test_user = User.objects.create_user("user", "user@seas.upenn.edu", "user") + self.client: APIClient = APIClient() + self.test_user: UserType = DjangoUserModel.objects.create_user( + "user", "user@seas.upenn.edu", "user" + ) self.client.force_authenticate(user=self.test_user) # enabling tokens and settings @@ -350,38 +360,41 @@ def setUp(self): start=g.start, end=g.end, creator=self.test_user, - group=Group.objects.get(owner=self.test_user), ) g.reservation = r g.save() @mock.patch("user.notifications.get_client", mock_client) - def test_send_reminder(self): + def test_send_reminder(self) -> None: call_command("send_gsr_reminders") r = Reservation.objects.all().first() + assert r is not None self.assertTrue(r.reminder_sent) - def test_send_reminder_no_gsrs(self): + def test_send_reminder_no_gsrs(self) -> None: GSRBooking.objects.all().delete() call_command("send_gsr_reminders") r = Reservation.objects.all().first() + assert r is not None self.assertFalse(r.reminder_sent) class TestSendShadowNotifs(TestCase): """Test Sending Shadow Notifications""" - def setUp(self): - self.client = APIClient() - self.test_user = User.objects.create_user("user", "user@seas.upenn.edu", "user") + def setUp(self) -> None: + self.client: APIClient = APIClient() + self.test_user: UserType = DjangoUserModel.objects.create_user( + "user", "user@seas.upenn.edu", "user" + ) self.client.force_authenticate(user=self.test_user) token_obj = NotificationToken.objects.get(user=self.test_user) token_obj.token = "test123" token_obj.save() @mock.patch("user.notifications.get_client", mock_client) - def test_shadow_notifications(self): + def test_shadow_notifications(self) -> None: # call command on every user call_command("send_shadow_notifs", "yes", '{"test":"test"}') diff --git a/backend/tests/user/test_user.py b/backend/tests/user/test_user.py index 27bc8b83..010f9cf2 100644 --- a/backend/tests/user/test_user.py +++ b/backend/tests/user/test_user.py @@ -1,16 +1,12 @@ from django.contrib import auth -from django.contrib.auth import get_user_model from django.test import TestCase from rest_framework.test import APIClient from user.models import Profile -User = get_user_model() - - class UserTestCase(TestCase): - def setUp(self): + def setUp(self) -> None: self.user1 = { "pennid": 1, "first_name": "First", @@ -35,21 +31,28 @@ def setUp(self): "token": {"access_token": "abc", "refresh_token": "123", "expires_in": 100}, } - self.client = APIClient() + self.client: APIClient = APIClient() self.client.login(username="user1", password="password") - def test_user1_profile(self): + def test_user1_profile(self) -> None: user = auth.authenticate(remote_user=self.user1) self.assertEqual(1, Profile.objects.all().count()) - self.assertEqual(user, Profile.objects.all().first().user) - self.assertEqual("user", str(Profile.objects.all().first())) + profile = Profile.objects.all().first() + assert profile is not None + self.assertEqual(user, profile.user) + self.assertEqual("user", str(profile)) - def test_user2_profile(self): + def test_user2_profile(self) -> None: self.assertEqual(0, Profile.objects.all().count()) user = auth.authenticate(remote_user=self.user2) + assert user is not None self.assertEqual(1, Profile.objects.all().count()) - self.assertEqual(user, Profile.objects.all().first().user) - user.name = "New Name" + profile = Profile.objects.all().first() + assert profile is not None + self.assertEqual(user, profile.user) + user.name = "New Name" # type: ignore user.save() self.assertEqual(1, Profile.objects.all().count()) - self.assertEqual(user, Profile.objects.all().first().user) + profile = Profile.objects.all().first() + assert profile is not None + self.assertEqual(user, profile.user) diff --git a/backend/tests/utils/test_email.py b/backend/tests/utils/test_email.py index 1b033c6c..b1a4a5e8 100644 --- a/backend/tests/utils/test_email.py +++ b/backend/tests/utils/test_email.py @@ -1,31 +1,30 @@ from unittest import mock -from django.contrib.auth import get_user_model from django.contrib.auth.models import Group from django.test import TestCase from utils.email import get_backend_manager_emails, send_automated_email, send_mail - - -User = get_user_model() +from utils.types import DjangoUserModel, UserType class EmailTestCase(TestCase): - def setUp(self): - self.group = Group.objects.create(name="backend_managers") - self.user1 = User.objects.create_user( + def setUp(self) -> None: + self.group: Group = Group.objects.create(name="backend_managers") + self.user1: UserType = DjangoUserModel.objects.create_user( username="user1", password="password", email="user1@domain.com" ) - self.user2 = User.objects.create_user( + self.user2: UserType = DjangoUserModel.objects.create_user( username="user2", password="password", email="user2@domain.com" ) - self.user3 = User.objects.create_user(username="user3", password="password") + self.user3: UserType = DjangoUserModel.objects.create_user( + username="user3", password="password" + ) - self.group.user_set.add(self.user1) - self.group.user_set.add(self.user3) + self.group.user_set.add(self.user1) # type: ignore[attr-defined] + self.group.user_set.add(self.user3) # type: ignore[attr-defined] @mock.patch("utils.email.django_send_mail") - def test_send_mail(self, mock_send_mail): + def test_send_mail(self, mock_send_mail: mock.Mock) -> None: send_mail("testing321", ["test@example.com"], message="test message?!") mock_send_mail.assert_called_once_with( subject="testing321", @@ -36,12 +35,12 @@ def test_send_mail(self, mock_send_mail): html_message=None, ) - def test_send_mail_error(self): + def test_send_mail_error(self) -> None: with self.assertRaises(ValueError): send_mail("testing321", None, message="test message?!") @mock.patch("utils.email.django_send_mail") - def test_send_automated_email(self, mock_send_mail): + def test_send_automated_email(self, mock_send_mail: mock.Mock) -> None: send_automated_email("testing123", ["test@example.com"], "test message?!") html_message = mock_send_mail.call_args[1]["html_message"] mock_send_mail.assert_called_once_with( @@ -55,7 +54,7 @@ def test_send_automated_email(self, mock_send_mail): self.assertIsNotNone(html_message) self.assertIn("test message?!", html_message) - def test_get_backend_manager_emails(self): + def test_get_backend_manager_emails(self) -> None: emails = get_backend_manager_emails() self.assertEqual(emails, ["user1@domain.com"]) diff --git a/backend/tests/utils/test_r_request.py b/backend/tests/utils/test_r_request.py index 409afa90..02adc4e5 100644 --- a/backend/tests/utils/test_r_request.py +++ b/backend/tests/utils/test_r_request.py @@ -1,68 +1,69 @@ from json.decoder import JSONDecodeError +from unittest import mock from unittest.mock import patch from django.test import TestCase -from utils.r_request import RRequest +from utils.r_request import Method, RRequest -def raise_decode_error(): +def raise_decode_error() -> None: raise JSONDecodeError("Invalid JSON data", "invalid_json", 0) class RRequestTestCase(TestCase): - def setUp(self): + def setUp(self) -> None: self.url = "https://pennlabs.org" self.json = {"data": "data"} self.rrequest = RRequest() @patch("requests.request") - def test_successful_request(self, mock_response): + def test_successful_request(self, mock_response: mock.Mock) -> None: mock_response.return_value.status_code = 200 - response = self.rrequest.request("get", self.url) + response = self.rrequest.request(Method.GET, self.url) self.assertEqual(200, response.status_code) @patch("requests.request") - def test_unsuccessful_request(self, mock_response): + def test_unsuccessful_request(self, mock_response: mock.Mock) -> None: mock_response.return_value.status_code = 400 mock_response.return_value.content = "Bad Error" - response = self.rrequest.request("post", self.url, json=self.json) + response = self.rrequest.request(Method.POST, self.url, json=self.json) self.assertEqual(400, response.status_code) self.assertEqual("Bad Error", response.content) @patch("requests.request") - def test_bad_json(self, mock_response): + def test_bad_json(self, mock_response: mock.Mock) -> None: mock_response.return_value.status_code = 200 mock_response.return_value.json = raise_decode_error response = self.rrequest.delete(self.url, json=self.json) self.assertEqual(200, response.status_code) @patch("requests.request") - def test_get_request(self, mock_response): + def test_get_request(self, mock_response: mock.Mock) -> None: mock_response.return_value.status_code = 200 response = self.rrequest.get(self.url) self.assertEqual(200, response.status_code) @patch("requests.request") - def test_post_request(self, mock_response): + def test_post_request(self, mock_response: mock.Mock) -> None: mock_response.return_value.status_code = 200 response = self.rrequest.post(self.url, json=self.json) self.assertEqual(200, response.status_code) @patch("requests.request") - def test_patch_request(self, mock_response): + def test_patch_request(self, mock_response: mock.Mock) -> None: mock_response.return_value.status_code = 200 response = self.rrequest.patch(self.url) self.assertEqual(200, response.status_code) @patch("requests.request") - def test_put_request(self, mock_response): + def test_put_request(self, mock_response: mock.Mock) -> None: mock_response.return_value.status_code = 200 response = self.rrequest.put(self.url, json=self.json) self.assertEqual(200, response.status_code) @patch("requests.request") - def test_delete_request(self, mock_response): + def test_delete_request(self, mock_response: mock.Mock) -> None: mock_response.return_value.status_code = 200 response = self.rrequest.delete(self.url, json=self.json) self.assertEqual(200, response.status_code) diff --git a/backend/user/management/commands/clear_cache.py b/backend/user/management/commands/clear_cache.py index b87d15c7..d5e64f55 100644 --- a/backend/user/management/commands/clear_cache.py +++ b/backend/user/management/commands/clear_cache.py @@ -1,4 +1,5 @@ import logging +from typing import Any import redis from django.conf import settings @@ -30,7 +31,7 @@ def clear_cache() -> int: class Command(BaseCommand): - def handle(self, *args, **options) -> None: + def handle(self, *args: Any, **options: Any) -> None: root_logger = logging.getLogger("") root_logger.setLevel(logging.DEBUG) diff --git a/backend/user/management/commands/profile_info.py b/backend/user/management/commands/profile_info.py index 667f4da2..d2350c14 100644 --- a/backend/user/management/commands/profile_info.py +++ b/backend/user/management/commands/profile_info.py @@ -1,4 +1,5 @@ from argparse import ArgumentParser +from typing import Any from django.contrib.auth import get_user_model from django.core.management.base import BaseCommand @@ -16,7 +17,7 @@ def add_arguments(self, parser: ArgumentParser) -> None: parser.add_argument("--pennkey", type=str, help="pennkey") parser.add_argument("--email", type=str, help="email") - def handle(self, *args, **kwargs): + def handle(self, *args: Any, **kwargs: Any) -> None: if kwargs["pennkey"] is None and kwargs["email"] is None: self.stdout.write("Please provide a pennkey or an email.") return diff --git a/backend/user/migrations/0002_profile_laundry_preferences.py b/backend/user/migrations/0002_profile_laundry_preferences.py index f34836f2..eb606b42 100644 --- a/backend/user/migrations/0002_profile_laundry_preferences.py +++ b/backend/user/migrations/0002_profile_laundry_preferences.py @@ -5,15 +5,12 @@ class Migration(migrations.Migration): - dependencies = [ - ("laundry", "0001_initial"), - ("user", "0001_initial"), - ] + dependencies = [("laundry", "0001_initial"), ("user", "0001_initial")] operations = [ migrations.AddField( model_name="profile", name="laundry_preferences", field=models.ManyToManyField(blank=True, to="laundry.LaundryRoom"), - ), + ) ] diff --git a/backend/user/migrations/0003_profile_dining_preferences.py b/backend/user/migrations/0003_profile_dining_preferences.py index 9b6e3503..592460dc 100644 --- a/backend/user/migrations/0003_profile_dining_preferences.py +++ b/backend/user/migrations/0003_profile_dining_preferences.py @@ -5,15 +5,12 @@ class Migration(migrations.Migration): - dependencies = [ - ("dining", "0001_initial"), - ("user", "0002_profile_laundry_preferences"), - ] + dependencies = [("dining", "0001_initial"), ("user", "0002_profile_laundry_preferences")] operations = [ migrations.AddField( model_name="profile", name="dining_preferences", field=models.ManyToManyField(to="dining.Venue"), - ), + ) ] diff --git a/backend/user/migrations/0004_auto_20210324_1851.py b/backend/user/migrations/0004_auto_20210324_1851.py index 37f3f2d4..448a3b4c 100644 --- a/backend/user/migrations/0004_auto_20210324_1851.py +++ b/backend/user/migrations/0004_auto_20210324_1851.py @@ -5,15 +5,12 @@ class Migration(migrations.Migration): - dependencies = [ - ("dining", "0001_initial"), - ("user", "0003_profile_dining_preferences"), - ] + dependencies = [("dining", "0001_initial"), ("user", "0003_profile_dining_preferences")] operations = [ migrations.AlterField( model_name="profile", name="dining_preferences", field=models.ManyToManyField(blank=True, to="dining.Venue"), - ), + ) ] diff --git a/backend/user/migrations/0005_auto_20211003_2240.py b/backend/user/migrations/0005_auto_20211003_2240.py index f9e011a7..c4abcd54 100644 --- a/backend/user/migrations/0005_auto_20211003_2240.py +++ b/backend/user/migrations/0005_auto_20211003_2240.py @@ -5,20 +5,10 @@ class Migration(migrations.Migration): - dependencies = [ - ("user", "0004_auto_20210324_1851"), - ] + dependencies = [("user", "0004_auto_20210324_1851")] operations = [ - migrations.RemoveField( - model_name="profile", - name="degrees", - ), - migrations.RemoveField( - model_name="profile", - name="expected_graduation", - ), - migrations.DeleteModel( - name="Degree", - ), + migrations.RemoveField(model_name="profile", name="degrees"), + migrations.RemoveField(model_name="profile", name="expected_graduation"), + migrations.DeleteModel(name="Degree"), ] diff --git a/backend/user/migrations/0007_alter_notificationsetting_service.py b/backend/user/migrations/0007_alter_notificationsetting_service.py index 58c68465..ff16cabe 100644 --- a/backend/user/migrations/0007_alter_notificationsetting_service.py +++ b/backend/user/migrations/0007_alter_notificationsetting_service.py @@ -37,9 +37,7 @@ def create_settings_for_users(apps, schema_editor): class Migration(migrations.Migration): - dependencies = [ - ("user", "0006_alter_notificationtoken_user_notificationsetting"), - ] + dependencies = [("user", "0006_alter_notificationtoken_user_notificationsetting")] operations = [ migrations.AlterField( diff --git a/backend/user/migrations/0008_remove_notificationtoken_dev.py b/backend/user/migrations/0008_remove_notificationtoken_dev.py index 693cce30..c1d6598c 100644 --- a/backend/user/migrations/0008_remove_notificationtoken_dev.py +++ b/backend/user/migrations/0008_remove_notificationtoken_dev.py @@ -5,13 +5,6 @@ class Migration(migrations.Migration): - dependencies = [ - ("user", "0007_alter_notificationsetting_service"), - ] + dependencies = [("user", "0007_alter_notificationsetting_service")] - operations = [ - migrations.RemoveField( - model_name="notificationtoken", - name="dev", - ), - ] + operations = [migrations.RemoveField(model_name="notificationtoken", name="dev")] diff --git a/backend/user/migrations/0009_profile_fitness_preferences.py b/backend/user/migrations/0009_profile_fitness_preferences.py index ca477331..0a298b52 100644 --- a/backend/user/migrations/0009_profile_fitness_preferences.py +++ b/backend/user/migrations/0009_profile_fitness_preferences.py @@ -15,5 +15,5 @@ class Migration(migrations.Migration): model_name="profile", name="fitness_preferences", field=models.ManyToManyField(blank=True, to="penndata.FitnessRoom"), - ), + ) ] diff --git a/backend/user/models.py b/backend/user/models.py index deb3714b..538bd6e2 100644 --- a/backend/user/models.py +++ b/backend/user/models.py @@ -1,11 +1,13 @@ +from typing import Any + from django.contrib.auth import get_user_model from django.db import models from django.db.models.signals import post_save from django.dispatch import receiver -from gsr_booking.models import Group from laundry.models import LaundryRoom from penndata.models import FitnessRoom +from utils.types import UserType User = get_user_model() @@ -16,9 +18,9 @@ class NotificationToken(models.Model): KIND_ANDROID = "ANDROID" KIND_OPTIONS = ((KIND_IOS, "iOS"), (KIND_ANDROID, "Android")) - user = models.OneToOneField(User, on_delete=models.CASCADE) - kind = models.CharField(max_length=7, choices=KIND_OPTIONS, default=KIND_IOS) - token = models.CharField(max_length=255) + user: models.OneToOneField = models.OneToOneField(User, on_delete=models.CASCADE) + kind: models.CharField = models.CharField(max_length=7, choices=KIND_OPTIONS, default=KIND_IOS) + token: models.CharField = models.CharField(max_length=255) class NotificationSetting(models.Model): @@ -49,29 +51,32 @@ class NotificationSetting(models.Model): (SERVICE_LAUNDRY, "Laundry"), ) - token = models.ForeignKey(NotificationToken, on_delete=models.CASCADE) - service = models.CharField(max_length=30, choices=SERVICE_OPTIONS, default=SERVICE_PENN_MOBILE) - enabled = models.BooleanField(default=True) + token: models.ForeignKey = models.ForeignKey(NotificationToken, on_delete=models.CASCADE) + service: models.CharField = models.CharField( + max_length=30, choices=SERVICE_OPTIONS, default=SERVICE_PENN_MOBILE + ) + enabled: models.BooleanField = models.BooleanField(default=True) class Profile(models.Model): - user = models.OneToOneField(User, on_delete=models.CASCADE) - laundry_preferences = models.ManyToManyField(LaundryRoom, blank=True) - fitness_preferences = models.ManyToManyField(FitnessRoom, blank=True) - dining_preferences = models.ManyToManyField("dining.Venue", blank=True) + user: models.OneToOneField = models.OneToOneField(User, on_delete=models.CASCADE) + laundry_preferences: models.ManyToManyField = models.ManyToManyField(LaundryRoom, blank=True) + fitness_preferences: models.ManyToManyField = models.ManyToManyField(FitnessRoom, blank=True) + dining_preferences: models.ManyToManyField = models.ManyToManyField("dining.Venue", blank=True) - def __str__(self): + def __str__(self) -> str: return str(self.user.username) @receiver(post_save, sender=User) -def create_or_update_user_profile(sender, instance, created, **kwargs): +def create_or_update_user_profile( + sender: UserType, instance: UserType, created: bool, **kwargs: Any +) -> None: """ This post_save hook triggers automatically when a User object is saved, and if no Profile object exists for that User, it will create one """ Profile.objects.get_or_create(user=instance) - Group.objects.get_or_create(owner=instance, name="Me", color="#14f7d1") # notifications token, _ = NotificationToken.objects.get_or_create(user=instance) diff --git a/backend/user/notifications.py b/backend/user/notifications.py index eacdea32..a79fe60c 100644 --- a/backend/user/notifications.py +++ b/backend/user/notifications.py @@ -1,7 +1,7 @@ import collections import os import sys -from typing import Optional +from typing import Optional, Tuple # Monkey Patch for apn2 errors, referenced from: @@ -12,34 +12,37 @@ were removed in 3.10. Specifically, the error is coming from a dependency of apns2 named hyper. """ - from collections import abc + import collections.abc as collections_abc - collections.Iterable = abc.Iterable - collections.Mapping = abc.Mapping - collections.MutableSet = abc.MutableSet - collections.MutableMapping = abc.MutableMapping + setattr(collections, "Iterable", collections_abc.Iterable) + setattr(collections, "Mapping", collections_abc.Mapping) + setattr(collections, "MutableSet", collections_abc.MutableSet) + setattr(collections, "MutableMapping", collections_abc.MutableMapping) from apns2.client import APNsClient from apns2.credentials import TokenCredentials from apns2.payload import Payload from celery import shared_task +from django.db.models import Manager, QuerySet from user.models import NotificationToken # taken from the apns2 method for batch notifications Notification = collections.namedtuple("Notification", ["token", "payload"]) +TokenPair = Tuple[str, str] # (username, token) +NotificationResult = Tuple[list[str], list[str]] # (success_users, failed_users) def send_push_notifications( users: Optional[list[str]], service: Optional[str], - title: str, + title: Optional[str], body: str, delay: int = 0, is_dev: bool = False, is_shadow: bool = False, -) -> tuple[list[str], list[str]]: +) -> NotificationResult: """ Sends push notifications. @@ -55,27 +58,31 @@ def send_push_notifications( # collect available usernames & their respective device tokens token_objects = get_tokens(users, service) if not token_objects: - return [], users + return [], users if users is not None else [] success_users, tokens = zip(*token_objects) + success_users_list = list(success_users) + tokens_list = list(tokens) # send notifications if delay: - send_delayed_notifications(tokens, title, body, service, is_dev, is_shadow, delay) + send_delayed_notifications( + tokens_list, title or "", body, service or "", is_dev, is_shadow, delay + ) else: - send_immediate_notifications(tokens, title, body, service, is_dev, is_shadow) + send_immediate_notifications(tokens_list, title, body, service or "", is_dev, is_shadow) if not users: # if to all users, can't be any failed pennkeys - return success_users, [] - failed_users = list(set(users) - set(success_users)) - return success_users, failed_users + return success_users_list, [] + failed_users = list(set(users) - set(success_users_list)) + return success_users_list, failed_users -def get_tokens( - users: Optional[list[str]] = None, service: Optional[str] = None -) -> list[tuple[str, str]]: +def get_tokens(users: Optional[list[str]] = None, service: Optional[str] = None) -> list[TokenPair]: """Returns list of token objects (with username & token value) for specified users""" - token_objs = NotificationToken.objects.select_related("user").filter( + token_objs: QuerySet[ + NotificationToken, Manager[NotificationToken] + ] = NotificationToken.objects.select_related("user").filter( kind=NotificationToken.KIND_IOS # NOTE: until Android implementation ) if users: @@ -84,7 +91,7 @@ def get_tokens( token_objs = token_objs.filter( notificationsetting__service=service, notificationsetting__enabled=True ) - return token_objs.exclude(token="").values_list("user__username", "token") + return list(token_objs.exclude(token="").values_list("user__username", "token")) @shared_task(name="notifications.send_immediate_notifications") diff --git a/backend/user/serializers.py b/backend/user/serializers.py index fce622da..e6c9ee90 100644 --- a/backend/user/serializers.py +++ b/backend/user/serializers.py @@ -56,11 +56,4 @@ class UserSerializer(serializers.ModelSerializer): class Meta: model = get_user_model() - fields = ( - "id", - "first_name", - "last_name", - "email", - "username", - "profile", - ) + fields = ("id", "first_name", "last_name", "email", "username", "profile") diff --git a/backend/user/views.py b/backend/user/views.py index 260d29d9..2b615977 100644 --- a/backend/user/views.py +++ b/backend/user/views.py @@ -1,8 +1,7 @@ -from typing import TYPE_CHECKING, Any, Optional +from typing import Optional -from django.contrib.auth import get_user_model -from django.db.models import QuerySet -from django.http import HttpResponseRedirect +from django.db.models import Manager, QuerySet +from django.http import HttpResponse, HttpResponseRedirect from django.shortcuts import get_object_or_404 from identity.permissions import B2BPermission from rest_framework import generics, viewsets @@ -19,16 +18,7 @@ NotificationTokenSerializer, UserSerializer, ) - - -if TYPE_CHECKING: - from django.contrib.auth.models import AbstractUser - - UserType = AbstractUser -else: - UserType = Any - -User = get_user_model() +from utils.types import DjangoUser, UserType, get_user class UserView(generics.RetrieveUpdateAPIView): @@ -60,7 +50,7 @@ class NotificationTokenView(viewsets.ModelViewSet): permission_classes = [IsAuthenticated] serializer_class = NotificationTokenSerializer - def get_queryset(self) -> QuerySet[NotificationToken]: + def get_queryset(self) -> QuerySet[NotificationToken, Manager[NotificationToken]]: return NotificationToken.objects.filter(user=self.request.user) @@ -80,9 +70,9 @@ class NotificationSettingView(viewsets.ModelViewSet): serializer_class = NotificationSettingSerializer def is_authorized(self, request: Request) -> bool: - return request.user and request.user.is_authenticated + return request.user is not None and request.user.is_authenticated - def get_queryset(self) -> QuerySet[NotificationSetting]: + def get_queryset(self) -> QuerySet[NotificationSetting, Manager[NotificationSetting]]: if self.is_authorized(self.request): return NotificationSetting.objects.filter(token__user=self.request.user) return NotificationSetting.objects.none() @@ -99,9 +89,9 @@ def check(self, request: Request, pk: Optional[str] = None) -> Response: pennkey = request.GET.get("pennkey") user = ( - request.user + get_user(request) if self.is_authorized(request) - else get_object_or_404(User, username=pennkey) + else get_object_or_404(DjangoUser, username=pennkey) ) token = NotificationToken.objects.filter(user=user).first() @@ -121,13 +111,13 @@ class NotificationAlertView(APIView): def post(self, request: Request) -> Response: users = ( - [self.request.user.username] - if request.user and request.user.is_authenticated + [get_user(self.request).username] + if get_user(request) and get_user(request).is_authenticated else request.data.get("users", list()) ) service = request.data.get("service") - title = request.data.get("title") - body = request.data.get("body") + title = request.data.get("title", None) + body = request.data.get("body", None) delay = max(request.data.get("delay", 0), 0) is_dev = request.data.get("is_dev", False) @@ -149,7 +139,7 @@ class ClearCookiesView(APIView): Clears all cookies from the browser """ - def get(self, request: Request) -> Response: + def get(self, request: Request) -> HttpResponse: next_url = request.GET.get("next", None) response = ( HttpResponseRedirect(f"/api/accounts/login/?next={next_url}") diff --git a/backend/utils/email.py b/backend/utils/email.py index c8608cc6..b44d2869 100644 --- a/backend/utils/email.py +++ b/backend/utils/email.py @@ -1,16 +1,22 @@ +from typing import Optional + from celery import shared_task -from django.contrib.auth.models import Group from django.core.mail import send_mail as django_send_mail from django.template.loader import get_template @shared_task(name="utils.send_mail") -def send_mail(subject, recipient_list, message=None, html_message=None): +def send_mail( + subject: str, + recipient_list: Optional[list[str]] = None, + message: Optional[str] = None, + html_message: Optional[str] = None, +) -> int: if recipient_list is None: raise ValueError("Recipient list cannot be None") success = django_send_mail( subject=subject, - message=message, + message=message, # type: ignore[arg-type] from_email=None, recipient_list=recipient_list, fail_silently=False, @@ -21,17 +27,24 @@ def send_mail(subject, recipient_list, message=None, html_message=None): @shared_task(name="utils.send_automated_email") -def send_automated_email(subject, recipient_list, message): +def send_automated_email( + subject: str, recipient_list: Optional[list[str]] = None, message: Optional[str] = None +) -> bool: template = get_template("email.html") html_message = template.render({"message": message}) return send_mail(subject, recipient_list, html_message=html_message) -def get_backend_manager_emails(): - if group := Group.objects.filter(name="backend_managers").first(): - return list( - group.user_set.exclude(email="") - .exclude(email__isnull=True) - .values_list("email", flat=True) - ) - return [] +def get_backend_manager_emails() -> list[str]: + from django.contrib.auth.models import Group + + try: + if group := Group.objects.filter(name="backend_managers").first(): + return list( + group.user_set.exclude(email="") + .exclude(email__isnull=True) + .values_list("email", flat=True) + ) + return [] + except Group.DoesNotExist: + return [] diff --git a/backend/utils/r_request.py b/backend/utils/r_request.py index 3df7e360..0ae4f96f 100644 --- a/backend/utils/r_request.py +++ b/backend/utils/r_request.py @@ -60,7 +60,7 @@ def request( verify: Optional[bool] = None, cert: Optional[str] = None, json: Optional[dict] = None, - ): + ) -> Response: response = self.__default_response() for _ in range(self.num_retries): @@ -93,7 +93,7 @@ def request( return response if not response.content: - response.content = "RRequest: Default Error" + response._content = b"RRequest: Default Error" return response diff --git a/backend/utils/types.py b/backend/utils/types.py new file mode 100644 index 00000000..893bfdb1 --- /dev/null +++ b/backend/utils/types.py @@ -0,0 +1,75 @@ +from typing import Any, Optional, Protocol, Type, TypeAlias, TypeVar, cast, runtime_checkable + +from django.contrib.auth import get_user_model +from django.contrib.auth.models import AbstractBaseUser +from django.db.models import Manager, QuerySet +from rest_framework.request import Request + + +# Get the actual User model +DjangoUser = get_user_model() + + +class UserManager(Protocol): + def create_user( + self, + username: str, + email: Optional[str] = None, + password: Optional[str] = None, + **kwargs: Any + ) -> "UserType": ... + + def create_superuser(self, username: str, email: str, password: str) -> "UserType": ... + + def get(self, **kwargs: Any) -> "UserType": ... + + def filter(self, **kwargs: Any) -> QuerySet["UserType", Manager["UserType"]]: ... + + def all(self) -> QuerySet["UserType", Manager["UserType"]]: ... + + +@runtime_checkable +class DjangoUserType(Protocol): + """Protocol defining the interface of our Django User""" + + objects: UserManager + is_superuser: bool + id: int + username: str + email: str + is_authenticated: bool + is_active: bool + is_staff: bool + date_joined: Any + last_login: Any + password: str + + def check_password(self, raw_password: str) -> bool: ... + + def set_password(self, raw_password: str) -> None: ... + + def save(self, *args: Any, **kwargs: Any) -> None: ... + + def get_username(self) -> str: ... + + +DjangoUserInstance = TypeVar("DjangoUserInstance", bound=AbstractBaseUser) +UserType: TypeAlias = DjangoUserInstance +DjangoUserModel: Type[DjangoUserType] = cast(Type[DjangoUserType], get_user_model()) + + +# Type for authenticated Django user requests +class AuthRequest(Request): + user: UserType + + +def get_auth_user(request: Request) -> DjangoUserType: + if not request.user.is_authenticated: + from rest_framework.exceptions import NotAuthenticated + + raise NotAuthenticated() + return cast(DjangoUserType, request.user) + + +def get_user(request: Request) -> UserType: + return cast(UserType, request.user) From c0bffdb7bb3eb880fb063abd12982bc4f97a37aa Mon Sep 17 00:00:00 2001 From: Ashley Zhang Date: Wed, 13 Nov 2024 16:33:13 -0800 Subject: [PATCH 11/12] =?UTF-8?q?=F0=9F=A7=B9=20clean=20up=20type=20aliase?= =?UTF-8?q?s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/penndata/views.py | 13 ++++--------- backend/portal/views.py | 16 +++++----------- backend/sublet/views.py | 25 ++++++++++--------------- 3 files changed, 19 insertions(+), 35 deletions(-) diff --git a/backend/penndata/views.py b/backend/penndata/views.py index b8a70d96..8fc9f851 100644 --- a/backend/penndata/views.py +++ b/backend/penndata/views.py @@ -1,6 +1,6 @@ import datetime from datetime import timedelta -from typing import Any, Optional, Sequence, TypeAlias, cast +from typing import Any, Optional, Sequence, cast import requests from bs4 import BeautifulSoup @@ -32,11 +32,6 @@ from utils.types import get_user -CalendarEventList: TypeAlias = QuerySet[CalendarEvent, Manager[CalendarEvent]] -EventList: TypeAlias = QuerySet[Event, Manager[Event]] -HomePageOrderList: TypeAlias = QuerySet[HomePageOrder, Manager[HomePageOrder]] - - class News(APIView): """ GET: Get's news article from the DP @@ -104,7 +99,7 @@ class Calendar(generics.ListAPIView): permission_classes = [AllowAny] serializer_class = CalendarEventSerializer - def get_queryset(self) -> CalendarEventList: + def get_queryset(self) -> QuerySet[CalendarEvent, Manager[CalendarEvent]]: return CalendarEvent.objects.filter( date_obj__gte=timezone.localtime(), date_obj__lte=timezone.localtime() + timedelta(days=30), @@ -120,7 +115,7 @@ class Events(generics.ListAPIView): permission_classes = [AllowAny] serializer_class = EventSerializer - def get_queryset(self) -> EventList: + def get_queryset(self) -> QuerySet[Event, Manager[Event]]: queryset = Event.objects.all() event_type = self.kwargs.get("type") @@ -148,7 +143,7 @@ class HomePageOrdering(generics.ListAPIView): permission_classes = [AllowAny] serializer_class = HomePageOrderSerializer - def get_queryset(self) -> HomePageOrderList: + def get_queryset(self) -> QuerySet[HomePageOrder, Manager[HomePageOrder]]: return HomePageOrder.objects.all() diff --git a/backend/portal/views.py b/backend/portal/views.py index b87b058c..e5c2f83b 100644 --- a/backend/portal/views.py +++ b/backend/portal/views.py @@ -1,4 +1,4 @@ -from typing import Any, List, Optional, TypeAlias +from typing import Any, List, Optional from django.db.models import Count, Manager, Q, QuerySet from django.db.models.functions import Trunc @@ -38,12 +38,6 @@ from utils.types import AuthRequest, get_auth_user -PollQuerySet: TypeAlias = QuerySet[Poll, Manager[Poll]] -PostQuerySet: TypeAlias = QuerySet[Post, Manager[Post]] -PollVoteQuerySet: TypeAlias = QuerySet[PollVote, Manager[PollVote]] -PollOptionQuerySet: TypeAlias = QuerySet[PollOption, Manager[PollOption]] - - class UserInfo(APIView): """Returns User information""" @@ -97,7 +91,7 @@ class Polls(viewsets.ModelViewSet[Poll]): permission_classes = [PollOwnerPermission | IsSuperUser] serializer_class = PollSerializer - def get_queryset(self) -> PollQuerySet: + def get_queryset(self) -> QuerySet[Poll, Manager[Poll]]: # all polls if superuser, polls corresponding to club for regular user user = get_auth_user(self.request) return ( @@ -193,7 +187,7 @@ class PollOptions(viewsets.ModelViewSet[PollOption]): permission_classes = [OptionOwnerPermission | IsSuperUser] serializer_class = PollOptionSerializer - def get_queryset(self) -> PollOptionQuerySet: + def get_queryset(self) -> QuerySet[PollOption, Manager[PollOption]]: # if user is admin, they can update anything # if user is not admin, they can only update their own options user = get_auth_user(self.request) @@ -217,7 +211,7 @@ class PollVotes(viewsets.ModelViewSet[PollVote]): permission_classes = [PollOwnerPermission | IsSuperUser] serializer_class = PollVoteSerializer - def get_queryset(self) -> PollVoteQuerySet: + def get_queryset(self) -> QuerySet[PollVote, Manager[PollVote]]: return PollVote.objects.none() @action(detail=False, methods=["post"]) @@ -284,7 +278,7 @@ class Posts(viewsets.ModelViewSet[Post]): permission_classes = [PostOwnerPermission | IsSuperUser] serializer_class = PostSerializer - def get_queryset(self) -> PostQuerySet: + def get_queryset(self) -> QuerySet[Post, Manager[Post]]: user = get_auth_user(self.request) return ( Post.objects.all() diff --git a/backend/sublet/views.py b/backend/sublet/views.py index c749514c..8d9cac1d 100644 --- a/backend/sublet/views.py +++ b/backend/sublet/views.py @@ -1,4 +1,4 @@ -from typing import Any, Type, TypeAlias, cast +from typing import Any, Type, cast from django.db.models import Manager, QuerySet, prefetch_related_objects from django.http import QueryDict @@ -32,13 +32,6 @@ from utils.types import get_user -SubletQuerySet: TypeAlias = QuerySet[Sublet, Manager[Sublet]] -OfferQuerySet: TypeAlias = QuerySet[Offer, Manager[Offer]] -AmenityQuerySet: TypeAlias = QuerySet[Amenity, Manager[Amenity]] -ImageList: TypeAlias = QuerySet[SubletImage, Manager[SubletImage]] -UserOfferQuerySet: TypeAlias = QuerySet[Offer, Manager[Offer]] - - class Amenities(generics.ListAPIView): serializer_class = AmenitySerializer queryset = Amenity.objects.all() @@ -52,7 +45,7 @@ class UserFavorites(generics.ListAPIView): serializer_class = SubletSerializerSimple permission_classes = [IsAuthenticated] - def get_queryset(self) -> SubletQuerySet: + def get_queryset(self) -> QuerySet[Sublet, Manager[Sublet]]: return get_user(self.request).sublets_favorited @@ -60,7 +53,7 @@ class UserOffers(generics.ListAPIView): serializer_class = OfferSerializer permission_classes = [IsAuthenticated] - def get_queryset(self) -> UserOfferQuerySet: + def get_queryset(self) -> QuerySet[Offer, Manager[Offer]]: return Offer.objects.filter(user=get_user(self.request)) @@ -84,7 +77,7 @@ class Properties(viewsets.ModelViewSet): def get_serializer_class(self) -> Type[BaseModelSerializer]: return SubletSerializerRead if self.action == "retrieve" else SubletSerializer - def get_queryset(self) -> SubletQuerySet: + def get_queryset(self) -> QuerySet[Sublet, Manager[Sublet]]: return Sublet.objects.all() def create(self, request: Request, *args: Any, **kwargs: Any) -> Response: @@ -137,7 +130,7 @@ def list(self, request: Request, *args: Any, **kwargs: Any) -> Response: """Returns a list of Sublets that match query parameters and user ownership.""" # Get query parameters from request (e.g., amenities, user_owned) params = request.query_params - queryset: SubletQuerySet = self.get_queryset() + queryset: QuerySet[Sublet, Manager[Sublet]] = self.get_queryset() if params.get("subletter", "false").lower() == "true": queryset = queryset.filter(subletter=get_user(request)) @@ -198,7 +191,9 @@ class CreateImages(generics.CreateAPIView): permission_classes = [SubletImageOwnerPermission | IsSuperUser] parser_classes = (MultiPartParser, FormParser) - def get_queryset(self, *args: Any, **kwargs: Any) -> ImageList: + def get_queryset( + self, *args: Any, **kwargs: Any + ) -> QuerySet[SubletImage, Manager[SubletImage]]: sublet = get_object_or_404(Sublet, id=int(self.kwargs["sublet_id"])) return SubletImage.objects.filter(sublet=sublet) @@ -241,7 +236,7 @@ class Favorites(mixins.DestroyModelMixin, mixins.CreateModelMixin, viewsets.Gene http_method_names = ["post", "delete"] permission_classes = [IsAuthenticated | IsSuperUser] - def get_queryset(self) -> SubletQuerySet: + def get_queryset(self) -> QuerySet[Sublet, Manager[Sublet]]: user = get_user(self.request) return user.sublets_favorited @@ -279,7 +274,7 @@ class Offers(viewsets.ModelViewSet): permission_classes = [OfferOwnerPermission | IsSuperUser] serializer_class = OfferSerializer - def get_queryset(self) -> OfferQuerySet: + def get_queryset(self) -> QuerySet[Offer, Manager[Offer]]: return Offer.objects.filter(sublet_id=int(self.kwargs["sublet_id"])).order_by( "created_date" ) From 5232436292f60ba823ac54862faab791c1dbd994 Mon Sep 17 00:00:00 2001 From: Ashley Zhang Date: Wed, 13 Nov 2024 16:36:06 -0800 Subject: [PATCH 12/12] =?UTF-8?q?=F0=9F=9A=BF=20clean=20up=20type=20aliase?= =?UTF-8?q?s=20more?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/gsr_booking/serializers.py | 9 ++++----- backend/pennmobile/admin.py | 8 ++------ backend/user/serializers.py | 11 ++++------- 3 files changed, 10 insertions(+), 18 deletions(-) diff --git a/backend/gsr_booking/serializers.py b/backend/gsr_booking/serializers.py index 206ab816..e0adaaeb 100644 --- a/backend/gsr_booking/serializers.py +++ b/backend/gsr_booking/serializers.py @@ -1,4 +1,4 @@ -from typing import Any, TypeAlias +from typing import Any from django.contrib.auth import get_user_model from rest_framework import serializers @@ -6,7 +6,6 @@ from gsr_booking.models import GSR, Group, GroupMembership, GSRBooking -ValidatedData: TypeAlias = dict[str, Any] User = get_user_model() @@ -19,7 +18,7 @@ class GroupRoomBookingRequestSerializer(serializers.Serializer): is_wharton = serializers.SerializerMethodField() - def get_is_wharton(self, obj: ValidatedData) -> bool: + def get_is_wharton(self, obj: dict[str, Any]) -> bool: return obj["lid"] == 1 @@ -46,7 +45,7 @@ class Meta: model = Group fields = ["owner", "memberships", "name", "color", "id"] - def create(self, validated_data: ValidatedData) -> Group: + def create(self, validated_data: dict[str, Any]) -> Group: request = self.context.get("request", None) if request is None: return super().create(validated_data) @@ -61,7 +60,7 @@ class GroupField(serializers.RelatedField): def to_representation(self, value: Group) -> dict[str, Any]: return {"name": value.name, "id": value.id, "color": value.color} - def to_internal_value(self, data: ValidatedData) -> None: + def to_internal_value(self, data: dict[str, Any]) -> None: return None # TODO: If you want to update based on BookingField, implement this. diff --git a/backend/pennmobile/admin.py b/backend/pennmobile/admin.py index 45aa6711..1be24e4e 100644 --- a/backend/pennmobile/admin.py +++ b/backend/pennmobile/admin.py @@ -1,5 +1,5 @@ # CUSTOM ADMIN SETTUP FOR PENN MOBILE -from typing import Any, Dict, Optional, Type, TypeAlias, cast +from typing import Any, Optional, Type, cast from django.contrib import admin, messages from django.contrib.admin.apps import AdminConfig @@ -10,10 +10,6 @@ from django.utils.html import format_html -AdminContext: TypeAlias = Dict[str, Any] -MessageText: TypeAlias = str - - def add_post_poll_message(request: HttpRequest, model: Type[Model]) -> None: from portal.models import Poll, Post @@ -41,7 +37,7 @@ class CustomAdminSite(admin.AdminSite): site_header = "Penn Mobile Backend Admin" def index( - self, request: HttpRequest, extra_context: Optional[AdminContext] = None + self, request: HttpRequest, extra_context: Optional[dict[str, Any]] = None ) -> TemplateResponse: from portal.models import Poll, Post diff --git a/backend/user/serializers.py b/backend/user/serializers.py index e6c9ee90..0893227c 100644 --- a/backend/user/serializers.py +++ b/backend/user/serializers.py @@ -1,4 +1,4 @@ -from typing import Any, TypeAlias +from typing import Any from django.contrib.auth import get_user_model from rest_framework import serializers @@ -6,15 +6,12 @@ from user.models import NotificationSetting, NotificationToken, Profile -ValidatedData: TypeAlias = dict[str, Any] - - class NotificationTokenSerializer(serializers.ModelSerializer): class Meta: model = NotificationToken fields = ("id", "kind", "token") - def create(self, validated_data: ValidatedData) -> NotificationToken: + def create(self, validated_data: dict[str, Any]) -> NotificationToken: validated_data["user"] = self.context["request"].user token_obj = NotificationToken.objects.filter(user=validated_data["user"]).first() if token_obj: @@ -27,7 +24,7 @@ class Meta: model = NotificationSetting fields = ("id", "service", "enabled") - def create(self, validated_data: ValidatedData) -> NotificationSetting: + def create(self, validated_data: dict[str, Any]) -> NotificationSetting: validated_data["token"] = NotificationToken.objects.get(user=self.context["request"].user) setting = NotificationSetting.objects.filter( token=validated_data["token"], service=validated_data["service"] @@ -37,7 +34,7 @@ def create(self, validated_data: ValidatedData) -> NotificationSetting: return super().create(validated_data) def update( - self, instance: NotificationSetting, validated_data: ValidatedData + self, instance: NotificationSetting, validated_data: dict[str, Any] ) -> NotificationSetting: if instance.service != validated_data["service"]: raise serializers.ValidationError(detail={"detail": "Cannot change setting service."})