diff --git a/.env.example b/.env.example index 3091903..93883f9 100644 --- a/.env.example +++ b/.env.example @@ -30,13 +30,17 @@ QUEST__LOGIN__API_KEY=dummy2 # QUEST__LOGIN__URL= # QUEST__LOGIN__QUESTIONID= -QUEST__RESET__API_KEY=dummy3 -# QUEST__RESET__URL= -# QUEST__RESET__QUESTIONID= +QUEST__RESET_PASSWORD__API_KEY=dummy3 +# QUEST__RESET_PASSWORD__URL= +# QUEST__RESET_PASSWORD__QUESTIONID= # --- Other Overrides (Optional) --- # Example of overriding a nested value in the AUTH dictionary # AUTH__OTP_TIMEOUT=60 +# Example of overridng web size constraints +# WEB__COURSE__PAGE_SIZE=5 +# WEB__REVIEW__PAGE_SIZE=10 +# WEB__REVIEW__COMMENT_MIN_LENGTH=30 # Example of overriding a list with a comma-separated string # ALLOWED_HOSTS=localhost,127.0.0.1,dev.my-app.com diff --git a/Makefile b/Makefile index 8e65005..8622be3 100644 --- a/Makefile +++ b/Makefile @@ -11,7 +11,7 @@ help: @echo " collect - Collects Django static files" @echo " install-frontend - Installs frontend dependencies using bun" @echo " format - Formats both backend (Python) and frontend (JS/TS/CSS) code" - @echo " format-backend - Formats Python code using isort and black" + @echo " format-backend - Formats Python code using ruff check and format" @echo " format-frontend - Formats frontend code using prettier" @echo " lint - Lints both backend (Python) and frontend (JS/TS/CSS) code" @echo " lint-backend - Lints Python code using ruff" @@ -45,8 +45,8 @@ format: format-backend format-frontend @echo "All code formatted successfully!" format-backend: - @echo "Formatting backend (Python) code with isort and black..." - uv run ruff check --select I . && \ + @echo "Formatting backend (Python) code with ruff check and format..." + uv run ruff check --select I . --fix && \ uv run ruff format format-frontend: diff --git a/apps/auth/urls.py b/apps/auth/urls.py new file mode 100644 index 0000000..822f4ad --- /dev/null +++ b/apps/auth/urls.py @@ -0,0 +1,16 @@ +from django.urls import re_path + +from apps.auth import views as auth_views + +urlpatterns = [ + re_path(r"^init/$", auth_views.auth_initiate_api, name="auth_initiate_api"), + re_path(r"^verify/$", auth_views.verify_callback_api, name="verify_callback_api"), + re_path( + r"^password/$", + auth_views.auth_reset_password_api, + name="auth_reset_password_api", + ), + re_path(r"^signup/$", auth_views.auth_signup_api, name="auth_signup_api"), + re_path(r"^login/$", auth_views.auth_login_api, name="auth_login_api"), + re_path(r"^logout/?$", auth_views.auth_logout_api, name="auth_logout_api"), +] diff --git a/apps/auth/utils.py b/apps/auth/utils.py index 74a236b..4c60c36 100644 --- a/apps/auth/utils.py +++ b/apps/auth/utils.py @@ -9,10 +9,13 @@ from django.contrib.auth.models import AbstractUser from django.contrib.auth.password_validation import validate_password from django.core.exceptions import ValidationError +from rest_framework.authentication import SessionAuthentication from rest_framework.response import Response from apps.web.models import Student +logger = logging.getLogger(__name__) + AUTH_SETTINGS = settings.AUTH PASSWORD_LENGTH_MIN = AUTH_SETTINGS["PASSWORD_LENGTH_MIN"] PASSWORD_LENGTH_MAX = AUTH_SETTINGS["PASSWORD_LENGTH_MAX"] @@ -23,22 +26,28 @@ QUEST_BASE_URL = QUEST_SETTINGS["BASE_URL"] +class CSRFCheckSessionAuthentication(SessionAuthentication): + def authenticate(self, request): + super().enforce_csrf(request) + return super().authenticate(request) + + def get_survey_details(action: str) -> dict[str, Any] | None: """ A single, clean function to get all survey details for a given action. - Valid actions: "signup", "login", "reset". + Valid actions: "signup", "login", "reset_password". """ action_details = QUEST_SETTINGS.get(action.upper()) if not action_details: - logging.error("Invalid quest action requested: %s", action) + logger.error("Invalid quest action requested: %s", action) return None try: question_id = int(action_details.get("QUESTIONID")) except (ValueError, TypeError): - logging.error( + logger.error( "Could not parse 'QUESTIONID' for action '%s'. Check your settings.", action ) return None @@ -66,18 +75,18 @@ async def verify_turnstile_token( }, ) if not response.json().get("success"): - logging.warning("Turnstile verification failed: %s", response.json()) + logger.warning("Turnstile verification failed: %s", response.json()) return False, Response( {"error": "Turnstile verification failed"}, status=403 ) return True, None except httpx.TimeoutException: - logging.error("Turnstile verification timed out") + logger.error("Turnstile verification timed out") return False, Response( {"error": "Turnstile verification timed out"}, status=504 ) - except Exception as e: - logging.error(f"Error verifying Turnstile token: {e}") + except Exception: + logger.error("Turnstile verification error") return False, Response({"error": "Turnstile verification error"}, status=500) @@ -132,19 +141,19 @@ async def get_latest_answer( response.raise_for_status() # Raise an exception for bad status codes full_data = response.json() except httpx.TimeoutException: - logging.exception("Questionnaire API query timed out") + logger.error("Questionnaire API query timed out") return None, Response( {"error": "Questionnaire API query timed out"}, status=504, ) - except httpx.RequestError as e: - logging.exception(f"Error querying questionnaire API: {e}") + except httpx.RequestError: + logger.error("Error querying questionnaire API") return None, Response( {"error": "Failed to query questionnaire API"}, status=500, ) - except Exception as e: - logging.exception(f"An unexpected error occurred: {e}") + except Exception: + logger.error("An unexpected error occurred") return None, Response({"error": "An unexpected error occurred"}, status=500) # Filter and return only the required fields from the first row @@ -180,7 +189,7 @@ async def get_latest_answer( key in filtered_data and filtered_data[key] is not None for key in ["id", "submitted_at", "account", "otp"] ): - logging.warning("Missing required field(s) in questionnaire response") + logger.warning("Missing required field(s) in questionnaire response") return None, Response( {"error": "Missing required field(s) in questionnaire response"}, status=400, @@ -211,7 +220,8 @@ def rate_password_strength(password: str) -> int: if re.search(r"[^a-zA-Z0-9\s]", password): score += 1 - length_step = (PASSWORD_LENGTH_MAX - PASSWORD_LENGTH_MIN) // 10 + length_range = max(1, PASSWORD_LENGTH_MAX - PASSWORD_LENGTH_MIN) + length_step = max(1, length_range // 10) score += (len(password) - PASSWORD_LENGTH_MIN) // length_step diff --git a/apps/auth/views.py b/apps/auth/views.py index 0474734..68be287 100644 --- a/apps/auth/views.py +++ b/apps/auth/views.py @@ -8,8 +8,8 @@ import dateutil.parser from django.conf import settings from django.contrib.auth import authenticate, get_user_model, login, logout +from django.views.decorators.csrf import ensure_csrf_cookie from django_redis import get_redis_connection -from rest_framework.authentication import SessionAuthentication from rest_framework.decorators import ( api_view, authentication_classes, @@ -21,25 +21,21 @@ from apps.auth import utils from apps.web.models import Student - -class CsrfExemptSessionAuthentication(SessionAuthentication): - def enforce_csrf(self, request): - return +logger = logging.getLogger(__name__) AUTH_SETTINGS = settings.AUTH OTP_TIMEOUT = AUTH_SETTINGS["OTP_TIMEOUT"] TEMP_TOKEN_TIMEOUT = AUTH_SETTINGS["TEMP_TOKEN_TIMEOUT"] -ACTION_LIST = ["signup", "login", "reset_password"] +ACTION_LIST = AUTH_SETTINGS["ACTION_LIST"] TOKEN_RATE_LIMIT = AUTH_SETTINGS["TOKEN_RATE_LIMIT"] TOKEN_RATE_LIMIT_TIME = AUTH_SETTINGS["TOKEN_RATE_LIMIT_TIME"] @api_view(["POST"]) -@authentication_classes([CsrfExemptSessionAuthentication]) @permission_classes([AllowAny]) def auth_initiate_api(request): - """Step 1: Authentication Initiation (/api/auth/initiate) + """Step 1: Authentication Initiation (/api/auth/init) 1. Receives action and turnstile_token from frontend 2. Verifies Turnstile token with Cloudflare's API @@ -52,9 +48,11 @@ def auth_initiate_api(request): turnstile_token = request.data.get("turnstile_token") if not action or not turnstile_token: + logger.warning("Missing action or turnstile_token in auth_initiate_api") return Response({"error": "Missing action or turnstile_token"}, status=400) if action not in ACTION_LIST: + logger.warning("Invalid action '%s' in auth_initiate_api", action) return Response({"error": "Invalid action"}, status=400) client_ip = ( @@ -68,6 +66,10 @@ def auth_initiate_api(request): utils.verify_turnstile_token(turnstile_token, client_ip) ) if not success: + logger.warning( + "verify_turnstile_token failed in auth_initiate_api:%s", + error_response.data, + ) return error_response # Generate cryptographically secure OTP and temp_token @@ -87,13 +89,12 @@ def auth_initiate_api(request): if existing_state_data: existing_state = json.loads(existing_state_data) r.delete(existing_state_key) - logging.info( - f"Cleaned up existing temp_token_state for action { - existing_state.get('action', 'unknown') - }" + logger.info( + "Cleaned up existing temp_token_state for action %s", + existing_state.get("action", "unknown"), ) - except Exception as e: - logging.warning(f"Error cleaning up existing temp_token: {e}") + except Exception: + logger.warning("Error cleaning up existing temp_token") # Store OTP -> temp_token mapping with initiated_at timestamp current_time = time.time() @@ -109,13 +110,15 @@ def auth_initiate_api(request): json.dumps(temp_token_state), ) - logging.info("Created auth intent for action %s with OTP and temp_token", action) + logger.info("Created auth intent for action %s with OTP and temp_token", action) details = utils.get_survey_details(action) if not details: + logger.error("Invalid action '%s' when fetching survey details", action) return Response({"error": "Invalid action"}, status=400) survey_url = details.get("url") if not survey_url: + logger.error("Survey URL missing for %s", action) return Response( {"error": "Something went wrong when fetching the survey URL"}, status=500, @@ -134,28 +137,36 @@ def auth_initiate_api(request): return response +@ensure_csrf_cookie @api_view(["POST"]) -@authentication_classes([CsrfExemptSessionAuthentication]) @permission_classes([AllowAny]) def verify_callback_api(request): """Callback Verification (/api/auth/verify) request data includes account, answer_id, action Handles the verification of questionnaire callback using temp_token from cookie. """ + logger.info( + "verify_callback_api called for account=%s, action=%s", + request.data.get("account"), + request.data.get("action"), + ) # Get required parameters from request account = request.data.get("account") answer_id = request.data.get("answer_id") action = request.data.get("action") if not account or not answer_id or not action: + logger.warning("Missing account, answer_id, or action in verify_callback_api") return Response({"error": "Missing account, answer_id, or action"}, status=400) if action not in ACTION_LIST: + logger.warning("Invalid action '%s' in verify_callback_api", action) return Response({"error": "Invalid action"}, status=400) # Get temp_token from HttpOnly cookie temp_token = request.COOKIES.get("temp_token") if not temp_token: + logger.warning("No temp_token found in verify_callback_api") return Response({"error": "No temp_token found"}, status=401) r = get_redis_connection("default") @@ -166,18 +177,22 @@ def verify_callback_api(request): state_data = r.get(state_key) if not state_data: + logger.warning("Temp token state not found or expired in verify_callback_api") return Response({"error": "Temp token state not found or expired"}, status=401) try: state_data = json.loads(state_data) except json.JSONDecodeError: + logger.error("Invalid temp token state data in verify_callback_api") return Response({"error": "Invalid temp token state data"}, status=401) # Verify status is pending and action matches if state_data.get("status") != "pending": + logger.warning("Temp token state not pending in verify_callback_api") return Response({"error": "Invalid temp token state"}, status=401) if state_data.get("action") != action: + logger.warning("Action mismatch in verify_callback_api") return Response({"error": "Action mismatch"}, status=403) # Step 2: Apply rate limiting per temp_token to prevent brute-force attempts @@ -191,6 +206,7 @@ def verify_callback_api(request): r.expire(rate_limit_key, TOKEN_RATE_LIMIT_TIME) if attempts > TOKEN_RATE_LIMIT: + logger.warning("Too many verification attempts in verify_callback_api") return Response({"error": "Too many verification attempts"}, status=429) # Step 3: Query questionnaire API for latest submission of the specific questionnaire of the action @@ -201,10 +217,12 @@ def verify_callback_api(request): return error_response if latest_answer is None: + logger.warning("No questionnaire submission found in verify_callback_api") return Response({"error": "No questionnaire submission found"}, status=404) # Check if this is the submission we're looking for if str(latest_answer.get("id")) != str(answer_id): + logger.warning("Answer ID mismatch in verify_callback_api") return Response({"error": "Answer ID mismatch"}, status=403) # Extract OTP and quest_id from submission @@ -215,6 +233,7 @@ def verify_callback_api(request): otp_data_raw = r.getdel(otp_key) if not otp_data_raw: + logger.warning("Invalid or expired OTP in verify_callback_api") return Response({"error": "Invalid or expired OTP"}, status=401) try: @@ -222,13 +241,16 @@ def verify_callback_api(request): expected_temp_token = otp_data.get("temp_token") initiated_at = otp_data.get("initiated_at") except (json.JSONDecodeError, AttributeError): + logger.error("Invalid OTP data format in verify_callback_api") return Response({"error": "Invalid OTP data format"}, status=401) if not expected_temp_token or not initiated_at: + logger.warning("Incomplete OTP data in verify_callback_api") return Response({"error": "Incomplete OTP data"}, status=401) # Step 5: StepVerify temp_token matches if expected_temp_token != temp_token: + logger.warning("Invalid temp_token in verify_callback_api") return Response({"error": "Invalid temp_token"}, status=401) # Step 6: Validate submission timestamp after OTP extraction @@ -246,8 +268,8 @@ def verify_callback_api(request): status=401, ) - except (ValueError, TypeError) as e: - logging.exception(f"Error parsing submission timestamp: {e}") + except (ValueError, TypeError): + logger.error("Error parsing submission timestamp") return Response({"error": "Invalid submission timestamp"}, status=401) # Step 7: Update state to verified and add user details @@ -265,8 +287,10 @@ def verify_callback_api(request): # Clear rate limiting on success r.delete(rate_limit_key) - logging.info( - "Successfully verified temp_token for user %s with action %s", account, action + logger.info( + "Successfully verified temp_token for user %s with action %s", + account, + action, ) # For login action, handle immediate session creation and cleanup @@ -275,15 +299,16 @@ def verify_callback_api(request): user, error_response = utils.create_user_session(request, account) if user is None: if error_response: - logging.error( + logger.error( "Failed to create session for login: %s", getattr(error_response, "data", {}).get("error", "Unknown error"), ) return error_response else: + logger.error("Failed to create user session in verify_callback_api") return Response({"error": "Failed to create user session"}, status=500) if not user.is_active: - logging.warning("Inactive user attempted OAuth login: %s", account) + logger.warning("Inactive user attempted OAuth login: %s", account) return Response({"error": "User account is inactive"}, status=403) try: # Create Django session @@ -292,7 +317,7 @@ def verify_callback_api(request): # Delete temp_token_state after successful login r.delete(state_key) except Exception: - logging.exception( + logger.exception( "Error during login session creation or cleanup for user %s", account ) return Response({"error": "Failed to finalize login process"}, status=500) @@ -355,6 +380,8 @@ def verify_token_pwd(request, action: str) -> tuple[dict | None, Response | None @api_view(["POST"]) +@authentication_classes([utils.CSRFCheckSessionAuthentication]) +@permission_classes([AllowAny]) def auth_signup_api(request) -> Response: """Signup API (/api/auth/signup) @@ -377,7 +404,7 @@ def auth_signup_api(request) -> Response: return error_response or Response( {"error": "Failed to create user session"}, status=500 ) - if user.password: + if user.has_usable_password(): return Response({"error": "User already exists with password."}, status=409) user.is_active = True @@ -385,6 +412,8 @@ def auth_signup_api(request) -> Response: user.set_password(password) user.save() + login(request, user) + # Cleanup: Delete temp_token_state and clear cookie r = get_redis_connection("default") r.delete(state_key) @@ -392,12 +421,14 @@ def auth_signup_api(request) -> Response: response.delete_cookie("temp_token") return response - except Exception as e: - logging.error(f"Error in auth_signup_api: {e}") + except Exception: + logger.error("Error in auth_signup_api") return Response({"error": "Failed to complete signup"}, status=500) @api_view(["POST"]) +@authentication_classes([utils.CSRFCheckSessionAuthentication]) +@permission_classes([AllowAny]) def auth_reset_password_api(request) -> Response: """Reset Password API (/api/auth/password) @@ -432,13 +463,12 @@ def auth_reset_password_api(request) -> Response: response.delete_cookie("temp_token") return response - except Exception as e: - logging.error(f"Error in auth_reset_password_api: {e}") + except Exception: + logger.error("Error in auth_reset_password_api") return Response({"error": "Failed to reset password"}, status=500) @api_view(["POST"]) -@authentication_classes([CsrfExemptSessionAuthentication]) @permission_classes([AllowAny]) def auth_login_api(request) -> Response: account = request.data.get("account", "").strip() @@ -446,6 +476,9 @@ def auth_login_api(request) -> Response: turnstile_token = request.data.get("turnstile_token", "") if not account or not password or not turnstile_token: + logger.warning( + "Account, password, and Turnstile token are missing in auth_login_api" + ) return Response( {"error": "Account, password, and Turnstile token are missing"}, status=400 ) @@ -475,9 +508,13 @@ def auth_login_api(request) -> Response: @api_view(["POST"]) -@authentication_classes([CsrfExemptSessionAuthentication]) +@authentication_classes([utils.CSRFCheckSessionAuthentication]) @permission_classes([AllowAny]) def auth_logout_api(request) -> Response: + logger.info( + "auth_logout_api called for user=%s", + getattr(request.user, "username", None), + ) """Logout a user.""" logout(request) return Response({"message": "Logged out successfully"}, status=200) diff --git a/apps/spider/urls.py b/apps/spider/urls.py new file mode 100644 index 0000000..bb8420c --- /dev/null +++ b/apps/spider/urls.py @@ -0,0 +1,12 @@ +from django.urls import re_path + +from apps.spider import views as spider_views + +urlpatterns = [ + re_path(r"^data/$", spider_views.crawled_data_list, name="crawled_datas"), + re_path( + r"^data/(?P[0-9]+)$", + spider_views.crawled_data_detail, + name="crawled_data", + ), +] diff --git a/apps/web/migrations/0010_remove_review_dislike_count_and_more.py b/apps/web/migrations/0010_remove_review_dislike_count_and_more.py new file mode 100644 index 0000000..89f6b0f --- /dev/null +++ b/apps/web/migrations/0010_remove_review_dislike_count_and_more.py @@ -0,0 +1,20 @@ +# Generated by Django 5.2 on 2025-10-13 15:42 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("web", "0009_remove_student_confirmation_link_and_more"), + ] + + operations = [ + migrations.RemoveField( + model_name="review", + name="dislike_count", + ), + migrations.RemoveField( + model_name="review", + name="kudos_count", + ), + ] diff --git a/apps/web/migrations/0011_remove_course_difficulty_score_and_more.py b/apps/web/migrations/0011_remove_course_difficulty_score_and_more.py new file mode 100644 index 0000000..95cde41 --- /dev/null +++ b/apps/web/migrations/0011_remove_course_difficulty_score_and_more.py @@ -0,0 +1,20 @@ +# Generated by Django 5.2.8 on 2025-12-03 08:04 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("web", "0010_remove_review_dislike_count_and_more"), + ] + + operations = [ + migrations.RemoveField( + model_name="course", + name="difficulty_score", + ), + migrations.RemoveField( + model_name="course", + name="quality_score", + ), + ] diff --git a/apps/web/models/course.py b/apps/web/models/course.py index 8b280b1..dd2e8a4 100644 --- a/apps/web/models/course.py +++ b/apps/web/models/course.py @@ -3,7 +3,8 @@ import re from django.db import models -from django.db.models import Q +from django.db.models import Avg, Count, Q +from django.db.models.functions import Coalesce from django.urls import reverse from lib.constants import CURRENT_TERM @@ -69,6 +70,37 @@ def search(self, query): ) return courses + def with_scores(self): + """Annotate courses with calculated scores and review count (for list view)""" + from apps.web.models import Vote + + return self.annotate( + quality_score=Coalesce( + Avg("vote__value", filter=Q(vote__category=Vote.CATEGORIES.QUALITY)), + 0.0, + ), + difficulty_score=Coalesce( + Avg("vote__value", filter=Q(vote__category=Vote.CATEGORIES.DIFFICULTY)), + 0.0, + ), + review_count=Count("review", distinct=True), + ) + + def with_scores_vote_counts(self): + """Annotate courses with vote counts (for detail view)""" + from apps.web.models import Vote + + return self.with_scores().annotate( + quality_vote_count=Count( + "vote", filter=Q(vote__category=Vote.CATEGORIES.QUALITY), distinct=True + ), + difficulty_vote_count=Count( + "vote", + filter=Q(vote__category=Vote.CATEGORIES.DIFFICULTY), + distinct=True, + ), + ) + class Course(models.Model): objects = CourseManager() @@ -99,9 +131,6 @@ class SOURCES: # subnumber = models.IntegerField(null=True, db_index=True, blank=True) # source = models.CharField(max_length=16, choices=SOURCES.CHOICES) - difficulty_score = models.FloatField(default=0.0) - quality_score = models.FloatField(default=0.0) - created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) @@ -188,7 +217,8 @@ def get_instructors(self, term=CURRENT_TERM): If term is None, returns instructors across all terms. """ instructors = [] - offerings = self.courseoffering_set.all() + # Prefetch instructors to avoid N+1 queries + offerings = self.courseoffering_set.prefetch_related("instructors").all() if term: offerings = offerings.filter(term=term) diff --git a/apps/web/models/forms/__init__.py b/apps/web/models/forms/__init__.py deleted file mode 100644 index a74d9b1..0000000 --- a/apps/web/models/forms/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from .review_form import ReviewForm - -__all__ = ["ReviewForm"] diff --git a/apps/web/models/forms/review_form.py b/apps/web/models/forms/review_form.py deleted file mode 100644 index 52bf649..0000000 --- a/apps/web/models/forms/review_form.py +++ /dev/null @@ -1,59 +0,0 @@ -from django import forms -from django.core.exceptions import ValidationError - -from apps.web.models import Review -from lib import constants -from lib.terms import is_valid_term - -REVIEW_MINIMUM_LENGTH = 30 - - -class ReviewForm(forms.ModelForm): - def clean_term(self): - term = self.cleaned_data["term"].upper() - if is_valid_term(term): - return term - else: - raise ValidationError( - "Please use a valid term, e.g. {}".format(constants.CURRENT_TERM) - ) - - def clean_professor(self): - professor = self.cleaned_data["professor"] - names = professor.split(" ") - - if len(names) < 2: - raise ValidationError("Please use a valid professor name, e.g. John Smith") - - return " ".join([n.capitalize() for n in names]) - - def clean_comments(self): - review = self.cleaned_data["comments"] - - if len(review) < REVIEW_MINIMUM_LENGTH: - raise ValidationError( - "Please write a longer review (at least {} characters)".format( - REVIEW_MINIMUM_LENGTH - ) - ) - - return review - - class Meta: - model = Review - fields = ["term", "professor", "comments"] - - widgets = { - "term": forms.TextInput( - attrs={"placeholder": "e.g. {}".format(constants.CURRENT_TERM)} - ), - "professor": forms.TextInput( - attrs={"placeholder": "Full name please, e.g. John Smith"} - ), - } - - labels = {"comments": "Review"} - - help_texts = { - "professor": "Please choose from the suggestions if you can.", - } diff --git a/apps/web/models/review.py b/apps/web/models/review.py index 3da86c2..b649d11 100644 --- a/apps/web/models/review.py +++ b/apps/web/models/review.py @@ -2,31 +2,55 @@ from django.contrib.auth.models import User from django.db import models +from django.db.models import Count, OuterRef, Q, Subquery class ReviewManager(models.Manager): def user_can_write_review(self, user, course): return not self.filter(user=user, course=course).exists() - def num_reviews_for_user(self, user): + def review_count_for_user(self, user): return self.filter(user=user).count() - def delete_reviews_for_user_course(self, user, course): - self.filter(course=course, user=user).delete() + def with_votes(self, vote_user=None, **kwargs): + """ + Return queryset with annotated vote counts (kudos, dislike) and user's vote. - def get_user_review_for_course(self, user, course): + Args: + vote_user: User object for user vote annotations + **kwargs: Additional filter parameters for queryset """ - Get the review written by a user for a specific course. - Returns the Review object if found, None otherwise. - If multiple reviews exist, returns the most recent one. + queryset = self.filter(**kwargs).annotate( + kudos_count=Count("votes", filter=Q(votes__is_kudos=True), distinct=True), + dislike_count=Count( + "votes", filter=Q(votes__is_kudos=False), distinct=True + ), + ) + + if vote_user and vote_user.is_authenticated: + from .vote_for_review import ReviewVote + + # Define subquery: get the is_kudos value for current user's vote on this review + vote_subquery = ReviewVote.objects.filter( + review=OuterRef("pk"), user=vote_user + ).values("is_kudos")[:1] + + queryset = queryset.annotate( + user_vote=Subquery( + vote_subquery, output_field=models.BooleanField(null=True) + ) + ) + + return queryset + + def raw_queryset(self, **kwargs): + """ + Return base queryset without vote annotations for better performance when votes aren't needed. + + Args: + **kwargs: Additional filter parameters """ - try: - return self.get(user=user, course=course) - except self.model.DoesNotExist: - return None - except self.model.MultipleObjectsReturned: - # If somehow there are multiple reviews, return the most recent one - return self.filter(user=user, course=course).order_by("-created_at").first() + return self.filter(**kwargs) class Review(models.Model): @@ -56,11 +80,6 @@ class Review(models.Model): ) difficulty_sentiment = models.FloatField(default=None, null=True, blank=True) quality_sentiment = models.FloatField(default=None, null=True, blank=True) - - # Kudos and dislike counts - kudos_count = models.PositiveIntegerField(default=0, db_index=True) - dislike_count = models.PositiveIntegerField(default=0, db_index=True) - created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) diff --git a/apps/web/models/vote.py b/apps/web/models/vote.py index 2c50177..9d8861e 100644 --- a/apps/web/models/vote.py +++ b/apps/web/models/vote.py @@ -2,6 +2,7 @@ from django.contrib.auth.models import User from django.db import models, transaction +from django.db.models import Avg from .course import Course @@ -9,21 +10,9 @@ class VoteManager(models.Manager): @transaction.atomic def vote(self, value, course_id, category, user): - is_unvote = False - - if value > 5 or value < 1: - return None, is_unvote, None - - course = Course.objects.get(id=course_id) + course = Course.objects.select_for_update().get(id=course_id) vote, created = self.get_or_create(course=course, category=category, user=user) - # if previously voted, reverse the old value of the vote - if not created: - if category == Vote.CATEGORIES.QUALITY: - course.quality_score -= vote.value - elif category == Vote.CATEGORIES.DIFFICULTY: - course.difficulty_score -= vote.value - is_unvote = not created and vote.value == value if is_unvote: @@ -33,23 +22,16 @@ def vote(self, value, course_id, category, user): vote.save() new_score = self._calculate_average_score(course, category) - if category == Vote.CATEGORIES.QUALITY: - course.quality_score = new_score - elif category == Vote.CATEGORIES.DIFFICULTY: - course.difficulty_score = new_score - course.save() - return new_score, is_unvote, self.get_vote_count(course, category) + vote_count = self.get_vote_count(course, category) - def _calculate_average_score(self, course, category): - """Calculate the average score for a course in a specific category""" - votes = self.filter(course=course, category=category) - if not votes.exists(): - return 0 + return new_score, is_unvote, vote_count - total_score = sum(vote.value for vote in votes) - vote_count = votes.count() - # Return average rounded to 1 decimal place - return round(total_score / vote_count, 1) + def _calculate_average_score(self, course, category): + result = self.filter(course=course, category=category).aggregate( + avg_score=Avg("value") + ) + avg = result["avg_score"] + return round(avg, 1) if avg is not None else 0 def get_vote_count(self, course, category): """Get the vote count for a course in a specific category""" @@ -116,6 +98,9 @@ class CATEGORIES: class Meta: unique_together = ("course", "user", "category") + indexes = [ + models.Index(fields=["course", "category", "value"]), + ] def __unicode__(self): return "{} for {} by {}".format( diff --git a/apps/web/models/vote_for_review.py b/apps/web/models/vote_for_review.py index 87fcefc..a60c3c0 100644 --- a/apps/web/models/vote_for_review.py +++ b/apps/web/models/vote_for_review.py @@ -34,43 +34,25 @@ def vote(self, review_id, user, is_kudos=True): ) if created: - # New vote, increment the appropriate counter - if is_kudos: - review.kudos_count = models.F("kudos_count") + 1 - else: - review.dislike_count = models.F("dislike_count") + 1 - review.save(update_fields=["kudos_count", "dislike_count"]) + # New vote vote_value = is_kudos else: # Existing vote if review_vote.is_kudos == is_kudos: - # Same vote type, remove it (cancel) review_vote.delete() - if is_kudos: - review.kudos_count = models.F("kudos_count") - 1 - else: - review.dislike_count = models.F("dislike_count") - 1 - review.save(update_fields=["kudos_count", "dislike_count"]) - vote_value = None # User cancelled their vote + vote_value = None else: - # Change vote from kudos to dislike or vice versa - old_is_kudos = review_vote.is_kudos review_vote.is_kudos = is_kudos review_vote.save() - - # Update counts: decrease old vote type, increase new vote type - if old_is_kudos: # Was kudos, changing to dislike - review.kudos_count = models.F("kudos_count") - 1 - review.dislike_count = models.F("dislike_count") + 1 - else: # Was dislike, changing to kudos - review.dislike_count = models.F("dislike_count") - 1 - review.kudos_count = models.F("kudos_count") + 1 - review.save(update_fields=["kudos_count", "dislike_count"]) vote_value = is_kudos - # Return updated counts and user's current vote - review.refresh_from_db() - return review.kudos_count, review.dislike_count, vote_value + review_with_votes = Review.objects.with_votes(id=review_id).first() + if review_with_votes: + kudos_count = review_with_votes.kudos_count + dislike_count = review_with_votes.dislike_count + else: + kudos_count, dislike_count = 0, 0 + return kudos_count, dislike_count, vote_value def get_user_vote(self, review, user): """Get the user's vote for a review""" @@ -81,12 +63,6 @@ def get_user_vote(self, review, user): except self.model.DoesNotExist: return None - def get_vote_counts(self, review): - """Get kudos and dislike counts for a review""" - kudos_count = self.filter(review=review, is_kudos=True).count() - dislike_count = self.filter(review=review, is_kudos=False).count() - return kudos_count, dislike_count - class ReviewVote(models.Model): """ diff --git a/apps/web/serializers.py b/apps/web/serializers.py index ca97c70..ae76365 100644 --- a/apps/web/serializers.py +++ b/apps/web/serializers.py @@ -1,4 +1,5 @@ # apps/web/serializers.py +from django.conf import settings from django.db.models import Count from rest_framework import serializers @@ -8,10 +9,10 @@ DistributiveRequirement, Instructor, Review, - ReviewVote, Vote, ) from lib import constants +from lib.terms import is_valid_term class DistributiveRequirementSerializer(serializers.ModelSerializer): @@ -29,10 +30,12 @@ class Meta: class ReviewSerializer(serializers.ModelSerializer): - # user = serializers.StringRelatedField() # Display username + # user = serializers.StringRelatedField() term = serializers.CharField() professor = serializers.CharField() user_vote = serializers.SerializerMethodField() + kudos_count = serializers.SerializerMethodField() + dislike_count = serializers.SerializerMethodField() class Meta: model = Review @@ -48,18 +51,60 @@ class Meta: "created_at", "user_vote", ) + read_only_fields = ( + "id", + "kudos_count", + "dislike_count", + "created_at", + "user_vote", + ) + + def get_kudos_count(self, obj): + """Get the number of kudos for this review""" + return getattr(obj, "kudos_count", 0) + + def get_dislike_count(self, obj): + """Get the number of dislikes for this review""" + return getattr(obj, "dislike_count", 0) def get_user_vote(self, obj): """Get the current user's vote for this review""" - request = self.context.get("request") - if not request or not request.user.is_authenticated: - return None + return getattr(obj, "user_vote", None) + + def validate_term(self, value): + """Validate term format""" + term = value.upper() + + if is_valid_term(term): + return term + else: + raise serializers.ValidationError( + "Please use a valid term, e.g. {}".format(constants.CURRENT_TERM) + ) + + def validate_professor(self, value): + """Validate professor name format""" + names = value.split(" ") + + if len(names) < 2: + raise serializers.ValidationError( + "Please use a valid professor name, e.g. John Smith" + ) + + return " ".join([n.capitalize() for n in names]) + + def validate_comments(self, value): + """Validate review minimum length""" + REVIEW_MINIMUM_LENGTH = settings.WEB["REVIEW"]["COMMENT_MIN_LENGTH"] + + if len(value) < REVIEW_MINIMUM_LENGTH: + raise serializers.ValidationError( + "Please write a longer review (at least {} characters)".format( + REVIEW_MINIMUM_LENGTH + ) + ) - try: - vote = ReviewVote.objects.get(review=obj, user=request.user) - return vote.is_kudos # True for kudos, False for dislike - except ReviewVote.DoesNotExist: - return None + return value class DepartmentSerializer(serializers.Serializer): @@ -73,6 +118,8 @@ class CourseSearchSerializer(serializers.ModelSerializer): review_count = serializers.SerializerMethodField() is_offered_in_current_term = serializers.SerializerMethodField() instructors = serializers.SerializerMethodField() + quality_score = serializers.SerializerMethodField() + difficulty_score = serializers.SerializerMethodField() class Meta: model = Course @@ -90,7 +137,13 @@ class Meta: ) def get_review_count(self, obj): - return obj.review_set.count() + return getattr(obj, "review_count", obj.review_set.count()) + + def get_quality_score(self, obj): + return getattr(obj, "quality_score", 0.0) + + def get_difficulty_score(self, obj): + return getattr(obj, "difficulty_score", 0.0) def get_is_offered_in_current_term(self, obj): return obj.courseoffering_set.filter(term=constants.CURRENT_TERM).exists() @@ -131,6 +184,15 @@ def to_representation(self, instance): return ret +class CourseVoteSerializer(serializers.Serializer): + value = serializers.IntegerField(min_value=1, max_value=5) + forLayup = serializers.BooleanField() + + +class ReviewVoteSerializer(serializers.Serializer): + is_kudos = serializers.BooleanField() + + class CourseSerializer(serializers.ModelSerializer): review_set = serializers.SerializerMethodField() courseoffering_set = CourseOfferingSerializer(many=True, read_only=True) @@ -146,6 +208,8 @@ class CourseSerializer(serializers.ModelSerializer): course_topics = serializers.SerializerMethodField() quality_vote_count = serializers.SerializerMethodField() difficulty_vote_count = serializers.SerializerMethodField() + quality_score = serializers.SerializerMethodField() + difficulty_score = serializers.SerializerMethodField() class Meta: model = Course @@ -198,7 +262,13 @@ def get_review_set(self, obj): return [] def get_review_count(self, obj): - return obj.review_set.count() + return getattr(obj, "review_count", obj.review_set.count()) + + def get_quality_score(self, obj): + return getattr(obj, "quality_score", 0.0) + + def get_difficulty_score(self, obj): + return getattr(obj, "difficulty_score", 0.0) def get_xlist(self, obj): return [ @@ -245,10 +315,14 @@ def get_quality_vote(self, obj): return None def get_quality_vote_count(self, obj): - return Vote.objects.get_vote_count(obj, "quality") + return getattr( + obj, "quality_vote_count", Vote.objects.get_vote_count(obj, "quality") + ) def get_difficulty_vote_count(self, obj): - return Vote.objects.get_vote_count(obj, "difficulty") + return getattr( + obj, "difficulty_vote_count", Vote.objects.get_vote_count(obj, "difficulty") + ) def get_can_write_review(self, obj): request = self.context.get("request") diff --git a/apps/web/urls.py b/apps/web/urls.py new file mode 100644 index 0000000..6915bd3 --- /dev/null +++ b/apps/web/urls.py @@ -0,0 +1,47 @@ +from django.urls import re_path + +from apps.web import views + +urlpatterns = [ + re_path(r"^user/status/?", views.user_status, name="user_status"), + re_path(r"^landing/$", views.landing_api, name="landing_api"), + re_path(r"^courses/$", views.CoursesListAPI.as_view(), name="courses_api"), + re_path( + r"^courses/(?P[0-9]+)/$", + views.CoursesDetailAPI.as_view(), + name="course_detail_api", + ), + re_path( + r"^courses/(?P[0-9].*)/instructors?/?", + views.course_instructors, + name="course_instructors", + ), + re_path(r"^courses/(?P[0-9].*)/medians", views.medians, name="medians"), + re_path( + r"^courses/(?P[0-9].*)/professors?/?", + views.course_professors, + name="course_professors", + ), + re_path( + r"^courses/(?P[0-9].*)/vote", + views.course_vote_api, + name="course_vote_api", + ), + re_path( + r"^courses/(?P[0-9]+)/reviews/$", + views.CoursesReviewsAPI.as_view(), + name="course_review_api", + ), + re_path(r"^reviews/?$", views.UserReviewsAPI.as_view(), name="user_reviews_api"), + re_path( + r"^reviews/(?P[0-9]+)/$", + views.UserReviewsAPI.as_view(), + name="user_review_api", + ), + re_path( + r"^reviews/(?P[0-9]+)/vote/$", + views.review_vote_api, + name="review_vote_api", + ), + re_path(r"^departments/$", views.departments_api, name="departments_api"), +] diff --git a/apps/web/views.py b/apps/web/views.py index 21fda40..47e177e 100644 --- a/apps/web/views.py +++ b/apps/web/views.py @@ -1,9 +1,8 @@ -import datetime -import uuid +import logging -import dateutil.parser -from django.core.paginator import EmptyPage, PageNotAnInteger, Paginator -from django.db.models import Count +from django.conf import settings +from django.db.models import Count, Prefetch, Q +from rest_framework import generics, mixins, pagination, status from rest_framework.decorators import ( api_view, permission_classes, @@ -19,45 +18,52 @@ ReviewVote, Vote, ) -from apps.web.models.forms import ReviewForm from apps.web.serializers import ( CourseSearchSerializer, CourseSerializer, + CourseVoteSerializer, ReviewSerializer, + ReviewVoteSerializer, ) -from lib import constants from lib.departments import get_department_name from lib.grades import numeric_value_for_grade from lib.terms import numeric_value_of_term -LIMITS = { - "courses": 20, - "reviews": 5, - "unauthenticated_review_search": 3, -} +logger = logging.getLogger(__name__) + + +class CoursesPagination(pagination.PageNumberPagination): + page_size = settings.WEB["COURSE"]["PAGE_SIZE"] @api_view(["GET"]) def user_status(request): + """ + Get user authentication status. + Input: + - None + Output: + - Authenticated user: {"isAuthenticated": true, "username": "string"} + - Anonymous user: {"isAuthenticated": false} + """ if request.user.is_authenticated: + logger.info("User is authenticated") return Response({"isAuthenticated": True, "username": request.user.username}) else: + logger.info("User is not authenticated") return Response({"isAuthenticated": False}) -def get_session_id(request): - if "user_id" not in request.session: - if not request.user.is_authenticated: - request.session["user_id"] = uuid.uuid4().hex - else: - request.session["user_id"] = request.user.username - return request.session["user_id"] - - @api_view(["GET"]) @permission_classes([AllowAny]) def landing_api(request): - """API endpoint for landing page data""" + """ + Get landing page statistics. + Input: + - None + Output: + {"review_count": int} + """ return Response( { "review_count": Review.objects.count(), @@ -65,143 +71,320 @@ def landing_api(request): ) -def get_prior_course_id(request, current_course_id): - prior_course_id = None - if ( - "prior_course_id" in request.session - and "prior_course_timestamp" in request.session - ): - prior_course_timestamp = request.session["prior_course_timestamp"] - if ( - dateutil.parser.parse(prior_course_timestamp) - + datetime.timedelta(minutes=10) - >= datetime.datetime.now() - ): - prior_course_id = request.session["prior_course_id"] - request.session["prior_course_id"] = current_course_id - request.session["prior_course_timestamp"] = datetime.datetime.now().isoformat() - return prior_course_id +class CoursesListAPI(generics.GenericAPIView, mixins.ListModelMixin): + """ + List courses with filtering, sorting, and pagination. + GET + Input: + - Query parameters: + - department (string): Filter by department code (case-insensitive) + - code (string): Filter by course code (partial match) + - min_quality (integer): Filter by minimum quality score (authenticated only) + - min_difficulty (integer): Filter by minimum difficulty score (authenticated only) + - sort_by (string): Sort field ("course_code", "review_count"),("quality_score", "difficulty_score")(authenticated only) + - sort_order (string): "asc" or "desc" (default: "asc") + - page (integer): Page number for pagination + + Output: + { + "count": integer, + "next": "string|null", + "previous": "string|null", + "results": [CourseSearchSerializer objects] + } + """ + + serializer_class = CourseSearchSerializer + permission_classes = [AllowAny] + pagination_class = CoursesPagination + + def get_queryset(self): + queryset = Course.objects.with_scores().prefetch_related("distribs") + return queryset + + def _filter(self, queryset): + """filter courses and filter by score.""" + queryset = self._filter_courses(queryset) + queryset = self._filter_by_score(queryset) + return queryset + + def _filter_courses(self, queryset): + """Helper function to apply all filters to courses queryset.""" + department = self.request.query_params.get("department") + code = self.request.query_params.get("code") + if department: + queryset = queryset.filter(department__iexact=department) + if code: + queryset = queryset.filter(course_code__icontains=code) + return queryset + + def _filter_by_score(self, queryset): + """Helper function to filter by quality and difficulty score.""" + if not self.request.user.is_authenticated: + return queryset + + query_param_mapping = [ + ("min_quality", "quality_score"), + ("min_difficulty", "difficulty_score"), + ] + + for param_name, field_name in query_param_mapping: + param_value = self.request.query_params.get(param_name) + if param_value: + try: + threshold = int(param_value) + queryset = queryset.filter(**{f"{field_name}__gte": threshold}) + except (ValueError, TypeError): + pass + return queryset + + def _sort(self, queryset): + """Helper function to sort courses based on request parameters.""" + sort_by = self.request.query_params.get("sort_by", "course_code") + sort_order = self.request.query_params.get("sort_order", "asc") + sort_prefix = "-" if sort_order.lower() == "desc" else "" + + allowed_sort_fields = ["course_code", "review_count"] + if self.request.user.is_authenticated: + allowed_sort_fields.extend(["quality_score", "difficulty_score"]) + + sort_field = sort_by if sort_by in allowed_sort_fields else "course_code" + return queryset.order_by(f"{sort_prefix}{sort_field}") + + def filter_queryset(self, queryset): + """Override to apply both filtering and sorting.""" + queryset = self._filter(queryset) + queryset = self._sort(queryset) + return queryset + + def get(self, request, *args, **kwargs): + return self.list(request, *args, **kwargs) + + +class CoursesDetailAPI(generics.GenericAPIView, mixins.RetrieveModelMixin): + """ + Retrieve details for a specific course. + GET + Input: + - URL parameter: course_id (integer, required) + + Output: + - CourseSerializer object + - Authenticated: Full details + - Non-authenticated: without scores, votes, and vote counts + """ + serializer_class = CourseSerializer + permission_classes = [AllowAny] + lookup_field = "id" + lookup_url_kwarg = "course_id" + + def get_queryset(self): + queryset = Course.objects.with_scores_vote_counts() + + # Prefetch reviews with votes if authenticated + request = self.request + if request and request.user.is_authenticated: + queryset = queryset.prefetch_related( + Prefetch( + "review_set", + queryset=Review.objects.with_votes(vote_user=request.user), + ) + ) -@api_view(["GET"]) -@permission_classes([AllowAny]) -def courses_api(request): + return queryset + + def get(self, request, *args, **kwargs): + return self.retrieve(request, *args, **kwargs) + + +class CoursesReviewsAPI( + generics.GenericAPIView, mixins.ListModelMixin, mixins.CreateModelMixin +): """ - API endpoint for listing courses with filtering, sorting, and pagination. + List and create reviews for a specific course. + + GET - List reviews:(Unused API) + Input: + - Authentication: Required + - URL parameter: course_id (integer, required) + - Query parameters: + - q (string, optional): Search query for review content + - author (string, optional): "me" to filter user's own reviews + + Output: + - Success (200): [ReviewSerializer objects] + + POST - Create review: + Input: + - POST request + - Authentication: Required + - URL parameter: course_id (integer, required) + - Body: "term","professor","comments"(required and only required) + Output: + Success (201): ReviewSerializer object + Error (400): Validation errors + Error (403): {"detail": "User cannot write review"} + Error (404): {"detail": "Course not found"} """ - queryset = Course.objects.all().prefetch_related("distribs", "review_set") - queryset = queryset.annotate(num_reviews=Count("review")) - # --- Filtering --- - department = request.query_params.get("department") - if department: - queryset = queryset.filter(department__iexact=department) + serializer_class = ReviewSerializer + permission_classes = [IsAuthenticated] - code = request.query_params.get("code") - if code: - queryset = queryset.filter(course_code__icontains=code) + def get_queryset(self): + course_id = self.kwargs.get("course_id") + try: + course = Course.objects.get(id=course_id) + return Review.objects.with_votes(vote_user=self.request.user, course=course) + except Course.DoesNotExist: + logger.warning("Course with id %d does not exist", course_id) + return Review.objects.none() + + def list(self, request, *args, **kwargs): + """List reviews with optional filtering.""" + queryset = self.get_queryset() + + # Apply author filter + if request.query_params.get("author") == "me": + queryset = queryset.filter(user=request.user) + + # Handle search query + query = request.query_params.get("q", "").strip() + if query: + queryset = queryset.order_by("-term").filter( + Q(comments__icontains=query) | Q(professor__icontains=query) + ) - if request.user.is_authenticated: - min_quality = request.query_params.get("min_quality") - if min_quality: - try: - queryset = queryset.filter(quality_score__gte=int(min_quality)) - except (ValueError, TypeError): - pass # Ignore invalid values - - min_difficulty = request.query_params.get("min_difficulty") # Layup score - if min_difficulty: - try: - queryset = queryset.filter(difficulty_score__gte=int(min_difficulty)) - except (ValueError, TypeError): - pass # Ignore invalid values - - # --- Sorting --- - sort_by = request.query_params.get("sort_by", "course_code") # Default sort - sort_order = request.query_params.get("sort_order", "asc") - sort_prefix = "-" if sort_order.lower() == "desc" else "" - - allowed_sort_fields = ["course_code", "num_reviews"] - if request.user.is_authenticated: - allowed_sort_fields.extend(["quality_score", "difficulty_score"]) + serializer = self.get_serializer(queryset, many=True) + return Response(serializer.data) - if sort_by in allowed_sort_fields: - sort_field = sort_by - else: - sort_field = "course_code" # Fallback to default if invalid or not allowed + def get(self, request, *args, **kwargs): + """Get list of reviews.""" + return self.list(request, *args, **kwargs) - queryset = queryset.order_by(f"{sort_prefix}{sort_field}") + def post(self, request, *args, **kwargs): + """Create a new review for a course.""" + course_id = self.kwargs.get("course_id") + try: + course = Course.objects.get(id=course_id) + except Course.DoesNotExist: + return Response( + {"detail": "Course not found"}, status=status.HTTP_404_NOT_FOUND + ) - # --- Pagination --- - paginator = Paginator(queryset, LIMITS["courses"]) - page_number = request.query_params.get("page", 1) - try: - page = paginator.page(page_number) - except (PageNotAnInteger, EmptyPage): - page = paginator.page(1) + # Check if user can write review + if not Review.objects.user_can_write_review(request.user.id, course.id): + logger.warning( + "User %d cannot write review for course %d", request.user.id, course.id + ) + return Response( + {"detail": "User cannot write review"}, status=status.HTTP_403_FORBIDDEN + ) - # --- Serialization --- - serializer = CourseSearchSerializer( - page.object_list, many=True, context={"request": request} - ) + # Validate and save review using ReviewSerializer + serializer = ReviewSerializer(data=request.data) + if not serializer.is_valid(): + logger.warning("Review serializer errors: %s", serializer.errors) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - return Response( - { - "courses": serializer.data, - "pagination": { - "current_page": page.number, - "total_pages": paginator.num_pages, - "total_courses": paginator.count, - "limit": LIMITS["courses"], - }, - "query_params": request.query_params, # Return applied params for context - } - ) + serializer.save(course=course, user=request.user) + return Response(serializer.data, status=status.HTTP_201_CREATED) -@api_view(["GET", "POST"]) -@permission_classes([AllowAny]) -def course_detail_api(request, course_id): - try: - course = Course.objects.get(pk=course_id) - except Course.DoesNotExist: - return Response(status=404) +class UserReviewsAPI( + generics.GenericAPIView, + mixins.ListModelMixin, + mixins.RetrieveModelMixin, + mixins.UpdateModelMixin, + mixins.DestroyModelMixin, +): + """ + Manage user's own reviews (CRUD operations). + + GET (List) - List user's reviews:(Unused API) + Input: + - Authentication: Required + - URL parameter: None + + Output: + Success (200): [ReviewSerializer objects] + + GET (Retrieve) - Get specific review: + Input: + - Authentication: Required + - URL parameter: review_id (integer, required) + + Output: + Success (200): ReviewSerializer object + Error (404): {"detail": "Not found."} + + PUT - Update review:(Unused API) + Input: + - Authentication: Required + - URL parameter: review_id (integer, required) + - Body: "term","professor","comments"(required and only required) + + Output: + Success (200): Updated ReviewSerializer object + Error (400): Validation errors + Error (404): {"detail": "Not found."} + + DELETE - Delete review: + Input: + - Authentication: Required + - URL parameter: review_id (integer, required) + + Output: + Success (204): No content + Error (404): {"detail": "Not found."} + """ - if request.method == "GET": - serializer = CourseSerializer(course, context={"request": request}) - return Response(serializer.data) - elif request.method == "POST": - if not request.user.is_authenticated: - return Response({"detail": "Authentication required"}, status=403) - - if not Review.objects.user_can_write_review(request.user.id, course_id): - return Response({"detail": "User cannot write review"}, status=403) - - form = ReviewForm(request.data) - if form.is_valid(): - review = form.save(commit=False) - review.course = course - review.user = request.user - review.save() - serializer = CourseSerializer( - course, context={"request": request} - ) # re-serialize with new data - return Response(serializer.data, status=201) - return Response(form.errors, status=400) - - -@api_view(["DELETE"]) -@permission_classes([IsAuthenticated]) -def delete_review_api(request, course_id): - course = Course.objects.get(id=course_id) - Review.objects.delete_reviews_for_user_course(user=request.user, course=course) - serializer = CourseSerializer(course, context={"request": request}) - return Response(serializer.data, status=200) + serializer_class = ReviewSerializer + permission_classes = [IsAuthenticated] + lookup_field = "id" + lookup_url_kwarg = "review_id" + + def get_queryset(self): + """Only reviews belonging to the authenticated user with vote annotations.""" + return Review.objects.with_votes( + vote_user=self.request.user, user=self.request.user + ) + + def get(self, request, *args, **kwargs): + """Handle both list (no id) and retrieve (with id) operations.""" + if "review_id" in kwargs: + return self.retrieve(request, *args, **kwargs) + else: + return self.list(request, *args, **kwargs) + + def put(self, request, *args, **kwargs): + """Update a specific review.""" + return self.update(request, *args, **kwargs) + + def delete(self, request, *args, **kwargs): + """Delete a specific review.""" + return self.destroy(request, *args, **kwargs) @api_view(["GET"]) @permission_classes([AllowAny]) def departments_api(request): + """ + Get list of all departments with course counts. + + Input: + - None + + Output: + Success (200): + [ + { + "code": "string", + "name": "string", + "count": int + }, ... + ] + """ department_codes_and_counts = ( Course.objects.values("department") .annotate(Count("department")) @@ -219,60 +402,10 @@ def departments_api(request): @api_view(["GET"]) @permission_classes([AllowAny]) -def course_search_api(request): - query = request.GET.get("q", "").strip() - - if len(query) < 2: - return Response({"query": query, "department": None, "courses": []}) - - courses = Course.objects.search(query).prefetch_related("review_set", "distribs") - - if len(query) not in Course.objects.DEPARTMENT_LENGTHS: - courses = sorted(courses, key=lambda c: c.review_set.count(), reverse=True) - - serializer = CourseSearchSerializer( - courses, many=True, context={"request": request} - ) - - return Response( - { - "query": query, - "department": get_department_name(query), - "term": constants.CURRENT_TERM, - "courses": serializer.data, - } - ) - - -@api_view(["GET"]) -@permission_classes([IsAuthenticated]) -def course_review_search_api(request, course_id): - try: - course = Course.objects.get(pk=course_id) - except Course.DoesNotExist: - return Response({"detail": "Course not found"}, status=404) - - query = request.GET.get("q", "").strip() - reviews = course.search_reviews(query) - review_count = reviews.count() - - # Since we now require authentication, no need to limit reviews - serializer = ReviewSerializer(reviews, many=True, context={"request": request}) - - return Response( - { - "query": query, - "course_id": course.id, - "course_short_name": course.short_name(), - "reviews_full_count": review_count, - "remaining": 0, # No remaining since user is authenticated - "reviews": serializer.data, - } - ) - - -@api_view(["GET"]) def medians(request, course_id): + """ + Unused API. + """ # retrieve course medians for term, and group by term for averaging medians_by_term = {} for course_median in CourseMedian.objects.filter(course=course_id): @@ -311,12 +444,16 @@ def medians(request, course_id): @api_view(["GET"]) +@permission_classes([AllowAny]) def course_professors(request, course_id): + """ + Unused API. + """ return Response( { "professors": sorted( set( - Review.objects.filter(course=course_id) + Review.objects.raw_queryset(course=course_id) .values_list("professor", flat=True) .distinct() ) @@ -334,7 +471,11 @@ def course_professors(request, course_id): @api_view(["GET"]) +@permission_classes([AllowAny]) def course_instructors(request, course_id): + """ + Unused API. + """ try: course = Course.objects.get(pk=course_id) instructors = course.get_instructors() @@ -342,23 +483,48 @@ def course_instructors(request, course_id): {"instructors": [instructor.name for instructor in instructors]}, status=200 ) except Course.DoesNotExist: + logger.warning("Course with id %d not found for instructors API", course_id) return Response({"error": "Course not found"}, status=404) @api_view(["POST"]) @permission_classes([IsAuthenticated]) def course_vote_api(request, course_id): - try: - value = request.data["value"] - forLayup = request.data["forLayup"] - except KeyError: - return Response( - {"detail": "Missing required fields: value, forLayup"}, status=400 - ) + """ + Vote on course quality or difficulty. + + Input: + - POST request + - Authentication: Required + - URL parameter: course_id (integer, required) + - Body (JSON): + { + "value": integer (vote score between 1-5), + "forLayup": boolean (true for difficulty, false for quality) + } + + Output: + Success (200): + { + "new_score": float, + "was_unvote": boolean, + "new_vote_count": integer + } + Error (400): + { + "detail": "Validation error with input fields" + } + """ + serializer = CourseVoteSerializer(data=request.data) + if not serializer.is_valid(): + return Response(serializer.errors, status=400) + + value = serializer.validated_data["value"] + forLayup = serializer.validated_data["forLayup"] category = Vote.CATEGORIES.DIFFICULTY if forLayup else Vote.CATEGORIES.QUALITY new_score, is_unvote, new_vote_count = Vote.objects.vote( - int(value), course_id, category, request.user + value, course_id, category, request.user ) return Response( @@ -374,83 +540,47 @@ def course_vote_api(request, course_id): @permission_classes([IsAuthenticated]) def review_vote_api(request, review_id): """ - API endpoint for voting on reviews (kudos/dislike). - - URL: /api/review/{review_id}/vote/ - POST data: - - is_kudos: boolean (True for kudos, False for dislike) - - Returns: - - kudos_count: updated kudos count - - dislike_count: updated dislike count - - user_vote: user's current vote (True/False/None) - """ - - try: - is_kudos = request.data.get("is_kudos") - - if is_kudos is None: - return Response({"detail": "is_kudos field is required"}, status=400) - - is_kudos = bool(is_kudos) + Vote on reviews (kudos/dislike). - # Use the ReviewVoteManager's vote method - kudos_count, dislike_count, user_vote = ReviewVote.objects.vote( - review_id=review_id, user=request.user, is_kudos=is_kudos - ) - - if kudos_count is None or dislike_count is None: - # Review doesn't exist - return Response({"detail": "Review not found"}, status=404) - - return Response( + Input: + - POST request + - Authentication: Required + - URL parameter: review_id (integer, required) + - Body (JSON): { - "kudos_count": kudos_count, - "dislike_count": dislike_count, - "user_vote": user_vote, + "is_kudos": boolean (true for kudos, false for dislike) } - ) - - except Exception: - return Response( - {"detail": "An error occurred processing your request"}, - status=500, - ) - - -@api_view(["GET"]) -@permission_classes([IsAuthenticated]) -def get_user_review_api(request, course_id): - """ - API endpoint to get the authenticated user's review for a specific course. - - Returns: - - Review data if the user has written a review for this course - - 404 if no review found - - 403 if user is not authenticated + Output: + Success (200): + { + "kudos_count": integer, + "dislike_count": integer, + "user_vote": boolean|null (true/false/null) + } + Error (400): + { + "detail": "Validation error with input fields" + } """ + serializer = ReviewVoteSerializer(data=request.data) + if not serializer.is_valid(): + return Response(serializer.errors, status=400) - try: - # Get the course - try: - course = Course.objects.get(id=course_id) - except Course.DoesNotExist: - return Response({"detail": "Course not found"}, status=404) - - # Get the user's review for this course - review = Review.objects.get_user_review_for_course(request.user, course) + is_kudos = serializer.validated_data["is_kudos"] - if review is None: - return Response( - {"detail": "No user review found for this course"}, status=404 - ) + kudos_count, dislike_count, user_vote = ReviewVote.objects.vote( + review_id=review_id, user=request.user, is_kudos=is_kudos + ) - # Serialize and return the review - serializer = ReviewSerializer(review) - return Response(serializer.data) + if kudos_count is None or dislike_count is None: + # Review doesn't exist + logger.warning("Review %s not found for voting", str(review_id)) + return Response({"detail": "Review not found"}, status=404) - except Exception: - return Response( - {"detail": "An error occurred processing your request"}, - status=500, - ) + return Response( + { + "kudos_count": kudos_count, + "dislike_count": dislike_count, + "user_vote": user_vote, + } + ) diff --git a/config.yaml.example b/config.yaml.example index 27105cd..2890816 100644 --- a/config.yaml.example +++ b/config.yaml.example @@ -22,6 +22,12 @@ CORS_ALLOWED_ORIGINS: # COOKIE_AGE: 2592000 # 30 days # SAVE_EVERY_REQUEST: true # +# WEB: +# COURSE: +# PAGE_SIZE: 5 +# REVIEW: +# PAGE_SIZE: 10 +# COMMENT_MIN_LENGTH : 30 # AUTH: # OTP_TIMEOUT: 120 # TEMP_TOKEN_TIMEOUT: 600 @@ -50,7 +56,7 @@ QUEST: # API_KEY: Use env URL: "https://wj.sjtu.edu.cn/q/dummy1" QUESTIONID: 10000001 - RESET: + RESET_PASSWORD: # API_KEY: Use env URL: "https://wj.sjtu.edu.cn/q/dummy2" QUESTIONID: 10000002 diff --git a/docs/auth.md b/docs/auth.md index 1ca3919..95ace4e 100644 --- a/docs/auth.md +++ b/docs/auth.md @@ -68,7 +68,7 @@ This authentication flow is hardened by its stateless, token-based design. It re - **Logic**: 1. Checks `localStorage` for a non-expired OTP record. If found, displays it to allow the user to re-copy. Expired OTP and auth flow state records in `localStorage` are cleared. 2. If no valid OTP exists, renders the Cloudflare Turnstile widget. - 3. On Turnstile success, calls `POST /api/auth/initiate` endpoint with its `action` prop. + 3. On Turnstile success, calls `POST /api/auth/init` endpoint with its `action` prop. 4. On receiving the `otp` and `redirect_url` (the `temp_token` is set as a cookie by the backend), stores `{ otp, expires_at }` and `{ status: 'pending', expires_at }` in `localStorage` to track the flow's state. 5. Displays the OTP and copy button. On click, it copies the OTP, provides visual feedback, and initiates the redirect to the URL received from backend. @@ -107,7 +107,7 @@ On mount, checks `localStorage` for an `auth_flow` state object with `status: 'v ### Detailed Backend Process -#### `POST /api/auth/initiate` +#### `POST /api/auth/init` 1. **Input**: Receives the user's intended `action` and the `turnstile_token`. 2. **Validation**: Verifies the `turnstile_token` with Cloudflare's API. diff --git a/docs/setup.md b/docs/setup.md index 6e46abe..48a252b 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -18,7 +18,7 @@ Environment: 4. `uv sync --all-groups` -5. `uv run pre-commit install` (for installing git hook in .git) +5. `uv run prek install` (for installing git hook in .git) 6. Make directory for builds of static files: `mkdir staticfiles` diff --git a/pyproject.toml b/pyproject.toml index 96f3a8d..90395bd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,7 +28,7 @@ package = false [dependency-groups] dev = [ - "pre-commit==4.4.0", + "prek>=0.2.24", ] lint = [ "ruff==0.14.5", diff --git a/uv.lock b/uv.lock index 3504554..01be51a 100644 --- a/uv.lock +++ b/uv.lock @@ -13,15 +13,15 @@ wheels = [ [[package]] name = "anyio" -version = "4.10.0" +version = "4.11.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "idna" }, { name = "sniffio" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f1/b4/636b3b65173d3ce9a38ef5f0522789614e590dab6a8d505340a4efe4c567/anyio-4.10.0.tar.gz", hash = "sha256:3f3fae35c96039744587aa5b8371e7e8e603c0702999535961dd336026973ba6", size = 213252, upload-time = "2025-08-04T08:54:26.451Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c6/78/7d432127c41b50bccba979505f272c16cbcadcc33645d5fa3a738110ae75/anyio-4.11.0.tar.gz", hash = "sha256:82a8d0b81e318cc5ce71a5f1f8b5c4e63619620b63141ef8c995fa0db95a57c4", size = 219094, upload-time = "2025-09-23T09:19:12.58Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6f/12/e5e0282d673bb9746bacfb6e2dba8719989d3660cdb2ea79aee9a9651afb/anyio-4.10.0-py3-none-any.whl", hash = "sha256:60e474ac86736bbfd6f210f7a61218939c318f43f9972497381f1c5e930ed3d1", size = 107213, upload-time = "2025-08-04T08:54:24.882Z" }, + { url = "https://files.pythonhosted.org/packages/15/b3/9b1a8074496371342ec1e796a96f99c82c945a339cd81a8e73de28b4cf9e/anyio-4.11.0-py3-none-any.whl", hash = "sha256:0287e96f4d26d4149305414d4e3bc32f0dcd0862365a4bddea19d7a1ec38c4fc", size = 109097, upload-time = "2025-09-23T09:19:10.601Z" }, ] [[package]] @@ -35,11 +35,11 @@ wheels = [ [[package]] name = "asgiref" -version = "3.8.1" +version = "3.11.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/29/38/b3395cc9ad1b56d2ddac9970bc8f4141312dbaec28bc7c218b0dfafd0f42/asgiref-3.8.1.tar.gz", hash = "sha256:c343bd80a0bec947a9860adb4c432ffa7db769836c64238fc34bdc3fec84d590", size = 35186, upload-time = "2024-03-22T14:39:36.863Z" } +sdist = { url = "https://files.pythonhosted.org/packages/76/b9/4db2509eabd14b4a8c71d1b24c8d5734c52b8560a7b1e1a8b56c8d25568b/asgiref-3.11.0.tar.gz", hash = "sha256:13acff32519542a1736223fb79a715acdebe24286d98e8b164a73085f40da2c4", size = 37969, upload-time = "2025-11-19T15:32:20.106Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/39/e3/893e8757be2612e6c266d9bb58ad2e3651524b5b40cf56761e985a28b13e/asgiref-3.8.1-py3-none-any.whl", hash = "sha256:3e1e3ecc849832fe52ccf2cb6686b7a55f82bb1d6aee72a58826471390335e47", size = 23828, upload-time = "2024-03-22T14:39:34.521Z" }, + { url = "https://files.pythonhosted.org/packages/91/be/317c2c55b8bbec407257d45f5c8d1b6867abc76d12043f2d3d58c538a4ea/asgiref-3.11.0-py3-none-any.whl", hash = "sha256:1db9021efadb0d9512ce8ffaf72fcef601c7b73a8807a1bb2ef143dc6b14846d", size = 24096, upload-time = "2025-11-19T15:32:19.004Z" }, ] [[package]] @@ -57,15 +57,15 @@ wheels = [ [[package]] name = "blessed" -version = "1.21.0" +version = "1.25.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "jinxed", marker = "sys_platform == 'win32'" }, { name = "wcwidth" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/0c/5e/3cada2f7514ee2a76bb8168c71f9b65d056840ebb711962e1ec08eeaa7b0/blessed-1.21.0.tar.gz", hash = "sha256:ece8bbc4758ab9176452f4e3a719d70088eb5739798cd5582c9e05f2a28337ec", size = 6660011, upload-time = "2025-04-26T21:56:58.199Z" } +sdist = { url = "https://files.pythonhosted.org/packages/33/cd/eed8b82f1fabcb817d84b24d0780b86600b5c3df7ec4f890bcbb2371b0ad/blessed-1.25.0.tar.gz", hash = "sha256:606aebfea69f85915c7ca6a96eb028e0031d30feccc5688e13fd5cec8277b28d", size = 6746381, upload-time = "2025-11-18T18:43:52.71Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ea/8e/0a37e44878fd76fac9eff5355a1bf760701f53cb5c38cdcd59a8fd9ab2a2/blessed-1.21.0-py2.py3-none-any.whl", hash = "sha256:f831e847396f5a2eac6c106f4dfadedf46c4f804733574b15fe86d2ed45a9588", size = 84727, upload-time = "2025-04-26T16:58:29.919Z" }, + { url = "https://files.pythonhosted.org/packages/7c/2c/e9b6dd824fb6e76dbd39a308fc6f497320afd455373aac8518ca3eba7948/blessed-1.25.0-py3-none-any.whl", hash = "sha256:e52b9f778b9e10c30b3f17f6b5f5d2208d1e9b53b270f1d94fc61a243fc4708f", size = 95646, upload-time = "2025-11-18T18:43:50.924Z" }, ] [[package]] @@ -87,29 +87,36 @@ wheels = [ [[package]] name = "certifi" -version = "2025.4.26" +version = "2025.11.12" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e8/9e/c05b3920a3b7d20d3d3310465f50348e5b3694f4f88c6daf736eef3024c4/certifi-2025.4.26.tar.gz", hash = "sha256:0a816057ea3cdefcef70270d2c515e4506bbc954f417fa5ade2021213bb8f0c6", size = 160705, upload-time = "2025-04-26T02:12:29.51Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/8c/58f469717fa48465e4a50c014a0400602d3c437d7c0c468e17ada824da3a/certifi-2025.11.12.tar.gz", hash = "sha256:d8ab5478f2ecd78af242878415affce761ca6bc54a22a27e026d7c25357c3316", size = 160538, upload-time = "2025-11-12T02:54:51.517Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/4a/7e/3db2bd1b1f9e95f7cddca6d6e75e2f2bd9f51b1246e546d88addca0106bd/certifi-2025.4.26-py3-none-any.whl", hash = "sha256:30350364dfe371162649852c63336a15c70c6510c2ad5015b21c2345311805f3", size = 159618, upload-time = "2025-04-26T02:12:27.662Z" }, -] - -[[package]] -name = "cfgv" -version = "3.4.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/11/74/539e56497d9bd1d484fd863dd69cbbfa653cd2aa27abfe35653494d85e94/cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560", size = 7114, upload-time = "2023-08-12T20:38:17.776Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c5/55/51844dd50c4fc7a33b653bfaba4c2456f06955289ca770a5dbd5fd267374/cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9", size = 7249, upload-time = "2023-08-12T20:38:16.269Z" }, + { url = "https://files.pythonhosted.org/packages/70/7d/9bc192684cea499815ff478dfcdc13835ddf401365057044fb721ec6bddb/certifi-2025.11.12-py3-none-any.whl", hash = "sha256:97de8790030bbd5c2d96b7ec782fc2f7820ef8dba6db909ccf95449f2d062d4b", size = 159438, upload-time = "2025-11-12T02:54:49.735Z" }, ] [[package]] name = "charset-normalizer" -version = "3.4.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/16/b0/572805e227f01586461c80e0fd25d65a2115599cc9dad142fee4b747c357/charset_normalizer-3.4.1.tar.gz", hash = "sha256:44251f18cd68a75b56585dd00dae26183e102cd5e0f9f1466e6df5da2ed64ea3", size = 123188, upload-time = "2024-12-24T18:12:35.43Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0e/f6/65ecc6878a89bb1c23a086ea335ad4bf21a588990c3f535a227b9eea9108/charset_normalizer-3.4.1-py3-none-any.whl", hash = "sha256:d98b1668f06378c6dbefec3b92299716b931cd4e6061f3c875a71ced1780ab85", size = 49767, upload-time = "2024-12-24T18:12:32.852Z" }, +version = "3.4.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/35/7051599bd493e62411d6ede36fd5af83a38f37c4767b92884df7301db25d/charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd", size = 207746, upload-time = "2025-10-14T04:41:33.773Z" }, + { url = "https://files.pythonhosted.org/packages/10/9a/97c8d48ef10d6cd4fcead2415523221624bf58bcf68a802721a6bc807c8f/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb", size = 147889, upload-time = "2025-10-14T04:41:34.897Z" }, + { url = "https://files.pythonhosted.org/packages/10/bf/979224a919a1b606c82bd2c5fa49b5c6d5727aa47b4312bb27b1734f53cd/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e", size = 143641, upload-time = "2025-10-14T04:41:36.116Z" }, + { url = "https://files.pythonhosted.org/packages/ba/33/0ad65587441fc730dc7bd90e9716b30b4702dc7b617e6ba4997dc8651495/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14", size = 160779, upload-time = "2025-10-14T04:41:37.229Z" }, + { url = "https://files.pythonhosted.org/packages/67/ed/331d6b249259ee71ddea93f6f2f0a56cfebd46938bde6fcc6f7b9a3d0e09/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191", size = 159035, upload-time = "2025-10-14T04:41:38.368Z" }, + { url = "https://files.pythonhosted.org/packages/67/ff/f6b948ca32e4f2a4576aa129d8bed61f2e0543bf9f5f2b7fc3758ed005c9/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838", size = 152542, upload-time = "2025-10-14T04:41:39.862Z" }, + { url = "https://files.pythonhosted.org/packages/16/85/276033dcbcc369eb176594de22728541a925b2632f9716428c851b149e83/charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6", size = 149524, upload-time = "2025-10-14T04:41:41.319Z" }, + { url = "https://files.pythonhosted.org/packages/9e/f2/6a2a1f722b6aba37050e626530a46a68f74e63683947a8acff92569f979a/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e", size = 150395, upload-time = "2025-10-14T04:41:42.539Z" }, + { url = "https://files.pythonhosted.org/packages/60/bb/2186cb2f2bbaea6338cad15ce23a67f9b0672929744381e28b0592676824/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c", size = 143680, upload-time = "2025-10-14T04:41:43.661Z" }, + { url = "https://files.pythonhosted.org/packages/7d/a5/bf6f13b772fbb2a90360eb620d52ed8f796f3c5caee8398c3b2eb7b1c60d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090", size = 162045, upload-time = "2025-10-14T04:41:44.821Z" }, + { url = "https://files.pythonhosted.org/packages/df/c5/d1be898bf0dc3ef9030c3825e5d3b83f2c528d207d246cbabe245966808d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152", size = 149687, upload-time = "2025-10-14T04:41:46.442Z" }, + { url = "https://files.pythonhosted.org/packages/a5/42/90c1f7b9341eef50c8a1cb3f098ac43b0508413f33affd762855f67a410e/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828", size = 160014, upload-time = "2025-10-14T04:41:47.631Z" }, + { url = "https://files.pythonhosted.org/packages/76/be/4d3ee471e8145d12795ab655ece37baed0929462a86e72372fd25859047c/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec", size = 154044, upload-time = "2025-10-14T04:41:48.81Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6f/8f7af07237c34a1defe7defc565a9bc1807762f672c0fde711a4b22bf9c0/charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9", size = 99940, upload-time = "2025-10-14T04:41:49.946Z" }, + { url = "https://files.pythonhosted.org/packages/4b/51/8ade005e5ca5b0d80fb4aff72a3775b325bdc3d27408c8113811a7cbe640/charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c", size = 107104, upload-time = "2025-10-14T04:41:51.051Z" }, + { url = "https://files.pythonhosted.org/packages/da/5f/6b8f83a55bb8278772c5ae54a577f3099025f9ade59d0136ac24a0df4bde/charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2", size = 100743, upload-time = "2025-10-14T04:41:52.122Z" }, + { url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" }, ] [[package]] @@ -139,7 +146,7 @@ dependencies = [ [package.dev-dependencies] dev = [ - { name = "pre-commit" }, + { name = "prek" }, ] lint = [ { name = "ruff" }, @@ -168,35 +175,49 @@ requires-dist = [ ] [package.metadata.requires-dev] -dev = [{ name = "pre-commit", specifier = "==4.4.0" }] +dev = [{ name = "prek", specifier = ">=0.2.24" }] lint = [{ name = "ruff", specifier = "==0.14.5" }] [[package]] name = "curtsies" -version = "0.4.2" +version = "0.4.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "blessed" }, { name = "cwcwidth" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/53/d2/ea91db929b5dcded637382235f9f1b7d06ef64b7f2af7fe1be1369e1f0d2/curtsies-0.4.2.tar.gz", hash = "sha256:6ebe33215bd7c92851a506049c720cca4cf5c192c1665c1d7a98a04c4702760e", size = 53559, upload-time = "2023-07-31T20:18:34.271Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d1/18/5741cb42624089a815520d5b65c39c3e59673a77fd1fab6ad65bdebf2f91/curtsies-0.4.3.tar.gz", hash = "sha256:102a0ffbf952124f1be222fd6989da4ec7cce04e49f613009e5f54ad37618825", size = 53401, upload-time = "2025-06-05T06:33:20.099Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5e/ab/c4ae7ff01c75001829dfa54da9b25632a8206fa5c9036ea0292096b402d0/curtsies-0.4.2-py3-none-any.whl", hash = "sha256:f24d676a8c4711fb9edba1ab7e6134bc52305a222980b3b717bb303f5e94cec6", size = 35444, upload-time = "2023-07-31T20:18:33.058Z" }, + { url = "https://files.pythonhosted.org/packages/ab/9b/b8ee3720d056309f4ab667bfc85995c4351f67b22e8c2008612b70350c3a/curtsies-0.4.3-py3-none-any.whl", hash = "sha256:65a1b4d6ff887bd9b0f0836cc6dc68c3a2c65c57f51a62f0ee5df408edee1a99", size = 35482, upload-time = "2025-06-05T06:33:19.122Z" }, ] [[package]] name = "cwcwidth" -version = "0.1.10" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/23/76/03fc9fb3441a13e9208bb6103ebb7200eba7647d040008b8303a1c03e152/cwcwidth-0.1.10.tar.gz", hash = "sha256:7468760f72c1f4107be1b2b2854bc000401ea36a69daed36fb966a1e19a7a124", size = 60265, upload-time = "2025-02-09T21:15:28.452Z" } - -[[package]] -name = "distlib" -version = "0.4.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/96/8e/709914eb2b5749865801041647dc7f4e6d00b549cfe88b65ca192995f07c/distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d", size = 614605, upload-time = "2025-07-17T16:52:00.465Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047, upload-time = "2025-07-17T16:51:58.613Z" }, +version = "0.1.12" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/86/5f/f5c3d1b4e9c8c541406ca0654efa1bfaa05414f8e7d1c14bc6e3fd0752f8/cwcwidth-0.1.12.tar.gz", hash = "sha256:bfc16531d1246dd2558eb9b3a63aa37a9978672b956860dc5426da2343ebf366", size = 72009, upload-time = "2025-11-01T17:48:53.683Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/48/42998c088895974ee2a5ce58d3e9bec504ffb4e063dbadc9e325499220d1/cwcwidth-0.1.12-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:a2c7ab3b9eb0abab9bb326fec751b36aca52e0cfe3987c0909f188b9f681042c", size = 24206, upload-time = "2025-11-01T17:48:17.749Z" }, + { url = "https://files.pythonhosted.org/packages/0d/09/4ca240f55596b9c0006d3ffc584bceed4973ee54a5ea68ce9751b712e869/cwcwidth-0.1.12-cp311-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:48ae48e69759e19eec41aeb6ba2217e5ac2885191b2d90c5ac426ac1aa61f38c", size = 83467, upload-time = "2025-11-01T17:48:18.705Z" }, + { url = "https://files.pythonhosted.org/packages/44/c0/f9cc45fda70866852dd3ea5ec9d95ae2f4f6eb0c37877f92a08f5f9c7dd9/cwcwidth-0.1.12-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7cf19286e0a388916c8af6b60a6174d641840d722e2870ccb327f67b10b531e8", size = 85763, upload-time = "2025-11-01T17:48:19.494Z" }, + { url = "https://files.pythonhosted.org/packages/86/84/ebb25d16e759915bffe77c684c9a359277f90f1a39423f4067bb47961e92/cwcwidth-0.1.12-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:2264b41d216d4cc8ac040a05d365f0221299a83ad8d45ab211c7b4301b19603a", size = 83632, upload-time = "2025-11-01T17:48:21.025Z" }, + { url = "https://files.pythonhosted.org/packages/ab/e7/45d6e1888a0240adf39634faacf3b2acd400309a83b4f33a2038851cb0ca/cwcwidth-0.1.12-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3608d4d076428543975a84bec9205f40f2935410816e01ec75bdb9b1a064be87", size = 84366, upload-time = "2025-11-01T17:48:21.948Z" }, + { url = "https://files.pythonhosted.org/packages/9b/b6/d65a429130c746f96f851850166008c2a0e0cf9225fe0ab1a3b6637e53f4/cwcwidth-0.1.12-cp311-abi3-win32.whl", hash = "sha256:02b7caa2afce141132edf191c080ce1b1d1c2251285407975db1ba63b509ba58", size = 22934, upload-time = "2025-11-01T17:48:22.983Z" }, + { url = "https://files.pythonhosted.org/packages/7d/63/1c0f5d4380402a00a8f18912ae28f1606774c106599e7341e56aa2bf83b8/cwcwidth-0.1.12-cp311-abi3-win_amd64.whl", hash = "sha256:0481c93b7392b27deda8a709eb9e1a9c95fc5b30d5f3bd5f995fd27c960d4ced", size = 24733, upload-time = "2025-11-01T17:48:24.094Z" }, + { url = "https://files.pythonhosted.org/packages/b3/c2/5d3eac3f4aed79011f30b287ba805dc0384123dc1faa9c8f99578735eb59/cwcwidth-0.1.12-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:62ac6a4623fb19411e495b3caca33c33051951f6f7ffe620666dcfa324b6f481", size = 25126, upload-time = "2025-11-01T17:48:42.204Z" }, + { url = "https://files.pythonhosted.org/packages/e9/8b/f212d553fab5aa32e98bf7134e594c613cbaaaffd638d918725b0a6a795d/cwcwidth-0.1.12-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:156a88f6c753497d4a6b637672be4030ab405b6196f0309845b8e67212f5880b", size = 100498, upload-time = "2025-11-01T17:48:43.23Z" }, + { url = "https://files.pythonhosted.org/packages/7d/db/4972da021adffee647874cfa15bfedf889b4ffa976bfa340b16286f157c1/cwcwidth-0.1.12-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f08870495da61c25ad8a4113b6c73081908bb40f1ff7485b5ff9b666576029ec", size = 103666, upload-time = "2025-11-01T17:48:44.009Z" }, + { url = "https://files.pythonhosted.org/packages/ca/f4/0c1e2f1107ce25006acaca533917d95b373ed3cb7adecb3278abf279dc1a/cwcwidth-0.1.12-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e9263f61070ca2f3156217936394cba53c91cc79718319301975616d4f8d7513", size = 101537, upload-time = "2025-11-01T17:48:44.781Z" }, + { url = "https://files.pythonhosted.org/packages/10/49/db0456f231e25c756fb733e5275c7d8fe556306b30120c684e9413553682/cwcwidth-0.1.12-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:68f1b1010bc457007515cbc89dfffb13ccb1b58a8db76a5fc34a4e77be3f6bf9", size = 100792, upload-time = "2025-11-01T17:48:45.571Z" }, + { url = "https://files.pythonhosted.org/packages/1d/b5/218c5c5259e3629fc26e588db4fade1ead5edbd5e4b354f4d0cf72f81648/cwcwidth-0.1.12-cp314-cp314-win32.whl", hash = "sha256:0df72403f42ce03e5bce23ee26f1c3da64d4a1ad100a0b6db9b4103ab54e7e68", size = 24733, upload-time = "2025-11-01T17:48:46.632Z" }, + { url = "https://files.pythonhosted.org/packages/c7/eb/fd01d63f49b8a774cecdb2df20b7f902871dc095dc19f4bfc19ed27f70ec/cwcwidth-0.1.12-cp314-cp314-win_amd64.whl", hash = "sha256:73dfc6926efa65343b129aad02364a61a238b2c6536f6d6388ef5611b42302d4", size = 26662, upload-time = "2025-11-01T17:48:47.298Z" }, + { url = "https://files.pythonhosted.org/packages/ec/84/9c25ddda092cfd405e59970dd7e96e2625e59ca7a0b5156d9dbc31c6c744/cwcwidth-0.1.12-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:611bc2c397123e7a24bb8a963539938e6f882c0a2ef2bf289ae3e7a752a642f3", size = 26531, upload-time = "2025-11-01T17:48:48.027Z" }, + { url = "https://files.pythonhosted.org/packages/ad/c8/7b79a7e28706d9da53ec66f5ad2d66c7be7687188bfd3ee35489940cf2fd/cwcwidth-0.1.12-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b997f0cbffd71aaaf32c170e657d4d47cf4122777ae1eba2da17e5112529da5c", size = 127465, upload-time = "2025-11-01T17:48:48.708Z" }, + { url = "https://files.pythonhosted.org/packages/4e/5e/f43a4c4c54650a5061f74521ebd99732f2782a29fe174f34098fbb8f74db/cwcwidth-0.1.12-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5daa2627bfa08c351920231ab1d594750c5fc48d95a2c4c3e5706fd57c6e8f91", size = 132434, upload-time = "2025-11-01T17:48:49.939Z" }, + { url = "https://files.pythonhosted.org/packages/11/f6/79c36b0f1b360c687e8a3f510ee6b7ce981c0fcd5efd2ba4ddf05065b257/cwcwidth-0.1.12-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c2dbce36c92ef0047ff252b2a1bebc41239b7edfd55716846006cf8f250f0c9d", size = 127850, upload-time = "2025-11-01T17:48:50.717Z" }, + { url = "https://files.pythonhosted.org/packages/05/c4/d0ae37f72d7ddff3be5a34abde28270c3eca9a26ddb526b963c21f5af441/cwcwidth-0.1.12-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c63145a882da594761156123e635b1fc5f8a5b3e1ec83c76392ac829f4733098", size = 127118, upload-time = "2025-11-01T17:48:51.472Z" }, + { url = "https://files.pythonhosted.org/packages/51/5c/72943d70049f9362e95ca7fca8fb485819c2150ff595530cbee92c6e0b2f/cwcwidth-0.1.12-cp314-cp314t-win32.whl", hash = "sha256:dd06c5e63650ec59f92ceb24b02a3f6002fb11aab92fce36d85d0a9c9203a9d8", size = 27350, upload-time = "2025-11-01T17:48:52.308Z" }, + { url = "https://files.pythonhosted.org/packages/a0/eb/e65a1a359063d019913cbcb95503d86fc415e18221023b4ec92e35e3d097/cwcwidth-0.1.12-cp314-cp314t-win_amd64.whl", hash = "sha256:fdcfb9632310d2c5b9cee4e8dfbffcfe07b6ca4968d3123b6ca618603b608deb", size = 29706, upload-time = "2025-11-01T17:48:52.965Z" }, ] [[package]] @@ -276,15 +297,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b0/ce/bf8b9d3f415be4ac5588545b5fcdbbb841977db1c1d923f7568eeabe1689/djangorestframework-3.16.1-py3-none-any.whl", hash = "sha256:33a59f47fb9c85ede792cbf88bde71893bcda0667bc573f784649521f1102cec", size = 1080442, upload-time = "2025-08-06T17:50:50.667Z" }, ] -[[package]] -name = "filelock" -version = "3.19.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/40/bb/0ab3e58d22305b6f5440629d20683af28959bf793d98d11950e305c1c326/filelock-3.19.1.tar.gz", hash = "sha256:66eda1888b0171c998b35be2bcc0f6d75c388a7ce20c3f3f37aa8e96c2dddf58", size = 17687, upload-time = "2025-08-14T16:56:03.016Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/42/14/42b2651a2f46b022ccd948bca9f2d5af0fd8929c4eec235b8d6d844fbe67/filelock-3.19.1-py3-none-any.whl", hash = "sha256:d38e30481def20772f5baf097c122c3babc4fcdb7e14e57049eb9d88c6dc017d", size = 15988, upload-time = "2025-08-14T16:56:01.633Z" }, -] - [[package]] name = "greenlet" version = "3.2.4" @@ -339,22 +351,13 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, ] -[[package]] -name = "identify" -version = "2.6.13" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/82/ca/ffbabe3635bb839aa36b3a893c91a9b0d368cb4d8073e03a12896970af82/identify-2.6.13.tar.gz", hash = "sha256:da8d6c828e773620e13bfa86ea601c5a5310ba4bcd65edf378198b56a1f9fb32", size = 99243, upload-time = "2025-08-09T19:35:00.6Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e7/ce/461b60a3ee109518c055953729bf9ed089a04db895d47e95444071dcdef2/identify-2.6.13-py2.py3-none-any.whl", hash = "sha256:60381139b3ae39447482ecc406944190f690d4a2997f2584062089848361b33b", size = 99153, upload-time = "2025-08-09T19:34:59.1Z" }, -] - [[package]] name = "idna" -version = "3.10" +version = "3.11" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490, upload-time = "2024-09-15T18:07:39.745Z" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" }, + { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, ] [[package]] @@ -381,59 +384,51 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/27/e3/0e0014d6ab159d48189e92044ace13b1e1fe9aa3024ba9f4e8cf172aa7c2/jinxed-1.3.0-py2.py3-none-any.whl", hash = "sha256:b993189f39dc2d7504d802152671535b06d380b26d78070559551cbf92df4fc5", size = 33085, upload-time = "2024-07-31T22:39:17.426Z" }, ] -[[package]] -name = "nodeenv" -version = "1.9.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/43/16/fc88b08840de0e0a72a2f9d8c6bae36be573e475a6326ae854bcc549fc45/nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f", size = 47437, upload-time = "2024-06-04T18:44:11.171Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d2/1d/1b658dbd2b9fa9c4c9f32accbfc0205d532c8c6194dc0f2a4c0428e7128a/nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9", size = 22314, upload-time = "2024-06-04T18:44:08.352Z" }, -] - [[package]] name = "parso" -version = "0.8.4" +version = "0.8.5" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/66/94/68e2e17afaa9169cf6412ab0f28623903be73d1b32e208d9e8e541bb086d/parso-0.8.4.tar.gz", hash = "sha256:eb3a7b58240fb99099a345571deecc0f9540ea5f4dd2fe14c2a99d6b281ab92d", size = 400609, upload-time = "2024-04-05T09:43:55.897Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d4/de/53e0bcf53d13e005bd8c92e7855142494f41171b34c2536b86187474184d/parso-0.8.5.tar.gz", hash = "sha256:034d7354a9a018bdce352f48b2a8a450f05e9d6ee85db84764e9b6bd96dafe5a", size = 401205, upload-time = "2025-08-23T15:15:28.028Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c6/ac/dac4a63f978e4dcb3c6d3a78c4d8e0192a113d288502a1216950c41b1027/parso-0.8.4-py2.py3-none-any.whl", hash = "sha256:a418670a20291dacd2dddc80c377c5c3791378ee1e8d12bffc35420643d43f18", size = 103650, upload-time = "2024-04-05T09:43:53.299Z" }, + { url = "https://files.pythonhosted.org/packages/16/32/f8e3c85d1d5250232a5d3477a2a28cc291968ff175caeadaf3cc19ce0e4a/parso-0.8.5-py2.py3-none-any.whl", hash = "sha256:646204b5ee239c396d040b90f9e272e9a8017c630092bf59980beb62fd033887", size = 106668, upload-time = "2025-08-23T15:15:25.663Z" }, ] [[package]] -name = "platformdirs" -version = "4.4.0" +name = "prek" +version = "0.2.24" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/23/e8/21db9c9987b0e728855bd57bff6984f67952bea55d6f75e055c46b5383e8/platformdirs-4.4.0.tar.gz", hash = "sha256:ca753cf4d81dc309bc67b0ea38fd15dc97bc30ce419a7f58d13eb3bf14c4febf", size = 21634, upload-time = "2025-08-26T14:32:04.268Z" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/67/33ff75b530d8f189f18a06b38dc8f684d07ffca045e043293bf043dd963b/prek-0.2.24.tar.gz", hash = "sha256:f7588b9aa0763baf3b2e2bd1b9f103f43e74e494e3e3e12c71270118f56b3f3e", size = 273552, upload-time = "2025-12-23T03:59:10.059Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/40/4b/2028861e724d3bd36227adfa20d3fd24c3fc6d52032f4a93c133be5d17ce/platformdirs-4.4.0-py3-none-any.whl", hash = "sha256:abd01743f24e5287cd7a5db3752faf1a2d65353f38ec26d98e25a6db65958c85", size = 18654, upload-time = "2025-08-26T14:32:02.735Z" }, -] - -[[package]] -name = "pre-commit" -version = "4.4.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "cfgv" }, - { name = "identify" }, - { name = "nodeenv" }, - { name = "pyyaml" }, - { name = "virtualenv" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/a6/49/7845c2d7bf6474efd8e27905b51b11e6ce411708c91e829b93f324de9929/pre_commit-4.4.0.tar.gz", hash = "sha256:f0233ebab440e9f17cabbb558706eb173d19ace965c68cdce2c081042b4fab15", size = 197501, upload-time = "2025-11-08T21:12:11.607Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/27/11/574fe7d13acf30bfd0a8dd7fa1647040f2b8064f13f43e8c963b1e65093b/pre_commit-4.4.0-py2.py3-none-any.whl", hash = "sha256:b35ea52957cbf83dcc5d8ee636cbead8624e3a15fbfa61a370e42158ac8a5813", size = 226049, upload-time = "2025-11-08T21:12:10.228Z" }, + { url = "https://files.pythonhosted.org/packages/27/bc/e67414efd29b81626016a16b7d9f33bb67f4adf47ea8554ae11b7fcb46e3/prek-0.2.24-py3-none-linux_armv6l.whl", hash = "sha256:2b36f04353cf0bbee35b510c83bf2a071682745be0d5265e821934a94869a7f7", size = 4793435, upload-time = "2025-12-23T03:59:19.779Z" }, + { url = "https://files.pythonhosted.org/packages/3f/66/9a724e7b3e3a389e1e0cbacf0f4707ee056c83361925cadef43489b5012d/prek-0.2.24-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:8149aa03eb993ba7c0a7509abccdf30665455db2405eb941c1c4174e3441c6b3", size = 4890722, upload-time = "2025-12-23T03:59:18.299Z" }, + { url = "https://files.pythonhosted.org/packages/2d/cf/ee4c057f08a137ec85cc525f4170c3b930d8edd0a8ead20952c8079199c7/prek-0.2.24-py3-none-macosx_11_0_arm64.whl", hash = "sha256:100bf066669834876f87af11c79bdd4a3c8c1e8abf49aa047bc9c52265f5f544", size = 4615935, upload-time = "2025-12-23T03:59:20.947Z" }, + { url = "https://files.pythonhosted.org/packages/c4/71/a84ae24a82814896220fa3a03f07a62fb2e3f3ed6aa9c3952aaedb008b12/prek-0.2.24-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:656295670b7646936d5d738a708b310900870f47757375214dfaa592786702be", size = 4812259, upload-time = "2025-12-23T03:59:26.671Z" }, + { url = "https://files.pythonhosted.org/packages/55/9a/a009873b954f726f8f43be8d660095c76d47208c6e9397d75f916f52b8fc/prek-0.2.24-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3b79fe57f59fa2649d8a727152af742353de8d537ade75285bedf49b66bf8768", size = 4713078, upload-time = "2025-12-23T03:59:29.51Z" }, + { url = "https://files.pythonhosted.org/packages/37/b3/daf4a1da6f009f4413ca6302b6f6480f824be2447dc74606981c47958ad1/prek-0.2.24-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f02a79c76a84339eecc2d01b1e5f81eb4e8769629e9a62343a8e4089778db956", size = 5034136, upload-time = "2025-12-23T03:59:06.775Z" }, + { url = "https://files.pythonhosted.org/packages/49/17/2b754198c7444f9b8f09c60280e601659afb6a4d6ce9fc5553e15218800b/prek-0.2.24-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:cbd9b7b568a5cdcb9ccce8c8b186b52c6547dfd2e70d0a2438e3cb17a37affb4", size = 5445865, upload-time = "2025-12-23T03:59:12.684Z" }, + { url = "https://files.pythonhosted.org/packages/67/61/d54c7db0f6ff1a12b0b7211b32b7b2685fcee81dd51fb1a139e757b648cd/prek-0.2.24-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fc788a1bb3dba31c9ad864ee73fc6320c07fd0f0a3d9652995dfee5d62ccc4f8", size = 5401392, upload-time = "2025-12-23T03:59:24.181Z" }, + { url = "https://files.pythonhosted.org/packages/5a/61/cd7e78e2f371a6603c6ac323ad2306c6793d39f4f6ee2723682b25d65478/prek-0.2.24-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1ee8d1293755f6b42e7fa4bbc7122781e7c3880ca06ed2f85f0ed40c0df14c9b", size = 5492942, upload-time = "2025-12-23T03:59:14.367Z" }, + { url = "https://files.pythonhosted.org/packages/10/ff/657c6269d65dbe682f82113620823c65e002c3ae4fd417f25adaa390179e/prek-0.2.24-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:933a49f0b22abc2baca378f02b4b1b6d9522800a2ccc9e247aa51ebe421fc6dc", size = 5083804, upload-time = "2025-12-23T03:59:28.213Z" }, + { url = "https://files.pythonhosted.org/packages/bc/d9/8929b12dd8849d4d00b6c8e22db1fec22fef4b1e7356c0812107eb0a4f6c/prek-0.2.24-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:f88defe48704eea1391e29b18b363fcd22ef5490af619b6328fece8092c9d24b", size = 4819786, upload-time = "2025-12-23T03:59:32.053Z" }, + { url = "https://files.pythonhosted.org/packages/db/a4/d9e0f7d445621a5c416a8883a33b079cf2c6f7e35a360d15c074f9b353fb/prek-0.2.24-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:3fd336eb13489460da3476bfb1bd185d6bd0f9d3f9bff7780b32d2c801026578", size = 4829112, upload-time = "2025-12-23T03:59:22.546Z" }, + { url = "https://files.pythonhosted.org/packages/10/da/4fdcd158268c337ad3fe4dad3fcb0716f46bba2fe202ee03a473e3eda9b9/prek-0.2.24-py3-none-musllinux_1_1_armv7l.whl", hash = "sha256:9eb952540fd17d540373eb4239ccdcc1e060ca1c33a7ec5d27f6ec03838848c5", size = 4698341, upload-time = "2025-12-23T03:59:11.184Z" }, + { url = "https://files.pythonhosted.org/packages/71/82/c9dd71e5c40c075314b6e3584067084dfbf56d9d1d74baea217d7581a5bf/prek-0.2.24-py3-none-musllinux_1_1_i686.whl", hash = "sha256:7168d6d86576704cddb7c38aff1b62c305312700492c85ff981dfa986013c265", size = 4917027, upload-time = "2025-12-23T03:59:30.751Z" }, + { url = "https://files.pythonhosted.org/packages/ef/05/0559b0504d39dc97f71d74f270918d043f3259fff4cbe11beccfdbb586e6/prek-0.2.24-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:4e500beb902c524b48d084deabc687cb344226ce91f926c6ab8a65a6754d8a9a", size = 5192231, upload-time = "2025-12-23T03:59:16.775Z" }, + { url = "https://files.pythonhosted.org/packages/1f/b3/e740f52236a0077890a82e1c8046d4e0ff8b140bd3c30e3e82f35fee2224/prek-0.2.24-py3-none-win32.whl", hash = "sha256:bab279d54b6adf85d95923590dacaa9956eb354cc64204c45983fa2d5c2f7a8a", size = 4603284, upload-time = "2025-12-23T03:59:15.544Z" }, + { url = "https://files.pythonhosted.org/packages/41/31/cf0773b3cd7b965a7d15264ec96f85ee5f451db5e9df5d0d9d87d3b8e4ce/prek-0.2.24-py3-none-win_amd64.whl", hash = "sha256:c89ad7f73e8b38bd5e79e83fec3bf234dec87295957c94cc7d94a125bc609ff0", size = 5295275, upload-time = "2025-12-23T03:59:25.354Z" }, + { url = "https://files.pythonhosted.org/packages/97/34/b44663946ea7be1d0b1c7877e748603638a8d0eff9f3969f97b9439aa17b/prek-0.2.24-py3-none-win_arm64.whl", hash = "sha256:9257b3293746a69d600736e0113534b3b80a0ce8ee23a1b0db36253e9c7e24ab", size = 4962129, upload-time = "2025-12-23T03:59:08.609Z" }, ] [[package]] name = "prompt-toolkit" -version = "3.0.51" +version = "3.0.52" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "wcwidth" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/bb/6e/9d084c929dfe9e3bfe0c6a47e31f78a25c54627d64a66e884a8bf5474f1c/prompt_toolkit-3.0.51.tar.gz", hash = "sha256:931a162e3b27fc90c86f1b48bb1fb2c528c2761475e57c9c06de13311c7b54ed", size = 428940, upload-time = "2025-04-15T09:18:47.731Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/96/06e01a7b38dce6fe1db213e061a4602dd6032a8a97ef6c1a862537732421/prompt_toolkit-3.0.52.tar.gz", hash = "sha256:28cde192929c8e7321de85de1ddbe736f1375148b02f2e17edd840042b1be855", size = 434198, upload-time = "2025-08-27T15:24:02.057Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ce/4f/5249960887b1fbe561d9ff265496d170b55a735b76724f10ef19f9e40716/prompt_toolkit-3.0.51-py3-none-any.whl", hash = "sha256:52742911fde84e2d423e2f9a4cf1de7d7ac4e51958f648d9540e0fb8db077b07", size = 387810, upload-time = "2025-04-15T09:18:44.753Z" }, + { url = "https://files.pythonhosted.org/packages/84/03/0d3ce49e2505ae70cf43bc5bb3033955d2fc9f932163e84dc0779cc47f48/prompt_toolkit-3.0.52-py3-none-any.whl", hash = "sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955", size = 391431, upload-time = "2025-08-27T15:23:59.498Z" }, ] [[package]] @@ -472,11 +467,11 @@ wheels = [ [[package]] name = "pygments" -version = "2.19.1" +version = "2.19.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7c/2d/c3338d48ea6cc0feb8446d8e6937e1408088a72a39937982cc6111d17f84/pygments-2.19.1.tar.gz", hash = "sha256:61c16d2a8576dc0649d9f39e089b5f02bcd27fba10d8fb4dcc28173f7a45151f", size = 4968581, upload-time = "2025-01-06T17:26:30.443Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8a/0b/9fcc47d19c48b59121088dd6da2488a49d5f72dacf8262e2790a1d2c7d15/pygments-2.19.1-py3-none-any.whl", hash = "sha256:9ea1544ad55cecf4b8242fab6dd35a93bbce657034b0611ee383099054ab6d8c", size = 1225293, upload-time = "2025-01-06T17:26:25.553Z" }, + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, ] [[package]] @@ -614,11 +609,11 @@ wheels = [ [[package]] name = "soupsieve" -version = "2.7" +version = "2.8" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/3f/f4/4a80cd6ef364b2e8b65b15816a843c0980f7a5a2b4dc701fc574952aa19f/soupsieve-2.7.tar.gz", hash = "sha256:ad282f9b6926286d2ead4750552c8a6142bc4c783fd66b0293547c8fe6ae126a", size = 103418, upload-time = "2025-04-20T18:50:08.518Z" } +sdist = { url = "https://files.pythonhosted.org/packages/6d/e6/21ccce3262dd4889aa3332e5a119a3491a95e8f60939870a3a035aabac0d/soupsieve-2.8.tar.gz", hash = "sha256:e2dd4a40a628cb5f28f6d4b0db8800b8f581b65bb380b97de22ba5ca8d72572f", size = 103472, upload-time = "2025-08-27T15:39:51.78Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e7/9c/0e6afc12c269578be5c0c1c9f4b49a8d32770a080260c333ac04cc1c832d/soupsieve-2.7-py3-none-any.whl", hash = "sha256:6e60cc5c1ffaf1cebcc12e8188320b72071e922c2e897f737cadce79ad5d30c4", size = 36677, upload-time = "2025-04-20T18:50:07.196Z" }, + { url = "https://files.pythonhosted.org/packages/14/a0/bb38d3b76b8cae341dad93a2dd83ab7462e6dbcdd84d43f54ee60a8dc167/soupsieve-2.8-py3-none-any.whl", hash = "sha256:0cc76456a30e20f5d7f2e14a98a4ae2ee4e5abdc7c5ea0aafe795f344bc7984c", size = 36679, upload-time = "2025-08-27T15:39:50.179Z" }, ] [[package]] @@ -632,11 +627,11 @@ wheels = [ [[package]] name = "typing-extensions" -version = "4.13.2" +version = "4.15.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f6/37/23083fcd6e35492953e8d2aaaa68b860eb422b34627b13f2ce3eb6106061/typing_extensions-4.13.2.tar.gz", hash = "sha256:e6c81219bd689f51865d9e372991c540bda33a0379d5573cddb9a3a23f7caaef", size = 106967, upload-time = "2025-04-10T14:19:05.416Z" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8b/54/b1ae86c0973cc6f0210b53d508ca3641fb6d0c56823f288d108bc7ab3cc8/typing_extensions-4.13.2-py3-none-any.whl", hash = "sha256:a439e7c04b49fec3e5d3e2beaa21755cadbbdc391694e28ccdd36ca4a1408f8c", size = 45806, upload-time = "2025-04-10T14:19:03.967Z" }, + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, ] [[package]] @@ -650,32 +645,18 @@ wheels = [ [[package]] name = "urllib3" -version = "2.4.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/8a/78/16493d9c386d8e60e442a35feac5e00f0913c0f4b7c217c11e8ec2ff53e0/urllib3-2.4.0.tar.gz", hash = "sha256:414bc6535b787febd7567804cc015fee39daab8ad86268f1310a9250697de466", size = 390672, upload-time = "2025-04-10T15:23:39.232Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/6b/11/cc635220681e93a0183390e26485430ca2c7b5f9d33b15c74c2861cb8091/urllib3-2.4.0-py3-none-any.whl", hash = "sha256:4e16665048960a0900c702d4a66415956a584919c03361cac9f1df5c5dd7e813", size = 128680, upload-time = "2025-04-10T15:23:37.377Z" }, -] - -[[package]] -name = "virtualenv" -version = "20.34.0" +version = "2.5.0" source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "distlib" }, - { name = "filelock" }, - { name = "platformdirs" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/1c/14/37fcdba2808a6c615681cd216fecae00413c9dab44fb2e57805ecf3eaee3/virtualenv-20.34.0.tar.gz", hash = "sha256:44815b2c9dee7ed86e387b842a84f20b93f7f417f95886ca1996a72a4138eb1a", size = 6003808, upload-time = "2025-08-13T14:24:07.464Z" } +sdist = { url = "https://files.pythonhosted.org/packages/15/22/9ee70a2574a4f4599c47dd506532914ce044817c7752a79b6a51286319bc/urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760", size = 393185, upload-time = "2025-06-18T14:07:41.644Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/76/06/04c8e804f813cf972e3262f3f8584c232de64f0cde9f703b46cf53a45090/virtualenv-20.34.0-py3-none-any.whl", hash = "sha256:341f5afa7eee943e4984a9207c025feedd768baff6753cd660c857ceb3e36026", size = 5983279, upload-time = "2025-08-13T14:24:05.111Z" }, + { url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795, upload-time = "2025-06-18T14:07:40.39Z" }, ] [[package]] name = "wcwidth" -version = "0.2.13" +version = "0.2.14" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6c/63/53559446a878410fc5a5974feb13d31d78d752eb18aeba59c7fef1af7598/wcwidth-0.2.13.tar.gz", hash = "sha256:72ea0c06399eb286d978fdedb6923a9eb47e1c486ce63e9b4e64fc18303972b5", size = 101301, upload-time = "2024-01-06T02:10:57.829Z" } +sdist = { url = "https://files.pythonhosted.org/packages/24/30/6b0809f4510673dc723187aeaf24c7f5459922d01e2f794277a3dfb90345/wcwidth-0.2.14.tar.gz", hash = "sha256:4d478375d31bc5395a3c55c40ccdf3354688364cd61c4f6adacaa9215d0b3605", size = 102293, upload-time = "2025-09-22T16:29:53.023Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fd/84/fd2ba7aafacbad3c4201d395674fc6348826569da3c0937e75505ead3528/wcwidth-0.2.13-py2.py3-none-any.whl", hash = "sha256:3da69048e4540d84af32131829ff948f1e022c1c6bdb8d6102117aac784f6859", size = 34166, upload-time = "2024-01-06T02:10:55.763Z" }, + { url = "https://files.pythonhosted.org/packages/af/b5/123f13c975e9f27ab9c0770f514345bd406d0e8d3b7a0723af9d43f710af/wcwidth-0.2.14-py2.py3-none-any.whl", hash = "sha256:a7bb560c8aee30f9957e5f9895805edd20602f2d7f720186dfd906e82b4982e1", size = 37286, upload-time = "2025-09-22T16:29:51.641Z" }, ] diff --git a/website/settings.py b/website/settings.py index ec120fc..d281cf3 100644 --- a/website/settings.py +++ b/website/settings.py @@ -18,6 +18,10 @@ "COOKIE_AGE": 2592000, # 30 days "SAVE_EVERY_REQUEST": True, }, + "WEB": { + "COURSE": {"PAGE_SIZE": 10}, + "REVIEW": {"PAGE_SIZE": 10, "COMMENT_MIN_LENGTH": 30}, + }, "AUTH": { "OTP_TIMEOUT": 120, "TEMP_TOKEN_TIMEOUT": 600, @@ -26,6 +30,7 @@ "PASSWORD_LENGTH_MIN": 10, "PASSWORD_LENGTH_MAX": 32, "EMAIL_DOMAIN_NAME": "sjtu.edu.cn", + "ACTION_LIST": ["signup", "login", "reset_password"], }, "DATABASE": {"URL": "sqlite:///db.sqlite3"}, "REDIS": {"URL": "redis://localhost:6379/0", "MAX_CONNECTIONS": 100}, @@ -42,7 +47,7 @@ "URL": None, "QUESTIONID": None, }, - "RESET": { + "RESET_PASSWORD": { "API_KEY": None, "URL": None, "QUESTIONID": None, @@ -87,6 +92,7 @@ # --- Application-Specific Settings --- AUTH = config.get("AUTH") +WEB = config.get("WEB") TURNSTILE_SECRET_KEY = config.get("TURNSTILE_SECRET_KEY") AUTO_IMPORT_CRAWLED_DATA = config.get("AUTO_IMPORT_CRAWLED_DATA", cast=bool) diff --git a/website/urls.py b/website/urls.py index eb49a18..707d854 100644 --- a/website/urls.py +++ b/website/urls.py @@ -1,97 +1,12 @@ from django.contrib import admin -from django.urls import re_path - -from apps.auth import views as auth_views -from apps.spider import views as spider_views -from apps.web import views +from django.urls import include, re_path urlpatterns = [ - # OAuth - re_path( - r"^api/auth/initiate/$", - auth_views.auth_initiate_api, - name="auth_initiate_api", - ), - re_path( - r"^api/auth/verify/$", - auth_views.verify_callback_api, - name="verify_callback_api", - ), - # Backwards-compatible alias (some front-end code calls verify-callback) - re_path( - r"^api/auth/verify-callback/$", - auth_views.verify_callback_api, - name="verify_callback_api_alias", - ), - re_path( - r"^api/auth/password/$", - auth_views.auth_reset_password_api, - name="auth_reset_password_api", - ), - re_path(r"^api/auth/signup/$", auth_views.auth_signup_api, name="auth_signup_api"), - # email+password login - re_path(r"^api/auth/login/$", auth_views.auth_login_api, name="auth_login_api"), - # log out - re_path(r"^api/auth/logout/?$", auth_views.auth_logout_api, name="auth_logout_api"), # administrative re_path(r"^admin/", admin.site.urls), - re_path(r"^api/user/status/?", views.user_status, name="user_status"), - # spider - re_path(r"^spider/data/$", spider_views.crawled_data_list, name="crawled_datas"), - re_path( - r"^spider/data/(?P[0-9]+)$", - spider_views.crawled_data_detail, - name="crawled_data", - ), - # primary views - re_path(r"^api/landing/$", views.landing_api, name="landing_api"), - re_path( - r"^api/course/(?P[0-9]+)/$", - views.course_detail_api, - name="course_detail_api", - ), - re_path( - r"^api/course/(?P[0-9].*)/instructors?/?", - views.course_instructors, - name="course_instructors", - ), - re_path( - r"^api/course/(?P[0-9].*)/medians", views.medians, name="medians" - ), - re_path( - r"^api/course/(?P[0-9].*)/professors?/?", - views.course_professors, - name="course_professors", - ), - re_path( - r"^api/course/(?P[0-9].*)/vote", - views.course_vote_api, - name="course_vote_api", - ), - re_path( - r"^api/course/(?P[0-9]+)/review/$", - views.delete_review_api, - name="delete_review_api", - ), - re_path( - r"^api/course/(?P[0-9]+)/my-review/$", - views.get_user_review_api, - name="get_user_review_api", - ), - re_path( - r"^api/review/(?P[0-9]+)/vote/$", - views.review_vote_api, - name="review_vote_api", - ), - re_path( - r"^api/departments/$", - views.departments_api, - name="departments_api", - ), - re_path(r"^api/courses/$", views.courses_api, name="courses_api"), - re_path( - r"^api/course/(?P[0-9]+)/review_search/$", - views.course_review_search_api, - name="course_review_search_api", - ), + # API routes + re_path(r"^api/auth/", include("apps.auth.urls")), + re_path(r"^api/", include("apps.web.urls")), + # Spider routes + re_path(r"^spider/", include("apps.spider.urls")), ]