From b276d91a23b20554954f4a22bde03722e2f15b4c Mon Sep 17 00:00:00 2001 From: alexis Date: Tue, 7 Oct 2025 21:28:18 +0800 Subject: [PATCH 01/30] chore: add logger for auth and web apps --- Makefile | 6 ++--- apps/auth/utils.py | 26 ++++++++++--------- apps/auth/views.py | 64 +++++++++++++++++++++++++++++++++++----------- apps/web/views.py | 49 ++++++++++++++++------------------- 4 files changed, 88 insertions(+), 57 deletions(-) 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/utils.py b/apps/auth/utils.py index 74a236b..aabc285 100644 --- a/apps/auth/utils.py +++ b/apps/auth/utils.py @@ -13,6 +13,8 @@ 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"] @@ -32,13 +34,13 @@ def get_survey_details(action: str) -> dict[str, Any] | None: 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 +68,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 +134,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 +182,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, diff --git a/apps/auth/views.py b/apps/auth/views.py index 0474734..96eb394 100644 --- a/apps/auth/views.py +++ b/apps/auth/views.py @@ -21,6 +21,8 @@ from apps.auth import utils from apps.web.models import Student +logger = logging.getLogger(__name__) + class CsrfExemptSessionAuthentication(SessionAuthentication): def enforce_csrf(self, request): @@ -52,9 +54,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(f"Invalid action '{action}' in auth_initiate_api") return Response({"error": "Invalid action"}, status=400) client_ip = ( @@ -68,6 +72,9 @@ def auth_initiate_api(request): utils.verify_turnstile_token(turnstile_token, client_ip) ) if not success: + logger.warning( + f"verify_turnstile_token failed in auth_initiate_api:{error_response.data}" + ) return error_response # Generate cryptographically secure OTP and temp_token @@ -87,13 +94,13 @@ def auth_initiate_api(request): if existing_state_data: existing_state = json.loads(existing_state_data) r.delete(existing_state_key) - logging.info( + logger.info( f"Cleaned up existing temp_token_state for action { 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 +116,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(f"Invalid action '{action}' when fetching survey details") return Response({"error": "Invalid action"}, status=400) survey_url = details.get("url") if not survey_url: + logger.error(f"Survey URL missing for {action}") return Response( {"error": "Something went wrong when fetching the survey URL"}, status=500, @@ -142,20 +151,26 @@ def verify_callback_api(request): request data includes account, answer_id, action Handles the verification of questionnaire callback using temp_token from cookie. """ + logger.info( + f"verify_callback_api called for account={request.data.get('account')}, action={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(f"Invalid action '{action}' in verify_callback_api") 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 +181,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 +210,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 +221,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 +237,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 +245,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 +272,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,7 +291,7 @@ def verify_callback_api(request): # Clear rate limiting on success r.delete(rate_limit_key) - logging.info( + logger.info( "Successfully verified temp_token for user %s with action %s", account, action ) @@ -275,15 +301,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 +319,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) @@ -392,8 +419,8 @@ 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) @@ -432,8 +459,8 @@ 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) @@ -446,6 +473,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 ) @@ -466,11 +496,12 @@ def auth_login_api(request) -> Response: user = authenticate(username=account, password=password) if user is None or not user.is_active: + logger.warning("Invalid account or password for account=%s", account) return Response({"error": "Invalid account or password"}, status=401) login(request, user) Student.objects.get_or_create(user=user) - + logger.info("User %s logged in successfully", account) return Response({"message": "Login successfully"}, status=200) @@ -478,6 +509,9 @@ def auth_login_api(request) -> Response: @authentication_classes([CsrfExemptSessionAuthentication]) @permission_classes([AllowAny]) def auth_logout_api(request) -> Response: + logger.info( + f"auth_logout_api called for user={getattr(request.user, 'username', None)}" + ) """Logout a user.""" logout(request) return Response({"message": "Logged out successfully"}, status=200) diff --git a/apps/web/views.py b/apps/web/views.py index 21fda40..606c6ac 100644 --- a/apps/web/views.py +++ b/apps/web/views.py @@ -1,4 +1,5 @@ import datetime +import logging import uuid import dateutil.parser @@ -30,6 +31,8 @@ from lib.grades import numeric_value_for_grade from lib.terms import numeric_value_of_term +logger = logging.getLogger(__name__) + LIMITS = { "courses": 20, "reviews": 5, @@ -40,20 +43,13 @@ @api_view(["GET"]) def user_status(request): 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): @@ -65,24 +61,6 @@ 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 - - @api_view(["GET"]) @permission_classes([AllowAny]) def courses_api(request): @@ -165,6 +143,7 @@ def course_detail_api(request, course_id): try: course = Course.objects.get(pk=course_id) except Course.DoesNotExist: + logger.warning(f"Course with id {course_id} does not exist") return Response(status=404) if request.method == "GET": @@ -172,9 +151,13 @@ def course_detail_api(request, course_id): return Response(serializer.data) elif request.method == "POST": if not request.user.is_authenticated: + logger.warning("Authentication required for posting review") return Response({"detail": "Authentication required"}, status=403) if not Review.objects.user_can_write_review(request.user.id, course_id): + logger.warning( + f"User {request.user.id} cannot write review for course {course_id}" + ) return Response({"detail": "User cannot write review"}, status=403) form = ReviewForm(request.data) @@ -187,6 +170,7 @@ def course_detail_api(request, course_id): course, context={"request": request} ) # re-serialize with new data return Response(serializer.data, status=201) + logger.warning(f"Review form errors: {form.errors}") return Response(form.errors, status=400) @@ -250,6 +234,7 @@ def course_review_search_api(request, course_id): try: course = Course.objects.get(pk=course_id) except Course.DoesNotExist: + logger.warning(f"Course with id {course_id} not found for review search") return Response({"detail": "Course not found"}, status=404) query = request.GET.get("q", "").strip() @@ -342,6 +327,7 @@ def course_instructors(request, course_id): {"instructors": [instructor.name for instructor in instructors]}, status=200 ) except Course.DoesNotExist: + logger.warning(f"Course with id {course_id} not found for instructors API") return Response({"error": "Course not found"}, status=404) @@ -352,6 +338,9 @@ def course_vote_api(request, course_id): value = request.data["value"] forLayup = request.data["forLayup"] except KeyError: + logger.warning( + f"Missing required fields in course vote API for course {course_id}" + ) return Response( {"detail": "Missing required fields: value, forLayup"}, status=400 ) @@ -390,6 +379,7 @@ def review_vote_api(request, review_id): is_kudos = request.data.get("is_kudos") if is_kudos is None: + logger.warning("is_kudos field is required for review vote API") return Response({"detail": "is_kudos field is required"}, status=400) is_kudos = bool(is_kudos) @@ -401,6 +391,7 @@ def review_vote_api(request, review_id): if kudos_count is None or dislike_count is None: # Review doesn't exist + logger.warning(f"Review {review_id} not found for voting") return Response({"detail": "Review not found"}, status=404) return Response( @@ -435,12 +426,16 @@ def get_user_review_api(request, course_id): try: course = Course.objects.get(id=course_id) except Course.DoesNotExist: + logger.warning(f"Course {course_id} not found for get_user_review_api") 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) if review is None: + logger.info( + f"No user review found for course {course_id} and user {request.user}" + ) return Response( {"detail": "No user review found for this course"}, status=404 ) From 6d5f8f1c9db43a1f73fe955a1fc136be4ecd9aae Mon Sep 17 00:00:00 2001 From: alexis Date: Wed, 8 Oct 2025 21:58:21 +0800 Subject: [PATCH 02/30] refactor: coursedetail, courselist, review related to class based view, and adjust url to restful --- apps/auth/views.py | 6 +- apps/web/views.py | 293 +++++++++++++++++++++----------------------- config.yaml.example | 6 + website/settings.py | 5 + website/urls.py | 24 +--- 5 files changed, 158 insertions(+), 176 deletions(-) diff --git a/apps/auth/views.py b/apps/auth/views.py index 96eb394..b3b93c9 100644 --- a/apps/auth/views.py +++ b/apps/auth/views.py @@ -32,7 +32,7 @@ def enforce_csrf(self, request): 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"] @@ -41,7 +41,7 @@ def enforce_csrf(self, request): @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 @@ -433,7 +433,7 @@ def auth_reset_password_api(request) -> Response: try: verification_data, error_response = verify_token_pwd( request, - action="reset_password", + action="reset", ) if verification_data is None: return error_response or Response( diff --git a/apps/web/views.py b/apps/web/views.py index 606c6ac..0f77614 100644 --- a/apps/web/views.py +++ b/apps/web/views.py @@ -2,9 +2,9 @@ import logging import uuid -import dateutil.parser -from django.core.paginator import EmptyPage, PageNotAnInteger, Paginator from django.db.models import Count +from django.conf import settings +from rest_framework import generics, mixins, pagination, status from rest_framework.decorators import ( api_view, permission_classes, @@ -33,11 +33,9 @@ logger = logging.getLogger(__name__) -LIMITS = { - "courses": 20, - "reviews": 5, - "unauthenticated_review_search": 3, -} + +class CoursesPagination(pagination.PageNumberPagination): + page_size = settings.DEFAULTS["WEB"]["COURSE"]["PAGE_SIZE"] @api_view(["GET"]) @@ -61,102 +59,142 @@ def landing_api(request): ) -@api_view(["GET"]) -@permission_classes([AllowAny]) -def courses_api(request): - """ - API endpoint for listing courses with filtering, sorting, and pagination. - """ - queryset = Course.objects.all().prefetch_related("distribs", "review_set") - queryset = queryset.annotate(num_reviews=Count("review")) +class CoursesListAPI(generics.GenericAPIView, mixins.ListModelMixin): + """API endpoint for listing courses with filtering, sorting, and pagination.""" - # --- Filtering --- - department = request.query_params.get("department") - if department: - queryset = queryset.filter(department__iexact=department) + serializer_class = CourseSearchSerializer + permission_classes = [AllowAny] + pagination_class = CoursesPagination - code = request.query_params.get("code") - if code: - queryset = queryset.filter(course_code__icontains=code) + def get_queryset(self): + queryset = Course.objects.all().prefetch_related("distribs", "review_set") + queryset = queryset.annotate(num_reviews=Count("review")) + return queryset - 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"]) + def _filter_courses(self, queryset): + """Helper function to apply all filters to courses queryset.""" + department = self.request.query_params.get("department") + if department: + queryset = queryset.filter(department__iexact=department) - if sort_by in allowed_sort_fields: - sort_field = sort_by - else: - sort_field = "course_code" # Fallback to default if invalid or not allowed + code = self.request.query_params.get("code") + if code: + queryset = queryset.filter(course_code__icontains=code) - queryset = queryset.order_by(f"{sort_prefix}{sort_field}") + queryset = self._filter_by_score_params(queryset) + return queryset - # --- 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) + def _filter_by_score_params(self, queryset): + """Helper function to filter by quality and difficulty score parameters.""" + if not self.request.user.is_authenticated: + return queryset - # --- Serialization --- - serializer = CourseSearchSerializer( - page.object_list, many=True, context={"request": request} - ) + query_param_mapping = [ + ("min_quality", "quality_score"), + ("min_difficulty", "difficulty_score"), + ] - 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 - } - ) + 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_courses(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 "" -@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: - logger.warning(f"Course with id {course_id} does not exist") - return Response(status=404) + allowed_sort_fields = ["course_code", "num_reviews"] + if self.request.user.is_authenticated: + allowed_sort_fields.extend(["quality_score", "difficulty_score"]) - if request.method == "GET": - serializer = CourseSerializer(course, context={"request": request}) - return Response(serializer.data) - elif request.method == "POST": - if not request.user.is_authenticated: - logger.warning("Authentication required for posting review") - return Response({"detail": "Authentication required"}, status=403) + 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_courses(queryset) + queryset = self._sort_courses(queryset) + return queryset + + def get(self, request, *args, **kwargs): + return self.list(request, *args, **kwargs) + + +class CourseDetailAPI(generics.GenericAPIView, mixins.RetrieveModelMixin): + """API endpoint for retrieving course details.""" + + serializer_class = CourseSerializer + permission_classes = [AllowAny] + queryset = Course.objects.all() + + def get_object(self): + course_id = self.kwargs.get("course_id") + try: + return Course.objects.get(id=course_id) + except Course.DoesNotExist: + logger.warning(f"Course with id {course_id} does not exist") + return None + + def get(self, request, *args, **kwargs): + return self.retrieve(request, *args, **kwargs) + + +class CourseReviewAPI(generics.GenericAPIView): + """API endpoint for course reviews - GET (search), POST (create), DELETE (delete).""" + + permission_classes = [IsAuthenticated] + + def get_queryset(self): + return Course.objects.all() + + def get_object(self): + """Get course object for review operations.""" + course_id = self.kwargs.get("course_id") + try: + return Course.objects.get(id=course_id) + except Course.DoesNotExist: + logger.warning(f"Course with id {course_id} does not exist") + return None + + def get(self, request, *args, **kwargs): + """Search reviews for a course.""" + course = self.get_object() + if course is None: + return Response({"detail": "Course not found"}, status=404) + + query = request.GET.get("q", "").strip() + reviews = course.search_reviews(query) + review_count = reviews.count() + + serializer = ReviewSerializer(reviews, many=True, context={"request": request}) - if not Review.objects.user_can_write_review(request.user.id, course_id): + 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, + } + ) + + def post(self, request, *args, **kwargs): + """Create a new review for a course.""" + course = self.get_object() + if course is None: + return Response({"detail": "Course not found"}, status=404) + + if not Review.objects.user_can_write_review(request.user.id, course.id): logger.warning( - f"User {request.user.id} cannot write review for course {course_id}" + f"User {request.user.id} cannot write review for course {course.id}" ) return Response({"detail": "User cannot write review"}, status=403) @@ -166,21 +204,21 @@ def course_detail_api(request, course_id): review.course = course review.user = request.user review.save() - serializer = CourseSerializer( - course, context={"request": request} - ) # re-serialize with new data + serializer = CourseSerializer(course, context={"request": request}) return Response(serializer.data, status=201) + logger.warning(f"Review form errors: {form.errors}") return Response(form.errors, status=400) + def delete(self, request, *args, **kwargs): + """Delete user's review for a course.""" + course = self.get_object() + if course is None: + return Response({"detail": "Course not found"}, status=404) -@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) + Review.objects.delete_reviews_for_user_course(user=request.user, course=course) + serializer = CourseSerializer(course, context={"request": request}) + return Response(serializer.data, status=200) @api_view(["GET"]) @@ -201,61 +239,6 @@ def departments_api(request): return Response(departments_data) -@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: - logger.warning(f"Course with id {course_id} not found for review search") - 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): # retrieve course medians for term, and group by term for averaging diff --git a/config.yaml.example b/config.yaml.example index 27105cd..c90287b 100644 --- a/config.yaml.example +++ b/config.yaml.example @@ -22,6 +22,11 @@ CORS_ALLOWED_ORIGINS: # COOKIE_AGE: 2592000 # 30 days # SAVE_EVERY_REQUEST: true # +# WEB: +# COURSE: +# PAGE_SIZE: 5 +# REVIEW: +# PAGE_SIZE: 10 # AUTH: # OTP_TIMEOUT: 120 # TEMP_TOKEN_TIMEOUT: 600 @@ -30,6 +35,7 @@ CORS_ALLOWED_ORIGINS: # PASSWORD_LENGTH_MIN: 10 # PASSWORD_LENGTH_MAX: 32 # EMAIL_DOMAIN_NAME: "sjtu.edu.cn" +# ACTION_LIST:[ "signup", "login", "reset" ] # # DATABASE: # URL: Use env diff --git a/website/settings.py b/website/settings.py index ec120fc..779de24 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}, + }, "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"], }, "DATABASE": {"URL": "sqlite:///db.sqlite3"}, "REDIS": {"URL": "redis://localhost:6379/0", "MAX_CONNECTIONS": 100}, diff --git a/website/urls.py b/website/urls.py index eb49a18..92c6a4f 100644 --- a/website/urls.py +++ b/website/urls.py @@ -6,9 +6,8 @@ from apps.web import views urlpatterns = [ - # OAuth re_path( - r"^api/auth/initiate/$", + r"^api/auth/init/$", auth_views.auth_initiate_api, name="auth_initiate_api", ), @@ -17,12 +16,6 @@ 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, @@ -47,7 +40,7 @@ re_path(r"^api/landing/$", views.landing_api, name="landing_api"), re_path( r"^api/course/(?P[0-9]+)/$", - views.course_detail_api, + views.CourseDetailAPI.as_view(), name="course_detail_api", ), re_path( @@ -70,11 +63,11 @@ ), re_path( r"^api/course/(?P[0-9]+)/review/$", - views.delete_review_api, - name="delete_review_api", + views.CourseReviewAPI.as_view(), + name="course_review_api", ), re_path( - r"^api/course/(?P[0-9]+)/my-review/$", + r"^api/course/(?P[0-9]+)/review/my/$", views.get_user_review_api, name="get_user_review_api", ), @@ -88,10 +81,5 @@ 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", - ), + re_path(r"^api/courses/$", views.CoursesListAPI.as_view(), name="courses_api"), ] From 54a81d46e7c246e8f24f9a5909050a8c0dd95398 Mon Sep 17 00:00:00 2001 From: alexis Date: Thu, 9 Oct 2025 15:14:27 +0800 Subject: [PATCH 03/30] refactor: two hierarchy for reviews, course-relevant or user-releavant, and use plural form in urls --- apps/web/models/review.py | 3 - apps/web/views.py | 183 +++++++++++++++++++------------------- website/urls.py | 31 ++++--- 3 files changed, 108 insertions(+), 109 deletions(-) diff --git a/apps/web/models/review.py b/apps/web/models/review.py index 3da86c2..1d5f164 100644 --- a/apps/web/models/review.py +++ b/apps/web/models/review.py @@ -11,9 +11,6 @@ def user_can_write_review(self, user, course): def num_reviews_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 get_user_review_for_course(self, user, course): """ Get the review written by a user for a specific course. diff --git a/apps/web/views.py b/apps/web/views.py index 0f77614..8dcc894 100644 --- a/apps/web/views.py +++ b/apps/web/views.py @@ -127,7 +127,7 @@ def get(self, request, *args, **kwargs): return self.list(request, *args, **kwargs) -class CourseDetailAPI(generics.GenericAPIView, mixins.RetrieveModelMixin): +class CoursesDetailAPI(generics.GenericAPIView, mixins.RetrieveModelMixin): """API endpoint for retrieving course details.""" serializer_class = CourseSerializer @@ -146,79 +146,118 @@ def get(self, request, *args, **kwargs): return self.retrieve(request, *args, **kwargs) -class CourseReviewAPI(generics.GenericAPIView): - """API endpoint for course reviews - GET (search), POST (create), DELETE (delete).""" +class CoursesReviewsAPI( + generics.GenericAPIView, mixins.ListModelMixin, mixins.CreateModelMixin +): + """API endpoint for course reviews - GET (list/search), POST (create).""" + serializer_class = ReviewSerializer permission_classes = [IsAuthenticated] def get_queryset(self): - return Course.objects.all() - - def get_object(self): - """Get course object for review operations.""" + """Get reviews for the specified course.""" course_id = self.kwargs.get("course_id") try: - return Course.objects.get(id=course_id) + course = Course.objects.get(id=course_id) + return Review.objects.filter(course=course) except Course.DoesNotExist: logger.warning(f"Course with id {course_id} does not exist") - return None - - def get(self, request, *args, **kwargs): - """Search reviews for a course.""" - course = self.get_object() - if course is None: - return Response({"detail": "Course not found"}, status=404) + return Review.objects.none() + + def list(self, request, *args, **kwargs): + """List reviews with optional filtering.""" + queryset = self.get_queryset() + + # Handle all query parameters here + query = request.query_params.get("q", "").strip() + if query: + course_id = self.kwargs.get("course_id") + try: + course = Course.objects.get(id=course_id) + queryset = course.search_reviews(query) + except Course.DoesNotExist: + return Response( + {"detail": "Course not found"}, status=status.HTTP_404_NOT_FOUND + ) - query = request.GET.get("q", "").strip() - reviews = course.search_reviews(query) - review_count = reviews.count() + # Apply author filter + if request.query_params.get("author") == "me": + queryset = queryset.filter(user=request.user) - serializer = ReviewSerializer(reviews, many=True, context={"request": request}) + serializer = self.get_serializer(queryset, many=True) + return Response(serializer.data) - 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, - } - ) + def get(self, request, *args, **kwargs): + """Get list of reviews.""" + return self.list(request, *args, **kwargs) def post(self, request, *args, **kwargs): """Create a new review for a course.""" - course = self.get_object() - if course is None: - return Response({"detail": "Course not found"}, status=404) + 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 + ) + # Check if user can write review if not Review.objects.user_can_write_review(request.user.id, course.id): logger.warning( f"User {request.user.id} cannot write review for course {course.id}" ) - return Response({"detail": "User cannot write review"}, status=403) + return Response( + {"detail": "User cannot write review"}, status=status.HTTP_403_FORBIDDEN + ) + # Validate and save review 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}) - return Response(serializer.data, status=201) + if not form.is_valid(): + logger.warning(f"Review form errors: {form.errors}") + return Response(form.errors, status=status.HTTP_400_BAD_REQUEST) + + review = form.save(commit=False) + review.course = course + review.user = request.user + review.save() + + # Return the created review + serializer = self.get_serializer(review) + return Response(serializer.data, status=status.HTTP_201_CREATED) + + +class UserReviewsAPI( + generics.GenericAPIView, + mixins.ListModelMixin, + mixins.RetrieveModelMixin, + mixins.UpdateModelMixin, + mixins.DestroyModelMixin, +): + """API endpoint for user review operations - LIST, GET, PUT, DELETE.""" + + serializer_class = ReviewSerializer + permission_classes = [IsAuthenticated] + lookup_field = "id" + lookup_url_kwarg = "review_id" - logger.warning(f"Review form errors: {form.errors}") - return Response(form.errors, status=400) + def get_queryset(self): + """Only reviews belonging to the authenticated user.""" + return Review.objects.filter(user=self.request.user) - def delete(self, request, *args, **kwargs): - """Delete user's review for a course.""" - course = self.get_object() - if course is None: - return Response({"detail": "Course not found"}, status=404) + 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) - Review.objects.delete_reviews_for_user_course(user=request.user, course=course) - serializer = CourseSerializer(course, context={"request": request}) - return Response(serializer.data, status=200) + 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"]) @@ -348,7 +387,7 @@ def review_vote_api(request, review_id): """ API endpoint for voting on reviews (kudos/dislike). - URL: /api/review/{review_id}/vote/ + URL: /api/reviews/{review_id}/vote/ POST data: - is_kudos: boolean (True for kudos, False for dislike) @@ -390,45 +429,3 @@ def review_vote_api(request, review_id): {"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 - """ - - try: - # Get the course - try: - course = Course.objects.get(id=course_id) - except Course.DoesNotExist: - logger.warning(f"Course {course_id} not found for get_user_review_api") - 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) - - if review is None: - logger.info( - f"No user review found for course {course_id} and user {request.user}" - ) - return Response( - {"detail": "No user review found for this course"}, status=404 - ) - - # Serialize and return the review - serializer = ReviewSerializer(review) - return Response(serializer.data) - - except Exception: - return Response( - {"detail": "An error occurred processing your request"}, - status=500, - ) diff --git a/website/urls.py b/website/urls.py index 92c6a4f..d610772 100644 --- a/website/urls.py +++ b/website/urls.py @@ -1,5 +1,5 @@ from django.contrib import admin -from django.urls import re_path +from django.urls import include, re_path from apps.auth import views as auth_views from apps.spider import views as spider_views @@ -39,40 +39,45 @@ # primary views re_path(r"^api/landing/$", views.landing_api, name="landing_api"), re_path( - r"^api/course/(?P[0-9]+)/$", - views.CourseDetailAPI.as_view(), + r"^api/courses/(?P[0-9]+)/$", + views.CoursesDetailAPI.as_view(), name="course_detail_api", ), re_path( - r"^api/course/(?P[0-9].*)/instructors?/?", + r"^api/courses/(?P[0-9].*)/instructors?/?", views.course_instructors, name="course_instructors", ), re_path( - r"^api/course/(?P[0-9].*)/medians", views.medians, name="medians" + r"^api/courses/(?P[0-9].*)/medians", views.medians, name="medians" ), re_path( - r"^api/course/(?P[0-9].*)/professors?/?", + r"^api/courses/(?P[0-9].*)/professors?/?", views.course_professors, name="course_professors", ), re_path( - r"^api/course/(?P[0-9].*)/vote", + r"^api/courses/(?P[0-9].*)/vote", views.course_vote_api, name="course_vote_api", ), re_path( - r"^api/course/(?P[0-9]+)/review/$", - views.CourseReviewAPI.as_view(), + r"^api/courses/(?P[0-9]+)/reviews/$", + views.CoursesReviewsAPI.as_view(), name="course_review_api", ), re_path( - r"^api/course/(?P[0-9]+)/review/my/$", - views.get_user_review_api, - name="get_user_review_api", + r"^api/reviews/?$", + views.UserReviewsAPI.as_view(), + name="user_reviews_api", ), re_path( - r"^api/review/(?P[0-9]+)/vote/$", + r"^api/reviews/(?P[0-9]+)/$", + views.UserReviewsAPI.as_view(), + name="user_review_api", + ), + re_path( + r"^api/reviews/(?P[0-9]+)/vote/$", views.review_vote_api, name="review_vote_api", ), From 6bf01e8e90e65060737d3d13d01421dc686e2e2b Mon Sep 17 00:00:00 2001 From: alexis Date: Tue, 14 Oct 2025 01:09:45 +0800 Subject: [PATCH 04/30] refactor: combine review_form to ReviewSerializer and calculate reviewvotes through reversed foreignkey --- ...10_remove_review_dislike_count_and_more.py | 20 +++++++ apps/web/models/review.py | 19 +++++-- apps/web/models/vote_for_review.py | 34 ++---------- apps/web/serializers.py | 53 +++++++++++++++++++ apps/web/views.py | 46 ++++++++-------- 5 files changed, 112 insertions(+), 60 deletions(-) create mode 100644 apps/web/migrations/0010_remove_review_dislike_count_and_more.py 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/models/review.py b/apps/web/models/review.py index 1d5f164..d849251 100644 --- a/apps/web/models/review.py +++ b/apps/web/models/review.py @@ -25,6 +25,20 @@ def get_user_review_for_course(self, user, course): # If somehow there are multiple reviews, return the most recent one return self.filter(user=user, course=course).order_by("-created_at").first() + def get_kudos_count(self, review_id): + """Get the number of kudos for a specific review""" + return self.get(id=review_id).votes.filter(is_kudos=True).count() + + def get_dislike_count(self, review_id): + """Get the number of dislikes for a specific review""" + return self.get(id=review_id).votes.filter(is_kudos=False).count() + + def get_vote_counts(self, review_id): + """Get both kudos and dislike counts for a specific review""" + kudos_count = self.get_kudos_count(review_id) + dislike_count = self.get_dislike_count(review_id) + return kudos_count, dislike_count + class Review(models.Model): objects = ReviewManager() @@ -53,11 +67,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_for_review.py b/apps/web/models/vote_for_review.py index 87fcefc..123e930 100644 --- a/apps/web/models/vote_for_review.py +++ b/apps/web/models/vote_for_review.py @@ -34,43 +34,23 @@ 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 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 + # Calculate and return updated counts and user's current vote + kudos_count, dislike_count = Review.objects.get_vote_counts(review_id) + return kudos_count, dislike_count, vote_value def get_user_vote(self, review, user): """Get the user's vote for a review""" @@ -81,12 +61,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..ea5e5d3 100644 --- a/apps/web/serializers.py +++ b/apps/web/serializers.py @@ -12,6 +12,7 @@ Vote, ) from lib import constants +from lib.terms import is_valid_term class DistributiveRequirementSerializer(serializers.ModelSerializer): @@ -33,6 +34,8 @@ class ReviewSerializer(serializers.ModelSerializer): term = serializers.CharField() professor = serializers.CharField() user_vote = serializers.SerializerMethodField() + kudos_count = serializers.SerializerMethodField() + dislike_count = serializers.SerializerMethodField() class Meta: model = Review @@ -48,6 +51,21 @@ 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 Review.objects.get_kudos_count(obj.id) + + def get_dislike_count(self, obj): + """Get the number of dislikes for this review""" + return Review.objects.get_dislike_count(obj.id) def get_user_vote(self, obj): """Get the current user's vote for this review""" @@ -61,6 +79,41 @@ def get_user_vote(self, obj): except ReviewVote.DoesNotExist: return 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 = 30 + + if len(value) < REVIEW_MINIMUM_LENGTH: + raise serializers.ValidationError( + "Please write a longer review (at least {} characters)".format( + REVIEW_MINIMUM_LENGTH + ) + ) + + return value + class DepartmentSerializer(serializers.Serializer): code = serializers.CharField() diff --git a/apps/web/views.py b/apps/web/views.py index 8dcc894..82e9024 100644 --- a/apps/web/views.py +++ b/apps/web/views.py @@ -1,9 +1,7 @@ -import datetime import logging -import uuid -from django.db.models import Count from django.conf import settings +from django.db.models import Count from rest_framework import generics, mixins, pagination, status from rest_framework.decorators import ( api_view, @@ -20,13 +18,11 @@ ReviewVote, Vote, ) -from apps.web.models.forms import ReviewForm from apps.web.serializers import ( CourseSearchSerializer, CourseSerializer, ReviewSerializer, ) -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 @@ -71,21 +67,24 @@ def get_queryset(self): queryset = queryset.annotate(num_reviews=Count("review")) 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) - - code = self.request.query_params.get("code") if code: queryset = queryset.filter(course_code__icontains=code) - - queryset = self._filter_by_score_params(queryset) return queryset - def _filter_by_score_params(self, queryset): - """Helper function to filter by quality and difficulty score parameters.""" + def _filter_by_score(self, queryset): + """Helper function to filter by quality and difficulty score.""" if not self.request.user.is_authenticated: return queryset @@ -104,7 +103,7 @@ def _filter_by_score_params(self, queryset): pass return queryset - def _sort_courses(self, 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") @@ -119,8 +118,8 @@ def _sort_courses(self, queryset): def filter_queryset(self, queryset): """Override to apply both filtering and sorting.""" - queryset = self._filter_courses(queryset) - queryset = self._sort_courses(queryset) + queryset = self._filter(queryset) + queryset = self._sort(queryset) return queryset def get(self, request, *args, **kwargs): @@ -210,16 +209,13 @@ def post(self, request, *args, **kwargs): {"detail": "User cannot write review"}, status=status.HTTP_403_FORBIDDEN ) - # Validate and save review - form = ReviewForm(request.data) - if not form.is_valid(): - logger.warning(f"Review form errors: {form.errors}") - return Response(form.errors, status=status.HTTP_400_BAD_REQUEST) + # Validate and save review using ReviewSerializer + serializer = ReviewSerializer(data=request.data) + if not serializer.is_valid(): + logger.warning(f"Review serializer errors: {serializer.errors}") + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - review = form.save(commit=False) - review.course = course - review.user = request.user - review.save() + review = serializer.save(course=course, user=request.user) # Return the created review serializer = self.get_serializer(review) @@ -361,7 +357,7 @@ def course_vote_api(request, course_id): forLayup = request.data["forLayup"] except KeyError: logger.warning( - f"Missing required fields in course vote API for course {course_id}" + f"Missing required fields: value, forLayup in course vote API for course {course_id}" ) return Response( {"detail": "Missing required fields: value, forLayup"}, status=400 @@ -413,7 +409,7 @@ def review_vote_api(request, review_id): if kudos_count is None or dislike_count is None: # Review doesn't exist - logger.warning(f"Review {review_id} not found for voting") + logger.warning("Review %d not found for voting", review_id) return Response({"detail": "Review not found"}, status=404) return Response( From b2a4d2448ea919dec132812fc20c8e0adda49faf Mon Sep 17 00:00:00 2001 From: alexis Date: Tue, 14 Oct 2025 12:40:25 +0800 Subject: [PATCH 05/30] docs: add api doc for useful endpoint in apps.web --- apps/web/views.py | 197 +++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 177 insertions(+), 20 deletions(-) diff --git a/apps/web/views.py b/apps/web/views.py index 82e9024..9eaef34 100644 --- a/apps/web/views.py +++ b/apps/web/views.py @@ -36,6 +36,14 @@ class CoursesPagination(pagination.PageNumberPagination): @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}) @@ -47,7 +55,13 @@ def user_status(request): @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(), @@ -56,7 +70,27 @@ def landing_api(request): class CoursesListAPI(generics.GenericAPIView, mixins.ListModelMixin): - """API endpoint for listing courses with filtering, sorting, and pagination.""" + """ + 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", "num_reviews"),("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] @@ -127,7 +161,17 @@ def get(self, request, *args, **kwargs): class CoursesDetailAPI(generics.GenericAPIView, mixins.RetrieveModelMixin): - """API endpoint for retrieving course details.""" + """ + 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] @@ -138,7 +182,7 @@ def get_object(self): try: return Course.objects.get(id=course_id) except Course.DoesNotExist: - logger.warning(f"Course with id {course_id} does not exist") + logger.warning("Course with id %d does not exist", course_id) return None def get(self, request, *args, **kwargs): @@ -148,7 +192,32 @@ def get(self, request, *args, **kwargs): class CoursesReviewsAPI( generics.GenericAPIView, mixins.ListModelMixin, mixins.CreateModelMixin ): - """API endpoint for course reviews - GET (list/search), POST (create).""" + """ + List and create reviews for a specific course. + + GET - List reviews: + 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"} + """ serializer_class = ReviewSerializer permission_classes = [IsAuthenticated] @@ -160,7 +229,7 @@ def get_queryset(self): course = Course.objects.get(id=course_id) return Review.objects.filter(course=course) except Course.DoesNotExist: - logger.warning(f"Course with id {course_id} does not exist") + logger.warning("Course with id %d does not exist", course_id) return Review.objects.none() def list(self, request, *args, **kwargs): @@ -203,7 +272,7 @@ def post(self, request, *args, **kwargs): # Check if user can write review if not Review.objects.user_can_write_review(request.user.id, course.id): logger.warning( - f"User {request.user.id} cannot write review for course {course.id}" + "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 @@ -212,7 +281,7 @@ def post(self, request, *args, **kwargs): # Validate and save review using ReviewSerializer serializer = ReviewSerializer(data=request.data) if not serializer.is_valid(): - logger.warning(f"Review serializer errors: {serializer.errors}") + logger.warning("Review serializer errors: %s", serializer.errors) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) review = serializer.save(course=course, user=request.user) @@ -229,7 +298,46 @@ class UserReviewsAPI( mixins.UpdateModelMixin, mixins.DestroyModelMixin, ): - """API endpoint for user review operations - LIST, GET, PUT, DELETE.""" + """ + Manage user's own reviews (CRUD operations). + + GET (List) - List user's reviews: + 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: + 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."} + """ serializer_class = ReviewSerializer permission_classes = [IsAuthenticated] @@ -259,6 +367,22 @@ def delete(self, 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")) @@ -345,19 +469,45 @@ def course_instructors(request, course_id): {"instructors": [instructor.name for instructor in instructors]}, status=200 ) except Course.DoesNotExist: - logger.warning(f"Course with id {course_id} not found for instructors API") + 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): + """ + Vote on course quality or difficulty. + + Input: + - POST request + - Authentication: Required + - URL parameter: course_id (integer, required) + - Body (JSON): + { + "value": integer (vote score), + "forLayup": boolean (true for difficulty, false for quality) + } + + Output: + Success (200): + { + "new_score": float, + "was_unvote": boolean, + "new_vote_count": integer + } + Error (400): + { + "detail": "Missing required fields: value, forLayup" + } + """ try: value = request.data["value"] forLayup = request.data["forLayup"] except KeyError: logger.warning( - f"Missing required fields: value, forLayup in course vote API for course {course_id}" + "Missing required fields: value, forLayup in course vote API for course %d", + course_id, ) return Response( {"detail": "Missing required fields: value, forLayup"}, status=400 @@ -381,16 +531,23 @@ 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/reviews/{review_id}/vote/ - POST data: - - is_kudos: boolean (True for kudos, False for dislike) + Vote on reviews (kudos/dislike). - Returns: - - kudos_count: updated kudos count - - dislike_count: updated dislike count - - user_vote: user's current vote (True/False/None) + Input: + - POST request + - Authentication: Required + - URL parameter: review_id (integer, required) + - Body (JSON): + { + "is_kudos": boolean (true for kudos, false for dislike) + } + Output: + Success (200): + { + "kudos_count": integer, + "dislike_count": integer, + "user_vote": boolean|null (true/false/null) + } """ try: From 460b828726bc16af421363c7c43e97c89e64b7d7 Mon Sep 17 00:00:00 2001 From: alexis Date: Sun, 9 Nov 2025 14:38:42 +0800 Subject: [PATCH 06/30] chore: update config and lock dep version --- apps/web/serializers.py | 3 +- apps/web/views.py | 2 +- config.yaml.example | 1 + uv.lock | 163 +++++++++++++++++++++++++--------------- website/settings.py | 3 +- 5 files changed, 107 insertions(+), 65 deletions(-) diff --git a/apps/web/serializers.py b/apps/web/serializers.py index ea5e5d3..1097e59 100644 --- a/apps/web/serializers.py +++ b/apps/web/serializers.py @@ -1,5 +1,6 @@ # apps/web/serializers.py from django.db.models import Count +from django.conf import settings from rest_framework import serializers from apps.web.models import ( @@ -103,7 +104,7 @@ def validate_professor(self, value): def validate_comments(self, value): """Validate review minimum length""" - REVIEW_MINIMUM_LENGTH = 30 + REVIEW_MINIMUM_LENGTH = settings.WEB["REVIEW"]["COMMENT_MIN_LENGTH"] if len(value) < REVIEW_MINIMUM_LENGTH: raise serializers.ValidationError( diff --git a/apps/web/views.py b/apps/web/views.py index 9eaef34..839b421 100644 --- a/apps/web/views.py +++ b/apps/web/views.py @@ -31,7 +31,7 @@ class CoursesPagination(pagination.PageNumberPagination): - page_size = settings.DEFAULTS["WEB"]["COURSE"]["PAGE_SIZE"] + page_size = settings.WEB["COURSE"]["PAGE_SIZE"] @api_view(["GET"]) diff --git a/config.yaml.example b/config.yaml.example index c90287b..489fbf8 100644 --- a/config.yaml.example +++ b/config.yaml.example @@ -27,6 +27,7 @@ CORS_ALLOWED_ORIGINS: # PAGE_SIZE: 5 # REVIEW: # PAGE_SIZE: 10 +# COMMENT_MIN_LENGTH : 30 # AUTH: # OTP_TIMEOUT: 120 # TEMP_TOKEN_TIMEOUT: 600 diff --git a/uv.lock b/uv.lock index 3504554..289fdee 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,45 @@ 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" }, + { 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 = "cfgv" -version = "3.4.0" +version = "3.5.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" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/b5/721b8799b04bf9afe054a3899c6cf4e880fcf8563cc71c15610242490a0c/cfgv-3.5.0.tar.gz", hash = "sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132", size = 7334, upload-time = "2025-11-19T20:55:51.612Z" } 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/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0", size = 7445, upload-time = "2025-11-19T20:55:50.744Z" }, ] [[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]] @@ -173,22 +189,45 @@ 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" } +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]] name = "distlib" @@ -278,11 +317,11 @@ wheels = [ [[package]] name = "filelock" -version = "3.19.1" +version = "3.20.0" 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" } +sdist = { url = "https://files.pythonhosted.org/packages/58/46/0028a82567109b5ef6e4d2a1f04a583fb513e6cf9527fcdd09afd817deeb/filelock-3.20.0.tar.gz", hash = "sha256:711e943b4ec6be42e1d4e6690b48dc175c822967466bb31c0c293f34334c13f4", size = 18922, upload-time = "2025-10-08T18:03:50.056Z" } 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" }, + { url = "https://files.pythonhosted.org/packages/76/91/7216b27286936c16f5b4d0c530087e4a54eead683e6b0b73dd0c64844af6/filelock-3.20.0-py3-none-any.whl", hash = "sha256:339b4732ffda5cd79b13f4e2711a31b0365ce445d95d243bb996273d072546a2", size = 16054, upload-time = "2025-10-08T18:03:48.35Z" }, ] [[package]] @@ -341,20 +380,20 @@ wheels = [ [[package]] name = "identify" -version = "2.6.13" +version = "2.6.15" 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" } +sdist = { url = "https://files.pythonhosted.org/packages/ff/e7/685de97986c916a6d93b3876139e00eef26ad5bbbd61925d670ae8013449/identify-2.6.15.tar.gz", hash = "sha256:e4f4864b96c6557ef2a1e1c951771838f4edc9df3a72ec7118b338801b11c7bf", size = 99311, upload-time = "2025-10-02T17:43:40.631Z" } 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" }, + { url = "https://files.pythonhosted.org/packages/0f/1c/e5fd8f973d4f375adb21565739498e2e9a1e54c858a97b9a8ccfdc81da9b/identify-2.6.15-py2.py3-none-any.whl", hash = "sha256:1181ef7608e00704db228516541eb83a88a9f94433a8c80bb9b5bd54b1d81757", size = 99183, upload-time = "2025-10-02T17:43:39.137Z" }, ] [[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]] @@ -392,20 +431,20 @@ wheels = [ [[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" +version = "4.5.0" 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/61/33/9611380c2bdb1225fdef633e2a9610622310fed35ab11dac9620972ee088/platformdirs-4.5.0.tar.gz", hash = "sha256:70ddccdd7c99fc5942e9fc25636a8b34d04c24b335100223152c2803e4063312", size = 21632, upload-time = "2025-10-08T17:44:48.791Z" } 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" }, + { url = "https://files.pythonhosted.org/packages/73/cb/ac7874b3e5d58441674fb70742e6c374b28b0c7cb988d37d991cde47166c/platformdirs-4.5.0-py3-none-any.whl", hash = "sha256:e578a81bb873cbb89a41fcc904c7ef523cc18284b7e3b3ccf06aca1403b7ebd3", size = 18651, upload-time = "2025-10-08T17:44:47.223Z" }, ] [[package]] @@ -426,14 +465,14 @@ wheels = [ [[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 +511,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 +653,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 +671,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 +689,32 @@ wheels = [ [[package]] name = "urllib3" -version = "2.4.0" +version = "2.5.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" } +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/6b/11/cc635220681e93a0183390e26485430ca2c7b5f9d33b15c74c2861cb8091/urllib3-2.4.0-py3-none-any.whl", hash = "sha256:4e16665048960a0900c702d4a66415956a584919c03361cac9f1df5c5dd7e813", size = 128680, upload-time = "2025-04-10T15:23:37.377Z" }, + { 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 = "virtualenv" -version = "20.34.0" +version = "20.35.4" 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/20/28/e6f1a6f655d620846bd9df527390ecc26b3805a0c5989048c210e22c5ca9/virtualenv-20.35.4.tar.gz", hash = "sha256:643d3914d73d3eeb0c552cbb12d7e82adf0e504dbf86a3182f8771a153a1971c", size = 6028799, upload-time = "2025-10-29T06:57:40.511Z" } 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/79/0c/c05523fa3181fdf0c9c52a6ba91a23fbf3246cc095f26f6516f9c60e6771/virtualenv-20.35.4-py3-none-any.whl", hash = "sha256:c21c9cede36c9753eeade68ba7d523529f228a403463376cf821eaae2b650f1b", size = 6005095, upload-time = "2025-10-29T06:57:37.598Z" }, ] [[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 779de24..6c423fb 100644 --- a/website/settings.py +++ b/website/settings.py @@ -20,7 +20,7 @@ }, "WEB": { "COURSE": {"PAGE_SIZE": 10}, - "REVIEW": {"PAGE_SIZE": 10}, + "REVIEW": {"PAGE_SIZE": 10, "COMMENT_MIN_LENGTH": 30}, }, "AUTH": { "OTP_TIMEOUT": 120, @@ -92,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) From a237322d7a8154438b2efaa1e4352ee9b0d432b9 Mon Sep 17 00:00:00 2001 From: alexis Date: Sun, 9 Nov 2025 16:49:22 +0800 Subject: [PATCH 07/30] refactor: rearrange urls under their own apps --- apps/auth/urls.py | 15 ++++++++ apps/spider/urls.py | 11 ++++++ apps/web/urls.py | 46 ++++++++++++++++++++++++ website/urls.py | 88 +++------------------------------------------ 4 files changed, 77 insertions(+), 83 deletions(-) create mode 100644 apps/auth/urls.py create mode 100644 apps/spider/urls.py create mode 100644 apps/web/urls.py diff --git a/apps/auth/urls.py b/apps/auth/urls.py new file mode 100644 index 0000000..73f6044 --- /dev/null +++ b/apps/auth/urls.py @@ -0,0 +1,15 @@ +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/spider/urls.py b/apps/spider/urls.py new file mode 100644 index 0000000..ba9fa96 --- /dev/null +++ b/apps/spider/urls.py @@ -0,0 +1,11 @@ +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/urls.py b/apps/web/urls.py new file mode 100644 index 0000000..4f74ab0 --- /dev/null +++ b/apps/web/urls.py @@ -0,0 +1,46 @@ +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/website/urls.py b/website/urls.py index d610772..707d854 100644 --- a/website/urls.py +++ b/website/urls.py @@ -1,90 +1,12 @@ from django.contrib import admin from django.urls import include, re_path -from apps.auth import views as auth_views -from apps.spider import views as spider_views -from apps.web import views - urlpatterns = [ - re_path( - r"^api/auth/init/$", - auth_views.auth_initiate_api, - name="auth_initiate_api", - ), - re_path( - r"^api/auth/verify/$", - auth_views.verify_callback_api, - name="verify_callback_api", - ), - 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/courses/(?P[0-9]+)/$", - views.CoursesDetailAPI.as_view(), - name="course_detail_api", - ), - re_path( - r"^api/courses/(?P[0-9].*)/instructors?/?", - views.course_instructors, - name="course_instructors", - ), - re_path( - r"^api/courses/(?P[0-9].*)/medians", views.medians, name="medians" - ), - re_path( - r"^api/courses/(?P[0-9].*)/professors?/?", - views.course_professors, - name="course_professors", - ), - re_path( - r"^api/courses/(?P[0-9].*)/vote", - views.course_vote_api, - name="course_vote_api", - ), - re_path( - r"^api/courses/(?P[0-9]+)/reviews/$", - views.CoursesReviewsAPI.as_view(), - name="course_review_api", - ), - re_path( - r"^api/reviews/?$", - views.UserReviewsAPI.as_view(), - name="user_reviews_api", - ), - re_path( - r"^api/reviews/(?P[0-9]+)/$", - views.UserReviewsAPI.as_view(), - name="user_review_api", - ), - re_path( - r"^api/reviews/(?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.CoursesListAPI.as_view(), name="courses_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")), ] From f4a7e424c0b53cbdc5be40f3cba4d942b49bd414 Mon Sep 17 00:00:00 2001 From: alexis Date: Thu, 20 Nov 2025 15:13:11 +0800 Subject: [PATCH 08/30] fix: rebase conflict --- apps/auth/urls.py | 1 + apps/auth/views.py | 30 ++++++++------ apps/spider/urls.py | 1 + apps/web/models/forms/__init__.py | 3 -- apps/web/models/forms/review_form.py | 59 ---------------------------- apps/web/serializers.py | 2 +- apps/web/urls.py | 1 + 7 files changed, 21 insertions(+), 76 deletions(-) delete mode 100644 apps/web/models/forms/__init__.py delete mode 100644 apps/web/models/forms/review_form.py diff --git a/apps/auth/urls.py b/apps/auth/urls.py index 73f6044..822f4ad 100644 --- a/apps/auth/urls.py +++ b/apps/auth/urls.py @@ -1,4 +1,5 @@ from django.urls import re_path + from apps.auth import views as auth_views urlpatterns = [ diff --git a/apps/auth/views.py b/apps/auth/views.py index b3b93c9..2f4caae 100644 --- a/apps/auth/views.py +++ b/apps/auth/views.py @@ -58,7 +58,7 @@ def auth_initiate_api(request): return Response({"error": "Missing action or turnstile_token"}, status=400) if action not in ACTION_LIST: - logger.warning(f"Invalid action '{action}' in auth_initiate_api") + logger.warning("Invalid action '%s' in auth_initiate_api", action) return Response({"error": "Invalid action"}, status=400) client_ip = ( @@ -73,7 +73,8 @@ def auth_initiate_api(request): ) if not success: logger.warning( - f"verify_turnstile_token failed in auth_initiate_api:{error_response.data}" + "verify_turnstile_token failed in auth_initiate_api:%s", + error_response.data, ) return error_response @@ -95,9 +96,8 @@ def auth_initiate_api(request): existing_state = json.loads(existing_state_data) r.delete(existing_state_key) logger.info( - f"Cleaned up existing temp_token_state for action { - existing_state.get('action', 'unknown') - }" + "Cleaned up existing temp_token_state for action %s", + existing_state.get("action", "unknown"), ) except Exception: logger.warning("Error cleaning up existing temp_token") @@ -120,11 +120,11 @@ def auth_initiate_api(request): details = utils.get_survey_details(action) if not details: - logger.error(f"Invalid action '{action}' when fetching survey 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(f"Survey URL missing for {action}") + logger.error("Survey URL missing for %s", action) return Response( {"error": "Something went wrong when fetching the survey URL"}, status=500, @@ -152,7 +152,9 @@ def verify_callback_api(request): Handles the verification of questionnaire callback using temp_token from cookie. """ logger.info( - f"verify_callback_api called for account={request.data.get('account')}, action={request.data.get('action')}" + "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") @@ -164,7 +166,7 @@ def verify_callback_api(request): return Response({"error": "Missing account, answer_id, or action"}, status=400) if action not in ACTION_LIST: - logger.warning(f"Invalid action '{action}' in verify_callback_api") + logger.warning("Invalid action '%s' in verify_callback_api", action) return Response({"error": "Invalid action"}, status=400) # Get temp_token from HttpOnly cookie @@ -292,7 +294,9 @@ def verify_callback_api(request): r.delete(rate_limit_key) logger.info( - "Successfully verified temp_token for user %s with action %s", account, action + "Successfully verified temp_token for user %s with action %s", + account, + action, ) # For login action, handle immediate session creation and cleanup @@ -496,12 +500,11 @@ def auth_login_api(request) -> Response: user = authenticate(username=account, password=password) if user is None or not user.is_active: - logger.warning("Invalid account or password for account=%s", account) return Response({"error": "Invalid account or password"}, status=401) login(request, user) Student.objects.get_or_create(user=user) - logger.info("User %s logged in successfully", account) + return Response({"message": "Login successfully"}, status=200) @@ -510,7 +513,8 @@ def auth_login_api(request) -> Response: @permission_classes([AllowAny]) def auth_logout_api(request) -> Response: logger.info( - f"auth_logout_api called for user={getattr(request.user, 'username', None)}" + "auth_logout_api called for user=%s", + getattr(request.user, "username", None), ) """Logout a user.""" logout(request) diff --git a/apps/spider/urls.py b/apps/spider/urls.py index ba9fa96..bb8420c 100644 --- a/apps/spider/urls.py +++ b/apps/spider/urls.py @@ -1,4 +1,5 @@ from django.urls import re_path + from apps.spider import views as spider_views urlpatterns = [ 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/serializers.py b/apps/web/serializers.py index 1097e59..dd055f8 100644 --- a/apps/web/serializers.py +++ b/apps/web/serializers.py @@ -1,6 +1,6 @@ # apps/web/serializers.py -from django.db.models import Count from django.conf import settings +from django.db.models import Count from rest_framework import serializers from apps.web.models import ( diff --git a/apps/web/urls.py b/apps/web/urls.py index 4f74ab0..6915bd3 100644 --- a/apps/web/urls.py +++ b/apps/web/urls.py @@ -1,4 +1,5 @@ from django.urls import re_path + from apps.web import views urlpatterns = [ From 047c2cfdc54af9acf3a2c23c27701d537993cb57 Mon Sep 17 00:00:00 2001 From: alexis Date: Sun, 30 Nov 2025 20:21:17 +0800 Subject: [PATCH 09/30] fix: N+1 issue for review votes --- apps/web/models/review.py | 63 ++++++++++++++++++------------ apps/web/models/vote_for_review.py | 7 +++- apps/web/serializers.py | 17 ++------ apps/web/views.py | 41 +++++++++---------- 4 files changed, 66 insertions(+), 62 deletions(-) diff --git a/apps/web/models/review.py b/apps/web/models/review.py index d849251..c3bb9c3 100644 --- a/apps/web/models/review.py +++ b/apps/web/models/review.py @@ -2,6 +2,7 @@ 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): @@ -11,33 +12,45 @@ def user_can_write_review(self, user, course): def num_reviews_for_user(self, user): return self.filter(user=user).count() - def get_user_review_for_course(self, user, course): + def with_votes(self, request_user=None, **kwargs): """ - 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. + Return queryset with annotated vote counts (kudos, dislike) and user's vote. + + Args: + request_user: User object for user vote annotations + **kwargs: Additional filter parameters for queryset + """ + 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 request_user and request_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=request_user + ).values("is_kudos")[:1] + + queryset = queryset.annotate( + user_vote=Subquery( + vote_subquery, output_field=models.BooleanField(null=True) + ) + ) + + return queryset + + def queryset_raw(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() - - def get_kudos_count(self, review_id): - """Get the number of kudos for a specific review""" - return self.get(id=review_id).votes.filter(is_kudos=True).count() - - def get_dislike_count(self, review_id): - """Get the number of dislikes for a specific review""" - return self.get(id=review_id).votes.filter(is_kudos=False).count() - - def get_vote_counts(self, review_id): - """Get both kudos and dislike counts for a specific review""" - kudos_count = self.get_kudos_count(review_id) - dislike_count = self.get_dislike_count(review_id) - return kudos_count, dislike_count + return self.filter(**kwargs) class Review(models.Model): diff --git a/apps/web/models/vote_for_review.py b/apps/web/models/vote_for_review.py index 123e930..08dd60a 100644 --- a/apps/web/models/vote_for_review.py +++ b/apps/web/models/vote_for_review.py @@ -49,7 +49,12 @@ def vote(self, review_id, user, is_kudos=True): vote_value = is_kudos # Calculate and return updated counts and user's current vote - kudos_count, dislike_count = Review.objects.get_vote_counts(review_id) + 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): diff --git a/apps/web/serializers.py b/apps/web/serializers.py index dd055f8..bb1649f 100644 --- a/apps/web/serializers.py +++ b/apps/web/serializers.py @@ -9,7 +9,6 @@ DistributiveRequirement, Instructor, Review, - ReviewVote, Vote, ) from lib import constants @@ -31,7 +30,7 @@ class Meta: class ReviewSerializer(serializers.ModelSerializer): - # user = serializers.StringRelatedField() # Display username + # user = serializers.StringRelatedField() term = serializers.CharField() professor = serializers.CharField() user_vote = serializers.SerializerMethodField() @@ -62,23 +61,15 @@ class Meta: def get_kudos_count(self, obj): """Get the number of kudos for this review""" - return Review.objects.get_kudos_count(obj.id) + return getattr(obj, "kudos_count", 0) def get_dislike_count(self, obj): """Get the number of dislikes for this review""" - return Review.objects.get_dislike_count(obj.id) + 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 - - 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 getattr(obj, "user_vote", None) def validate_term(self, value): """Validate term format""" diff --git a/apps/web/views.py b/apps/web/views.py index 839b421..bd37073 100644 --- a/apps/web/views.py +++ b/apps/web/views.py @@ -1,7 +1,7 @@ import logging from django.conf import settings -from django.db.models import Count +from django.db.models import Count, Q from rest_framework import generics, mixins, pagination, status from rest_framework.decorators import ( api_view, @@ -223,11 +223,12 @@ class CoursesReviewsAPI( permission_classes = [IsAuthenticated] def get_queryset(self): - """Get reviews for the specified course.""" course_id = self.kwargs.get("course_id") try: course = Course.objects.get(id=course_id) - return Review.objects.filter(course=course) + return Review.objects.with_votes( + request_user=self.request.user, course=course + ) except Course.DoesNotExist: logger.warning("Course with id %d does not exist", course_id) return Review.objects.none() @@ -236,22 +237,17 @@ def list(self, request, *args, **kwargs): """List reviews with optional filtering.""" queryset = self.get_queryset() - # Handle all query parameters here - query = request.query_params.get("q", "").strip() - if query: - course_id = self.kwargs.get("course_id") - try: - course = Course.objects.get(id=course_id) - queryset = course.search_reviews(query) - except Course.DoesNotExist: - return Response( - {"detail": "Course not found"}, status=status.HTTP_404_NOT_FOUND - ) - # 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) + ) + serializer = self.get_serializer(queryset, many=True) return Response(serializer.data) @@ -284,10 +280,7 @@ def post(self, request, *args, **kwargs): logger.warning("Review serializer errors: %s", serializer.errors) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - review = serializer.save(course=course, user=request.user) - - # Return the created review - serializer = self.get_serializer(review) + serializer.save(course=course, user=request.user) return Response(serializer.data, status=status.HTTP_201_CREATED) @@ -345,8 +338,10 @@ class UserReviewsAPI( lookup_url_kwarg = "review_id" def get_queryset(self): - """Only reviews belonging to the authenticated user.""" - return Review.objects.filter(user=self.request.user) + """Only reviews belonging to the authenticated user with vote annotations.""" + return Review.objects.with_votes( + request_user=self.request.user, user=self.request.user + ) def get(self, request, *args, **kwargs): """Handle both list (no id) and retrieve (with id) operations.""" @@ -443,7 +438,7 @@ def course_professors(request, course_id): { "professors": sorted( set( - Review.objects.filter(course=course_id) + Review.objects.queryset_raw(course=course_id) .values_list("professor", flat=True) .distinct() ) @@ -566,7 +561,7 @@ def review_vote_api(request, review_id): if kudos_count is None or dislike_count is None: # Review doesn't exist - logger.warning("Review %d not found for voting", review_id) + logger.warning("Review %s not found for voting", str(review_id)) return Response({"detail": "Review not found"}, status=404) return Response( From 37348793c9532d0bc357d174bba40a3845dec5bb Mon Sep 17 00:00:00 2001 From: alexis Date: Sun, 30 Nov 2025 21:23:41 +0800 Subject: [PATCH 10/30] style: queryset_raw to raw_queryset --- apps/web/models/review.py | 2 +- apps/web/views.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/models/review.py b/apps/web/models/review.py index c3bb9c3..9b7e099 100644 --- a/apps/web/models/review.py +++ b/apps/web/models/review.py @@ -43,7 +43,7 @@ def with_votes(self, request_user=None, **kwargs): return queryset - def queryset_raw(self, **kwargs): + def raw_queryset(self, **kwargs): """ Return base queryset without vote annotations for better performance when votes aren't needed. diff --git a/apps/web/views.py b/apps/web/views.py index bd37073..7d6f29d 100644 --- a/apps/web/views.py +++ b/apps/web/views.py @@ -438,7 +438,7 @@ def course_professors(request, course_id): { "professors": sorted( set( - Review.objects.queryset_raw(course=course_id) + Review.objects.raw_queryset(course=course_id) .values_list("professor", flat=True) .distinct() ) From 9f877cde8ba6531afd53ea6e766ad0cceb6f3dd5 Mon Sep 17 00:00:00 2001 From: alexis Date: Thu, 4 Dec 2025 12:35:33 +0800 Subject: [PATCH 11/30] fix: add csrf check for logout signup reset --- apps/auth/utils.py | 8 +++++++- apps/auth/views.py | 16 +++++----------- config.yaml.example | 3 +-- 3 files changed, 13 insertions(+), 14 deletions(-) diff --git a/apps/auth/utils.py b/apps/auth/utils.py index aabc285..809cab6 100644 --- a/apps/auth/utils.py +++ b/apps/auth/utils.py @@ -10,7 +10,7 @@ from django.contrib.auth.password_validation import validate_password from django.core.exceptions import ValidationError from rest_framework.response import Response - +from rest_framework.authentication import SessionAuthentication from apps.web.models import Student logger = logging.getLogger(__name__) @@ -25,6 +25,12 @@ 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. diff --git a/apps/auth/views.py b/apps/auth/views.py index 2f4caae..cf46095 100644 --- a/apps/auth/views.py +++ b/apps/auth/views.py @@ -8,12 +8,12 @@ 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, permission_classes, + authentication_classes, ) from rest_framework.permissions import AllowAny from rest_framework.response import Response @@ -24,11 +24,6 @@ logger = logging.getLogger(__name__) -class CsrfExemptSessionAuthentication(SessionAuthentication): - def enforce_csrf(self, request): - return - - AUTH_SETTINGS = settings.AUTH OTP_TIMEOUT = AUTH_SETTINGS["OTP_TIMEOUT"] TEMP_TOKEN_TIMEOUT = AUTH_SETTINGS["TEMP_TOKEN_TIMEOUT"] @@ -38,7 +33,6 @@ def enforce_csrf(self, request): @api_view(["POST"]) -@authentication_classes([CsrfExemptSessionAuthentication]) @permission_classes([AllowAny]) def auth_initiate_api(request): """Step 1: Authentication Initiation (/api/auth/init) @@ -143,8 +137,8 @@ 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) @@ -386,6 +380,7 @@ def verify_token_pwd(request, action: str) -> tuple[dict | None, Response | None @api_view(["POST"]) +@authentication_classes([utils.CSRFCheckSessionAuthentication]) def auth_signup_api(request) -> Response: """Signup API (/api/auth/signup) @@ -429,6 +424,7 @@ def auth_signup_api(request) -> Response: @api_view(["POST"]) +@authentication_classes([utils.CSRFCheckSessionAuthentication]) def auth_reset_password_api(request) -> Response: """Reset Password API (/api/auth/password) @@ -469,7 +465,6 @@ def auth_reset_password_api(request) -> Response: @api_view(["POST"]) -@authentication_classes([CsrfExemptSessionAuthentication]) @permission_classes([AllowAny]) def auth_login_api(request) -> Response: account = request.data.get("account", "").strip() @@ -509,7 +504,6 @@ def auth_login_api(request) -> Response: @api_view(["POST"]) -@authentication_classes([CsrfExemptSessionAuthentication]) @permission_classes([AllowAny]) def auth_logout_api(request) -> Response: logger.info( diff --git a/config.yaml.example b/config.yaml.example index 489fbf8..71bc8b1 100644 --- a/config.yaml.example +++ b/config.yaml.example @@ -27,7 +27,7 @@ CORS_ALLOWED_ORIGINS: # PAGE_SIZE: 5 # REVIEW: # PAGE_SIZE: 10 -# COMMENT_MIN_LENGTH : 30 +# COMMENT_MIN_LENGTH : 30 # AUTH: # OTP_TIMEOUT: 120 # TEMP_TOKEN_TIMEOUT: 600 @@ -36,7 +36,6 @@ CORS_ALLOWED_ORIGINS: # PASSWORD_LENGTH_MIN: 10 # PASSWORD_LENGTH_MAX: 32 # EMAIL_DOMAIN_NAME: "sjtu.edu.cn" -# ACTION_LIST:[ "signup", "login", "reset" ] # # DATABASE: # URL: Use env From 79633bab23ab9a2a8f5de8a790a8cee882c888b5 Mon Sep 17 00:00:00 2001 From: alexis Date: Thu, 4 Dec 2025 13:07:43 +0800 Subject: [PATCH 12/30] fix: use annotation for course scores to fix race condition and N+1 --- ...remove_course_difficulty_score_and_more.py | 20 ++++++++++ apps/web/models/course.py | 37 +++++++++++++++-- apps/web/models/review.py | 2 +- apps/web/models/vote.py | 40 +++++++------------ apps/web/serializers.py | 28 +++++++++++-- apps/web/views.py | 33 +++++++++------ 6 files changed, 112 insertions(+), 48 deletions(-) create mode 100644 apps/web/migrations/0011_remove_course_difficulty_score_and_more.py 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..7e88a3b 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) diff --git a/apps/web/models/review.py b/apps/web/models/review.py index 9b7e099..9e17245 100644 --- a/apps/web/models/review.py +++ b/apps/web/models/review.py @@ -9,7 +9,7 @@ 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 with_votes(self, request_user=None, **kwargs): diff --git a/apps/web/models/vote.py b/apps/web/models/vote.py index 2c50177..c1befa1 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,12 @@ 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 + return None, False, 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 +25,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 +101,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/serializers.py b/apps/web/serializers.py index bb1649f..a7bd097 100644 --- a/apps/web/serializers.py +++ b/apps/web/serializers.py @@ -118,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 @@ -135,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() @@ -191,6 +199,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 @@ -243,7 +253,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 [ @@ -290,10 +306,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/views.py b/apps/web/views.py index 7d6f29d..81b2c5f 100644 --- a/apps/web/views.py +++ b/apps/web/views.py @@ -1,7 +1,7 @@ import logging from django.conf import settings -from django.db.models import Count, Q +from django.db.models import Count, Prefetch, Q from rest_framework import generics, mixins, pagination, status from rest_framework.decorators import ( api_view, @@ -79,7 +79,7 @@ class CoursesListAPI(generics.GenericAPIView, mixins.ListModelMixin): - 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", "num_reviews"),("quality_score", "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 @@ -97,8 +97,7 @@ class CoursesListAPI(generics.GenericAPIView, mixins.ListModelMixin): pagination_class = CoursesPagination def get_queryset(self): - queryset = Course.objects.all().prefetch_related("distribs", "review_set") - queryset = queryset.annotate(num_reviews=Count("review")) + queryset = Course.objects.with_scores().prefetch_related("distribs") return queryset def _filter(self, queryset): @@ -143,7 +142,7 @@ def _sort(self, queryset): sort_order = self.request.query_params.get("sort_order", "asc") sort_prefix = "-" if sort_order.lower() == "desc" else "" - allowed_sort_fields = ["course_code", "num_reviews"] + allowed_sort_fields = ["course_code", "review_count"] if self.request.user.is_authenticated: allowed_sort_fields.extend(["quality_score", "difficulty_score"]) @@ -175,15 +174,23 @@ class CoursesDetailAPI(generics.GenericAPIView, mixins.RetrieveModelMixin): serializer_class = CourseSerializer permission_classes = [AllowAny] - queryset = Course.objects.all() + lookup_field = "id" + lookup_url_kwarg = "course_id" - def get_object(self): - course_id = self.kwargs.get("course_id") - try: - return Course.objects.get(id=course_id) - except Course.DoesNotExist: - logger.warning("Course with id %d does not exist", course_id) - return None + 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(request_user=request.user), + ) + ) + + return queryset def get(self, request, *args, **kwargs): return self.retrieve(request, *args, **kwargs) From 3414439e40e63e969387f67d4619c7bde05859dc Mon Sep 17 00:00:00 2001 From: alexis Date: Thu, 4 Dec 2025 13:16:20 +0800 Subject: [PATCH 13/30] fix: ReviewManager.with_votes request_user param renamed to vote_user --- apps/web/models/review.py | 8 ++++---- apps/web/views.py | 8 +++----- 2 files changed, 7 insertions(+), 9 deletions(-) diff --git a/apps/web/models/review.py b/apps/web/models/review.py index 9e17245..b649d11 100644 --- a/apps/web/models/review.py +++ b/apps/web/models/review.py @@ -12,12 +12,12 @@ def user_can_write_review(self, user, course): def review_count_for_user(self, user): return self.filter(user=user).count() - def with_votes(self, request_user=None, **kwargs): + def with_votes(self, vote_user=None, **kwargs): """ Return queryset with annotated vote counts (kudos, dislike) and user's vote. Args: - request_user: User object for user vote annotations + vote_user: User object for user vote annotations **kwargs: Additional filter parameters for queryset """ queryset = self.filter(**kwargs).annotate( @@ -27,12 +27,12 @@ def with_votes(self, request_user=None, **kwargs): ), ) - if request_user and request_user.is_authenticated: + 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=request_user + review=OuterRef("pk"), user=vote_user ).values("is_kudos")[:1] queryset = queryset.annotate( diff --git a/apps/web/views.py b/apps/web/views.py index 81b2c5f..de40240 100644 --- a/apps/web/views.py +++ b/apps/web/views.py @@ -186,7 +186,7 @@ def get_queryset(self): queryset = queryset.prefetch_related( Prefetch( "review_set", - queryset=Review.objects.with_votes(request_user=request.user), + queryset=Review.objects.with_votes(vote_user=request.user), ) ) @@ -233,9 +233,7 @@ def get_queryset(self): course_id = self.kwargs.get("course_id") try: course = Course.objects.get(id=course_id) - return Review.objects.with_votes( - request_user=self.request.user, course=course - ) + 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() @@ -347,7 +345,7 @@ class UserReviewsAPI( def get_queryset(self): """Only reviews belonging to the authenticated user with vote annotations.""" return Review.objects.with_votes( - request_user=self.request.user, user=self.request.user + vote_user=self.request.user, user=self.request.user ) def get(self, request, *args, **kwargs): From 92cffb2aaa6bc86e3d6182d6d04ecc4d6d584a92 Mon Sep 17 00:00:00 2001 From: alexis Date: Tue, 23 Dec 2025 12:36:10 +0800 Subject: [PATCH 14/30] chore: add celery dep --- pyproject.toml | 1 + uv.lock | 156 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 157 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 96f3a8d..1abd717 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,6 +21,7 @@ dependencies = [ "django-cors-headers==4.9.0", "django-redis==6.0.0", "pyyaml==6.0.3", + "celery==5.6.0" ] [tool.uv] diff --git a/uv.lock b/uv.lock index 289fdee..d966c6a 100644 --- a/uv.lock +++ b/uv.lock @@ -2,6 +2,18 @@ version = 1 revision = 3 requires-python = "==3.14.*" +[[package]] +name = "amqp" +version = "5.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "vine" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/79/fc/ec94a357dfc6683d8c86f8b4cfa5416a4c36b28052ec8260c77aca96a443/amqp-5.3.1.tar.gz", hash = "sha256:cddc00c725449522023bad949f70fff7b48f0b1ade74d170a6f10ab044739432", size = 129013, upload-time = "2024-11-12T19:55:44.051Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/26/99/fc813cd978842c26c82534010ea849eee9ab3a13ea2b74e95cb9c99e747b/amqp-5.3.1-py3-none-any.whl", hash = "sha256:43b3319e1b4e7d1251833a93d672b4af1e40f3d632d479b98661a95f117880a2", size = 50944, upload-time = "2024-11-12T19:55:41.782Z" }, +] + [[package]] name = "ansicon" version = "1.89.0" @@ -55,6 +67,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/94/fe/3aed5d0be4d404d12d36ab97e2f1791424d9ca39c2f754a6285d59a3b01d/beautifulsoup4-4.14.2-py3-none-any.whl", hash = "sha256:5ef6fa3a8cbece8488d66985560f97ed091e22bbc4e9c2338508a9d5de6d4515", size = 106392, upload-time = "2025-09-29T10:05:43.771Z" }, ] +[[package]] +name = "billiard" +version = "4.2.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/23/b12ac0bcdfb7360d664f40a00b1bda139cbbbced012c34e375506dbd0143/billiard-4.2.4.tar.gz", hash = "sha256:55f542c371209e03cd5862299b74e52e4fbcba8250ba611ad94276b369b6a85f", size = 156537, upload-time = "2025-11-30T13:28:48.52Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/87/8bab77b323f16d67be364031220069f79159117dd5e43eeb4be2fef1ac9b/billiard-4.2.4-py3-none-any.whl", hash = "sha256:525b42bdec68d2b983347ac312f892db930858495db601b5836ac24e6477cde5", size = 87070, upload-time = "2025-11-30T13:28:47.016Z" }, +] + [[package]] name = "blessed" version = "1.25.0" @@ -85,6 +106,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ea/92/26d8d98de4c1676305e03ec2be67850afaf883b507bf71b917d852585ec8/bpython-0.26-py3-none-any.whl", hash = "sha256:91bdbbe667078677dc6b236493fc03e47a04cd099630a32ca3f72d6d49b71e20", size = 175988, upload-time = "2025-10-28T07:19:40.114Z" }, ] +[[package]] +name = "celery" +version = "5.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "billiard" }, + { name = "click" }, + { name = "click-didyoumean" }, + { name = "click-plugins" }, + { name = "click-repl" }, + { name = "exceptiongroup" }, + { name = "kombu" }, + { name = "python-dateutil" }, + { name = "tzlocal" }, + { name = "vine" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ad/5f/b681ae3c89290d2ea6562ea96b40f5af6f6fc5f7743e2cd1a19e47721548/celery-5.6.0.tar.gz", hash = "sha256:641405206042d52ae460e4e9751a2e31b06cf80ab836fcf92e0b9311d7ea8113", size = 1712522, upload-time = "2025-11-30T17:39:46.282Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/01/4e/53a125038d6a814491a0ae3457435c13cf8821eb602292cf9db37ce35f62/celery-5.6.0-py3-none-any.whl", hash = "sha256:33cf01477b175017fc8f22c5ee8a65157591043ba8ca78a443fe703aa910f581", size = 444561, upload-time = "2025-11-30T17:39:44.314Z" }, +] + [[package]] name = "certifi" version = "2025.11.12" @@ -128,6 +170,64 @@ wheels = [ { 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]] +name = "click" +version = "8.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" }, +] + +[[package]] +name = "click-didyoumean" +version = "0.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/30/ce/217289b77c590ea1e7c24242d9ddd6e249e52c795ff10fac2c50062c48cb/click_didyoumean-0.3.1.tar.gz", hash = "sha256:4f82fdff0dbe64ef8ab2279bd6aa3f6a99c3b28c05aa09cbfc07c9d7fbb5a463", size = 3089, upload-time = "2024-03-24T08:22:07.499Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1b/5b/974430b5ffdb7a4f1941d13d83c64a0395114503cc357c6b9ae4ce5047ed/click_didyoumean-0.3.1-py3-none-any.whl", hash = "sha256:5c4bb6007cfea5f2fd6583a2fb6701a22a41eb98957e63d0fac41c10e7c3117c", size = 3631, upload-time = "2024-03-24T08:22:06.356Z" }, +] + +[[package]] +name = "click-plugins" +version = "1.1.1.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c3/a4/34847b59150da33690a36da3681d6bbc2ec14ee9a846bc30a6746e5984e4/click_plugins-1.1.1.2.tar.gz", hash = "sha256:d7af3984a99d243c131aa1a828331e7630f4a88a9741fd05c927b204bcf92261", size = 8343, upload-time = "2025-06-25T00:47:37.555Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3d/9a/2abecb28ae875e39c8cad711eb1186d8d14eab564705325e77e4e6ab9ae5/click_plugins-1.1.1.2-py2.py3-none-any.whl", hash = "sha256:008d65743833ffc1f5417bf0e78e8d2c23aab04d9745ba817bd3e71b0feb6aa6", size = 11051, upload-time = "2025-06-25T00:47:36.731Z" }, +] + +[[package]] +name = "click-repl" +version = "0.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "prompt-toolkit" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cb/a2/57f4ac79838cfae6912f997b4d1a64a858fb0c86d7fcaae6f7b58d267fca/click-repl-0.3.0.tar.gz", hash = "sha256:17849c23dba3d667247dc4defe1757fff98694e90fe37474f3feebb69ced26a9", size = 10449, upload-time = "2023-06-15T12:43:51.141Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/52/40/9d857001228658f0d59e97ebd4c346fe73e138c6de1bce61dc568a57c7f8/click_repl-0.3.0-py3-none-any.whl", hash = "sha256:fb7e06deb8da8de86180a33a9da97ac316751c094c6899382da7feeeeb51b812", size = 10289, upload-time = "2023-06-15T12:43:48.626Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + [[package]] name = "course-review" version = "0.0.1" @@ -135,6 +235,7 @@ source = { virtual = "." } dependencies = [ { name = "beautifulsoup4" }, { name = "bpython" }, + { name = "celery" }, { name = "dj-database-url" }, { name = "django" }, { name = "django-cors-headers" }, @@ -165,6 +266,7 @@ lint = [ requires-dist = [ { name = "beautifulsoup4", specifier = "==4.14.2" }, { name = "bpython", specifier = "==0.26" }, + { name = "celery", specifier = "==5.6.0" }, { name = "dj-database-url", specifier = "==3.0.1" }, { name = "django", specifier = "==5.2.8" }, { name = "django-cors-headers", specifier = "==4.9.0" }, @@ -315,6 +417,15 @@ 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 = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, +] + [[package]] name = "filelock" version = "3.20.0" @@ -420,6 +531,21 @@ 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 = "kombu" +version = "5.6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "amqp" }, + { name = "packaging" }, + { name = "tzdata" }, + { name = "vine" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/05/749ada8e51718445d915af13f1d18bc4333848e8faa0cb234028a3328ec8/kombu-5.6.1.tar.gz", hash = "sha256:90f1febb57ad4f53ca327a87598191b2520e0c793c75ea3b88d98e3b111282e4", size = 471548, upload-time = "2025-11-25T11:07:33.504Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/d6/943cf84117cd9ddecf6e1707a3f712a49fc64abdb8ac31b19132871af1dd/kombu-5.6.1-py3-none-any.whl", hash = "sha256:b69e3f5527ec32fc5196028a36376501682973e9620d6175d1c3d4eaf7e95409", size = 214141, upload-time = "2025-11-25T11:07:31.54Z" }, +] + [[package]] name = "nodeenv" version = "1.9.1" @@ -429,6 +555,15 @@ 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 = "packaging" +version = "25.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, +] + [[package]] name = "parso" version = "0.8.5" @@ -687,6 +822,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5c/23/c7abc0ca0a1526a0774eca151daeb8de62ec457e77262b66b359c3c7679e/tzdata-2025.2-py2.py3-none-any.whl", hash = "sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8", size = 347839, upload-time = "2025-03-23T13:54:41.845Z" }, ] +[[package]] +name = "tzlocal" +version = "5.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "tzdata", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8b/2e/c14812d3d4d9cd1773c6be938f89e5735a1f11a9f184ac3639b93cef35d5/tzlocal-5.3.1.tar.gz", hash = "sha256:cceffc7edecefea1f595541dbd6e990cb1ea3d19bf01b2809f362a03dd7921fd", size = 30761, upload-time = "2025-03-05T21:17:41.549Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/14/e2a54fabd4f08cd7af1c07030603c3356b74da07f7cc056e600436edfa17/tzlocal-5.3.1-py3-none-any.whl", hash = "sha256:eb1a66c3ef5847adf7a834f1be0800581b683b5608e74f86ecbcef8ab91bb85d", size = 18026, upload-time = "2025-03-05T21:17:39.857Z" }, +] + [[package]] name = "urllib3" version = "2.5.0" @@ -696,6 +843,15 @@ wheels = [ { 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 = "vine" +version = "5.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bd/e4/d07b5f29d283596b9727dd5275ccbceb63c44a1a82aa9e4bfd20426762ac/vine-5.1.0.tar.gz", hash = "sha256:8b62e981d35c41049211cf62a0a1242d8c1ee9bd15bb196ce38aefd6799e61e0", size = 48980, upload-time = "2023-11-05T08:46:53.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/ff/7c0c86c43b3cbb927e0ccc0255cb4057ceba4799cd44ae95174ce8e8b5b2/vine-5.1.0-py3-none-any.whl", hash = "sha256:40fdf3c48b2cfe1c38a49e9ae2da6fda88e4794c810050a728bd7413811fb1dc", size = 9636, upload-time = "2023-11-05T08:46:51.205Z" }, +] + [[package]] name = "virtualenv" version = "20.35.4" From 875949ab96697576644b8f04567b19a6e7b25bdd Mon Sep 17 00:00:00 2001 From: alexis Date: Tue, 23 Dec 2025 12:38:33 +0800 Subject: [PATCH 15/30] fix: use serializer for examining input of vote apis --- apps/web/models/vote.py | 3 -- apps/web/models/vote_for_review.py | 5 +- apps/web/serializers.py | 9 ++++ apps/web/views.py | 79 ++++++++++++++---------------- 4 files changed, 46 insertions(+), 50 deletions(-) diff --git a/apps/web/models/vote.py b/apps/web/models/vote.py index c1befa1..9d8861e 100644 --- a/apps/web/models/vote.py +++ b/apps/web/models/vote.py @@ -10,9 +10,6 @@ class VoteManager(models.Manager): @transaction.atomic def vote(self, value, course_id, category, user): - if value > 5 or value < 1: - return None, False, None - course = Course.objects.select_for_update().get(id=course_id) vote, created = self.get_or_create(course=course, category=category, user=user) diff --git a/apps/web/models/vote_for_review.py b/apps/web/models/vote_for_review.py index 08dd60a..a60c3c0 100644 --- a/apps/web/models/vote_for_review.py +++ b/apps/web/models/vote_for_review.py @@ -39,16 +39,13 @@ def vote(self, review_id, user, is_kudos=True): else: # Existing vote if review_vote.is_kudos == is_kudos: - # Same vote type, remove it (cancel) review_vote.delete() - vote_value = None # User cancelled their vote + vote_value = None else: - # Change vote from kudos to dislike or vice versa review_vote.is_kudos = is_kudos review_vote.save() vote_value = is_kudos - # Calculate and return updated counts and user's current vote review_with_votes = Review.objects.with_votes(id=review_id).first() if review_with_votes: kudos_count = review_with_votes.kudos_count diff --git a/apps/web/serializers.py b/apps/web/serializers.py index a7bd097..ae76365 100644 --- a/apps/web/serializers.py +++ b/apps/web/serializers.py @@ -184,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) diff --git a/apps/web/views.py b/apps/web/views.py index de40240..32bb317 100644 --- a/apps/web/views.py +++ b/apps/web/views.py @@ -21,7 +21,9 @@ from apps.web.serializers import ( CourseSearchSerializer, CourseSerializer, + CourseVoteSerializer, ReviewSerializer, + ReviewVoteSerializer, ) from lib.departments import get_department_name from lib.grades import numeric_value_for_grade @@ -399,6 +401,7 @@ def departments_api(request): @api_view(["GET"]) +@permission_classes([AllowAny]) def medians(request, course_id): # retrieve course medians for term, and group by term for averaging medians_by_term = {} @@ -438,6 +441,7 @@ def medians(request, course_id): @api_view(["GET"]) +@permission_classes([AllowAny]) def course_professors(request, course_id): return Response( { @@ -461,6 +465,7 @@ def course_professors(request, course_id): @api_view(["GET"]) +@permission_classes([AllowAny]) def course_instructors(request, course_id): try: course = Course.objects.get(pk=course_id) @@ -485,7 +490,7 @@ def course_vote_api(request, course_id): - URL parameter: course_id (integer, required) - Body (JSON): { - "value": integer (vote score), + "value": integer (vote score between 1-5), "forLayup": boolean (true for difficulty, false for quality) } @@ -498,24 +503,19 @@ def course_vote_api(request, course_id): } Error (400): { - "detail": "Missing required fields: value, forLayup" + "detail": "Validation error with input fields" } """ - try: - value = request.data["value"] - forLayup = request.data["forLayup"] - except KeyError: - logger.warning( - "Missing required fields: value, forLayup in course vote API for course %d", - course_id, - ) - return Response( - {"detail": "Missing required fields: value, forLayup"}, status=400 - ) + 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( @@ -548,37 +548,30 @@ def review_vote_api(request, review_id): "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: - is_kudos = request.data.get("is_kudos") - - if is_kudos is None: - logger.warning("is_kudos field is required for review vote API") - return Response({"detail": "is_kudos field is required"}, status=400) - - is_kudos = bool(is_kudos) + is_kudos = serializer.validated_data["is_kudos"] - # 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 - logger.warning("Review %s not found for voting", str(review_id)) - return Response({"detail": "Review not found"}, status=404) + kudos_count, dislike_count, user_vote = ReviewVote.objects.vote( + review_id=review_id, user=request.user, is_kudos=is_kudos + ) - return Response( - { - "kudos_count": kudos_count, - "dislike_count": dislike_count, - "user_vote": user_vote, - } - ) + 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, + } + ) From 53ec5889956c9b145759c160b422b63f4f97a87f Mon Sep 17 00:00:00 2001 From: alexis Date: Tue, 23 Dec 2025 12:40:08 +0800 Subject: [PATCH 16/30] fix: N+1 issue in course offering --- apps/auth/utils.py | 3 ++- apps/auth/views.py | 2 +- apps/web/models/course.py | 3 ++- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/apps/auth/utils.py b/apps/auth/utils.py index 809cab6..e23fc34 100644 --- a/apps/auth/utils.py +++ b/apps/auth/utils.py @@ -9,8 +9,9 @@ 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.response import Response from rest_framework.authentication import SessionAuthentication +from rest_framework.response import Response + from apps.web.models import Student logger = logging.getLogger(__name__) diff --git a/apps/auth/views.py b/apps/auth/views.py index cf46095..7569e8d 100644 --- a/apps/auth/views.py +++ b/apps/auth/views.py @@ -12,8 +12,8 @@ from django_redis import get_redis_connection from rest_framework.decorators import ( api_view, - permission_classes, authentication_classes, + permission_classes, ) from rest_framework.permissions import AllowAny from rest_framework.response import Response diff --git a/apps/web/models/course.py b/apps/web/models/course.py index 7e88a3b..dd2e8a4 100644 --- a/apps/web/models/course.py +++ b/apps/web/models/course.py @@ -217,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) From 9e5d3fa78052b7ebbd2537a0d06e83460b1b16ee Mon Sep 17 00:00:00 2001 From: alexis Date: Tue, 23 Dec 2025 13:32:48 +0800 Subject: [PATCH 17/30] chore: rm celery dep --- pyproject.toml | 1 - uv.lock | 156 ------------------------------------------------- 2 files changed, 157 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 1abd717..96f3a8d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,7 +21,6 @@ dependencies = [ "django-cors-headers==4.9.0", "django-redis==6.0.0", "pyyaml==6.0.3", - "celery==5.6.0" ] [tool.uv] diff --git a/uv.lock b/uv.lock index d966c6a..289fdee 100644 --- a/uv.lock +++ b/uv.lock @@ -2,18 +2,6 @@ version = 1 revision = 3 requires-python = "==3.14.*" -[[package]] -name = "amqp" -version = "5.3.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "vine" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/79/fc/ec94a357dfc6683d8c86f8b4cfa5416a4c36b28052ec8260c77aca96a443/amqp-5.3.1.tar.gz", hash = "sha256:cddc00c725449522023bad949f70fff7b48f0b1ade74d170a6f10ab044739432", size = 129013, upload-time = "2024-11-12T19:55:44.051Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/26/99/fc813cd978842c26c82534010ea849eee9ab3a13ea2b74e95cb9c99e747b/amqp-5.3.1-py3-none-any.whl", hash = "sha256:43b3319e1b4e7d1251833a93d672b4af1e40f3d632d479b98661a95f117880a2", size = 50944, upload-time = "2024-11-12T19:55:41.782Z" }, -] - [[package]] name = "ansicon" version = "1.89.0" @@ -67,15 +55,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/94/fe/3aed5d0be4d404d12d36ab97e2f1791424d9ca39c2f754a6285d59a3b01d/beautifulsoup4-4.14.2-py3-none-any.whl", hash = "sha256:5ef6fa3a8cbece8488d66985560f97ed091e22bbc4e9c2338508a9d5de6d4515", size = 106392, upload-time = "2025-09-29T10:05:43.771Z" }, ] -[[package]] -name = "billiard" -version = "4.2.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/58/23/b12ac0bcdfb7360d664f40a00b1bda139cbbbced012c34e375506dbd0143/billiard-4.2.4.tar.gz", hash = "sha256:55f542c371209e03cd5862299b74e52e4fbcba8250ba611ad94276b369b6a85f", size = 156537, upload-time = "2025-11-30T13:28:48.52Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/cb/87/8bab77b323f16d67be364031220069f79159117dd5e43eeb4be2fef1ac9b/billiard-4.2.4-py3-none-any.whl", hash = "sha256:525b42bdec68d2b983347ac312f892db930858495db601b5836ac24e6477cde5", size = 87070, upload-time = "2025-11-30T13:28:47.016Z" }, -] - [[package]] name = "blessed" version = "1.25.0" @@ -106,27 +85,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ea/92/26d8d98de4c1676305e03ec2be67850afaf883b507bf71b917d852585ec8/bpython-0.26-py3-none-any.whl", hash = "sha256:91bdbbe667078677dc6b236493fc03e47a04cd099630a32ca3f72d6d49b71e20", size = 175988, upload-time = "2025-10-28T07:19:40.114Z" }, ] -[[package]] -name = "celery" -version = "5.6.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "billiard" }, - { name = "click" }, - { name = "click-didyoumean" }, - { name = "click-plugins" }, - { name = "click-repl" }, - { name = "exceptiongroup" }, - { name = "kombu" }, - { name = "python-dateutil" }, - { name = "tzlocal" }, - { name = "vine" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/ad/5f/b681ae3c89290d2ea6562ea96b40f5af6f6fc5f7743e2cd1a19e47721548/celery-5.6.0.tar.gz", hash = "sha256:641405206042d52ae460e4e9751a2e31b06cf80ab836fcf92e0b9311d7ea8113", size = 1712522, upload-time = "2025-11-30T17:39:46.282Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/01/4e/53a125038d6a814491a0ae3457435c13cf8821eb602292cf9db37ce35f62/celery-5.6.0-py3-none-any.whl", hash = "sha256:33cf01477b175017fc8f22c5ee8a65157591043ba8ca78a443fe703aa910f581", size = 444561, upload-time = "2025-11-30T17:39:44.314Z" }, -] - [[package]] name = "certifi" version = "2025.11.12" @@ -170,64 +128,6 @@ wheels = [ { 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]] -name = "click" -version = "8.3.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" }, -] - -[[package]] -name = "click-didyoumean" -version = "0.3.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "click" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/30/ce/217289b77c590ea1e7c24242d9ddd6e249e52c795ff10fac2c50062c48cb/click_didyoumean-0.3.1.tar.gz", hash = "sha256:4f82fdff0dbe64ef8ab2279bd6aa3f6a99c3b28c05aa09cbfc07c9d7fbb5a463", size = 3089, upload-time = "2024-03-24T08:22:07.499Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1b/5b/974430b5ffdb7a4f1941d13d83c64a0395114503cc357c6b9ae4ce5047ed/click_didyoumean-0.3.1-py3-none-any.whl", hash = "sha256:5c4bb6007cfea5f2fd6583a2fb6701a22a41eb98957e63d0fac41c10e7c3117c", size = 3631, upload-time = "2024-03-24T08:22:06.356Z" }, -] - -[[package]] -name = "click-plugins" -version = "1.1.1.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "click" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/c3/a4/34847b59150da33690a36da3681d6bbc2ec14ee9a846bc30a6746e5984e4/click_plugins-1.1.1.2.tar.gz", hash = "sha256:d7af3984a99d243c131aa1a828331e7630f4a88a9741fd05c927b204bcf92261", size = 8343, upload-time = "2025-06-25T00:47:37.555Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3d/9a/2abecb28ae875e39c8cad711eb1186d8d14eab564705325e77e4e6ab9ae5/click_plugins-1.1.1.2-py2.py3-none-any.whl", hash = "sha256:008d65743833ffc1f5417bf0e78e8d2c23aab04d9745ba817bd3e71b0feb6aa6", size = 11051, upload-time = "2025-06-25T00:47:36.731Z" }, -] - -[[package]] -name = "click-repl" -version = "0.3.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "click" }, - { name = "prompt-toolkit" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/cb/a2/57f4ac79838cfae6912f997b4d1a64a858fb0c86d7fcaae6f7b58d267fca/click-repl-0.3.0.tar.gz", hash = "sha256:17849c23dba3d667247dc4defe1757fff98694e90fe37474f3feebb69ced26a9", size = 10449, upload-time = "2023-06-15T12:43:51.141Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/52/40/9d857001228658f0d59e97ebd4c346fe73e138c6de1bce61dc568a57c7f8/click_repl-0.3.0-py3-none-any.whl", hash = "sha256:fb7e06deb8da8de86180a33a9da97ac316751c094c6899382da7feeeeb51b812", size = 10289, upload-time = "2023-06-15T12:43:48.626Z" }, -] - -[[package]] -name = "colorama" -version = "0.4.6" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, -] - [[package]] name = "course-review" version = "0.0.1" @@ -235,7 +135,6 @@ source = { virtual = "." } dependencies = [ { name = "beautifulsoup4" }, { name = "bpython" }, - { name = "celery" }, { name = "dj-database-url" }, { name = "django" }, { name = "django-cors-headers" }, @@ -266,7 +165,6 @@ lint = [ requires-dist = [ { name = "beautifulsoup4", specifier = "==4.14.2" }, { name = "bpython", specifier = "==0.26" }, - { name = "celery", specifier = "==5.6.0" }, { name = "dj-database-url", specifier = "==3.0.1" }, { name = "django", specifier = "==5.2.8" }, { name = "django-cors-headers", specifier = "==4.9.0" }, @@ -417,15 +315,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 = "exceptiongroup" -version = "1.3.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, -] - [[package]] name = "filelock" version = "3.20.0" @@ -531,21 +420,6 @@ 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 = "kombu" -version = "5.6.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "amqp" }, - { name = "packaging" }, - { name = "tzdata" }, - { name = "vine" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/ac/05/749ada8e51718445d915af13f1d18bc4333848e8faa0cb234028a3328ec8/kombu-5.6.1.tar.gz", hash = "sha256:90f1febb57ad4f53ca327a87598191b2520e0c793c75ea3b88d98e3b111282e4", size = 471548, upload-time = "2025-11-25T11:07:33.504Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/14/d6/943cf84117cd9ddecf6e1707a3f712a49fc64abdb8ac31b19132871af1dd/kombu-5.6.1-py3-none-any.whl", hash = "sha256:b69e3f5527ec32fc5196028a36376501682973e9620d6175d1c3d4eaf7e95409", size = 214141, upload-time = "2025-11-25T11:07:31.54Z" }, -] - [[package]] name = "nodeenv" version = "1.9.1" @@ -555,15 +429,6 @@ 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 = "packaging" -version = "25.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, -] - [[package]] name = "parso" version = "0.8.5" @@ -822,18 +687,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5c/23/c7abc0ca0a1526a0774eca151daeb8de62ec457e77262b66b359c3c7679e/tzdata-2025.2-py2.py3-none-any.whl", hash = "sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8", size = 347839, upload-time = "2025-03-23T13:54:41.845Z" }, ] -[[package]] -name = "tzlocal" -version = "5.3.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "tzdata", marker = "sys_platform == 'win32'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/8b/2e/c14812d3d4d9cd1773c6be938f89e5735a1f11a9f184ac3639b93cef35d5/tzlocal-5.3.1.tar.gz", hash = "sha256:cceffc7edecefea1f595541dbd6e990cb1ea3d19bf01b2809f362a03dd7921fd", size = 30761, upload-time = "2025-03-05T21:17:41.549Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c2/14/e2a54fabd4f08cd7af1c07030603c3356b74da07f7cc056e600436edfa17/tzlocal-5.3.1-py3-none-any.whl", hash = "sha256:eb1a66c3ef5847adf7a834f1be0800581b683b5608e74f86ecbcef8ab91bb85d", size = 18026, upload-time = "2025-03-05T21:17:39.857Z" }, -] - [[package]] name = "urllib3" version = "2.5.0" @@ -843,15 +696,6 @@ wheels = [ { 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 = "vine" -version = "5.1.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/bd/e4/d07b5f29d283596b9727dd5275ccbceb63c44a1a82aa9e4bfd20426762ac/vine-5.1.0.tar.gz", hash = "sha256:8b62e981d35c41049211cf62a0a1242d8c1ee9bd15bb196ce38aefd6799e61e0", size = 48980, upload-time = "2023-11-05T08:46:53.857Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/03/ff/7c0c86c43b3cbb927e0ccc0255cb4057ceba4799cd44ae95174ce8e8b5b2/vine-5.1.0-py3-none-any.whl", hash = "sha256:40fdf3c48b2cfe1c38a49e9ae2da6fda88e4794c810050a728bd7413811fb1dc", size = 9636, upload-time = "2023-11-05T08:46:51.205Z" }, -] - [[package]] name = "virtualenv" version = "20.35.4" From 1d45ff277249386de05676288930f24d0843423a Mon Sep 17 00:00:00 2001 From: alexis Date: Tue, 23 Dec 2025 14:14:37 +0800 Subject: [PATCH 18/30] chore: change pre-commit to prek --- docs/setup.md | 2 +- pyproject.toml | 2 +- uv.lock | 104 +++++++++++-------------------------------------- 3 files changed, 25 insertions(+), 83 deletions(-) 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 289fdee..01be51a 100644 --- a/uv.lock +++ b/uv.lock @@ -94,15 +94,6 @@ wheels = [ { 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 = "cfgv" -version = "3.5.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/4e/b5/721b8799b04bf9afe054a3899c6cf4e880fcf8563cc71c15610242490a0c/cfgv-3.5.0.tar.gz", hash = "sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132", size = 7334, upload-time = "2025-11-19T20:55:51.612Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0", size = 7445, upload-time = "2025-11-19T20:55:50.744Z" }, -] - [[package]] name = "charset-normalizer" version = "3.4.4" @@ -155,7 +146,7 @@ dependencies = [ [package.dev-dependencies] dev = [ - { name = "pre-commit" }, + { name = "prek" }, ] lint = [ { name = "ruff" }, @@ -184,7 +175,7 @@ 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]] @@ -229,15 +220,6 @@ wheels = [ { 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]] -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" }, -] - [[package]] name = "dj-database-url" version = "3.0.1" @@ -315,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.20.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/58/46/0028a82567109b5ef6e4d2a1f04a583fb513e6cf9527fcdd09afd817deeb/filelock-3.20.0.tar.gz", hash = "sha256:711e943b4ec6be42e1d4e6690b48dc175c822967466bb31c0c293f34334c13f4", size = 18922, upload-time = "2025-10-08T18:03:50.056Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/76/91/7216b27286936c16f5b4d0c530087e4a54eead683e6b0b73dd0c64844af6/filelock-3.20.0-py3-none-any.whl", hash = "sha256:339b4732ffda5cd79b13f4e2711a31b0365ce445d95d243bb996273d072546a2", size = 16054, upload-time = "2025-10-08T18:03:48.35Z" }, -] - [[package]] name = "greenlet" version = "3.2.4" @@ -378,15 +351,6 @@ 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.15" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ff/e7/685de97986c916a6d93b3876139e00eef26ad5bbbd61925d670ae8013449/identify-2.6.15.tar.gz", hash = "sha256:e4f4864b96c6557ef2a1e1c951771838f4edc9df3a72ec7118b338801b11c7bf", size = 99311, upload-time = "2025-10-02T17:43:40.631Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0f/1c/e5fd8f973d4f375adb21565739498e2e9a1e54c858a97b9a8ccfdc81da9b/identify-2.6.15-py2.py3-none-any.whl", hash = "sha256:1181ef7608e00704db228516541eb83a88a9f94433a8c80bb9b5bd54b1d81757", size = 99183, upload-time = "2025-10-02T17:43:39.137Z" }, -] - [[package]] name = "idna" version = "3.11" @@ -420,15 +384,6 @@ 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.5" @@ -439,28 +394,29 @@ wheels = [ ] [[package]] -name = "platformdirs" -version = "4.5.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/61/33/9611380c2bdb1225fdef633e2a9610622310fed35ab11dac9620972ee088/platformdirs-4.5.0.tar.gz", hash = "sha256:70ddccdd7c99fc5942e9fc25636a8b34d04c24b335100223152c2803e4063312", size = 21632, upload-time = "2025-10-08T17:44:48.791Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/73/cb/ac7874b3e5d58441674fb70742e6c374b28b0c7cb988d37d991cde47166c/platformdirs-4.5.0-py3-none-any.whl", hash = "sha256:e578a81bb873cbb89a41fcc904c7ef523cc18284b7e3b3ccf06aca1403b7ebd3", size = 18651, upload-time = "2025-10-08T17:44:47.223Z" }, -] - -[[package]] -name = "pre-commit" -version = "4.4.0" +name = "prek" +version = "0.2.24" 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" } +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/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]] @@ -696,20 +652,6 @@ wheels = [ { 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 = "virtualenv" -version = "20.35.4" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "distlib" }, - { name = "filelock" }, - { name = "platformdirs" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/20/28/e6f1a6f655d620846bd9df527390ecc26b3805a0c5989048c210e22c5ca9/virtualenv-20.35.4.tar.gz", hash = "sha256:643d3914d73d3eeb0c552cbb12d7e82adf0e504dbf86a3182f8771a153a1971c", size = 6028799, upload-time = "2025-10-29T06:57:40.511Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/79/0c/c05523fa3181fdf0c9c52a6ba91a23fbf3246cc095f26f6516f9c60e6771/virtualenv-20.35.4-py3-none-any.whl", hash = "sha256:c21c9cede36c9753eeade68ba7d523529f228a403463376cf821eaae2b650f1b", size = 6005095, upload-time = "2025-10-29T06:57:37.598Z" }, -] - [[package]] name = "wcwidth" version = "0.2.14" From f3cf237e8f154a8f8196f0492719655f24331d44 Mon Sep 17 00:00:00 2001 From: alexis Date: Tue, 23 Dec 2025 16:09:37 +0800 Subject: [PATCH 19/30] docs: update env example and docstrings for unused api --- .env.example | 4 ++++ apps/web/views.py | 15 ++++++++++++--- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/.env.example b/.env.example index 3091903..6b07254 100644 --- a/.env.example +++ b/.env.example @@ -37,6 +37,10 @@ QUEST__RESET__API_KEY=dummy3 # --- 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/apps/web/views.py b/apps/web/views.py index 32bb317..47e177e 100644 --- a/apps/web/views.py +++ b/apps/web/views.py @@ -204,7 +204,7 @@ class CoursesReviewsAPI( """ List and create reviews for a specific course. - GET - List reviews: + GET - List reviews:(Unused API) Input: - Authentication: Required - URL parameter: course_id (integer, required) @@ -301,7 +301,7 @@ class UserReviewsAPI( """ Manage user's own reviews (CRUD operations). - GET (List) - List user's reviews: + GET (List) - List user's reviews:(Unused API) Input: - Authentication: Required - URL parameter: None @@ -318,7 +318,7 @@ class UserReviewsAPI( Success (200): ReviewSerializer object Error (404): {"detail": "Not found."} - PUT - Update review: + PUT - Update review:(Unused API) Input: - Authentication: Required - URL parameter: review_id (integer, required) @@ -403,6 +403,9 @@ def departments_api(request): @api_view(["GET"]) @permission_classes([AllowAny]) 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): @@ -443,6 +446,9 @@ def medians(request, course_id): @api_view(["GET"]) @permission_classes([AllowAny]) def course_professors(request, course_id): + """ + Unused API. + """ return Response( { "professors": sorted( @@ -467,6 +473,9 @@ 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() From 415af5411ff3251751c8136cca83f25b2dcebe6c Mon Sep 17 00:00:00 2001 From: Pachakutiq <101460915+PACHAKUTlQ@users.noreply.github.com> Date: Tue, 23 Dec 2025 17:34:05 +0800 Subject: [PATCH 20/30] fix(auth)!: Login user after signup --- apps/auth/views.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/auth/views.py b/apps/auth/views.py index 7569e8d..b242eef 100644 --- a/apps/auth/views.py +++ b/apps/auth/views.py @@ -411,6 +411,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) From 54e334296b596a0bd934b897abc978575f745182 Mon Sep 17 00:00:00 2001 From: Pachakutiq <101460915+PACHAKUTlQ@users.noreply.github.com> Date: Tue, 23 Dec 2025 17:38:24 +0800 Subject: [PATCH 21/30] fix(auth): Make sure length_step is non-zero --- apps/auth/utils.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/auth/utils.py b/apps/auth/utils.py index e23fc34..af03f79 100644 --- a/apps/auth/utils.py +++ b/apps/auth/utils.py @@ -220,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 From 68b3ed05e177d05aabd459630d3cfabd5e03c279 Mon Sep 17 00:00:00 2001 From: Pachakutiq <101460915+PACHAKUTlQ@users.noreply.github.com> Date: Tue, 23 Dec 2025 17:38:50 +0800 Subject: [PATCH 22/30] chore: Add ruff as dev dependency --- pyproject.toml | 1 + uv.lock | 6 +++++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 90395bd..4eac269 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,6 +29,7 @@ package = false [dependency-groups] dev = [ "prek>=0.2.24", + "ruff>=0.14.5", ] lint = [ "ruff==0.14.5", diff --git a/uv.lock b/uv.lock index 01be51a..d2790da 100644 --- a/uv.lock +++ b/uv.lock @@ -147,6 +147,7 @@ dependencies = [ [package.dev-dependencies] dev = [ { name = "prek" }, + { name = "ruff" }, ] lint = [ { name = "ruff" }, @@ -175,7 +176,10 @@ requires-dist = [ ] [package.metadata.requires-dev] -dev = [{ name = "prek", specifier = ">=0.2.24" }] +dev = [ + { name = "prek", specifier = ">=0.2.24" }, + { name = "ruff", specifier = ">=0.14.5" }, +] lint = [{ name = "ruff", specifier = "==0.14.5" }] [[package]] From e72930147346fd667b33ed2333de54709029659d Mon Sep 17 00:00:00 2001 From: Pachakutiq <101460915+PACHAKUTlQ@users.noreply.github.com> Date: Tue, 23 Dec 2025 17:44:05 +0800 Subject: [PATCH 23/30] fix(auth): Add CSRF protection for logout api --- apps/auth/views.py | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/auth/views.py b/apps/auth/views.py index b242eef..96ffefe 100644 --- a/apps/auth/views.py +++ b/apps/auth/views.py @@ -506,6 +506,7 @@ def auth_login_api(request) -> Response: @api_view(["POST"]) +@authentication_classes([utils.CSRFCheckSessionAuthentication]) @permission_classes([AllowAny]) def auth_logout_api(request) -> Response: logger.info( From 8f851fca68b77ec8f763ac907eccfab63c9cedca Mon Sep 17 00:00:00 2001 From: Pachakutiq <101460915+PACHAKUTlQ@users.noreply.github.com> Date: Tue, 23 Dec 2025 17:44:58 +0800 Subject: [PATCH 24/30] fix(auth): Add CSRF protection for login api --- apps/auth/views.py | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/auth/views.py b/apps/auth/views.py index 96ffefe..9428697 100644 --- a/apps/auth/views.py +++ b/apps/auth/views.py @@ -467,6 +467,7 @@ def auth_reset_password_api(request) -> Response: @api_view(["POST"]) +@authentication_classes([utils.CSRFCheckSessionAuthentication]) @permission_classes([AllowAny]) def auth_login_api(request) -> Response: account = request.data.get("account", "").strip() From cd09a018637acc146912623d8973cea2dae9cac5 Mon Sep 17 00:00:00 2001 From: Pachakutiq <101460915+PACHAKUTlQ@users.noreply.github.com> Date: Tue, 23 Dec 2025 17:46:17 +0800 Subject: [PATCH 25/30] feat(auth): Add explicit `@permission_classes([AllowAny])` for clarity --- apps/auth/views.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/auth/views.py b/apps/auth/views.py index 9428697..2c83da3 100644 --- a/apps/auth/views.py +++ b/apps/auth/views.py @@ -381,6 +381,7 @@ 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) @@ -427,6 +428,7 @@ def auth_signup_api(request) -> Response: @api_view(["POST"]) @authentication_classes([utils.CSRFCheckSessionAuthentication]) +@permission_classes([AllowAny]) def auth_reset_password_api(request) -> Response: """Reset Password API (/api/auth/password) From 20949e2072495aa63808d68ba50be8522bb8ef00 Mon Sep 17 00:00:00 2001 From: Pachakutiq <101460915+PACHAKUTlQ@users.noreply.github.com> Date: Tue, 23 Dec 2025 17:49:29 +0800 Subject: [PATCH 26/30] fix(auth): Use `user.has_usable_password()` to account for users with unusable passwords --- apps/auth/views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/auth/views.py b/apps/auth/views.py index 2c83da3..962616a 100644 --- a/apps/auth/views.py +++ b/apps/auth/views.py @@ -404,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 From 79f9c429b938ea615fe687906098c192c74b09ec Mon Sep 17 00:00:00 2001 From: Pachakutiq <101460915+PACHAKUTlQ@users.noreply.github.com> Date: Tue, 23 Dec 2025 18:08:13 +0800 Subject: [PATCH 27/30] docs(auth): Make auth initiate API /api/auth/init, which is consistent with code --- docs/auth.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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. From 4ba49c3fb44d339023f4c776b41759d4cc28f294 Mon Sep 17 00:00:00 2001 From: Pachakutiq <101460915+PACHAKUTlQ@users.noreply.github.com> Date: Tue, 23 Dec 2025 18:28:26 +0800 Subject: [PATCH 28/30] Revert "chore: Add ruff as dev dependency" This reverts commit 68b3ed05e177d05aabd459630d3cfabd5e03c279. --- pyproject.toml | 1 - uv.lock | 6 +----- 2 files changed, 1 insertion(+), 6 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 4eac269..90395bd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,7 +29,6 @@ package = false [dependency-groups] dev = [ "prek>=0.2.24", - "ruff>=0.14.5", ] lint = [ "ruff==0.14.5", diff --git a/uv.lock b/uv.lock index d2790da..01be51a 100644 --- a/uv.lock +++ b/uv.lock @@ -147,7 +147,6 @@ dependencies = [ [package.dev-dependencies] dev = [ { name = "prek" }, - { name = "ruff" }, ] lint = [ { name = "ruff" }, @@ -176,10 +175,7 @@ requires-dist = [ ] [package.metadata.requires-dev] -dev = [ - { name = "prek", specifier = ">=0.2.24" }, - { name = "ruff", specifier = ">=0.14.5" }, -] +dev = [{ name = "prek", specifier = ">=0.2.24" }] lint = [{ name = "ruff", specifier = "==0.14.5" }] [[package]] From e9effe10b0f54baca7b50282b49a37ddbda903f2 Mon Sep 17 00:00:00 2001 From: alexis Date: Fri, 26 Dec 2025 14:11:17 +0800 Subject: [PATCH 29/30] fix: rm csrf check for password login --- apps/auth/views.py | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/auth/views.py b/apps/auth/views.py index 962616a..d7bd490 100644 --- a/apps/auth/views.py +++ b/apps/auth/views.py @@ -469,7 +469,6 @@ def auth_reset_password_api(request) -> Response: @api_view(["POST"]) -@authentication_classes([utils.CSRFCheckSessionAuthentication]) @permission_classes([AllowAny]) def auth_login_api(request) -> Response: account = request.data.get("account", "").strip() From 2b31769c78294ab1c66316bc6544fb6d1e3e2a69 Mon Sep 17 00:00:00 2001 From: alexis Date: Fri, 26 Dec 2025 14:13:19 +0800 Subject: [PATCH 30/30] refactor: change reset to reset_password in action list --- .env.example | 6 +++--- apps/auth/utils.py | 2 +- apps/auth/views.py | 2 +- config.yaml.example | 2 +- website/settings.py | 4 ++-- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/.env.example b/.env.example index 6b07254..93883f9 100644 --- a/.env.example +++ b/.env.example @@ -30,9 +30,9 @@ 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 diff --git a/apps/auth/utils.py b/apps/auth/utils.py index af03f79..4c60c36 100644 --- a/apps/auth/utils.py +++ b/apps/auth/utils.py @@ -35,7 +35,7 @@ def authenticate(self, 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()) diff --git a/apps/auth/views.py b/apps/auth/views.py index d7bd490..68be287 100644 --- a/apps/auth/views.py +++ b/apps/auth/views.py @@ -437,7 +437,7 @@ def auth_reset_password_api(request) -> Response: try: verification_data, error_response = verify_token_pwd( request, - action="reset", + action="reset_password", ) if verification_data is None: return error_response or Response( diff --git a/config.yaml.example b/config.yaml.example index 71bc8b1..2890816 100644 --- a/config.yaml.example +++ b/config.yaml.example @@ -56,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/website/settings.py b/website/settings.py index 6c423fb..d281cf3 100644 --- a/website/settings.py +++ b/website/settings.py @@ -30,7 +30,7 @@ "PASSWORD_LENGTH_MIN": 10, "PASSWORD_LENGTH_MAX": 32, "EMAIL_DOMAIN_NAME": "sjtu.edu.cn", - "ACTION_LIST": ["signup", "login", "reset"], + "ACTION_LIST": ["signup", "login", "reset_password"], }, "DATABASE": {"URL": "sqlite:///db.sqlite3"}, "REDIS": {"URL": "redis://localhost:6379/0", "MAX_CONNECTIONS": 100}, @@ -47,7 +47,7 @@ "URL": None, "QUESTIONID": None, }, - "RESET": { + "RESET_PASSWORD": { "API_KEY": None, "URL": None, "QUESTIONID": None,