diff --git a/.env.example b/.env.example index 3091903..93883f9 100644 --- a/.env.example +++ b/.env.example @@ -30,13 +30,17 @@ QUEST__LOGIN__API_KEY=dummy2 # QUEST__LOGIN__URL= # QUEST__LOGIN__QUESTIONID= -QUEST__RESET__API_KEY=dummy3 -# QUEST__RESET__URL= -# QUEST__RESET__QUESTIONID= +QUEST__RESET_PASSWORD__API_KEY=dummy3 +# QUEST__RESET_PASSWORD__URL= +# QUEST__RESET_PASSWORD__QUESTIONID= # --- Other Overrides (Optional) --- # Example of overriding a nested value in the AUTH dictionary # AUTH__OTP_TIMEOUT=60 +# Example of overridng web size constraints +# WEB__COURSE__PAGE_SIZE=5 +# WEB__REVIEW__PAGE_SIZE=10 +# WEB__REVIEW__COMMENT_MIN_LENGTH=30 # Example of overriding a list with a comma-separated string # ALLOWED_HOSTS=localhost,127.0.0.1,dev.my-app.com diff --git a/.github/workflows/bot.yaml b/.github/workflows/bot.yaml new file mode 100644 index 0000000..3a11154 --- /dev/null +++ b/.github/workflows/bot.yaml @@ -0,0 +1,63 @@ +name: feishu bot + +on: + branch_protection_rule: + types: [created, deleted] + check_run: + types: [rerequested, completed] + check_suite: + types: [completed] + create: + delete: + deployment_status: + discussion: + types: [created, edited, answered] + discussion_comment: + types: [created, deleted] + fork: + gollum: + issues: + types: [opened, edited, milestoned, pinned, reopened] + issue_comment: + types: [created, deleted] + label: + types: [created, deleted] + merge_group: + types: [checks_requested] + milestone: + types: [opened, deleted] + page_build: + project: + types: [created, deleted, reopened] + project_card: + types: [created, deleted] + project_column: + types: [created, deleted] + public: + pull_request: + branches: + - '*' + types: [opened, reopened] + pull_request_review: + types: [edited, dismissed, submitted] + pull_request_review_comment: + types: [created, edited, deleted] + pull_request_target: + types: [assigned, opened, synchronize, reopened] + push: + branches: + - '*' + registry_package: + types: [published] + release: + types: [published] + +jobs: + send-event: + name: Webhook + runs-on: ubuntu-latest + steps: + - uses: junka/feishu-bot-webhook-action@main + with: + webhook: ${{ secrets.FEISHU_BOT_WEBHOOK }} + signkey: ${{ secrets.FEISHU_BOT_SIGNKEY }} diff --git a/.gitignore b/.gitignore index 6b28f35..a18385a 100644 --- a/.gitignore +++ b/.gitignore @@ -198,9 +198,9 @@ cython_debug/ .abstra/ # Visual Studio Code -# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore +# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore # that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore -# and can be added to the global gitignore or merged into this file. However, if you prefer, +# and can be added to the global gitignore or merged into this file. However, if you prefer, # you could uncomment the following to ignore the entire vscode folder # .vscode/ @@ -217,4 +217,3 @@ __marimo__/ # Streamlit .streamlit/secrets.toml - diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index f0cff26..87696b3 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,9 +1,20 @@ repos: - - repo: local + + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v6.0.0 hooks: - - id: format-and-add - name: Format code and stage changes - entry: uv run scripts/pre-commit-format.py - language: system - pass_filenames: false + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-yaml + - id: check-added-large-files + + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.14.5 + hooks: + - id: ruff-check + types_or: [python, pyi] + always_run: true + args: ["--fix"] + - id: ruff-format + types_or: [python, pyi] always_run: true diff --git a/Makefile b/Makefile index ad8f5f9..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,9 @@ format: format-backend format-frontend @echo "All code formatted successfully!" format-backend: - @echo "Formatting backend (Python) code with isort and black..." - uvx ruff format + @echo "Formatting backend (Python) code with ruff check and format..." + uv run ruff check --select I . --fix && \ + uv run ruff format format-frontend: @echo "Formatting frontend code with prettier..." @@ -57,7 +58,7 @@ lint: lint-backend lint-frontend lint-backend: format-backend @echo "Linting backend (Python) code with ruff..." - uvx ruff check + uv run ruff check lint-frontend: format-frontend @echo "Linting frontend code with eslint..." diff --git a/apps/auth/admin.py b/apps/auth/admin.py deleted file mode 100644 index 8c38f3f..0000000 --- a/apps/auth/admin.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.contrib import admin - -# Register your models here. diff --git a/apps/auth/models.py b/apps/auth/models.py deleted file mode 100644 index 71a8362..0000000 --- a/apps/auth/models.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.db import models - -# Create your models here. diff --git a/apps/auth/tests.py b/apps/auth/tests.py deleted file mode 100644 index 7ce503c..0000000 --- a/apps/auth/tests.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.test import TestCase - -# Create your tests here. diff --git a/apps/auth/urls.py b/apps/auth/urls.py new file mode 100644 index 0000000..822f4ad --- /dev/null +++ b/apps/auth/urls.py @@ -0,0 +1,16 @@ +from django.urls import re_path + +from apps.auth import views as auth_views + +urlpatterns = [ + re_path(r"^init/$", auth_views.auth_initiate_api, name="auth_initiate_api"), + re_path(r"^verify/$", auth_views.verify_callback_api, name="verify_callback_api"), + re_path( + r"^password/$", + auth_views.auth_reset_password_api, + name="auth_reset_password_api", + ), + re_path(r"^signup/$", auth_views.auth_signup_api, name="auth_signup_api"), + re_path(r"^login/$", auth_views.auth_login_api, name="auth_login_api"), + re_path(r"^logout/?$", auth_views.auth_logout_api, name="auth_logout_api"), +] diff --git a/apps/auth/utils.py b/apps/auth/utils.py index e2dfa14..4c60c36 100644 --- a/apps/auth/utils.py +++ b/apps/auth/utils.py @@ -1,6 +1,7 @@ import json import logging import re +from typing import Any import httpx from django.conf import settings @@ -8,11 +9,13 @@ from django.contrib.auth.models import AbstractUser from django.contrib.auth.password_validation import validate_password from django.core.exceptions import ValidationError +from rest_framework.authentication import SessionAuthentication from rest_framework.response import Response -from typing import Any from apps.web.models import Student +logger = logging.getLogger(__name__) + AUTH_SETTINGS = settings.AUTH PASSWORD_LENGTH_MIN = AUTH_SETTINGS["PASSWORD_LENGTH_MIN"] PASSWORD_LENGTH_MAX = AUTH_SETTINGS["PASSWORD_LENGTH_MAX"] @@ -23,22 +26,28 @@ QUEST_BASE_URL = QUEST_SETTINGS["BASE_URL"] +class CSRFCheckSessionAuthentication(SessionAuthentication): + def authenticate(self, request): + super().enforce_csrf(request) + return super().authenticate(request) + + def get_survey_details(action: str) -> dict[str, Any] | None: """ A single, clean function to get all survey details for a given action. - Valid actions: "signup", "login", "reset". + Valid actions: "signup", "login", "reset_password". """ action_details = QUEST_SETTINGS.get(action.upper()) if not action_details: - logging.error("Invalid quest action requested: %s", action) + logger.error("Invalid quest action requested: %s", action) return None try: question_id = int(action_details.get("QUESTIONID")) except (ValueError, TypeError): - logging.error( + logger.error( "Could not parse 'QUESTIONID' for action '%s'. Check your settings.", action ) return None @@ -66,18 +75,18 @@ async def verify_turnstile_token( }, ) if not response.json().get("success"): - logging.warning("Turnstile verification failed: %s", response.json()) + logger.warning("Turnstile verification failed: %s", response.json()) return False, Response( {"error": "Turnstile verification failed"}, status=403 ) return True, None except httpx.TimeoutException: - logging.error("Turnstile verification timed out") + logger.error("Turnstile verification timed out") return False, Response( {"error": "Turnstile verification timed out"}, status=504 ) - except Exception as e: - logging.error(f"Error verifying Turnstile token: {e}") + except Exception: + logger.error("Turnstile verification error") return False, Response({"error": "Turnstile verification error"}, status=500) @@ -132,19 +141,19 @@ async def get_latest_answer( response.raise_for_status() # Raise an exception for bad status codes full_data = response.json() except httpx.TimeoutException: - logging.exception("Questionnaire API query timed out") + logger.error("Questionnaire API query timed out") return None, Response( {"error": "Questionnaire API query timed out"}, status=504, ) - except httpx.RequestError as e: - logging.exception(f"Error querying questionnaire API: {e}") + except httpx.RequestError: + logger.error("Error querying questionnaire API") return None, Response( {"error": "Failed to query questionnaire API"}, status=500, ) - except Exception as e: - logging.exception(f"An unexpected error occurred: {e}") + except Exception: + logger.error("An unexpected error occurred") return None, Response({"error": "An unexpected error occurred"}, status=500) # Filter and return only the required fields from the first row @@ -180,7 +189,7 @@ async def get_latest_answer( key in filtered_data and filtered_data[key] is not None for key in ["id", "submitted_at", "account", "otp"] ): - logging.warning("Missing required field(s) in questionnaire response") + logger.warning("Missing required field(s) in questionnaire response") return None, Response( {"error": "Missing required field(s) in questionnaire response"}, status=400, @@ -211,7 +220,8 @@ def rate_password_strength(password: str) -> int: if re.search(r"[^a-zA-Z0-9\s]", password): score += 1 - length_step = (PASSWORD_LENGTH_MAX - PASSWORD_LENGTH_MIN) // 10 + length_range = max(1, PASSWORD_LENGTH_MAX - PASSWORD_LENGTH_MIN) + length_step = max(1, length_range // 10) score += (len(password) - PASSWORD_LENGTH_MIN) // length_step diff --git a/apps/auth/views.py b/apps/auth/views.py index 6350dc0..68be287 100644 --- a/apps/auth/views.py +++ b/apps/auth/views.py @@ -1,5 +1,4 @@ import asyncio -import base64 import hashlib import json import logging @@ -7,11 +6,10 @@ import time import dateutil.parser -import httpx 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, @@ -23,25 +21,21 @@ from apps.auth import utils from apps.web.models import Student - -class CsrfExemptSessionAuthentication(SessionAuthentication): - def enforce_csrf(self, request): - return +logger = logging.getLogger(__name__) AUTH_SETTINGS = settings.AUTH OTP_TIMEOUT = AUTH_SETTINGS["OTP_TIMEOUT"] TEMP_TOKEN_TIMEOUT = AUTH_SETTINGS["TEMP_TOKEN_TIMEOUT"] -ACTION_LIST = ["signup", "login", "reset_password"] +ACTION_LIST = AUTH_SETTINGS["ACTION_LIST"] TOKEN_RATE_LIMIT = AUTH_SETTINGS["TOKEN_RATE_LIMIT"] TOKEN_RATE_LIMIT_TIME = AUTH_SETTINGS["TOKEN_RATE_LIMIT_TIME"] @api_view(["POST"]) -@authentication_classes([CsrfExemptSessionAuthentication]) @permission_classes([AllowAny]) def auth_initiate_api(request): - """Step 1: Authentication Initiation (/api/auth/initiate) + """Step 1: Authentication Initiation (/api/auth/init) 1. Receives action and turnstile_token from frontend 2. Verifies Turnstile token with Cloudflare's API @@ -54,9 +48,11 @@ def auth_initiate_api(request): turnstile_token = request.data.get("turnstile_token") if not action or not turnstile_token: + logger.warning("Missing action or turnstile_token in auth_initiate_api") return Response({"error": "Missing action or turnstile_token"}, status=400) if action not in ACTION_LIST: + logger.warning("Invalid action '%s' in auth_initiate_api", action) return Response({"error": "Invalid action"}, status=400) client_ip = ( @@ -70,6 +66,10 @@ def auth_initiate_api(request): utils.verify_turnstile_token(turnstile_token, client_ip) ) if not success: + logger.warning( + "verify_turnstile_token failed in auth_initiate_api:%s", + error_response.data, + ) return error_response # Generate cryptographically secure OTP and temp_token @@ -89,13 +89,12 @@ def auth_initiate_api(request): if existing_state_data: existing_state = json.loads(existing_state_data) r.delete(existing_state_key) - logging.info( - f"Cleaned up existing temp_token_state for action { - existing_state.get('action', 'unknown') - }" + logger.info( + "Cleaned up existing temp_token_state for action %s", + existing_state.get("action", "unknown"), ) - except Exception as e: - logging.warning(f"Error cleaning up existing temp_token: {e}") + except Exception: + logger.warning("Error cleaning up existing temp_token") # Store OTP -> temp_token mapping with initiated_at timestamp current_time = time.time() @@ -111,13 +110,15 @@ def auth_initiate_api(request): json.dumps(temp_token_state), ) - logging.info("Created auth intent for action %s with OTP and temp_token", action) + logger.info("Created auth intent for action %s with OTP and temp_token", action) details = utils.get_survey_details(action) if not details: + logger.error("Invalid action '%s' when fetching survey details", action) return Response({"error": "Invalid action"}, status=400) survey_url = details.get("url") if not survey_url: + logger.error("Survey URL missing for %s", action) return Response( {"error": "Something went wrong when fetching the survey URL"}, status=500, @@ -136,28 +137,36 @@ def auth_initiate_api(request): return response +@ensure_csrf_cookie @api_view(["POST"]) -@authentication_classes([CsrfExemptSessionAuthentication]) @permission_classes([AllowAny]) def verify_callback_api(request): """Callback Verification (/api/auth/verify) request data includes account, answer_id, action Handles the verification of questionnaire callback using temp_token from cookie. """ + logger.info( + "verify_callback_api called for account=%s, action=%s", + request.data.get("account"), + request.data.get("action"), + ) # Get required parameters from request account = request.data.get("account") answer_id = request.data.get("answer_id") action = request.data.get("action") if not account or not answer_id or not action: + logger.warning("Missing account, answer_id, or action in verify_callback_api") return Response({"error": "Missing account, answer_id, or action"}, status=400) if action not in ACTION_LIST: + logger.warning("Invalid action '%s' in verify_callback_api", action) return Response({"error": "Invalid action"}, status=400) # Get temp_token from HttpOnly cookie temp_token = request.COOKIES.get("temp_token") if not temp_token: + logger.warning("No temp_token found in verify_callback_api") return Response({"error": "No temp_token found"}, status=401) r = get_redis_connection("default") @@ -168,18 +177,22 @@ def verify_callback_api(request): state_data = r.get(state_key) if not state_data: + logger.warning("Temp token state not found or expired in verify_callback_api") return Response({"error": "Temp token state not found or expired"}, status=401) try: state_data = json.loads(state_data) except json.JSONDecodeError: + logger.error("Invalid temp token state data in verify_callback_api") return Response({"error": "Invalid temp token state data"}, status=401) # Verify status is pending and action matches if state_data.get("status") != "pending": + logger.warning("Temp token state not pending in verify_callback_api") return Response({"error": "Invalid temp token state"}, status=401) if state_data.get("action") != action: + logger.warning("Action mismatch in verify_callback_api") return Response({"error": "Action mismatch"}, status=403) # Step 2: Apply rate limiting per temp_token to prevent brute-force attempts @@ -193,6 +206,7 @@ def verify_callback_api(request): r.expire(rate_limit_key, TOKEN_RATE_LIMIT_TIME) if attempts > TOKEN_RATE_LIMIT: + logger.warning("Too many verification attempts in verify_callback_api") return Response({"error": "Too many verification attempts"}, status=429) # Step 3: Query questionnaire API for latest submission of the specific questionnaire of the action @@ -203,10 +217,12 @@ def verify_callback_api(request): return error_response if latest_answer is None: + logger.warning("No questionnaire submission found in verify_callback_api") return Response({"error": "No questionnaire submission found"}, status=404) # Check if this is the submission we're looking for if str(latest_answer.get("id")) != str(answer_id): + logger.warning("Answer ID mismatch in verify_callback_api") return Response({"error": "Answer ID mismatch"}, status=403) # Extract OTP and quest_id from submission @@ -217,6 +233,7 @@ def verify_callback_api(request): otp_data_raw = r.getdel(otp_key) if not otp_data_raw: + logger.warning("Invalid or expired OTP in verify_callback_api") return Response({"error": "Invalid or expired OTP"}, status=401) try: @@ -224,13 +241,16 @@ def verify_callback_api(request): expected_temp_token = otp_data.get("temp_token") initiated_at = otp_data.get("initiated_at") except (json.JSONDecodeError, AttributeError): + logger.error("Invalid OTP data format in verify_callback_api") return Response({"error": "Invalid OTP data format"}, status=401) if not expected_temp_token or not initiated_at: + logger.warning("Incomplete OTP data in verify_callback_api") return Response({"error": "Incomplete OTP data"}, status=401) # Step 5: StepVerify temp_token matches if expected_temp_token != temp_token: + logger.warning("Invalid temp_token in verify_callback_api") return Response({"error": "Invalid temp_token"}, status=401) # Step 6: Validate submission timestamp after OTP extraction @@ -248,8 +268,8 @@ def verify_callback_api(request): status=401, ) - except (ValueError, TypeError) as e: - logging.exception(f"Error parsing submission timestamp: {e}") + except (ValueError, TypeError): + logger.error("Error parsing submission timestamp") return Response({"error": "Invalid submission timestamp"}, status=401) # Step 7: Update state to verified and add user details @@ -267,8 +287,10 @@ def verify_callback_api(request): # Clear rate limiting on success r.delete(rate_limit_key) - logging.info( - "Successfully verified temp_token for user %s with action %s", account, action + logger.info( + "Successfully verified temp_token for user %s with action %s", + account, + action, ) # For login action, handle immediate session creation and cleanup @@ -277,15 +299,16 @@ def verify_callback_api(request): user, error_response = utils.create_user_session(request, account) if user is None: if error_response: - logging.error( + logger.error( "Failed to create session for login: %s", getattr(error_response, "data", {}).get("error", "Unknown error"), ) return error_response else: + logger.error("Failed to create user session in verify_callback_api") return Response({"error": "Failed to create user session"}, status=500) if not user.is_active: - logging.warning("Inactive user attempted OAuth login: %s", account) + logger.warning("Inactive user attempted OAuth login: %s", account) return Response({"error": "User account is inactive"}, status=403) try: # Create Django session @@ -294,7 +317,7 @@ def verify_callback_api(request): # Delete temp_token_state after successful login r.delete(state_key) except Exception: - logging.exception( + logger.exception( "Error during login session creation or cleanup for user %s", account ) return Response({"error": "Failed to finalize login process"}, status=500) @@ -357,6 +380,8 @@ def verify_token_pwd(request, action: str) -> tuple[dict | None, Response | None @api_view(["POST"]) +@authentication_classes([utils.CSRFCheckSessionAuthentication]) +@permission_classes([AllowAny]) def auth_signup_api(request) -> Response: """Signup API (/api/auth/signup) @@ -379,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 @@ -387,6 +412,8 @@ def auth_signup_api(request) -> Response: user.set_password(password) user.save() + login(request, user) + # Cleanup: Delete temp_token_state and clear cookie r = get_redis_connection("default") r.delete(state_key) @@ -394,12 +421,14 @@ def auth_signup_api(request) -> Response: response.delete_cookie("temp_token") return response - except Exception as e: - logging.error(f"Error in auth_signup_api: {e}") + except Exception: + logger.error("Error in auth_signup_api") return Response({"error": "Failed to complete signup"}, status=500) @api_view(["POST"]) +@authentication_classes([utils.CSRFCheckSessionAuthentication]) +@permission_classes([AllowAny]) def auth_reset_password_api(request) -> Response: """Reset Password API (/api/auth/password) @@ -434,13 +463,12 @@ def auth_reset_password_api(request) -> Response: response.delete_cookie("temp_token") return response - except Exception as e: - logging.error(f"Error in auth_reset_password_api: {e}") + except Exception: + logger.error("Error in auth_reset_password_api") return Response({"error": "Failed to reset password"}, status=500) @api_view(["POST"]) -@authentication_classes([CsrfExemptSessionAuthentication]) @permission_classes([AllowAny]) def auth_login_api(request) -> Response: account = request.data.get("account", "").strip() @@ -448,6 +476,9 @@ def auth_login_api(request) -> Response: turnstile_token = request.data.get("turnstile_token", "") if not account or not password or not turnstile_token: + logger.warning( + "Account, password, and Turnstile token are missing in auth_login_api" + ) return Response( {"error": "Account, password, and Turnstile token are missing"}, status=400 ) @@ -477,9 +508,13 @@ def auth_login_api(request) -> Response: @api_view(["POST"]) -@authentication_classes([CsrfExemptSessionAuthentication]) +@authentication_classes([utils.CSRFCheckSessionAuthentication]) @permission_classes([AllowAny]) def auth_logout_api(request) -> Response: + logger.info( + "auth_logout_api called for user=%s", + getattr(request.user, "username", None), + ) """Logout a user.""" logout(request) return Response({"message": "Logged out successfully"}, status=200) diff --git a/apps/spider/urls.py b/apps/spider/urls.py new file mode 100644 index 0000000..bb8420c --- /dev/null +++ b/apps/spider/urls.py @@ -0,0 +1,12 @@ +from django.urls import re_path + +from apps.spider import views as spider_views + +urlpatterns = [ + re_path(r"^data/$", spider_views.crawled_data_list, name="crawled_datas"), + re_path( + r"^data/(?P[0-9]+)$", + spider_views.crawled_data_detail, + name="crawled_data", + ), +] diff --git a/apps/web/migrations/0010_remove_review_dislike_count_and_more.py b/apps/web/migrations/0010_remove_review_dislike_count_and_more.py new file mode 100644 index 0000000..89f6b0f --- /dev/null +++ b/apps/web/migrations/0010_remove_review_dislike_count_and_more.py @@ -0,0 +1,20 @@ +# Generated by Django 5.2 on 2025-10-13 15:42 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("web", "0009_remove_student_confirmation_link_and_more"), + ] + + operations = [ + migrations.RemoveField( + model_name="review", + name="dislike_count", + ), + migrations.RemoveField( + model_name="review", + name="kudos_count", + ), + ] diff --git a/apps/web/migrations/0011_remove_course_difficulty_score_and_more.py b/apps/web/migrations/0011_remove_course_difficulty_score_and_more.py new file mode 100644 index 0000000..95cde41 --- /dev/null +++ b/apps/web/migrations/0011_remove_course_difficulty_score_and_more.py @@ -0,0 +1,20 @@ +# Generated by Django 5.2.8 on 2025-12-03 08:04 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("web", "0010_remove_review_dislike_count_and_more"), + ] + + operations = [ + migrations.RemoveField( + model_name="course", + name="difficulty_score", + ), + migrations.RemoveField( + model_name="course", + name="quality_score", + ), + ] diff --git a/apps/web/models.py b/apps/web/models.py deleted file mode 100644 index 71a8362..0000000 --- a/apps/web/models.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.db import models - -# Create your models here. diff --git a/apps/web/models/__init__.py b/apps/web/models/__init__.py index 778deea..6c7fa92 100644 --- a/apps/web/models/__init__.py +++ b/apps/web/models/__init__.py @@ -7,3 +7,15 @@ from .student import Student from .vote import Vote from .vote_for_review import ReviewVote + +__all__ = [ + "Course", + "CourseMedian", + "CourseOffering", + "DistributiveRequirement", + "Instructor", + "Review", + "Student", + "Vote", + "ReviewVote", +] diff --git a/apps/web/models/course.py b/apps/web/models/course.py index 8b280b1..dd2e8a4 100644 --- a/apps/web/models/course.py +++ b/apps/web/models/course.py @@ -3,7 +3,8 @@ import re from django.db import models -from django.db.models import Q +from django.db.models import Avg, Count, Q +from django.db.models.functions import Coalesce from django.urls import reverse from lib.constants import CURRENT_TERM @@ -69,6 +70,37 @@ def search(self, query): ) return courses + def with_scores(self): + """Annotate courses with calculated scores and review count (for list view)""" + from apps.web.models import Vote + + return self.annotate( + quality_score=Coalesce( + Avg("vote__value", filter=Q(vote__category=Vote.CATEGORIES.QUALITY)), + 0.0, + ), + difficulty_score=Coalesce( + Avg("vote__value", filter=Q(vote__category=Vote.CATEGORIES.DIFFICULTY)), + 0.0, + ), + review_count=Count("review", distinct=True), + ) + + def with_scores_vote_counts(self): + """Annotate courses with vote counts (for detail view)""" + from apps.web.models import Vote + + return self.with_scores().annotate( + quality_vote_count=Count( + "vote", filter=Q(vote__category=Vote.CATEGORIES.QUALITY), distinct=True + ), + difficulty_vote_count=Count( + "vote", + filter=Q(vote__category=Vote.CATEGORIES.DIFFICULTY), + distinct=True, + ), + ) + class Course(models.Model): objects = CourseManager() @@ -99,9 +131,6 @@ class SOURCES: # subnumber = models.IntegerField(null=True, db_index=True, blank=True) # source = models.CharField(max_length=16, choices=SOURCES.CHOICES) - difficulty_score = models.FloatField(default=0.0) - quality_score = models.FloatField(default=0.0) - created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) @@ -188,7 +217,8 @@ def get_instructors(self, term=CURRENT_TERM): If term is None, returns instructors across all terms. """ instructors = [] - offerings = self.courseoffering_set.all() + # Prefetch instructors to avoid N+1 queries + offerings = self.courseoffering_set.prefetch_related("instructors").all() if term: offerings = offerings.filter(term=term) diff --git a/apps/web/models/forms/__init__.py b/apps/web/models/forms/__init__.py deleted file mode 100644 index a566c4c..0000000 --- a/apps/web/models/forms/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from .review_form import ReviewForm diff --git a/apps/web/models/forms/review_form.py b/apps/web/models/forms/review_form.py deleted file mode 100644 index 1d7b69f..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 Course, Review -from lib import constants -from lib.terms import is_valid_term - -REVIEW_MINIMUM_LENGTH = 30 - - -class ReviewForm(forms.ModelForm): - def clean_term(self): - term = self.cleaned_data["term"].upper() - if is_valid_term(term): - return term - else: - raise ValidationError( - "Please use a valid term, e.g. {}".format(constants.CURRENT_TERM) - ) - - def clean_professor(self): - professor = self.cleaned_data["professor"] - names = professor.split(" ") - - if len(names) < 2: - raise ValidationError("Please use a valid professor name, e.g. John Smith") - - return " ".join([n.capitalize() for n in names]) - - def clean_comments(self): - review = self.cleaned_data["comments"] - - if len(review) < REVIEW_MINIMUM_LENGTH: - raise ValidationError( - "Please write a longer review (at least {} characters)".format( - REVIEW_MINIMUM_LENGTH - ) - ) - - return review - - class Meta: - model = Review - fields = ["term", "professor", "comments"] - - widgets = { - "term": forms.TextInput( - attrs={"placeholder": "e.g. {}".format(constants.CURRENT_TERM)} - ), - "professor": forms.TextInput( - attrs={"placeholder": "Full name please, e.g. John Smith"} - ), - } - - labels = {"comments": "Review"} - - help_texts = { - "professor": "Please choose from the suggestions if you can.", - } diff --git a/apps/web/models/review.py b/apps/web/models/review.py index 3da86c2..b649d11 100644 --- a/apps/web/models/review.py +++ b/apps/web/models/review.py @@ -2,31 +2,55 @@ from django.contrib.auth.models import User from django.db import models +from django.db.models import Count, OuterRef, Q, Subquery class ReviewManager(models.Manager): def user_can_write_review(self, user, course): return not self.filter(user=user, course=course).exists() - def num_reviews_for_user(self, user): + def review_count_for_user(self, user): return self.filter(user=user).count() - def delete_reviews_for_user_course(self, user, course): - self.filter(course=course, user=user).delete() + def with_votes(self, vote_user=None, **kwargs): + """ + Return queryset with annotated vote counts (kudos, dislike) and user's vote. - def get_user_review_for_course(self, user, course): + Args: + vote_user: User object for user vote annotations + **kwargs: Additional filter parameters for queryset """ - Get the review written by a user for a specific course. - Returns the Review object if found, None otherwise. - If multiple reviews exist, returns the most recent one. + queryset = self.filter(**kwargs).annotate( + kudos_count=Count("votes", filter=Q(votes__is_kudos=True), distinct=True), + dislike_count=Count( + "votes", filter=Q(votes__is_kudos=False), distinct=True + ), + ) + + if vote_user and vote_user.is_authenticated: + from .vote_for_review import ReviewVote + + # Define subquery: get the is_kudos value for current user's vote on this review + vote_subquery = ReviewVote.objects.filter( + review=OuterRef("pk"), user=vote_user + ).values("is_kudos")[:1] + + queryset = queryset.annotate( + user_vote=Subquery( + vote_subquery, output_field=models.BooleanField(null=True) + ) + ) + + return queryset + + def raw_queryset(self, **kwargs): + """ + Return base queryset without vote annotations for better performance when votes aren't needed. + + Args: + **kwargs: Additional filter parameters """ - try: - return self.get(user=user, course=course) - except self.model.DoesNotExist: - return None - except self.model.MultipleObjectsReturned: - # If somehow there are multiple reviews, return the most recent one - return self.filter(user=user, course=course).order_by("-created_at").first() + return self.filter(**kwargs) class Review(models.Model): @@ -56,11 +80,6 @@ class Review(models.Model): ) difficulty_sentiment = models.FloatField(default=None, null=True, blank=True) quality_sentiment = models.FloatField(default=None, null=True, blank=True) - - # Kudos and dislike counts - kudos_count = models.PositiveIntegerField(default=0, db_index=True) - dislike_count = models.PositiveIntegerField(default=0, db_index=True) - created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) diff --git a/apps/web/models/vote.py b/apps/web/models/vote.py index 2c50177..9d8861e 100644 --- a/apps/web/models/vote.py +++ b/apps/web/models/vote.py @@ -2,6 +2,7 @@ from django.contrib.auth.models import User from django.db import models, transaction +from django.db.models import Avg from .course import Course @@ -9,21 +10,9 @@ class VoteManager(models.Manager): @transaction.atomic def vote(self, value, course_id, category, user): - is_unvote = False - - if value > 5 or value < 1: - return None, is_unvote, None - - course = Course.objects.get(id=course_id) + course = Course.objects.select_for_update().get(id=course_id) vote, created = self.get_or_create(course=course, category=category, user=user) - # if previously voted, reverse the old value of the vote - if not created: - if category == Vote.CATEGORIES.QUALITY: - course.quality_score -= vote.value - elif category == Vote.CATEGORIES.DIFFICULTY: - course.difficulty_score -= vote.value - is_unvote = not created and vote.value == value if is_unvote: @@ -33,23 +22,16 @@ def vote(self, value, course_id, category, user): vote.save() new_score = self._calculate_average_score(course, category) - if category == Vote.CATEGORIES.QUALITY: - course.quality_score = new_score - elif category == Vote.CATEGORIES.DIFFICULTY: - course.difficulty_score = new_score - course.save() - return new_score, is_unvote, self.get_vote_count(course, category) + vote_count = self.get_vote_count(course, category) - def _calculate_average_score(self, course, category): - """Calculate the average score for a course in a specific category""" - votes = self.filter(course=course, category=category) - if not votes.exists(): - return 0 + return new_score, is_unvote, vote_count - total_score = sum(vote.value for vote in votes) - vote_count = votes.count() - # Return average rounded to 1 decimal place - return round(total_score / vote_count, 1) + def _calculate_average_score(self, course, category): + result = self.filter(course=course, category=category).aggregate( + avg_score=Avg("value") + ) + avg = result["avg_score"] + return round(avg, 1) if avg is not None else 0 def get_vote_count(self, course, category): """Get the vote count for a course in a specific category""" @@ -116,6 +98,9 @@ class CATEGORIES: class Meta: unique_together = ("course", "user", "category") + indexes = [ + models.Index(fields=["course", "category", "value"]), + ] def __unicode__(self): return "{} for {} by {}".format( diff --git a/apps/web/models/vote_for_review.py b/apps/web/models/vote_for_review.py index 87fcefc..a60c3c0 100644 --- a/apps/web/models/vote_for_review.py +++ b/apps/web/models/vote_for_review.py @@ -34,43 +34,25 @@ def vote(self, review_id, user, is_kudos=True): ) if created: - # New vote, increment the appropriate counter - if is_kudos: - review.kudos_count = models.F("kudos_count") + 1 - else: - review.dislike_count = models.F("dislike_count") + 1 - review.save(update_fields=["kudos_count", "dislike_count"]) + # New vote vote_value = is_kudos else: # Existing vote if review_vote.is_kudos == is_kudos: - # Same vote type, remove it (cancel) review_vote.delete() - if is_kudos: - review.kudos_count = models.F("kudos_count") - 1 - else: - review.dislike_count = models.F("dislike_count") - 1 - review.save(update_fields=["kudos_count", "dislike_count"]) - vote_value = None # User cancelled their vote + vote_value = None else: - # Change vote from kudos to dislike or vice versa - old_is_kudos = review_vote.is_kudos review_vote.is_kudos = is_kudos review_vote.save() - - # Update counts: decrease old vote type, increase new vote type - if old_is_kudos: # Was kudos, changing to dislike - review.kudos_count = models.F("kudos_count") - 1 - review.dislike_count = models.F("dislike_count") + 1 - else: # Was dislike, changing to kudos - review.dislike_count = models.F("dislike_count") - 1 - review.kudos_count = models.F("kudos_count") + 1 - review.save(update_fields=["kudos_count", "dislike_count"]) vote_value = is_kudos - # Return updated counts and user's current vote - review.refresh_from_db() - return review.kudos_count, review.dislike_count, vote_value + review_with_votes = Review.objects.with_votes(id=review_id).first() + if review_with_votes: + kudos_count = review_with_votes.kudos_count + dislike_count = review_with_votes.dislike_count + else: + kudos_count, dislike_count = 0, 0 + return kudos_count, dislike_count, vote_value def get_user_vote(self, review, user): """Get the user's vote for a review""" @@ -81,12 +63,6 @@ def get_user_vote(self, review, user): except self.model.DoesNotExist: return None - def get_vote_counts(self, review): - """Get kudos and dislike counts for a review""" - kudos_count = self.filter(review=review, is_kudos=True).count() - dislike_count = self.filter(review=review, is_kudos=False).count() - return kudos_count, dislike_count - class ReviewVote(models.Model): """ diff --git a/apps/web/serializers.py b/apps/web/serializers.py index 53c1db8..ae76365 100644 --- a/apps/web/serializers.py +++ b/apps/web/serializers.py @@ -1,18 +1,18 @@ # apps/web/serializers.py +from django.conf import settings from django.db.models import Count from rest_framework import serializers from apps.web.models import ( Course, - CourseMedian, CourseOffering, DistributiveRequirement, Instructor, Review, Vote, - ReviewVote, ) from lib import constants +from lib.terms import is_valid_term class DistributiveRequirementSerializer(serializers.ModelSerializer): @@ -30,10 +30,12 @@ class Meta: class ReviewSerializer(serializers.ModelSerializer): - # user = serializers.StringRelatedField() # Display username + # user = serializers.StringRelatedField() term = serializers.CharField() professor = serializers.CharField() user_vote = serializers.SerializerMethodField() + kudos_count = serializers.SerializerMethodField() + dislike_count = serializers.SerializerMethodField() class Meta: model = Review @@ -49,18 +51,60 @@ class Meta: "created_at", "user_vote", ) + read_only_fields = ( + "id", + "kudos_count", + "dislike_count", + "created_at", + "user_vote", + ) + + def get_kudos_count(self, obj): + """Get the number of kudos for this review""" + return getattr(obj, "kudos_count", 0) + + def get_dislike_count(self, obj): + """Get the number of dislikes for this review""" + return getattr(obj, "dislike_count", 0) def get_user_vote(self, obj): """Get the current user's vote for this review""" - request = self.context.get("request") - if not request or not request.user.is_authenticated: - return None + return getattr(obj, "user_vote", None) + + def validate_term(self, value): + """Validate term format""" + term = value.upper() + + if is_valid_term(term): + return term + else: + raise serializers.ValidationError( + "Please use a valid term, e.g. {}".format(constants.CURRENT_TERM) + ) + + def validate_professor(self, value): + """Validate professor name format""" + names = value.split(" ") + + if len(names) < 2: + raise serializers.ValidationError( + "Please use a valid professor name, e.g. John Smith" + ) + + return " ".join([n.capitalize() for n in names]) + + def validate_comments(self, value): + """Validate review minimum length""" + REVIEW_MINIMUM_LENGTH = settings.WEB["REVIEW"]["COMMENT_MIN_LENGTH"] + + if len(value) < REVIEW_MINIMUM_LENGTH: + raise serializers.ValidationError( + "Please write a longer review (at least {} characters)".format( + REVIEW_MINIMUM_LENGTH + ) + ) - try: - vote = ReviewVote.objects.get(review=obj, user=request.user) - return vote.is_kudos # True for kudos, False for dislike - except ReviewVote.DoesNotExist: - return None + return value class DepartmentSerializer(serializers.Serializer): @@ -74,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 @@ -91,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() @@ -132,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) @@ -147,6 +208,8 @@ class CourseSerializer(serializers.ModelSerializer): course_topics = serializers.SerializerMethodField() quality_vote_count = serializers.SerializerMethodField() difficulty_vote_count = serializers.SerializerMethodField() + quality_score = serializers.SerializerMethodField() + difficulty_score = serializers.SerializerMethodField() class Meta: model = Course @@ -199,7 +262,13 @@ def get_review_set(self, obj): return [] def get_review_count(self, obj): - return obj.review_set.count() + return getattr(obj, "review_count", obj.review_set.count()) + + def get_quality_score(self, obj): + return getattr(obj, "quality_score", 0.0) + + def get_difficulty_score(self, obj): + return getattr(obj, "difficulty_score", 0.0) def get_xlist(self, obj): return [ @@ -246,10 +315,14 @@ def get_quality_vote(self, obj): return None def get_quality_vote_count(self, obj): - return Vote.objects.get_vote_count(obj, "quality") + return getattr( + obj, "quality_vote_count", Vote.objects.get_vote_count(obj, "quality") + ) def get_difficulty_vote_count(self, obj): - return Vote.objects.get_vote_count(obj, "difficulty") + return getattr( + obj, "difficulty_vote_count", Vote.objects.get_vote_count(obj, "difficulty") + ) def get_can_write_review(self, obj): request = self.context.get("request") diff --git a/apps/web/templates/base.html b/apps/web/templates/base.html index 63bd281..7c8cda1 100644 --- a/apps/web/templates/base.html +++ b/apps/web/templates/base.html @@ -70,4 +70,4 @@ - \ No newline at end of file + diff --git a/apps/web/tests.py b/apps/web/tests.py deleted file mode 100644 index 7ce503c..0000000 --- a/apps/web/tests.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.test import TestCase - -# Create your tests here. diff --git a/apps/web/tests/factories.py b/apps/web/tests/factories.py index 420c70c..f501948 100644 --- a/apps/web/tests/factories.py +++ b/apps/web/tests/factories.py @@ -1,6 +1,5 @@ import factory from django.contrib.auth.models import User -from django.db.models.signals import post_save from apps.web import models from lib import constants diff --git a/apps/web/tests/model_tests/test_student.py b/apps/web/tests/model_tests/test_student.py index ba81436..0cf902f 100644 --- a/apps/web/tests/model_tests/test_student.py +++ b/apps/web/tests/model_tests/test_student.py @@ -1,57 +1,17 @@ -from datetime import datetime - from django.test import TestCase -from apps.web.models import Review, Student, Vote +from apps.web.models import Vote from apps.web.tests import factories from lib import constants class StudentTestCase(TestCase): - def test_is_valid_sjtu_student_email_only_allows_dartmouth(self): - self.assertFalse( - Student.objects.is_valid_sjtu_student_email("layuplist@gmail.com") - ) - self.assertTrue( - Student.objects.is_valid_sjtu_student_email("layuplist.16@dartmouth.edu") - ) - - def test_is_valid_sjtu_student_email_allows_four_years_from_now(self): - self.assertTrue( - Student.objects.is_valid_sjtu_student_email( - "layuplist.{}@dartmouth.edu".format(str(datetime.now().year + 5)[2:]) - ) - ) - - def test_is_valid_sjtu_student_email_allows_dual_degree(self): - self.assertTrue( - Student.objects.is_valid_sjtu_student_email("layuplist.ug@dartmouth.edu") - ) - self.assertTrue( - Student.objects.is_valid_sjtu_student_email("layuplist.UG@dartmouth.edu") - ) - - def test_is_valid_sjtu_student_email_allows_grad(self): - self.assertTrue( - Student.objects.is_valid_sjtu_student_email("layuplist.GR@dartmouth.edu") - ) - self.assertTrue( - Student.objects.is_valid_sjtu_student_email("layuplist.gr@dartmouth.edu") - ) - - def test_is_valid_sjtu_student_email_forbids_alum(self): - self.assertFalse( - Student.objects.is_valid_sjtu_student_email( - "layuplist.16@alumni.dartmouth.edu" - ) - ) - def test_can_see_recommendations(self): s = factories.StudentFactory() self.assertFalse(s.can_see_recommendations()) # create sufficient votes of wrong type - for _ in xrange(constants.REC_UPVOTE_REQ): + for _ in range(constants.REC_UPVOTE_REQ): factories.VoteFactory( user=s.user, category=Vote.CATEGORIES.DIFFICULTY, value=1 ) @@ -62,7 +22,7 @@ def test_can_see_recommendations(self): # cannot view if does not reach vote count Vote.objects.all().delete() factories.ReviewFactory(user=s.user) - for _ in xrange(constants.REC_UPVOTE_REQ - 1): + for _ in range(constants.REC_UPVOTE_REQ - 1): factories.VoteFactory( user=s.user, category=Vote.CATEGORIES.QUALITY, value=1 ) diff --git a/apps/web/urls.py b/apps/web/urls.py new file mode 100644 index 0000000..6915bd3 --- /dev/null +++ b/apps/web/urls.py @@ -0,0 +1,47 @@ +from django.urls import re_path + +from apps.web import views + +urlpatterns = [ + re_path(r"^user/status/?", views.user_status, name="user_status"), + re_path(r"^landing/$", views.landing_api, name="landing_api"), + re_path(r"^courses/$", views.CoursesListAPI.as_view(), name="courses_api"), + re_path( + r"^courses/(?P[0-9]+)/$", + views.CoursesDetailAPI.as_view(), + name="course_detail_api", + ), + re_path( + r"^courses/(?P[0-9].*)/instructors?/?", + views.course_instructors, + name="course_instructors", + ), + re_path(r"^courses/(?P[0-9].*)/medians", views.medians, name="medians"), + re_path( + r"^courses/(?P[0-9].*)/professors?/?", + views.course_professors, + name="course_professors", + ), + re_path( + r"^courses/(?P[0-9].*)/vote", + views.course_vote_api, + name="course_vote_api", + ), + re_path( + r"^courses/(?P[0-9]+)/reviews/$", + views.CoursesReviewsAPI.as_view(), + name="course_review_api", + ), + re_path(r"^reviews/?$", views.UserReviewsAPI.as_view(), name="user_reviews_api"), + re_path( + r"^reviews/(?P[0-9]+)/$", + views.UserReviewsAPI.as_view(), + name="user_review_api", + ), + re_path( + r"^reviews/(?P[0-9]+)/vote/$", + views.review_vote_api, + name="review_vote_api", + ), + re_path(r"^departments/$", views.departments_api, name="departments_api"), +] diff --git a/apps/web/views.py b/apps/web/views.py index c2e3cf7..47e177e 100644 --- a/apps/web/views.py +++ b/apps/web/views.py @@ -1,3 +1,15 @@ +import logging + +from django.conf import settings +from django.db.models import Count, Prefetch, Q +from rest_framework import generics, mixins, pagination, status +from rest_framework.decorators import ( + api_view, + permission_classes, +) +from rest_framework.permissions import AllowAny, IsAuthenticated +from rest_framework.response import Response + from apps.web.models import ( Course, CourseMedian, @@ -6,63 +18,52 @@ ReviewVote, Vote, ) - -from apps.web.models.forms import ReviewForm - from apps.web.serializers import ( CourseSearchSerializer, CourseSerializer, + CourseVoteSerializer, ReviewSerializer, + ReviewVoteSerializer, ) - -from lib import constants from lib.departments import get_department_name from lib.grades import numeric_value_for_grade from lib.terms import numeric_value_of_term -import datetime -import uuid -import dateutil.parser +logger = logging.getLogger(__name__) -from django.core.paginator import EmptyPage, PageNotAnInteger, Paginator -from django.db.models import Count -from rest_framework.authentication import SessionAuthentication -from rest_framework.decorators import ( - api_view, - permission_classes, -) -from rest_framework.permissions import AllowAny, IsAuthenticated -from rest_framework.response import Response - -LIMITS = { - "courses": 20, - "reviews": 5, - "unauthenticated_review_search": 3, -} +class CoursesPagination(pagination.PageNumberPagination): + page_size = settings.WEB["COURSE"]["PAGE_SIZE"] @api_view(["GET"]) def user_status(request): + """ + Get user authentication status. + Input: + - None + Output: + - Authenticated user: {"isAuthenticated": true, "username": "string"} + - Anonymous user: {"isAuthenticated": false} + """ if request.user.is_authenticated: + logger.info("User is authenticated") return Response({"isAuthenticated": True, "username": request.user.username}) else: + logger.info("User is not authenticated") return Response({"isAuthenticated": False}) -def get_session_id(request): - if "user_id" not in request.session: - if not request.user.is_authenticated: - request.session["user_id"] = uuid.uuid4().hex - else: - request.session["user_id"] = request.user.username - return request.session["user_id"] - - @api_view(["GET"]) @permission_classes([AllowAny]) def landing_api(request): - """API endpoint for landing page data""" + """ + Get landing page statistics. + Input: + - None + Output: + {"review_count": int} + """ return Response( { "review_count": Review.objects.count(), @@ -70,143 +71,320 @@ def landing_api(request): ) -def get_prior_course_id(request, current_course_id): - prior_course_id = None - if ( - "prior_course_id" in request.session - and "prior_course_timestamp" in request.session - ): - prior_course_timestamp = request.session["prior_course_timestamp"] - if ( - dateutil.parser.parse(prior_course_timestamp) - + datetime.timedelta(minutes=10) - >= datetime.datetime.now() - ): - prior_course_id = request.session["prior_course_id"] - request.session["prior_course_id"] = current_course_id - request.session["prior_course_timestamp"] = datetime.datetime.now().isoformat() - return prior_course_id +class CoursesListAPI(generics.GenericAPIView, mixins.ListModelMixin): + """ + List courses with filtering, sorting, and pagination. + GET + Input: + - Query parameters: + - department (string): Filter by department code (case-insensitive) + - code (string): Filter by course code (partial match) + - min_quality (integer): Filter by minimum quality score (authenticated only) + - min_difficulty (integer): Filter by minimum difficulty score (authenticated only) + - sort_by (string): Sort field ("course_code", "review_count"),("quality_score", "difficulty_score")(authenticated only) + - sort_order (string): "asc" or "desc" (default: "asc") + - page (integer): Page number for pagination + + Output: + { + "count": integer, + "next": "string|null", + "previous": "string|null", + "results": [CourseSearchSerializer objects] + } + """ + serializer_class = CourseSearchSerializer + permission_classes = [AllowAny] + pagination_class = CoursesPagination + + def get_queryset(self): + queryset = Course.objects.with_scores().prefetch_related("distribs") + return queryset + + def _filter(self, queryset): + """filter courses and filter by score.""" + queryset = self._filter_courses(queryset) + queryset = self._filter_by_score(queryset) + return queryset + + def _filter_courses(self, queryset): + """Helper function to apply all filters to courses queryset.""" + department = self.request.query_params.get("department") + code = self.request.query_params.get("code") + if department: + queryset = queryset.filter(department__iexact=department) + if code: + queryset = queryset.filter(course_code__icontains=code) + return queryset + + def _filter_by_score(self, queryset): + """Helper function to filter by quality and difficulty score.""" + if not self.request.user.is_authenticated: + return queryset + + query_param_mapping = [ + ("min_quality", "quality_score"), + ("min_difficulty", "difficulty_score"), + ] + + for param_name, field_name in query_param_mapping: + param_value = self.request.query_params.get(param_name) + if param_value: + try: + threshold = int(param_value) + queryset = queryset.filter(**{f"{field_name}__gte": threshold}) + except (ValueError, TypeError): + pass + return queryset + + def _sort(self, queryset): + """Helper function to sort courses based on request parameters.""" + sort_by = self.request.query_params.get("sort_by", "course_code") + sort_order = self.request.query_params.get("sort_order", "asc") + sort_prefix = "-" if sort_order.lower() == "desc" else "" + + allowed_sort_fields = ["course_code", "review_count"] + if self.request.user.is_authenticated: + allowed_sort_fields.extend(["quality_score", "difficulty_score"]) + + sort_field = sort_by if sort_by in allowed_sort_fields else "course_code" + return queryset.order_by(f"{sort_prefix}{sort_field}") + + def filter_queryset(self, queryset): + """Override to apply both filtering and sorting.""" + queryset = self._filter(queryset) + queryset = self._sort(queryset) + return queryset + + def get(self, request, *args, **kwargs): + return self.list(request, *args, **kwargs) + + +class CoursesDetailAPI(generics.GenericAPIView, mixins.RetrieveModelMixin): + """ + Retrieve details for a specific course. + GET + Input: + - URL parameter: course_id (integer, required) + + Output: + - CourseSerializer object + - Authenticated: Full details + - Non-authenticated: without scores, votes, and vote counts + """ -@api_view(["GET"]) -@permission_classes([AllowAny]) -def courses_api(request): + serializer_class = CourseSerializer + permission_classes = [AllowAny] + lookup_field = "id" + lookup_url_kwarg = "course_id" + + def get_queryset(self): + queryset = Course.objects.with_scores_vote_counts() + + # Prefetch reviews with votes if authenticated + request = self.request + if request and request.user.is_authenticated: + queryset = queryset.prefetch_related( + Prefetch( + "review_set", + queryset=Review.objects.with_votes(vote_user=request.user), + ) + ) + + return queryset + + def get(self, request, *args, **kwargs): + return self.retrieve(request, *args, **kwargs) + + +class CoursesReviewsAPI( + generics.GenericAPIView, mixins.ListModelMixin, mixins.CreateModelMixin +): """ - API endpoint for listing courses with filtering, sorting, and pagination. + List and create reviews for a specific course. + + GET - List reviews:(Unused API) + Input: + - Authentication: Required + - URL parameter: course_id (integer, required) + - Query parameters: + - q (string, optional): Search query for review content + - author (string, optional): "me" to filter user's own reviews + + Output: + - Success (200): [ReviewSerializer objects] + + POST - Create review: + Input: + - POST request + - Authentication: Required + - URL parameter: course_id (integer, required) + - Body: "term","professor","comments"(required and only required) + Output: + Success (201): ReviewSerializer object + Error (400): Validation errors + Error (403): {"detail": "User cannot write review"} + Error (404): {"detail": "Course not found"} """ - queryset = Course.objects.all().prefetch_related("distribs", "review_set") - queryset = queryset.annotate(num_reviews=Count("review")) - # --- Filtering --- - department = request.query_params.get("department") - if department: - queryset = queryset.filter(department__iexact=department) + serializer_class = ReviewSerializer + permission_classes = [IsAuthenticated] - code = request.query_params.get("code") - if code: - queryset = queryset.filter(course_code__icontains=code) + def get_queryset(self): + course_id = self.kwargs.get("course_id") + try: + course = Course.objects.get(id=course_id) + return Review.objects.with_votes(vote_user=self.request.user, course=course) + except Course.DoesNotExist: + logger.warning("Course with id %d does not exist", course_id) + return Review.objects.none() + + def list(self, request, *args, **kwargs): + """List reviews with optional filtering.""" + queryset = self.get_queryset() + + # Apply author filter + if request.query_params.get("author") == "me": + queryset = queryset.filter(user=request.user) + + # Handle search query + query = request.query_params.get("q", "").strip() + if query: + queryset = queryset.order_by("-term").filter( + Q(comments__icontains=query) | Q(professor__icontains=query) + ) - if request.user.is_authenticated: - min_quality = request.query_params.get("min_quality") - if min_quality: - try: - queryset = queryset.filter(quality_score__gte=int(min_quality)) - except (ValueError, TypeError): - pass # Ignore invalid values - - min_difficulty = request.query_params.get("min_difficulty") # Layup score - if min_difficulty: - try: - queryset = queryset.filter(difficulty_score__gte=int(min_difficulty)) - except (ValueError, TypeError): - pass # Ignore invalid values - - # --- Sorting --- - sort_by = request.query_params.get("sort_by", "course_code") # Default sort - sort_order = request.query_params.get("sort_order", "asc") - sort_prefix = "-" if sort_order.lower() == "desc" else "" - - allowed_sort_fields = ["course_code", "num_reviews"] - if request.user.is_authenticated: - allowed_sort_fields.extend(["quality_score", "difficulty_score"]) + serializer = self.get_serializer(queryset, many=True) + return Response(serializer.data) - if sort_by in allowed_sort_fields: - sort_field = sort_by - else: - sort_field = "course_code" # Fallback to default if invalid or not allowed + def get(self, request, *args, **kwargs): + """Get list of reviews.""" + return self.list(request, *args, **kwargs) - queryset = queryset.order_by(f"{sort_prefix}{sort_field}") + def post(self, request, *args, **kwargs): + """Create a new review for a course.""" + course_id = self.kwargs.get("course_id") + try: + course = Course.objects.get(id=course_id) + except Course.DoesNotExist: + return Response( + {"detail": "Course not found"}, status=status.HTTP_404_NOT_FOUND + ) - # --- Pagination --- - paginator = Paginator(queryset, LIMITS["courses"]) - page_number = request.query_params.get("page", 1) - try: - page = paginator.page(page_number) - except (PageNotAnInteger, EmptyPage): - page = paginator.page(1) + # Check if user can write review + if not Review.objects.user_can_write_review(request.user.id, course.id): + logger.warning( + "User %d cannot write review for course %d", request.user.id, course.id + ) + return Response( + {"detail": "User cannot write review"}, status=status.HTTP_403_FORBIDDEN + ) - # --- Serialization --- - serializer = CourseSearchSerializer( - page.object_list, many=True, context={"request": request} - ) + # Validate and save review using ReviewSerializer + serializer = ReviewSerializer(data=request.data) + if not serializer.is_valid(): + logger.warning("Review serializer errors: %s", serializer.errors) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - return Response( - { - "courses": serializer.data, - "pagination": { - "current_page": page.number, - "total_pages": paginator.num_pages, - "total_courses": paginator.count, - "limit": LIMITS["courses"], - }, - "query_params": request.query_params, # Return applied params for context - } - ) + serializer.save(course=course, user=request.user) + return Response(serializer.data, status=status.HTTP_201_CREATED) -@api_view(["GET", "POST"]) -@permission_classes([AllowAny]) -def course_detail_api(request, course_id): - try: - course = Course.objects.get(pk=course_id) - except Course.DoesNotExist: - return Response(status=404) +class UserReviewsAPI( + generics.GenericAPIView, + mixins.ListModelMixin, + mixins.RetrieveModelMixin, + mixins.UpdateModelMixin, + mixins.DestroyModelMixin, +): + """ + Manage user's own reviews (CRUD operations). + + GET (List) - List user's reviews:(Unused API) + Input: + - Authentication: Required + - URL parameter: None + + Output: + Success (200): [ReviewSerializer objects] + + GET (Retrieve) - Get specific review: + Input: + - Authentication: Required + - URL parameter: review_id (integer, required) + + Output: + Success (200): ReviewSerializer object + Error (404): {"detail": "Not found."} + + PUT - Update review:(Unused API) + Input: + - Authentication: Required + - URL parameter: review_id (integer, required) + - Body: "term","professor","comments"(required and only required) + + Output: + Success (200): Updated ReviewSerializer object + Error (400): Validation errors + Error (404): {"detail": "Not found."} + + DELETE - Delete review: + Input: + - Authentication: Required + - URL parameter: review_id (integer, required) + + Output: + Success (204): No content + Error (404): {"detail": "Not found."} + """ - if request.method == "GET": - serializer = CourseSerializer(course, context={"request": request}) - return Response(serializer.data) - elif request.method == "POST": - if not request.user.is_authenticated: - return Response({"detail": "Authentication required"}, status=403) - - if not Review.objects.user_can_write_review(request.user.id, course_id): - return Response({"detail": "User cannot write review"}, status=403) - - form = ReviewForm(request.data) - if form.is_valid(): - review = form.save(commit=False) - review.course = course - review.user = request.user - review.save() - serializer = CourseSerializer( - course, context={"request": request} - ) # re-serialize with new data - return Response(serializer.data, status=201) - return Response(form.errors, status=400) - - -@api_view(["DELETE"]) -@permission_classes([IsAuthenticated]) -def delete_review_api(request, course_id): - course = Course.objects.get(id=course_id) - Review.objects.delete_reviews_for_user_course(user=request.user, course=course) - serializer = CourseSerializer(course, context={"request": request}) - return Response(serializer.data, status=200) + serializer_class = ReviewSerializer + permission_classes = [IsAuthenticated] + lookup_field = "id" + lookup_url_kwarg = "review_id" + + def get_queryset(self): + """Only reviews belonging to the authenticated user with vote annotations.""" + return Review.objects.with_votes( + vote_user=self.request.user, user=self.request.user + ) + + def get(self, request, *args, **kwargs): + """Handle both list (no id) and retrieve (with id) operations.""" + if "review_id" in kwargs: + return self.retrieve(request, *args, **kwargs) + else: + return self.list(request, *args, **kwargs) + + def put(self, request, *args, **kwargs): + """Update a specific review.""" + return self.update(request, *args, **kwargs) + + def delete(self, request, *args, **kwargs): + """Delete a specific review.""" + return self.destroy(request, *args, **kwargs) @api_view(["GET"]) @permission_classes([AllowAny]) def departments_api(request): + """ + Get list of all departments with course counts. + + Input: + - None + + Output: + Success (200): + [ + { + "code": "string", + "name": "string", + "count": int + }, ... + ] + """ department_codes_and_counts = ( Course.objects.values("department") .annotate(Count("department")) @@ -224,60 +402,10 @@ def departments_api(request): @api_view(["GET"]) @permission_classes([AllowAny]) -def course_search_api(request): - query = request.GET.get("q", "").strip() - - if len(query) < 2: - return Response({"query": query, "department": None, "courses": []}) - - courses = Course.objects.search(query).prefetch_related("review_set", "distribs") - - if len(query) not in Course.objects.DEPARTMENT_LENGTHS: - courses = sorted(courses, key=lambda c: c.review_set.count(), reverse=True) - - serializer = CourseSearchSerializer( - courses, many=True, context={"request": request} - ) - - return Response( - { - "query": query, - "department": get_department_name(query), - "term": constants.CURRENT_TERM, - "courses": serializer.data, - } - ) - - -@api_view(["GET"]) -@permission_classes([IsAuthenticated]) -def course_review_search_api(request, course_id): - try: - course = Course.objects.get(pk=course_id) - except Course.DoesNotExist: - return Response({"detail": "Course not found"}, status=404) - - query = request.GET.get("q", "").strip() - reviews = course.search_reviews(query) - review_count = reviews.count() - - # Since we now require authentication, no need to limit reviews - serializer = ReviewSerializer(reviews, many=True, context={"request": request}) - - return Response( - { - "query": query, - "course_id": course.id, - "course_short_name": course.short_name(), - "reviews_full_count": review_count, - "remaining": 0, # No remaining since user is authenticated - "reviews": serializer.data, - } - ) - - -@api_view(["GET"]) def medians(request, course_id): + """ + Unused API. + """ # retrieve course medians for term, and group by term for averaging medians_by_term = {} for course_median in CourseMedian.objects.filter(course=course_id): @@ -316,12 +444,16 @@ def medians(request, course_id): @api_view(["GET"]) +@permission_classes([AllowAny]) def course_professors(request, course_id): + """ + Unused API. + """ return Response( { "professors": sorted( set( - Review.objects.filter(course=course_id) + Review.objects.raw_queryset(course=course_id) .values_list("professor", flat=True) .distinct() ) @@ -339,7 +471,11 @@ def course_professors(request, course_id): @api_view(["GET"]) +@permission_classes([AllowAny]) def course_instructors(request, course_id): + """ + Unused API. + """ try: course = Course.objects.get(pk=course_id) instructors = course.get_instructors() @@ -347,23 +483,48 @@ def course_instructors(request, course_id): {"instructors": [instructor.name for instructor in instructors]}, status=200 ) except Course.DoesNotExist: + logger.warning("Course with id %d not found for instructors API", course_id) return Response({"error": "Course not found"}, status=404) @api_view(["POST"]) @permission_classes([IsAuthenticated]) def course_vote_api(request, course_id): - try: - value = request.data["value"] - forLayup = request.data["forLayup"] - except KeyError: - return Response( - {"detail": "Missing required fields: value, forLayup"}, status=400 - ) + """ + Vote on course quality or difficulty. + + Input: + - POST request + - Authentication: Required + - URL parameter: course_id (integer, required) + - Body (JSON): + { + "value": integer (vote score between 1-5), + "forLayup": boolean (true for difficulty, false for quality) + } + + Output: + Success (200): + { + "new_score": float, + "was_unvote": boolean, + "new_vote_count": integer + } + Error (400): + { + "detail": "Validation error with input fields" + } + """ + serializer = CourseVoteSerializer(data=request.data) + if not serializer.is_valid(): + return Response(serializer.errors, status=400) + + value = serializer.validated_data["value"] + forLayup = serializer.validated_data["forLayup"] category = Vote.CATEGORIES.DIFFICULTY if forLayup else Vote.CATEGORIES.QUALITY new_score, is_unvote, new_vote_count = Vote.objects.vote( - int(value), course_id, category, request.user + value, course_id, category, request.user ) return Response( @@ -379,83 +540,47 @@ def course_vote_api(request, course_id): @permission_classes([IsAuthenticated]) def review_vote_api(request, review_id): """ - API endpoint for voting on reviews (kudos/dislike). - - URL: /api/review/{review_id}/vote/ - POST data: - - is_kudos: boolean (True for kudos, False for dislike) - - Returns: - - kudos_count: updated kudos count - - dislike_count: updated dislike count - - user_vote: user's current vote (True/False/None) - """ - - try: - is_kudos = request.data.get("is_kudos") - - if is_kudos is None: - return Response({"detail": "is_kudos field is required"}, status=400) - - is_kudos = bool(is_kudos) - - # 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 - ) + Vote on reviews (kudos/dislike). - if kudos_count is None or dislike_count is None: - # Review doesn't exist - return Response({"detail": "Review not found"}, status=404) - - return Response( + Input: + - POST request + - Authentication: Required + - URL parameter: review_id (integer, required) + - Body (JSON): { - "kudos_count": kudos_count, - "dislike_count": dislike_count, - "user_vote": user_vote, + "is_kudos": boolean (true for kudos, false for dislike) } - ) - - except Exception: - return Response( - {"detail": "An error occurred processing your request"}, - status=500, - ) - - -@api_view(["GET"]) -@permission_classes([IsAuthenticated]) -def get_user_review_api(request, course_id): - """ - API endpoint to get the authenticated user's review for a specific course. - - Returns: - - Review data if the user has written a review for this course - - 404 if no review found - - 403 if user is not authenticated + Output: + Success (200): + { + "kudos_count": integer, + "dislike_count": integer, + "user_vote": boolean|null (true/false/null) + } + Error (400): + { + "detail": "Validation error with input fields" + } """ + serializer = ReviewVoteSerializer(data=request.data) + if not serializer.is_valid(): + return Response(serializer.errors, status=400) - try: - # Get the course - try: - course = Course.objects.get(id=course_id) - except Course.DoesNotExist: - return Response({"detail": "Course not found"}, status=404) - - # Get the user's review for this course - review = Review.objects.get_user_review_for_course(request.user, course) + is_kudos = serializer.validated_data["is_kudos"] - if review is None: - return Response( - {"detail": "No user review found for this course"}, status=404 - ) + kudos_count, dislike_count, user_vote = ReviewVote.objects.vote( + review_id=review_id, user=request.user, is_kudos=is_kudos + ) - # Serialize and return the review - serializer = ReviewSerializer(review) - return Response(serializer.data) + if kudos_count is None or dislike_count is None: + # Review doesn't exist + logger.warning("Review %s not found for voting", str(review_id)) + return Response({"detail": "Review not found"}, status=404) - except Exception: - return Response( - {"detail": "An error occurred processing your request"}, - status=500, - ) + return Response( + { + "kudos_count": kudos_count, + "dislike_count": dislike_count, + "user_vote": user_vote, + } + ) diff --git a/config.yaml.example b/config.yaml.example index 27105cd..2890816 100644 --- a/config.yaml.example +++ b/config.yaml.example @@ -22,6 +22,12 @@ CORS_ALLOWED_ORIGINS: # COOKIE_AGE: 2592000 # 30 days # SAVE_EVERY_REQUEST: true # +# WEB: +# COURSE: +# PAGE_SIZE: 5 +# REVIEW: +# PAGE_SIZE: 10 +# COMMENT_MIN_LENGTH : 30 # AUTH: # OTP_TIMEOUT: 120 # TEMP_TOKEN_TIMEOUT: 600 @@ -50,7 +56,7 @@ QUEST: # API_KEY: Use env URL: "https://wj.sjtu.edu.cn/q/dummy1" QUESTIONID: 10000001 - RESET: + RESET_PASSWORD: # API_KEY: Use env URL: "https://wj.sjtu.edu.cn/q/dummy2" QUESTIONID: 10000002 diff --git a/docs/auth.md b/docs/auth.md index 1ca3919..95ace4e 100644 --- a/docs/auth.md +++ b/docs/auth.md @@ -68,7 +68,7 @@ This authentication flow is hardened by its stateless, token-based design. It re - **Logic**: 1. Checks `localStorage` for a non-expired OTP record. If found, displays it to allow the user to re-copy. Expired OTP and auth flow state records in `localStorage` are cleared. 2. If no valid OTP exists, renders the Cloudflare Turnstile widget. - 3. On Turnstile success, calls `POST /api/auth/initiate` endpoint with its `action` prop. + 3. On Turnstile success, calls `POST /api/auth/init` endpoint with its `action` prop. 4. On receiving the `otp` and `redirect_url` (the `temp_token` is set as a cookie by the backend), stores `{ otp, expires_at }` and `{ status: 'pending', expires_at }` in `localStorage` to track the flow's state. 5. Displays the OTP and copy button. On click, it copies the OTP, provides visual feedback, and initiates the redirect to the URL received from backend. @@ -107,7 +107,7 @@ On mount, checks `localStorage` for an `auth_flow` state object with `status: 'v ### Detailed Backend Process -#### `POST /api/auth/initiate` +#### `POST /api/auth/init` 1. **Input**: Receives the user's intended `action` and the `turnstile_token`. 2. **Validation**: Verifies the `turnstile_token` with Cloudflare's API. diff --git a/docs/setup.md b/docs/setup.md index 74a4a4c..48a252b 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -16,9 +16,9 @@ Environment: 3. `git checkout dev` -4. `uv sync` +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/frontend b/frontend index cd0bc12..464bd99 160000 --- a/frontend +++ b/frontend @@ -1 +1 @@ -Subproject commit cd0bc129f97ecc3e066a42045759a17953e7eabb +Subproject commit 464bd99514f4ae80a36107c4efc9663b526cc9bb diff --git a/pyproject.toml b/pyproject.toml index ef49a79..90395bd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,24 +1,26 @@ [project] -name = "" +name = "course-review" +requires-python = ">=3.14, <3.15" version = "0.0.1" dependencies = [ - "beautifulsoup4>=4.13.3", - "dj-database-url>=2.3.0", - "django>=5.1.6", - "django-debug-toolbar>=5.0.1", - "httpx>=0.28.1", - "psycopg2-binary>=2.9.10", - "python-dateutil>=2.9.0", - "python-dotenv>=1.0.1", - "pytz>=2025.1", - "redis>=5.2.1", - "requests>=2.32.3", - "bpython>=0.25", - "ptpython>=3.0.29", - "djangorestframework>=3.16.0", - "django-cors-headers>=4.7.0", - "django-redis", - "pyyaml>=6.0.2", + "beautifulsoup4==4.14.2", + "dj-database-url==3.0.1", + "django==5.2.8", + "django-debug-toolbar==6.1.0", + "httpx==0.28.1", + "psycopg2-binary==2.9.11", + "python-dateutil==2.9.0", + "python-dotenv==1.2.1", + "pytz==2025.2", + "redis==7.1.0", + "requests==2.32.5", + "bpython==0.26", + "greenlet==3.2.4", + "ptpython==3.0.31", + "djangorestframework==3.16.1", + "django-cors-headers==4.9.0", + "django-redis==6.0.0", + "pyyaml==6.0.3", ] [tool.uv] @@ -26,5 +28,8 @@ package = false [dependency-groups] dev = [ - "pre-commit>=4.3.0", + "prek>=0.2.24", +] +lint = [ + "ruff==0.14.5", ] diff --git a/scripts/pre-commit-format.py b/scripts/pre-commit-format.py deleted file mode 100644 index e0e8c90..0000000 --- a/scripts/pre-commit-format.py +++ /dev/null @@ -1,27 +0,0 @@ -#!/usr/bin/env python3 - -import subprocess -import sys - - -def main(): - """Pre-commit formatting script""" - print("Running formatter...") - - try: - # Run make format and capture exit code - subprocess.run(["make", "format"], check=True) - - # Format succeeded, stage the changes - subprocess.run(["git", "add", "--update"], check=True) - print("formatted.") - return 0 - - except subprocess.CalledProcessError: - # Format failed, stop the commit - print("ERROR: Formatting failed! Commit aborted.") - return 1 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/uv.lock b/uv.lock index 5202b8b..01be51a 100644 --- a/uv.lock +++ b/uv.lock @@ -1,59 +1,6 @@ version = 1 revision = 3 -requires-python = ">=3.13" - -[[package]] -name = "" -version = "0.0.1" -source = { virtual = "." } -dependencies = [ - { name = "beautifulsoup4" }, - { name = "bpython" }, - { name = "dj-database-url" }, - { name = "django" }, - { name = "django-cors-headers" }, - { name = "django-debug-toolbar" }, - { name = "django-redis" }, - { name = "djangorestframework" }, - { name = "httpx" }, - { name = "psycopg2-binary" }, - { name = "ptpython" }, - { name = "python-dateutil" }, - { name = "python-dotenv" }, - { name = "pytz" }, - { name = "pyyaml" }, - { name = "redis" }, - { name = "requests" }, -] - -[package.dev-dependencies] -dev = [ - { name = "pre-commit" }, -] - -[package.metadata] -requires-dist = [ - { name = "beautifulsoup4", specifier = ">=4.13.3" }, - { name = "bpython", specifier = ">=0.25" }, - { name = "dj-database-url", specifier = ">=2.3.0" }, - { name = "django", specifier = ">=5.1.6" }, - { name = "django-cors-headers", specifier = ">=4.7.0" }, - { name = "django-debug-toolbar", specifier = ">=5.0.1" }, - { name = "django-redis" }, - { name = "djangorestframework", specifier = ">=3.16.0" }, - { name = "httpx", specifier = ">=0.28.1" }, - { name = "psycopg2-binary", specifier = ">=2.9.10" }, - { name = "ptpython", specifier = ">=3.0.29" }, - { name = "python-dateutil", specifier = ">=2.9.0" }, - { name = "python-dotenv", specifier = ">=1.0.1" }, - { name = "pytz", specifier = ">=2025.1" }, - { name = "pyyaml", specifier = ">=6.0.2" }, - { name = "redis", specifier = ">=5.2.1" }, - { name = "requests", specifier = ">=2.32.3" }, -] - -[package.metadata.requires-dev] -dev = [{ name = "pre-commit", specifier = ">=4.3.0" }] +requires-python = "==3.14.*" [[package]] name = "ansicon" @@ -66,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]] @@ -88,42 +35,42 @@ 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]] name = "beautifulsoup4" -version = "4.13.4" +version = "4.14.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "soupsieve" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d8/e4/0c4c39e18fd76d6a628d4dd8da40543d136ce2d1752bd6eeeab0791f4d6b/beautifulsoup4-4.13.4.tar.gz", hash = "sha256:dbb3c4e1ceae6aefebdaf2423247260cd062430a410e38c66f2baa50a8437195", size = 621067, upload-time = "2025-04-15T17:05:13.836Z" } +sdist = { url = "https://files.pythonhosted.org/packages/77/e9/df2358efd7659577435e2177bfa69cba6c33216681af51a707193dec162a/beautifulsoup4-4.14.2.tar.gz", hash = "sha256:2a98ab9f944a11acee9cc848508ec28d9228abfd522ef0fad6a02a72e0ded69e", size = 625822, upload-time = "2025-09-29T10:05:42.613Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/50/cd/30110dc0ffcf3b131156077b90e9f60ed75711223f306da4db08eff8403b/beautifulsoup4-4.13.4-py3-none-any.whl", hash = "sha256:9bbbb14bfde9d79f38b8cd5f8c7c85f4b8f2523190ebed90e950a8dea4cb1c4b", size = 187285, upload-time = "2025-04-15T17:05:12.221Z" }, + { 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 = "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]] name = "bpython" -version = "0.25" +version = "0.26" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "curtsies" }, @@ -133,139 +80,196 @@ dependencies = [ { name = "pyxdg" }, { name = "requests" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ba/dd/cc02bf66f342a4673867fdf6c1f9fce90ec1e91e651b21bc4af4890101da/bpython-0.25.tar.gz", hash = "sha256:c246fc909ef6dcc26e9d8cb4615b0e6b1613f3543d12269b19ffd0782166c65b", size = 207610, upload-time = "2025-01-17T09:35:22.382Z" } +sdist = { url = "https://files.pythonhosted.org/packages/44/29/cd80e9108a6fc6a925ffb915f8f69198a2bb2388e39167a41d743ac2a8f4/bpython-0.26.tar.gz", hash = "sha256:f79083e1e3723be9b49c9994ad1dd3a19ccb4d0d4f9a6f5b3a73bef8bc327433", size = 207564, upload-time = "2025-10-28T07:19:41.97Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ba/74/5470df025854d5e213793b62cbea032fd66919562662955789fcc5dc17d6/bpython-0.25-py3-none-any.whl", hash = "sha256:28fd86008ca5ef6100ead407c9743aa60c51293a18ba5b18fcacea7f5b7f2257", size = 176131, upload-time = "2025-01-17T09:35:19.444Z" }, + { 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 = "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" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/11/74/539e56497d9bd1d484fd863dd69cbbfa653cd2aa27abfe35653494d85e94/cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560", size = 7114, upload-time = "2023-08-12T20:38:17.776Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c5/55/51844dd50c4fc7a33b653bfaba4c2456f06955289ca770a5dbd5fd267374/cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9", size = 7249, upload-time = "2023-08-12T20:38:16.269Z" }, +name = "charset-normalizer" +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]] +name = "course-review" +version = "0.0.1" +source = { virtual = "." } +dependencies = [ + { name = "beautifulsoup4" }, + { name = "bpython" }, + { name = "dj-database-url" }, + { name = "django" }, + { name = "django-cors-headers" }, + { name = "django-debug-toolbar" }, + { name = "django-redis" }, + { name = "djangorestframework" }, + { name = "greenlet" }, + { name = "httpx" }, + { name = "psycopg2-binary" }, + { name = "ptpython" }, + { name = "python-dateutil" }, + { name = "python-dotenv" }, + { name = "pytz" }, + { name = "pyyaml" }, + { name = "redis" }, + { name = "requests" }, ] -[[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/38/94/ce8e6f63d18049672c76d07d119304e1e2d7c6098f0841b51c666e9f44a0/charset_normalizer-3.4.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:aabfa34badd18f1da5ec1bc2715cadc8dca465868a4e73a0173466b688f29dda", size = 195698, upload-time = "2024-12-24T18:11:05.834Z" }, - { url = "https://files.pythonhosted.org/packages/24/2e/dfdd9770664aae179a96561cc6952ff08f9a8cd09a908f259a9dfa063568/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:22e14b5d70560b8dd51ec22863f370d1e595ac3d024cb8ad7d308b4cd95f8313", size = 140162, upload-time = "2024-12-24T18:11:07.064Z" }, - { url = "https://files.pythonhosted.org/packages/24/4e/f646b9093cff8fc86f2d60af2de4dc17c759de9d554f130b140ea4738ca6/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8436c508b408b82d87dc5f62496973a1805cd46727c34440b0d29d8a2f50a6c9", size = 150263, upload-time = "2024-12-24T18:11:08.374Z" }, - { url = "https://files.pythonhosted.org/packages/5e/67/2937f8d548c3ef6e2f9aab0f6e21001056f692d43282b165e7c56023e6dd/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2d074908e1aecee37a7635990b2c6d504cd4766c7bc9fc86d63f9c09af3fa11b", size = 142966, upload-time = "2024-12-24T18:11:09.831Z" }, - { url = "https://files.pythonhosted.org/packages/52/ed/b7f4f07de100bdb95c1756d3a4d17b90c1a3c53715c1a476f8738058e0fa/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:955f8851919303c92343d2f66165294848d57e9bba6cf6e3625485a70a038d11", size = 144992, upload-time = "2024-12-24T18:11:12.03Z" }, - { url = "https://files.pythonhosted.org/packages/96/2c/d49710a6dbcd3776265f4c923bb73ebe83933dfbaa841c5da850fe0fd20b/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:44ecbf16649486d4aebafeaa7ec4c9fed8b88101f4dd612dcaf65d5e815f837f", size = 147162, upload-time = "2024-12-24T18:11:13.372Z" }, - { url = "https://files.pythonhosted.org/packages/b4/41/35ff1f9a6bd380303dea55e44c4933b4cc3c4850988927d4082ada230273/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0924e81d3d5e70f8126529951dac65c1010cdf117bb75eb02dd12339b57749dd", size = 140972, upload-time = "2024-12-24T18:11:14.628Z" }, - { url = "https://files.pythonhosted.org/packages/fb/43/c6a0b685fe6910d08ba971f62cd9c3e862a85770395ba5d9cad4fede33ab/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2967f74ad52c3b98de4c3b32e1a44e32975e008a9cd2a8cc8966d6a5218c5cb2", size = 149095, upload-time = "2024-12-24T18:11:17.672Z" }, - { url = "https://files.pythonhosted.org/packages/4c/ff/a9a504662452e2d2878512115638966e75633519ec11f25fca3d2049a94a/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c75cb2a3e389853835e84a2d8fb2b81a10645b503eca9bcb98df6b5a43eb8886", size = 152668, upload-time = "2024-12-24T18:11:18.989Z" }, - { url = "https://files.pythonhosted.org/packages/6c/71/189996b6d9a4b932564701628af5cee6716733e9165af1d5e1b285c530ed/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:09b26ae6b1abf0d27570633b2b078a2a20419c99d66fb2823173d73f188ce601", size = 150073, upload-time = "2024-12-24T18:11:21.507Z" }, - { url = "https://files.pythonhosted.org/packages/e4/93/946a86ce20790e11312c87c75ba68d5f6ad2208cfb52b2d6a2c32840d922/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fa88b843d6e211393a37219e6a1c1df99d35e8fd90446f1118f4216e307e48cd", size = 145732, upload-time = "2024-12-24T18:11:22.774Z" }, - { url = "https://files.pythonhosted.org/packages/cd/e5/131d2fb1b0dddafc37be4f3a2fa79aa4c037368be9423061dccadfd90091/charset_normalizer-3.4.1-cp313-cp313-win32.whl", hash = "sha256:eb8178fe3dba6450a3e024e95ac49ed3400e506fd4e9e5c32d30adda88cbd407", size = 95391, upload-time = "2024-12-24T18:11:24.139Z" }, - { url = "https://files.pythonhosted.org/packages/27/f2/4f9a69cc7712b9b5ad8fdb87039fd89abba997ad5cbe690d1835d40405b0/charset_normalizer-3.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:b1ac5992a838106edb89654e0aebfc24f5848ae2547d22c2c3f66454daa11971", size = 102702, upload-time = "2024-12-24T18:11:26.535Z" }, - { 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" }, +[package.dev-dependencies] +dev = [ + { name = "prek" }, +] +lint = [ + { name = "ruff" }, ] +[package.metadata] +requires-dist = [ + { name = "beautifulsoup4", specifier = "==4.14.2" }, + { name = "bpython", specifier = "==0.26" }, + { name = "dj-database-url", specifier = "==3.0.1" }, + { name = "django", specifier = "==5.2.8" }, + { name = "django-cors-headers", specifier = "==4.9.0" }, + { name = "django-debug-toolbar", specifier = "==6.1.0" }, + { name = "django-redis", specifier = "==6.0.0" }, + { name = "djangorestframework", specifier = "==3.16.1" }, + { name = "greenlet", specifier = "==3.2.4" }, + { name = "httpx", specifier = "==0.28.1" }, + { name = "psycopg2-binary", specifier = "==2.9.11" }, + { name = "ptpython", specifier = "==3.0.31" }, + { name = "python-dateutil", specifier = "==2.9.0" }, + { name = "python-dotenv", specifier = "==1.2.1" }, + { name = "pytz", specifier = "==2025.2" }, + { name = "pyyaml", specifier = "==6.0.3" }, + { name = "redis", specifier = "==7.1.0" }, + { name = "requests", specifier = "==2.32.5" }, +] + +[package.metadata.requires-dev] +dev = [{ name = "prek", specifier = ">=0.2.24" }] +lint = [{ name = "ruff", specifier = "==0.14.5" }] + [[package]] name = "curtsies" -version = "0.4.2" +version = "0.4.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "blessed" }, { name = "cwcwidth" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/53/d2/ea91db929b5dcded637382235f9f1b7d06ef64b7f2af7fe1be1369e1f0d2/curtsies-0.4.2.tar.gz", hash = "sha256:6ebe33215bd7c92851a506049c720cca4cf5c192c1665c1d7a98a04c4702760e", size = 53559, upload-time = "2023-07-31T20:18:34.271Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d1/18/5741cb42624089a815520d5b65c39c3e59673a77fd1fab6ad65bdebf2f91/curtsies-0.4.3.tar.gz", hash = "sha256:102a0ffbf952124f1be222fd6989da4ec7cce04e49f613009e5f54ad37618825", size = 53401, upload-time = "2025-06-05T06:33:20.099Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5e/ab/c4ae7ff01c75001829dfa54da9b25632a8206fa5c9036ea0292096b402d0/curtsies-0.4.2-py3-none-any.whl", hash = "sha256:f24d676a8c4711fb9edba1ab7e6134bc52305a222980b3b717bb303f5e94cec6", size = 35444, upload-time = "2023-07-31T20:18:33.058Z" }, + { url = "https://files.pythonhosted.org/packages/ab/9b/b8ee3720d056309f4ab667bfc85995c4351f67b22e8c2008612b70350c3a/curtsies-0.4.3-py3-none-any.whl", hash = "sha256:65a1b4d6ff887bd9b0f0836cc6dc68c3a2c65c57f51a62f0ee5df408edee1a99", size = 35482, upload-time = "2025-06-05T06:33:19.122Z" }, ] [[package]] name = "cwcwidth" -version = "0.1.10" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/23/76/03fc9fb3441a13e9208bb6103ebb7200eba7647d040008b8303a1c03e152/cwcwidth-0.1.10.tar.gz", hash = "sha256:7468760f72c1f4107be1b2b2854bc000401ea36a69daed36fb966a1e19a7a124", size = 60265, upload-time = "2025-02-09T21:15:28.452Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/af/f7/8c4cfe0b08053eea4da585ad5e12fef7cd11a0c9e4603ac8644c2a0b04b5/cwcwidth-0.1.10-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2391073280d774ab5d9af1d3aaa26ec456956d04daa1134fb71c31cd72ba5bba", size = 22344, upload-time = "2025-02-09T21:15:10.136Z" }, - { url = "https://files.pythonhosted.org/packages/2a/48/176bbaf56520c5d6b72cbbe0d46821989eaa30df628daa5baecdd7f35458/cwcwidth-0.1.10-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6bfbdc2943631ec770ee781b35b8876fa7e283ff2273f944e2a9ae1f3df4ecdf", size = 94907, upload-time = "2025-02-09T21:15:11.178Z" }, - { url = "https://files.pythonhosted.org/packages/bc/fc/4dfed13b316a67bf2419a63db53566e3e5e4d4fc5a94ef493d3334be3c1f/cwcwidth-0.1.10-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eb0103c7db8d86e260e016ff89f8f00ef5eb75c481abc346bfaa756da9f976b4", size = 100046, upload-time = "2025-02-09T21:15:12.279Z" }, - { url = "https://files.pythonhosted.org/packages/4e/83/612eecdeddbb1329d0f4d416f643459c2c5ae7b753490e31d9dccfa6deed/cwcwidth-0.1.10-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:3b7d38c552edf663bf13f32310f9fd6661070409807b1b5bf89917e2de804ab1", size = 96143, upload-time = "2025-02-09T21:15:14.136Z" }, - { url = "https://files.pythonhosted.org/packages/57/98/87d10d88b5a6de3a4a3452802abed18b909510b9f118ad7f3a40ae48700a/cwcwidth-0.1.10-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1132be818498163a9208b9f4a28d759e08d3efeb885dfd10364434ccc7fa6a17", size = 99735, upload-time = "2025-02-09T21:15:15.216Z" }, - { url = "https://files.pythonhosted.org/packages/61/de/e0c02f84b0418db5938eeb1269f53dee195615a856ed12f370ef79f6cd5b/cwcwidth-0.1.10-cp313-cp313-win32.whl", hash = "sha256:dcead1b7b60c99f8cda249feb8059e4da38587c34d0b5f3aa130f13c62c0ce35", size = 22922, upload-time = "2025-02-09T21:15:16.999Z" }, - { url = "https://files.pythonhosted.org/packages/d8/4a/1d272a69f14924e43f5d6d4a7935c7a892e25c6e5b9a2c4459472132ef0c/cwcwidth-0.1.10-cp313-cp313-win_amd64.whl", hash = "sha256:b6eafd16d3edfec9acfc3d7b8856313bc252e0eccd56fb088f51ceed14c1bbdd", size = 25211, upload-time = "2025-02-09T21:15:17.898Z" }, -] - -[[package]] -name = "distlib" -version = "0.4.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/96/8e/709914eb2b5749865801041647dc7f4e6d00b549cfe88b65ca192995f07c/distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d", size = 614605, upload-time = "2025-07-17T16:52:00.465Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047, upload-time = "2025-07-17T16:51:58.613Z" }, +version = "0.1.12" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/86/5f/f5c3d1b4e9c8c541406ca0654efa1bfaa05414f8e7d1c14bc6e3fd0752f8/cwcwidth-0.1.12.tar.gz", hash = "sha256:bfc16531d1246dd2558eb9b3a63aa37a9978672b956860dc5426da2343ebf366", size = 72009, upload-time = "2025-11-01T17:48:53.683Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/48/42998c088895974ee2a5ce58d3e9bec504ffb4e063dbadc9e325499220d1/cwcwidth-0.1.12-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:a2c7ab3b9eb0abab9bb326fec751b36aca52e0cfe3987c0909f188b9f681042c", size = 24206, upload-time = "2025-11-01T17:48:17.749Z" }, + { url = "https://files.pythonhosted.org/packages/0d/09/4ca240f55596b9c0006d3ffc584bceed4973ee54a5ea68ce9751b712e869/cwcwidth-0.1.12-cp311-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:48ae48e69759e19eec41aeb6ba2217e5ac2885191b2d90c5ac426ac1aa61f38c", size = 83467, upload-time = "2025-11-01T17:48:18.705Z" }, + { url = "https://files.pythonhosted.org/packages/44/c0/f9cc45fda70866852dd3ea5ec9d95ae2f4f6eb0c37877f92a08f5f9c7dd9/cwcwidth-0.1.12-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7cf19286e0a388916c8af6b60a6174d641840d722e2870ccb327f67b10b531e8", size = 85763, upload-time = "2025-11-01T17:48:19.494Z" }, + { url = "https://files.pythonhosted.org/packages/86/84/ebb25d16e759915bffe77c684c9a359277f90f1a39423f4067bb47961e92/cwcwidth-0.1.12-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:2264b41d216d4cc8ac040a05d365f0221299a83ad8d45ab211c7b4301b19603a", size = 83632, upload-time = "2025-11-01T17:48:21.025Z" }, + { url = "https://files.pythonhosted.org/packages/ab/e7/45d6e1888a0240adf39634faacf3b2acd400309a83b4f33a2038851cb0ca/cwcwidth-0.1.12-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3608d4d076428543975a84bec9205f40f2935410816e01ec75bdb9b1a064be87", size = 84366, upload-time = "2025-11-01T17:48:21.948Z" }, + { url = "https://files.pythonhosted.org/packages/9b/b6/d65a429130c746f96f851850166008c2a0e0cf9225fe0ab1a3b6637e53f4/cwcwidth-0.1.12-cp311-abi3-win32.whl", hash = "sha256:02b7caa2afce141132edf191c080ce1b1d1c2251285407975db1ba63b509ba58", size = 22934, upload-time = "2025-11-01T17:48:22.983Z" }, + { url = "https://files.pythonhosted.org/packages/7d/63/1c0f5d4380402a00a8f18912ae28f1606774c106599e7341e56aa2bf83b8/cwcwidth-0.1.12-cp311-abi3-win_amd64.whl", hash = "sha256:0481c93b7392b27deda8a709eb9e1a9c95fc5b30d5f3bd5f995fd27c960d4ced", size = 24733, upload-time = "2025-11-01T17:48:24.094Z" }, + { url = "https://files.pythonhosted.org/packages/b3/c2/5d3eac3f4aed79011f30b287ba805dc0384123dc1faa9c8f99578735eb59/cwcwidth-0.1.12-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:62ac6a4623fb19411e495b3caca33c33051951f6f7ffe620666dcfa324b6f481", size = 25126, upload-time = "2025-11-01T17:48:42.204Z" }, + { url = "https://files.pythonhosted.org/packages/e9/8b/f212d553fab5aa32e98bf7134e594c613cbaaaffd638d918725b0a6a795d/cwcwidth-0.1.12-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:156a88f6c753497d4a6b637672be4030ab405b6196f0309845b8e67212f5880b", size = 100498, upload-time = "2025-11-01T17:48:43.23Z" }, + { url = "https://files.pythonhosted.org/packages/7d/db/4972da021adffee647874cfa15bfedf889b4ffa976bfa340b16286f157c1/cwcwidth-0.1.12-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f08870495da61c25ad8a4113b6c73081908bb40f1ff7485b5ff9b666576029ec", size = 103666, upload-time = "2025-11-01T17:48:44.009Z" }, + { url = "https://files.pythonhosted.org/packages/ca/f4/0c1e2f1107ce25006acaca533917d95b373ed3cb7adecb3278abf279dc1a/cwcwidth-0.1.12-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e9263f61070ca2f3156217936394cba53c91cc79718319301975616d4f8d7513", size = 101537, upload-time = "2025-11-01T17:48:44.781Z" }, + { url = "https://files.pythonhosted.org/packages/10/49/db0456f231e25c756fb733e5275c7d8fe556306b30120c684e9413553682/cwcwidth-0.1.12-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:68f1b1010bc457007515cbc89dfffb13ccb1b58a8db76a5fc34a4e77be3f6bf9", size = 100792, upload-time = "2025-11-01T17:48:45.571Z" }, + { url = "https://files.pythonhosted.org/packages/1d/b5/218c5c5259e3629fc26e588db4fade1ead5edbd5e4b354f4d0cf72f81648/cwcwidth-0.1.12-cp314-cp314-win32.whl", hash = "sha256:0df72403f42ce03e5bce23ee26f1c3da64d4a1ad100a0b6db9b4103ab54e7e68", size = 24733, upload-time = "2025-11-01T17:48:46.632Z" }, + { url = "https://files.pythonhosted.org/packages/c7/eb/fd01d63f49b8a774cecdb2df20b7f902871dc095dc19f4bfc19ed27f70ec/cwcwidth-0.1.12-cp314-cp314-win_amd64.whl", hash = "sha256:73dfc6926efa65343b129aad02364a61a238b2c6536f6d6388ef5611b42302d4", size = 26662, upload-time = "2025-11-01T17:48:47.298Z" }, + { url = "https://files.pythonhosted.org/packages/ec/84/9c25ddda092cfd405e59970dd7e96e2625e59ca7a0b5156d9dbc31c6c744/cwcwidth-0.1.12-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:611bc2c397123e7a24bb8a963539938e6f882c0a2ef2bf289ae3e7a752a642f3", size = 26531, upload-time = "2025-11-01T17:48:48.027Z" }, + { url = "https://files.pythonhosted.org/packages/ad/c8/7b79a7e28706d9da53ec66f5ad2d66c7be7687188bfd3ee35489940cf2fd/cwcwidth-0.1.12-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b997f0cbffd71aaaf32c170e657d4d47cf4122777ae1eba2da17e5112529da5c", size = 127465, upload-time = "2025-11-01T17:48:48.708Z" }, + { url = "https://files.pythonhosted.org/packages/4e/5e/f43a4c4c54650a5061f74521ebd99732f2782a29fe174f34098fbb8f74db/cwcwidth-0.1.12-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5daa2627bfa08c351920231ab1d594750c5fc48d95a2c4c3e5706fd57c6e8f91", size = 132434, upload-time = "2025-11-01T17:48:49.939Z" }, + { url = "https://files.pythonhosted.org/packages/11/f6/79c36b0f1b360c687e8a3f510ee6b7ce981c0fcd5efd2ba4ddf05065b257/cwcwidth-0.1.12-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c2dbce36c92ef0047ff252b2a1bebc41239b7edfd55716846006cf8f250f0c9d", size = 127850, upload-time = "2025-11-01T17:48:50.717Z" }, + { url = "https://files.pythonhosted.org/packages/05/c4/d0ae37f72d7ddff3be5a34abde28270c3eca9a26ddb526b963c21f5af441/cwcwidth-0.1.12-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c63145a882da594761156123e635b1fc5f8a5b3e1ec83c76392ac829f4733098", size = 127118, upload-time = "2025-11-01T17:48:51.472Z" }, + { url = "https://files.pythonhosted.org/packages/51/5c/72943d70049f9362e95ca7fca8fb485819c2150ff595530cbee92c6e0b2f/cwcwidth-0.1.12-cp314-cp314t-win32.whl", hash = "sha256:dd06c5e63650ec59f92ceb24b02a3f6002fb11aab92fce36d85d0a9c9203a9d8", size = 27350, upload-time = "2025-11-01T17:48:52.308Z" }, + { url = "https://files.pythonhosted.org/packages/a0/eb/e65a1a359063d019913cbcb95503d86fc415e18221023b4ec92e35e3d097/cwcwidth-0.1.12-cp314-cp314t-win_amd64.whl", hash = "sha256:fdcfb9632310d2c5b9cee4e8dfbffcfe07b6ca4968d3123b6ca618603b608deb", size = 29706, upload-time = "2025-11-01T17:48:52.965Z" }, ] [[package]] name = "dj-database-url" -version = "2.3.0" +version = "3.0.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "django" }, - { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/98/9f/fc9905758256af4f68a55da94ab78a13e7775074edfdcaddd757d4921686/dj_database_url-2.3.0.tar.gz", hash = "sha256:ae52e8e634186b57e5a45e445da5dc407a819c2ceed8a53d1fac004cc5288787", size = 10980, upload-time = "2024-10-23T10:05:19.953Z" } +sdist = { url = "https://files.pythonhosted.org/packages/75/05/2ec51009f4ce424877dbd8ad95868faec0c3494ed0ff1635f9ab53d9e0ee/dj_database_url-3.0.1.tar.gz", hash = "sha256:8994961efb888fc6bf8c41550870c91f6f7691ca751888ebaa71442b7f84eff8", size = 12556, upload-time = "2025-07-02T09:40:11.424Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e5/91/641a4e5c8903ed59f6cbcce571003bba9c5d2f731759c31db0ba83bb0bdb/dj_database_url-2.3.0-py3-none-any.whl", hash = "sha256:bb0d414ba0ac5cd62773ec7f86f8cc378a9dbb00a80884c2fc08cc570452521e", size = 7793, upload-time = "2024-10-23T10:05:41.254Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5e/86a43c6fdaa41c12d58e4ff3ebbfd6b71a7cb0360a08614e3754ef2e9afb/dj_database_url-3.0.1-py3-none-any.whl", hash = "sha256:43950018e1eeea486bf11136384aec0fe55b29fe6fd8a44553231b85661d9383", size = 8808, upload-time = "2025-07-02T09:40:26.326Z" }, ] [[package]] name = "django" -version = "5.2" +version = "5.2.8" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "asgiref" }, { name = "sqlparse" }, { name = "tzdata", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/4c/1b/c6da718c65228eb3a7ff7ba6a32d8e80fa840ca9057490504e099e4dd1ef/Django-5.2.tar.gz", hash = "sha256:1a47f7a7a3d43ce64570d350e008d2949abe8c7e21737b351b6a1611277c6d89", size = 10824891, upload-time = "2025-04-02T13:08:06.874Z" } +sdist = { url = "https://files.pythonhosted.org/packages/05/a2/933dbbb3dd9990494960f6e64aca2af4c0745b63b7113f59a822df92329e/django-5.2.8.tar.gz", hash = "sha256:23254866a5bb9a2cfa6004e8b809ec6246eba4b58a7589bc2772f1bcc8456c7f", size = 10849032, upload-time = "2025-11-05T14:07:32.778Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/63/e0/6a5b5ea350c5bd63fe94b05e4c146c18facb51229d9dee42aa39f9fc2214/Django-5.2-py3-none-any.whl", hash = "sha256:91ceed4e3a6db5aedced65e3c8f963118ea9ba753fc620831c77074e620e7d83", size = 8301361, upload-time = "2025-04-02T13:08:01.465Z" }, + { url = "https://files.pythonhosted.org/packages/5e/3d/a035a4ee9b1d4d4beee2ae6e8e12fe6dee5514b21f62504e22efcbd9fb46/django-5.2.8-py3-none-any.whl", hash = "sha256:37e687f7bd73ddf043e2b6b97cfe02fcbb11f2dbb3adccc6a2b18c6daa054d7f", size = 8289692, upload-time = "2025-11-05T14:07:28.761Z" }, ] [[package]] name = "django-cors-headers" -version = "4.7.0" +version = "4.9.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "asgiref" }, { name = "django" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/93/6c/16f6cb6064c63074fd5b2bd494eb319afd846236d9c1a6c765946df2c289/django_cors_headers-4.7.0.tar.gz", hash = "sha256:6fdf31bf9c6d6448ba09ef57157db2268d515d94fc5c89a0a1028e1fc03ee52b", size = 21037, upload-time = "2025-02-06T22:15:28.924Z" } +sdist = { url = "https://files.pythonhosted.org/packages/21/39/55822b15b7ec87410f34cd16ce04065ff390e50f9e29f31d6d116fc80456/django_cors_headers-4.9.0.tar.gz", hash = "sha256:fe5d7cb59fdc2c8c646ce84b727ac2bca8912a247e6e68e1fb507372178e59e8", size = 21458, upload-time = "2025-09-18T10:40:52.326Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7e/a2/7bcfff86314bd9dd698180e31ba00604001606efb518a06cca6833a54285/django_cors_headers-4.7.0-py3-none-any.whl", hash = "sha256:f1c125dcd58479fe7a67fe2499c16ee38b81b397463cf025f0e2c42937421070", size = 12794, upload-time = "2025-02-06T22:15:24.341Z" }, + { url = "https://files.pythonhosted.org/packages/30/d8/19ed1e47badf477d17fb177c1c19b5a21da0fd2d9f093f23be3fb86c5fab/django_cors_headers-4.9.0-py3-none-any.whl", hash = "sha256:15c7f20727f90044dcee2216a9fd7303741a864865f0c3657e28b7056f61b449", size = 12809, upload-time = "2025-09-18T10:40:50.843Z" }, ] [[package]] name = "django-debug-toolbar" -version = "5.1.0" +version = "6.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "django" }, { name = "sqlparse" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/5b/6b/41281bf3f9939713010f24f46a033a74cf90599f52f09aaa8b0b118692b7/django_debug_toolbar-5.1.0.tar.gz", hash = "sha256:8a3b9da4aeab8d384a366e20304bd939a451f0242523c5b7b402248ad474eed2", size = 294567, upload-time = "2025-03-20T16:17:08.496Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c0/50/acae2dd379164f6f4c6b6b36fd48a4d21b02095a03f4df7c30a8d1f1a62c/django_debug_toolbar-6.1.0.tar.gz", hash = "sha256:e962ec350c9be8bdba918138e975a9cdb193f60ec396af2bb71b769e8e165519", size = 309141, upload-time = "2025-10-30T19:50:39.458Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/62/ce/39831ce0a946979fdf19c32e6dcd1754a70e3280815aa7a377f61d5e021c/django_debug_toolbar-5.1.0-py3-none-any.whl", hash = "sha256:c0591e338ee9603bdfce5aebf8d18ca7341fdbb69595e2b0b34869be5857180e", size = 261531, upload-time = "2025-03-20T16:17:05.812Z" }, + { url = "https://files.pythonhosted.org/packages/6d/72/685c978af45ad08257e2c69687a873eda6b6531c79b6e6091794c41c5ff6/django_debug_toolbar-6.1.0-py3-none-any.whl", hash = "sha256:e214dea4494087e7cebdcea84223819c5eb97f9de3110a3665ad673f0ba98413", size = 269069, upload-time = "2025-10-30T19:50:37.71Z" }, ] [[package]] @@ -283,48 +287,31 @@ wheels = [ [[package]] name = "djangorestframework" -version = "3.16.0" +version = "3.16.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "django" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/7d/97/112c5a72e6917949b6d8a18ad6c6e72c46da4290c8f36ee5f1c1dcbc9901/djangorestframework-3.16.0.tar.gz", hash = "sha256:f022ff46613584de994c0c6a4aebbace5fd700555fbe9d33b865ebf173eba6c9", size = 1068408, upload-time = "2025-03-28T14:18:42.065Z" } +sdist = { url = "https://files.pythonhosted.org/packages/8a/95/5376fe618646fde6899b3cdc85fd959716bb67542e273a76a80d9f326f27/djangorestframework-3.16.1.tar.gz", hash = "sha256:166809528b1aced0a17dc66c24492af18049f2c9420dbd0be29422029cfc3ff7", size = 1089735, upload-time = "2025-08-06T17:50:53.251Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/eb/3e/2448e93f4f87fc9a9f35e73e3c05669e0edd0c2526834686e949bb1fd303/djangorestframework-3.16.0-py3-none-any.whl", hash = "sha256:bea7e9f6b96a8584c5224bfb2e4348dfb3f8b5e34edbecb98da258e892089361", size = 1067305, upload-time = "2025-03-28T14:18:39.489Z" }, + { url = "https://files.pythonhosted.org/packages/b0/ce/bf8b9d3f415be4ac5588545b5fcdbbb841977db1c1d923f7568eeabe1689/djangorestframework-3.16.1-py3-none-any.whl", hash = "sha256:33a59f47fb9c85ede792cbf88bde71893bcda0667bc573f784649521f1102cec", size = 1080442, upload-time = "2025-08-06T17:50:50.667Z" }, ] [[package]] -name = "filelock" -version = "3.19.1" +name = "greenlet" +version = "3.2.4" 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/03/b8/704d753a5a45507a7aab61f18db9509302ed3d0a27ac7e0359ec2905b1a6/greenlet-3.2.4.tar.gz", hash = "sha256:0dca0d95ff849f9a364385f36ab49f50065d76964944638be9691e1832e9f86d", size = 188260, upload-time = "2025-08-07T13:24:33.51Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/42/14/42b2651a2f46b022ccd948bca9f2d5af0fd8929c4eec235b8d6d844fbe67/filelock-3.19.1-py3-none-any.whl", hash = "sha256:d38e30481def20772f5baf097c122c3babc4fcdb7e14e57049eb9d88c6dc017d", size = 15988, upload-time = "2025-08-14T16:56:01.633Z" }, -] - -[[package]] -name = "greenlet" -version = "3.2.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/3f/74/907bb43af91782e0366b0960af62a8ce1f9398e4291cac7beaeffbee0c04/greenlet-3.2.1.tar.gz", hash = "sha256:9f4dd4b4946b14bb3bf038f81e1d2e535b7d94f1b2a59fdba1293cd9c1a0a4d7", size = 184475, upload-time = "2025-04-22T14:40:18.206Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/77/2a/581b3808afec55b2db838742527c40b4ce68b9b64feedff0fd0123f4b19a/greenlet-3.2.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:e1967882f0c42eaf42282a87579685c8673c51153b845fde1ee81be720ae27ac", size = 269119, upload-time = "2025-04-22T14:25:01.798Z" }, - { url = "https://files.pythonhosted.org/packages/b0/f3/1c4e27fbdc84e13f05afc2baf605e704668ffa26e73a43eca93e1120813e/greenlet-3.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e77ae69032a95640a5fe8c857ec7bee569a0997e809570f4c92048691ce4b437", size = 637314, upload-time = "2025-04-22T14:53:46.214Z" }, - { url = "https://files.pythonhosted.org/packages/fc/1a/9fc43cb0044f425f7252da9847893b6de4e3b20c0a748bce7ab3f063d5bc/greenlet-3.2.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3227c6ec1149d4520bc99edac3b9bc8358d0034825f3ca7572165cb502d8f29a", size = 651421, upload-time = "2025-04-22T14:55:00.852Z" }, - { url = "https://files.pythonhosted.org/packages/8a/65/d47c03cdc62c6680206b7420c4a98363ee997e87a5e9da1e83bd7eeb57a8/greenlet-3.2.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0ddda0197c5b46eedb5628d33dad034c455ae77708c7bf192686e760e26d6a0c", size = 645789, upload-time = "2025-04-22T15:04:37.702Z" }, - { url = "https://files.pythonhosted.org/packages/2f/40/0faf8bee1b106c241780f377b9951dd4564ef0972de1942ef74687aa6bba/greenlet-3.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:de62b542e5dcf0b6116c310dec17b82bb06ef2ceb696156ff7bf74a7a498d982", size = 648262, upload-time = "2025-04-22T14:27:07.55Z" }, - { url = "https://files.pythonhosted.org/packages/e0/a8/73305f713183c2cb08f3ddd32eaa20a6854ba9c37061d682192db9b021c3/greenlet-3.2.1-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c07a0c01010df42f1f058b3973decc69c4d82e036a951c3deaf89ab114054c07", size = 606770, upload-time = "2025-04-22T14:25:58.34Z" }, - { url = "https://files.pythonhosted.org/packages/c3/05/7d726e1fb7f8a6ac55ff212a54238a36c57db83446523c763e20cd30b837/greenlet-3.2.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:2530bfb0abcd451ea81068e6d0a1aac6dabf3f4c23c8bd8e2a8f579c2dd60d95", size = 1117960, upload-time = "2025-04-22T14:59:00.373Z" }, - { url = "https://files.pythonhosted.org/packages/bf/9f/2b6cb1bd9f1537e7b08c08705c4a1d7bd4f64489c67d102225c4fd262bda/greenlet-3.2.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:1c472adfca310f849903295c351d297559462067f618944ce2650a1878b84123", size = 1145500, upload-time = "2025-04-22T14:28:12.441Z" }, - { url = "https://files.pythonhosted.org/packages/e4/f6/339c6e707062319546598eb9827d3ca8942a3eccc610d4a54c1da7b62527/greenlet-3.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:24a496479bc8bd01c39aa6516a43c717b4cee7196573c47b1f8e1011f7c12495", size = 295994, upload-time = "2025-04-22T14:50:44.796Z" }, - { url = "https://files.pythonhosted.org/packages/f1/72/2a251d74a596af7bb1717e891ad4275a3fd5ac06152319d7ad8c77f876af/greenlet-3.2.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:175d583f7d5ee57845591fc30d852b75b144eb44b05f38b67966ed6df05c8526", size = 629889, upload-time = "2025-04-22T14:53:48.434Z" }, - { url = "https://files.pythonhosted.org/packages/29/2e/d7ed8bf97641bf704b6a43907c0e082cdf44d5bc026eb8e1b79283e7a719/greenlet-3.2.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3ecc9d33ca9428e4536ea53e79d781792cee114d2fa2695b173092bdbd8cd6d5", size = 635261, upload-time = "2025-04-22T14:55:02.258Z" }, - { url = "https://files.pythonhosted.org/packages/1e/75/802aa27848a6fcb5e566f69c64534f572e310f0f12d41e9201a81e741551/greenlet-3.2.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3f56382ac4df3860ebed8ed838f268f03ddf4e459b954415534130062b16bc32", size = 632523, upload-time = "2025-04-22T15:04:39.221Z" }, - { url = "https://files.pythonhosted.org/packages/56/09/f7c1c3bab9b4c589ad356503dd71be00935e9c4db4db516ed88fc80f1187/greenlet-3.2.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cc45a7189c91c0f89aaf9d69da428ce8301b0fd66c914a499199cfb0c28420fc", size = 628816, upload-time = "2025-04-22T14:27:08.869Z" }, - { url = "https://files.pythonhosted.org/packages/79/e0/1bb90d30b5450eac2dffeaac6b692857c4bd642c21883b79faa8fa056cf2/greenlet-3.2.1-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:51a2f49da08cff79ee42eb22f1658a2aed60c72792f0a0a95f5f0ca6d101b1fb", size = 593687, upload-time = "2025-04-22T14:25:59.676Z" }, - { url = "https://files.pythonhosted.org/packages/c5/b5/adbe03c8b4c178add20cc716021183ae6b0326d56ba8793d7828c94286f6/greenlet-3.2.1-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:0c68bbc639359493420282d2f34fa114e992a8724481d700da0b10d10a7611b8", size = 1105754, upload-time = "2025-04-22T14:59:02.585Z" }, - { url = "https://files.pythonhosted.org/packages/39/93/84582d7ef38dec009543ccadec6ab41079a6cbc2b8c0566bcd07bf1aaf6c/greenlet-3.2.1-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:e775176b5c203a1fa4be19f91da00fd3bff536868b77b237da3f4daa5971ae5d", size = 1125160, upload-time = "2025-04-22T14:28:13.975Z" }, - { url = "https://files.pythonhosted.org/packages/01/e6/f9d759788518a6248684e3afeb3691f3ab0276d769b6217a1533362298c8/greenlet-3.2.1-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:d6668caf15f181c1b82fb6406f3911696975cc4c37d782e19cb7ba499e556189", size = 269897, upload-time = "2025-04-22T14:27:14.044Z" }, + { url = "https://files.pythonhosted.org/packages/22/5c/85273fd7cc388285632b0498dbbab97596e04b154933dfe0f3e68156c68c/greenlet-3.2.4-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:49a30d5fda2507ae77be16479bdb62a660fa51b1eb4928b524975b3bde77b3c0", size = 273586, upload-time = "2025-08-07T13:16:08.004Z" }, + { url = "https://files.pythonhosted.org/packages/d1/75/10aeeaa3da9332c2e761e4c50d4c3556c21113ee3f0afa2cf5769946f7a3/greenlet-3.2.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:299fd615cd8fc86267b47597123e3f43ad79c9d8a22bebdce535e53550763e2f", size = 686346, upload-time = "2025-08-07T13:42:59.944Z" }, + { url = "https://files.pythonhosted.org/packages/c0/aa/687d6b12ffb505a4447567d1f3abea23bd20e73a5bed63871178e0831b7a/greenlet-3.2.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:c17b6b34111ea72fc5a4e4beec9711d2226285f0386ea83477cbb97c30a3f3a5", size = 699218, upload-time = "2025-08-07T13:45:30.969Z" }, + { url = "https://files.pythonhosted.org/packages/dc/8b/29aae55436521f1d6f8ff4e12fb676f3400de7fcf27fccd1d4d17fd8fecd/greenlet-3.2.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b4a1870c51720687af7fa3e7cda6d08d801dae660f75a76f3845b642b4da6ee1", size = 694659, upload-time = "2025-08-07T13:53:17.759Z" }, + { url = "https://files.pythonhosted.org/packages/92/2e/ea25914b1ebfde93b6fc4ff46d6864564fba59024e928bdc7de475affc25/greenlet-3.2.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:061dc4cf2c34852b052a8620d40f36324554bc192be474b9e9770e8c042fd735", size = 695355, upload-time = "2025-08-07T13:18:34.517Z" }, + { url = "https://files.pythonhosted.org/packages/72/60/fc56c62046ec17f6b0d3060564562c64c862948c9d4bc8aa807cf5bd74f4/greenlet-3.2.4-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44358b9bf66c8576a9f57a590d5f5d6e72fa4228b763d0e43fee6d3b06d3a337", size = 657512, upload-time = "2025-08-07T13:18:33.969Z" }, + { url = "https://files.pythonhosted.org/packages/23/6e/74407aed965a4ab6ddd93a7ded3180b730d281c77b765788419484cdfeef/greenlet-3.2.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2917bdf657f5859fbf3386b12d68ede4cf1f04c90c3a6bc1f013dd68a22e2269", size = 1612508, upload-time = "2025-11-04T12:42:23.427Z" }, + { url = "https://files.pythonhosted.org/packages/0d/da/343cd760ab2f92bac1845ca07ee3faea9fe52bee65f7bcb19f16ad7de08b/greenlet-3.2.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:015d48959d4add5d6c9f6c5210ee3803a830dce46356e3bc326d6776bde54681", size = 1680760, upload-time = "2025-11-04T12:42:25.341Z" }, + { url = "https://files.pythonhosted.org/packages/e3/a5/6ddab2b4c112be95601c13428db1d8b6608a8b6039816f2ba09c346c08fc/greenlet-3.2.4-cp314-cp314-win_amd64.whl", hash = "sha256:e37ab26028f12dbb0ff65f29a8d3d44a765c61e729647bf2ddfbbed621726f01", size = 303425, upload-time = "2025-08-07T13:32:27.59Z" }, ] [[package]] @@ -364,22 +351,13 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, ] -[[package]] -name = "identify" -version = "2.6.13" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/82/ca/ffbabe3635bb839aa36b3a893c91a9b0d368cb4d8073e03a12896970af82/identify-2.6.13.tar.gz", hash = "sha256:da8d6c828e773620e13bfa86ea601c5a5310ba4bcd65edf378198b56a1f9fb32", size = 99243, upload-time = "2025-08-09T19:35:00.6Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e7/ce/461b60a3ee109518c055953729bf9ed089a04db895d47e95444071dcdef2/identify-2.6.13-py2.py3-none-any.whl", hash = "sha256:60381139b3ae39447482ecc406944190f690d4a2997f2584062089848361b33b", size = 99153, upload-time = "2025-08-09T19:34:59.1Z" }, -] - [[package]] name = "idna" -version = "3.10" +version = "3.11" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490, upload-time = "2024-09-15T18:07:39.745Z" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" }, + { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, ] [[package]] @@ -406,83 +384,75 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/27/e3/0e0014d6ab159d48189e92044ace13b1e1fe9aa3024ba9f4e8cf172aa7c2/jinxed-1.3.0-py2.py3-none-any.whl", hash = "sha256:b993189f39dc2d7504d802152671535b06d380b26d78070559551cbf92df4fc5", size = 33085, upload-time = "2024-07-31T22:39:17.426Z" }, ] -[[package]] -name = "nodeenv" -version = "1.9.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/43/16/fc88b08840de0e0a72a2f9d8c6bae36be573e475a6326ae854bcc549fc45/nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f", size = 47437, upload-time = "2024-06-04T18:44:11.171Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d2/1d/1b658dbd2b9fa9c4c9f32accbfc0205d532c8c6194dc0f2a4c0428e7128a/nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9", size = 22314, upload-time = "2024-06-04T18:44:08.352Z" }, -] - [[package]] name = "parso" -version = "0.8.4" +version = "0.8.5" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/66/94/68e2e17afaa9169cf6412ab0f28623903be73d1b32e208d9e8e541bb086d/parso-0.8.4.tar.gz", hash = "sha256:eb3a7b58240fb99099a345571deecc0f9540ea5f4dd2fe14c2a99d6b281ab92d", size = 400609, upload-time = "2024-04-05T09:43:55.897Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d4/de/53e0bcf53d13e005bd8c92e7855142494f41171b34c2536b86187474184d/parso-0.8.5.tar.gz", hash = "sha256:034d7354a9a018bdce352f48b2a8a450f05e9d6ee85db84764e9b6bd96dafe5a", size = 401205, upload-time = "2025-08-23T15:15:28.028Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c6/ac/dac4a63f978e4dcb3c6d3a78c4d8e0192a113d288502a1216950c41b1027/parso-0.8.4-py2.py3-none-any.whl", hash = "sha256:a418670a20291dacd2dddc80c377c5c3791378ee1e8d12bffc35420643d43f18", size = 103650, upload-time = "2024-04-05T09:43:53.299Z" }, + { url = "https://files.pythonhosted.org/packages/16/32/f8e3c85d1d5250232a5d3477a2a28cc291968ff175caeadaf3cc19ce0e4a/parso-0.8.5-py2.py3-none-any.whl", hash = "sha256:646204b5ee239c396d040b90f9e272e9a8017c630092bf59980beb62fd033887", size = 106668, upload-time = "2025-08-23T15:15:25.663Z" }, ] [[package]] -name = "platformdirs" -version = "4.4.0" +name = "prek" +version = "0.2.24" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/23/e8/21db9c9987b0e728855bd57bff6984f67952bea55d6f75e055c46b5383e8/platformdirs-4.4.0.tar.gz", hash = "sha256:ca753cf4d81dc309bc67b0ea38fd15dc97bc30ce419a7f58d13eb3bf14c4febf", size = 21634, upload-time = "2025-08-26T14:32:04.268Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/40/4b/2028861e724d3bd36227adfa20d3fd24c3fc6d52032f4a93c133be5d17ce/platformdirs-4.4.0-py3-none-any.whl", hash = "sha256:abd01743f24e5287cd7a5db3752faf1a2d65353f38ec26d98e25a6db65958c85", size = 18654, upload-time = "2025-08-26T14:32:02.735Z" }, -] - -[[package]] -name = "pre-commit" -version = "4.3.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "cfgv" }, - { name = "identify" }, - { name = "nodeenv" }, - { name = "pyyaml" }, - { name = "virtualenv" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/ff/29/7cf5bbc236333876e4b41f56e06857a87937ce4bf91e117a6991a2dbb02a/pre_commit-4.3.0.tar.gz", hash = "sha256:499fe450cc9d42e9d58e606262795ecb64dd05438943c62b66f6a8673da30b16", size = 193792, upload-time = "2025-08-09T18:56:14.651Z" } +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/5b/a5/987a405322d78a73b66e39e4a90e4ef156fd7141bf71df987e50717c321b/pre_commit-4.3.0-py2.py3-none-any.whl", hash = "sha256:2b0747ad7e6e967169136edffee14c16e148a778a54e4f967921aa1ebf2308d8", size = 220965, upload-time = "2025-08-09T18:56:13.192Z" }, + { url = "https://files.pythonhosted.org/packages/27/bc/e67414efd29b81626016a16b7d9f33bb67f4adf47ea8554ae11b7fcb46e3/prek-0.2.24-py3-none-linux_armv6l.whl", hash = "sha256:2b36f04353cf0bbee35b510c83bf2a071682745be0d5265e821934a94869a7f7", size = 4793435, upload-time = "2025-12-23T03:59:19.779Z" }, + { url = "https://files.pythonhosted.org/packages/3f/66/9a724e7b3e3a389e1e0cbacf0f4707ee056c83361925cadef43489b5012d/prek-0.2.24-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:8149aa03eb993ba7c0a7509abccdf30665455db2405eb941c1c4174e3441c6b3", size = 4890722, upload-time = "2025-12-23T03:59:18.299Z" }, + { url = "https://files.pythonhosted.org/packages/2d/cf/ee4c057f08a137ec85cc525f4170c3b930d8edd0a8ead20952c8079199c7/prek-0.2.24-py3-none-macosx_11_0_arm64.whl", hash = "sha256:100bf066669834876f87af11c79bdd4a3c8c1e8abf49aa047bc9c52265f5f544", size = 4615935, upload-time = "2025-12-23T03:59:20.947Z" }, + { url = "https://files.pythonhosted.org/packages/c4/71/a84ae24a82814896220fa3a03f07a62fb2e3f3ed6aa9c3952aaedb008b12/prek-0.2.24-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:656295670b7646936d5d738a708b310900870f47757375214dfaa592786702be", size = 4812259, upload-time = "2025-12-23T03:59:26.671Z" }, + { url = "https://files.pythonhosted.org/packages/55/9a/a009873b954f726f8f43be8d660095c76d47208c6e9397d75f916f52b8fc/prek-0.2.24-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3b79fe57f59fa2649d8a727152af742353de8d537ade75285bedf49b66bf8768", size = 4713078, upload-time = "2025-12-23T03:59:29.51Z" }, + { url = "https://files.pythonhosted.org/packages/37/b3/daf4a1da6f009f4413ca6302b6f6480f824be2447dc74606981c47958ad1/prek-0.2.24-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f02a79c76a84339eecc2d01b1e5f81eb4e8769629e9a62343a8e4089778db956", size = 5034136, upload-time = "2025-12-23T03:59:06.775Z" }, + { url = "https://files.pythonhosted.org/packages/49/17/2b754198c7444f9b8f09c60280e601659afb6a4d6ce9fc5553e15218800b/prek-0.2.24-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:cbd9b7b568a5cdcb9ccce8c8b186b52c6547dfd2e70d0a2438e3cb17a37affb4", size = 5445865, upload-time = "2025-12-23T03:59:12.684Z" }, + { url = "https://files.pythonhosted.org/packages/67/61/d54c7db0f6ff1a12b0b7211b32b7b2685fcee81dd51fb1a139e757b648cd/prek-0.2.24-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fc788a1bb3dba31c9ad864ee73fc6320c07fd0f0a3d9652995dfee5d62ccc4f8", size = 5401392, upload-time = "2025-12-23T03:59:24.181Z" }, + { url = "https://files.pythonhosted.org/packages/5a/61/cd7e78e2f371a6603c6ac323ad2306c6793d39f4f6ee2723682b25d65478/prek-0.2.24-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1ee8d1293755f6b42e7fa4bbc7122781e7c3880ca06ed2f85f0ed40c0df14c9b", size = 5492942, upload-time = "2025-12-23T03:59:14.367Z" }, + { url = "https://files.pythonhosted.org/packages/10/ff/657c6269d65dbe682f82113620823c65e002c3ae4fd417f25adaa390179e/prek-0.2.24-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:933a49f0b22abc2baca378f02b4b1b6d9522800a2ccc9e247aa51ebe421fc6dc", size = 5083804, upload-time = "2025-12-23T03:59:28.213Z" }, + { url = "https://files.pythonhosted.org/packages/bc/d9/8929b12dd8849d4d00b6c8e22db1fec22fef4b1e7356c0812107eb0a4f6c/prek-0.2.24-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:f88defe48704eea1391e29b18b363fcd22ef5490af619b6328fece8092c9d24b", size = 4819786, upload-time = "2025-12-23T03:59:32.053Z" }, + { url = "https://files.pythonhosted.org/packages/db/a4/d9e0f7d445621a5c416a8883a33b079cf2c6f7e35a360d15c074f9b353fb/prek-0.2.24-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:3fd336eb13489460da3476bfb1bd185d6bd0f9d3f9bff7780b32d2c801026578", size = 4829112, upload-time = "2025-12-23T03:59:22.546Z" }, + { url = "https://files.pythonhosted.org/packages/10/da/4fdcd158268c337ad3fe4dad3fcb0716f46bba2fe202ee03a473e3eda9b9/prek-0.2.24-py3-none-musllinux_1_1_armv7l.whl", hash = "sha256:9eb952540fd17d540373eb4239ccdcc1e060ca1c33a7ec5d27f6ec03838848c5", size = 4698341, upload-time = "2025-12-23T03:59:11.184Z" }, + { url = "https://files.pythonhosted.org/packages/71/82/c9dd71e5c40c075314b6e3584067084dfbf56d9d1d74baea217d7581a5bf/prek-0.2.24-py3-none-musllinux_1_1_i686.whl", hash = "sha256:7168d6d86576704cddb7c38aff1b62c305312700492c85ff981dfa986013c265", size = 4917027, upload-time = "2025-12-23T03:59:30.751Z" }, + { url = "https://files.pythonhosted.org/packages/ef/05/0559b0504d39dc97f71d74f270918d043f3259fff4cbe11beccfdbb586e6/prek-0.2.24-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:4e500beb902c524b48d084deabc687cb344226ce91f926c6ab8a65a6754d8a9a", size = 5192231, upload-time = "2025-12-23T03:59:16.775Z" }, + { url = "https://files.pythonhosted.org/packages/1f/b3/e740f52236a0077890a82e1c8046d4e0ff8b140bd3c30e3e82f35fee2224/prek-0.2.24-py3-none-win32.whl", hash = "sha256:bab279d54b6adf85d95923590dacaa9956eb354cc64204c45983fa2d5c2f7a8a", size = 4603284, upload-time = "2025-12-23T03:59:15.544Z" }, + { url = "https://files.pythonhosted.org/packages/41/31/cf0773b3cd7b965a7d15264ec96f85ee5f451db5e9df5d0d9d87d3b8e4ce/prek-0.2.24-py3-none-win_amd64.whl", hash = "sha256:c89ad7f73e8b38bd5e79e83fec3bf234dec87295957c94cc7d94a125bc609ff0", size = 5295275, upload-time = "2025-12-23T03:59:25.354Z" }, + { url = "https://files.pythonhosted.org/packages/97/34/b44663946ea7be1d0b1c7877e748603638a8d0eff9f3969f97b9439aa17b/prek-0.2.24-py3-none-win_arm64.whl", hash = "sha256:9257b3293746a69d600736e0113534b3b80a0ce8ee23a1b0db36253e9c7e24ab", size = 4962129, upload-time = "2025-12-23T03:59:08.609Z" }, ] [[package]] name = "prompt-toolkit" -version = "3.0.51" +version = "3.0.52" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "wcwidth" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/bb/6e/9d084c929dfe9e3bfe0c6a47e31f78a25c54627d64a66e884a8bf5474f1c/prompt_toolkit-3.0.51.tar.gz", hash = "sha256:931a162e3b27fc90c86f1b48bb1fb2c528c2761475e57c9c06de13311c7b54ed", size = 428940, upload-time = "2025-04-15T09:18:47.731Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/96/06e01a7b38dce6fe1db213e061a4602dd6032a8a97ef6c1a862537732421/prompt_toolkit-3.0.52.tar.gz", hash = "sha256:28cde192929c8e7321de85de1ddbe736f1375148b02f2e17edd840042b1be855", size = 434198, upload-time = "2025-08-27T15:24:02.057Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ce/4f/5249960887b1fbe561d9ff265496d170b55a735b76724f10ef19f9e40716/prompt_toolkit-3.0.51-py3-none-any.whl", hash = "sha256:52742911fde84e2d423e2f9a4cf1de7d7ac4e51958f648d9540e0fb8db077b07", size = 387810, upload-time = "2025-04-15T09:18:44.753Z" }, + { url = "https://files.pythonhosted.org/packages/84/03/0d3ce49e2505ae70cf43bc5bb3033955d2fc9f932163e84dc0779cc47f48/prompt_toolkit-3.0.52-py3-none-any.whl", hash = "sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955", size = 391431, upload-time = "2025-08-27T15:23:59.498Z" }, ] [[package]] name = "psycopg2-binary" -version = "2.9.10" +version = "2.9.11" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/cb/0e/bdc8274dc0585090b4e3432267d7be4dfbfd8971c0fa59167c711105a6bf/psycopg2-binary-2.9.10.tar.gz", hash = "sha256:4b3df0e6990aa98acda57d983942eff13d824135fe2250e6522edaa782a06de2", size = 385764, upload-time = "2024-10-16T11:24:58.126Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ac/6c/8767aaa597ba424643dc87348c6f1754dd9f48e80fdc1b9f7ca5c3a7c213/psycopg2-binary-2.9.11.tar.gz", hash = "sha256:b6aed9e096bf63f9e75edf2581aa9a7e7186d97ab5c177aa6c87797cd591236c", size = 379620, upload-time = "2025-10-10T11:14:48.041Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3e/30/d41d3ba765609c0763505d565c4d12d8f3c79793f0d0f044ff5a28bf395b/psycopg2_binary-2.9.10-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:26540d4a9a4e2b096f1ff9cce51253d0504dca5a85872c7f7be23be5a53eb18d", size = 3044699, upload-time = "2024-10-16T11:21:42.841Z" }, - { url = "https://files.pythonhosted.org/packages/35/44/257ddadec7ef04536ba71af6bc6a75ec05c5343004a7ec93006bee66c0bc/psycopg2_binary-2.9.10-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:e217ce4d37667df0bc1c397fdcd8de5e81018ef305aed9415c3b093faaeb10fb", size = 3275245, upload-time = "2024-10-16T11:21:51.989Z" }, - { url = "https://files.pythonhosted.org/packages/1b/11/48ea1cd11de67f9efd7262085588790a95d9dfcd9b8a687d46caf7305c1a/psycopg2_binary-2.9.10-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:245159e7ab20a71d989da00f280ca57da7641fa2cdcf71749c193cea540a74f7", size = 2851631, upload-time = "2024-10-16T11:21:57.584Z" }, - { url = "https://files.pythonhosted.org/packages/62/e0/62ce5ee650e6c86719d621a761fe4bc846ab9eff8c1f12b1ed5741bf1c9b/psycopg2_binary-2.9.10-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c4ded1a24b20021ebe677b7b08ad10bf09aac197d6943bfe6fec70ac4e4690d", size = 3082140, upload-time = "2024-10-16T11:22:02.005Z" }, - { url = "https://files.pythonhosted.org/packages/27/ce/63f946c098611f7be234c0dd7cb1ad68b0b5744d34f68062bb3c5aa510c8/psycopg2_binary-2.9.10-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3abb691ff9e57d4a93355f60d4f4c1dd2d68326c968e7db17ea96df3c023ef73", size = 3264762, upload-time = "2024-10-16T11:22:06.412Z" }, - { url = "https://files.pythonhosted.org/packages/43/25/c603cd81402e69edf7daa59b1602bd41eb9859e2824b8c0855d748366ac9/psycopg2_binary-2.9.10-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8608c078134f0b3cbd9f89b34bd60a943b23fd33cc5f065e8d5f840061bd0673", size = 3020967, upload-time = "2024-10-16T11:22:11.583Z" }, - { url = "https://files.pythonhosted.org/packages/5f/d6/8708d8c6fca531057fa170cdde8df870e8b6a9b136e82b361c65e42b841e/psycopg2_binary-2.9.10-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:230eeae2d71594103cd5b93fd29d1ace6420d0b86f4778739cb1a5a32f607d1f", size = 2872326, upload-time = "2024-10-16T11:22:16.406Z" }, - { url = "https://files.pythonhosted.org/packages/ce/ac/5b1ea50fc08a9df82de7e1771537557f07c2632231bbab652c7e22597908/psycopg2_binary-2.9.10-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:bb89f0a835bcfc1d42ccd5f41f04870c1b936d8507c6df12b7737febc40f0909", size = 2822712, upload-time = "2024-10-16T11:22:21.366Z" }, - { url = "https://files.pythonhosted.org/packages/c4/fc/504d4503b2abc4570fac3ca56eb8fed5e437bf9c9ef13f36b6621db8ef00/psycopg2_binary-2.9.10-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f0c2d907a1e102526dd2986df638343388b94c33860ff3bbe1384130828714b1", size = 2920155, upload-time = "2024-10-16T11:22:25.684Z" }, - { url = "https://files.pythonhosted.org/packages/b2/d1/323581e9273ad2c0dbd1902f3fb50c441da86e894b6e25a73c3fda32c57e/psycopg2_binary-2.9.10-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f8157bed2f51db683f31306aa497311b560f2265998122abe1dce6428bd86567", size = 2959356, upload-time = "2024-10-16T11:22:30.562Z" }, - { url = "https://files.pythonhosted.org/packages/08/50/d13ea0a054189ae1bc21af1d85b6f8bb9bbc5572991055d70ad9006fe2d6/psycopg2_binary-2.9.10-cp313-cp313-win_amd64.whl", hash = "sha256:27422aa5f11fbcd9b18da48373eb67081243662f9b46e6fd07c3eb46e4535142", size = 2569224, upload-time = "2025-01-04T20:09:19.234Z" }, + { url = "https://files.pythonhosted.org/packages/64/12/93ef0098590cf51d9732b4f139533732565704f45bdc1ffa741b7c95fb54/psycopg2_binary-2.9.11-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:92e3b669236327083a2e33ccfa0d320dd01b9803b3e14dd986a4fc54aa00f4e1", size = 3756567, upload-time = "2025-10-10T11:13:11.885Z" }, + { url = "https://files.pythonhosted.org/packages/7c/a9/9d55c614a891288f15ca4b5209b09f0f01e3124056924e17b81b9fa054cc/psycopg2_binary-2.9.11-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e0deeb03da539fa3577fcb0b3f2554a97f7e5477c246098dbb18091a4a01c16f", size = 3864755, upload-time = "2025-10-10T11:13:17.727Z" }, + { url = "https://files.pythonhosted.org/packages/13/1e/98874ce72fd29cbde93209977b196a2edae03f8490d1bd8158e7f1daf3a0/psycopg2_binary-2.9.11-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:9b52a3f9bb540a3e4ec0f6ba6d31339727b2950c9772850d6545b7eae0b9d7c5", size = 4411646, upload-time = "2025-10-10T11:13:24.432Z" }, + { url = "https://files.pythonhosted.org/packages/5a/bd/a335ce6645334fb8d758cc358810defca14a1d19ffbc8a10bd38a2328565/psycopg2_binary-2.9.11-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:db4fd476874ccfdbb630a54426964959e58da4c61c9feba73e6094d51303d7d8", size = 4468701, upload-time = "2025-10-10T11:13:29.266Z" }, + { url = "https://files.pythonhosted.org/packages/44/d6/c8b4f53f34e295e45709b7568bf9b9407a612ea30387d35eb9fa84f269b4/psycopg2_binary-2.9.11-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:47f212c1d3be608a12937cc131bd85502954398aaa1320cb4c14421a0ffccf4c", size = 4166293, upload-time = "2025-10-10T11:13:33.336Z" }, + { url = "https://files.pythonhosted.org/packages/4b/e0/f8cc36eadd1b716ab36bb290618a3292e009867e5c97ce4aba908cb99644/psycopg2_binary-2.9.11-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e35b7abae2b0adab776add56111df1735ccc71406e56203515e228a8dc07089f", size = 3983184, upload-time = "2025-10-30T02:55:32.483Z" }, + { url = "https://files.pythonhosted.org/packages/53/3e/2a8fe18a4e61cfb3417da67b6318e12691772c0696d79434184a511906dc/psycopg2_binary-2.9.11-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fcf21be3ce5f5659daefd2b3b3b6e4727b028221ddc94e6c1523425579664747", size = 3652650, upload-time = "2025-10-10T11:13:38.181Z" }, + { url = "https://files.pythonhosted.org/packages/76/36/03801461b31b29fe58d228c24388f999fe814dfc302856e0d17f97d7c54d/psycopg2_binary-2.9.11-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:9bd81e64e8de111237737b29d68039b9c813bdf520156af36d26819c9a979e5f", size = 3298663, upload-time = "2025-10-10T11:13:44.878Z" }, + { url = "https://files.pythonhosted.org/packages/97/77/21b0ea2e1a73aa5fa9222b2a6b8ba325c43c3a8d54272839c991f2345656/psycopg2_binary-2.9.11-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:32770a4d666fbdafab017086655bcddab791d7cb260a16679cc5a7338b64343b", size = 3044737, upload-time = "2025-10-30T02:55:35.69Z" }, + { url = "https://files.pythonhosted.org/packages/67/69/f36abe5f118c1dca6d3726ceae164b9356985805480731ac6712a63f24f0/psycopg2_binary-2.9.11-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c3cb3a676873d7506825221045bd70e0427c905b9c8ee8d6acd70cfcbd6e576d", size = 3347643, upload-time = "2025-10-10T11:13:53.499Z" }, + { url = "https://files.pythonhosted.org/packages/e1/36/9c0c326fe3a4227953dfb29f5d0c8ae3b8eb8c1cd2967aa569f50cb3c61f/psycopg2_binary-2.9.11-cp314-cp314-win_amd64.whl", hash = "sha256:4012c9c954dfaccd28f94e84ab9f94e12df76b4afb22331b1f0d3154893a6316", size = 2803913, upload-time = "2025-10-10T11:13:57.058Z" }, ] [[package]] name = "ptpython" -version = "3.0.30" +version = "3.0.31" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "appdirs" }, @@ -490,39 +460,39 @@ dependencies = [ { name = "prompt-toolkit" }, { name = "pygments" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c9/ce/4441ac40762c73d74b48088a7311e368d28beec92602d66e632a59792a93/ptpython-3.0.30.tar.gz", hash = "sha256:51a07f9b8ebf8435a5aaeb22831cca4a52e87029771a2637df2763c79d3d8776", size = 72812, upload-time = "2025-04-15T09:26:37.534Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b2/55/6275ed7bcfc146719ecbe22291054c18847c464285854265ee516a5b4c8b/ptpython-3.0.31.tar.gz", hash = "sha256:4fed0be42bad01b7c299922cf262f51d8a77c9c8ab8e261c902e981a57439c13", size = 73045, upload-time = "2025-08-27T15:30:11.577Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/05/15/77dfd9a52fa6c87b50232b246df0cfacacc0665c95ebe4a517cc994819f0/ptpython-3.0.30-py3-none-any.whl", hash = "sha256:bec3045f0285ac817c902ef98d6ece31d3e00a4604ef3fdde07d365c78bde23c", size = 67249, upload-time = "2025-04-15T09:26:35.693Z" }, + { url = "https://files.pythonhosted.org/packages/f6/18/3d9874ef021a9df79e1f0fc971f4e990cee55750c8bc9fe547a24c130009/ptpython-3.0.31-py3-none-any.whl", hash = "sha256:ddd25fadb6f2ecd4469a699c068d2dcd40d77c7105922569bba6dc79c0523458", size = 67295, upload-time = "2025-08-27T15:30:09.984Z" }, ] [[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]] name = "python-dateutil" -version = "2.9.0.post0" +version = "2.9.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "six" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d9/77/bd458a2e387e98f71de86dcc2ca2cab64489736004c80bc12b70da8b5488/python-dateutil-2.9.0.tar.gz", hash = "sha256:78e73e19c63f5b20ffa567001531680d939dc042bf7850431877645523c66709", size = 342990, upload-time = "2024-03-01T03:52:54.963Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, + { url = "https://files.pythonhosted.org/packages/13/7f/98d6f9ca8b731506c85785bbb8806c01f5966a4df6d68c0d1cf3b16967e1/python_dateutil-2.9.0-py2.py3-none-any.whl", hash = "sha256:cbf2f1da5e6083ac2fbfd4da39a25f34312230110440f424a14c7558bb85d82e", size = 230495, upload-time = "2024-03-01T03:52:51.479Z" }, ] [[package]] name = "python-dotenv" -version = "1.1.0" +version = "1.2.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/88/2c/7bb1416c5620485aa793f2de31d3df393d3686aa8a8506d11e10e13c5baf/python_dotenv-1.1.0.tar.gz", hash = "sha256:41f90bc6f5f177fb41f53e87666db362025010eb28f60a01c9143bfa33a2b2d5", size = 39920, upload-time = "2025-03-25T10:14:56.835Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f0/26/19cadc79a718c5edbec86fd4919a6b6d3f681039a2f6d66d14be94e75fb9/python_dotenv-1.2.1.tar.gz", hash = "sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6", size = 44221, upload-time = "2025-10-26T15:12:10.434Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1e/18/98a99ad95133c6a6e2005fe89faedf294a748bd5dc803008059409ac9b1e/python_dotenv-1.1.0-py3-none-any.whl", hash = "sha256:d7c01d9e2293916c18baf562d95698754b0dbbb5e74d457c45d4f6561fb9d55d", size = 20256, upload-time = "2025-03-25T10:14:55.034Z" }, + { url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230, upload-time = "2025-10-26T15:12:09.109Z" }, ] [[package]] @@ -545,33 +515,42 @@ wheels = [ [[package]] name = "pyyaml" -version = "6.0.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/54/ed/79a089b6be93607fa5cdaedf301d7dfb23af5f25c398d5ead2525b063e17/pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e", size = 130631, upload-time = "2024-08-06T20:33:50.674Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ef/e3/3af305b830494fa85d95f6d95ef7fa73f2ee1cc8ef5b495c7c3269fb835f/PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba", size = 181309, upload-time = "2024-08-06T20:32:43.4Z" }, - { url = "https://files.pythonhosted.org/packages/45/9f/3b1c20a0b7a3200524eb0076cc027a970d320bd3a6592873c85c92a08731/PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1", size = 171679, upload-time = "2024-08-06T20:32:44.801Z" }, - { url = "https://files.pythonhosted.org/packages/7c/9a/337322f27005c33bcb656c655fa78325b730324c78620e8328ae28b64d0c/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133", size = 733428, upload-time = "2024-08-06T20:32:46.432Z" }, - { url = "https://files.pythonhosted.org/packages/a3/69/864fbe19e6c18ea3cc196cbe5d392175b4cf3d5d0ac1403ec3f2d237ebb5/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484", size = 763361, upload-time = "2024-08-06T20:32:51.188Z" }, - { url = "https://files.pythonhosted.org/packages/04/24/b7721e4845c2f162d26f50521b825fb061bc0a5afcf9a386840f23ea19fa/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5", size = 759523, upload-time = "2024-08-06T20:32:53.019Z" }, - { url = "https://files.pythonhosted.org/packages/2b/b2/e3234f59ba06559c6ff63c4e10baea10e5e7df868092bf9ab40e5b9c56b6/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc", size = 726660, upload-time = "2024-08-06T20:32:54.708Z" }, - { url = "https://files.pythonhosted.org/packages/fe/0f/25911a9f080464c59fab9027482f822b86bf0608957a5fcc6eaac85aa515/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652", size = 751597, upload-time = "2024-08-06T20:32:56.985Z" }, - { url = "https://files.pythonhosted.org/packages/14/0d/e2c3b43bbce3cf6bd97c840b46088a3031085179e596d4929729d8d68270/PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183", size = 140527, upload-time = "2024-08-06T20:33:03.001Z" }, - { url = "https://files.pythonhosted.org/packages/fa/de/02b54f42487e3d3c6efb3f89428677074ca7bf43aae402517bc7cca949f3/PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563", size = 156446, upload-time = "2024-08-06T20:33:04.33Z" }, +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, ] [[package]] name = "redis" -version = "6.2.0" +version = "7.1.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ea/9a/0551e01ba52b944f97480721656578c8a7c46b51b99d66814f85fe3a4f3e/redis-6.2.0.tar.gz", hash = "sha256:e821f129b75dde6cb99dd35e5c76e8c49512a5a0d8dfdc560b2fbd44b85ca977", size = 4639129, upload-time = "2025-05-28T05:01:18.91Z" } +sdist = { url = "https://files.pythonhosted.org/packages/43/c8/983d5c6579a411d8a99bc5823cc5712768859b5ce2c8afe1a65b37832c81/redis-7.1.0.tar.gz", hash = "sha256:b1cc3cfa5a2cb9c2ab3ba700864fb0ad75617b41f01352ce5779dabf6d5f9c3c", size = 4796669, upload-time = "2025-11-19T15:54:39.961Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/13/67/e60968d3b0e077495a8fee89cf3f2373db98e528288a48f1ee44967f6e8c/redis-6.2.0-py3-none-any.whl", hash = "sha256:c8ddf316ee0aab65f04a11229e94a64b2618451dab7a67cb2f77eb799d872d5e", size = 278659, upload-time = "2025-05-28T05:01:16.955Z" }, + { url = "https://files.pythonhosted.org/packages/89/f0/8956f8a86b20d7bb9d6ac0187cf4cd54d8065bc9a1a09eb8011d4d326596/redis-7.1.0-py3-none-any.whl", hash = "sha256:23c52b208f92b56103e17c5d06bdc1a6c2c0b3106583985a76a18f83b265de2b", size = 354159, upload-time = "2025-11-19T15:54:38.064Z" }, ] [[package]] name = "requests" -version = "2.32.3" +version = "2.32.5" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "certifi" }, @@ -579,9 +558,35 @@ dependencies = [ { name = "idna" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/63/70/2bf7780ad2d390a8d301ad0b550f1581eadbd9a20f896afe06353c2a2913/requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760", size = 131218, upload-time = "2024-05-29T15:37:49.536Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, +] + +[[package]] +name = "ruff" +version = "0.14.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/fa/fbb67a5780ae0f704876cb8ac92d6d76da41da4dc72b7ed3565ab18f2f52/ruff-0.14.5.tar.gz", hash = "sha256:8d3b48d7d8aad423d3137af7ab6c8b1e38e4de104800f0d596990f6ada1a9fc1", size = 5615944, upload-time = "2025-11-13T19:58:51.155Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f9/9b/335f9764261e915ed497fcdeb11df5dfd6f7bf257d4a6a2a686d80da4d54/requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6", size = 64928, upload-time = "2024-05-29T15:37:47.027Z" }, + { url = "https://files.pythonhosted.org/packages/68/31/c07e9c535248d10836a94e4f4e8c5a31a1beed6f169b31405b227872d4f4/ruff-0.14.5-py3-none-linux_armv6l.whl", hash = "sha256:f3b8248123b586de44a8018bcc9fefe31d23dda57a34e6f0e1e53bd51fd63594", size = 13171630, upload-time = "2025-11-13T19:57:54.894Z" }, + { url = "https://files.pythonhosted.org/packages/8e/5c/283c62516dca697cd604c2796d1487396b7a436b2f0ecc3fd412aca470e0/ruff-0.14.5-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:f7a75236570318c7a30edd7f5491945f0169de738d945ca8784500b517163a72", size = 13413925, upload-time = "2025-11-13T19:57:59.181Z" }, + { url = "https://files.pythonhosted.org/packages/b6/f3/aa319f4afc22cb6fcba2b9cdfc0f03bbf747e59ab7a8c5e90173857a1361/ruff-0.14.5-py3-none-macosx_11_0_arm64.whl", hash = "sha256:6d146132d1ee115f8802356a2dc9a634dbf58184c51bff21f313e8cd1c74899a", size = 12574040, upload-time = "2025-11-13T19:58:02.056Z" }, + { url = "https://files.pythonhosted.org/packages/f9/7f/cb5845fcc7c7e88ed57f58670189fc2ff517fe2134c3821e77e29fd3b0c8/ruff-0.14.5-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e2380596653dcd20b057794d55681571a257a42327da8894b93bbd6111aa801f", size = 13009755, upload-time = "2025-11-13T19:58:05.172Z" }, + { url = "https://files.pythonhosted.org/packages/21/d2/bcbedbb6bcb9253085981730687ddc0cc7b2e18e8dc13cf4453de905d7a0/ruff-0.14.5-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2d1fa985a42b1f075a098fa1ab9d472b712bdb17ad87a8ec86e45e7fa6273e68", size = 12937641, upload-time = "2025-11-13T19:58:08.345Z" }, + { url = "https://files.pythonhosted.org/packages/a4/58/e25de28a572bdd60ffc6bb71fc7fd25a94ec6a076942e372437649cbb02a/ruff-0.14.5-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:88f0770d42b7fa02bbefddde15d235ca3aa24e2f0137388cc15b2dcbb1f7c7a7", size = 13610854, upload-time = "2025-11-13T19:58:11.419Z" }, + { url = "https://files.pythonhosted.org/packages/7d/24/43bb3fd23ecee9861970978ea1a7a63e12a204d319248a7e8af539984280/ruff-0.14.5-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:3676cb02b9061fee7294661071c4709fa21419ea9176087cb77e64410926eb78", size = 15061088, upload-time = "2025-11-13T19:58:14.551Z" }, + { url = "https://files.pythonhosted.org/packages/23/44/a022f288d61c2f8c8645b24c364b719aee293ffc7d633a2ca4d116b9c716/ruff-0.14.5-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b595bedf6bc9cab647c4a173a61acf4f1ac5f2b545203ba82f30fcb10b0318fb", size = 14734717, upload-time = "2025-11-13T19:58:17.518Z" }, + { url = "https://files.pythonhosted.org/packages/58/81/5c6ba44de7e44c91f68073e0658109d8373b0590940efe5bd7753a2585a3/ruff-0.14.5-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f55382725ad0bdb2e8ee2babcbbfb16f124f5a59496a2f6a46f1d9d99d93e6e2", size = 14028812, upload-time = "2025-11-13T19:58:20.533Z" }, + { url = "https://files.pythonhosted.org/packages/ad/ef/41a8b60f8462cb320f68615b00299ebb12660097c952c600c762078420f8/ruff-0.14.5-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7497d19dce23976bdaca24345ae131a1d38dcfe1b0850ad8e9e6e4fa321a6e19", size = 13825656, upload-time = "2025-11-13T19:58:23.345Z" }, + { url = "https://files.pythonhosted.org/packages/7c/00/207e5de737fdb59b39eb1fac806904fe05681981b46d6a6db9468501062e/ruff-0.14.5-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:410e781f1122d6be4f446981dd479470af86537fb0b8857f27a6e872f65a38e4", size = 13959922, upload-time = "2025-11-13T19:58:26.537Z" }, + { url = "https://files.pythonhosted.org/packages/bc/7e/fa1f5c2776db4be405040293618846a2dece5c70b050874c2d1f10f24776/ruff-0.14.5-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:c01be527ef4c91a6d55e53b337bfe2c0f82af024cc1a33c44792d6844e2331e1", size = 12932501, upload-time = "2025-11-13T19:58:29.822Z" }, + { url = "https://files.pythonhosted.org/packages/67/d8/d86bf784d693a764b59479a6bbdc9515ae42c340a5dc5ab1dabef847bfaa/ruff-0.14.5-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:f66e9bb762e68d66e48550b59c74314168ebb46199886c5c5aa0b0fbcc81b151", size = 12927319, upload-time = "2025-11-13T19:58:32.923Z" }, + { url = "https://files.pythonhosted.org/packages/ac/de/ee0b304d450ae007ce0cb3e455fe24fbcaaedae4ebaad6c23831c6663651/ruff-0.14.5-py3-none-musllinux_1_2_i686.whl", hash = "sha256:d93be8f1fa01022337f1f8f3bcaa7ffee2d0b03f00922c45c2207954f351f465", size = 13206209, upload-time = "2025-11-13T19:58:35.952Z" }, + { url = "https://files.pythonhosted.org/packages/33/aa/193ca7e3a92d74f17d9d5771a765965d2cf42c86e6f0fd95b13969115723/ruff-0.14.5-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:c135d4b681f7401fe0e7312017e41aba9b3160861105726b76cfa14bc25aa367", size = 13953709, upload-time = "2025-11-13T19:58:39.002Z" }, + { url = "https://files.pythonhosted.org/packages/cc/f1/7119e42aa1d3bf036ffc9478885c2e248812b7de9abea4eae89163d2929d/ruff-0.14.5-py3-none-win32.whl", hash = "sha256:c83642e6fccfb6dea8b785eb9f456800dcd6a63f362238af5fc0c83d027dd08b", size = 12925808, upload-time = "2025-11-13T19:58:42.779Z" }, + { url = "https://files.pythonhosted.org/packages/3b/9d/7c0a255d21e0912114784e4a96bf62af0618e2190cae468cd82b13625ad2/ruff-0.14.5-py3-none-win_amd64.whl", hash = "sha256:9d55d7af7166f143c94eae1db3312f9ea8f95a4defef1979ed516dbb38c27621", size = 14331546, upload-time = "2025-11-13T19:58:45.691Z" }, + { url = "https://files.pythonhosted.org/packages/e5/80/69756670caedcf3b9be597a6e12276a6cf6197076eb62aad0c608f8efce0/ruff-0.14.5-py3-none-win_arm64.whl", hash = "sha256:4b700459d4649e2594b31f20a9de33bc7c19976d4746d8d0798ad959621d64a4", size = 13433331, upload-time = "2025-11-13T19:58:48.434Z" }, ] [[package]] @@ -604,11 +609,11 @@ wheels = [ [[package]] name = "soupsieve" -version = "2.7" +version = "2.8" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/3f/f4/4a80cd6ef364b2e8b65b15816a843c0980f7a5a2b4dc701fc574952aa19f/soupsieve-2.7.tar.gz", hash = "sha256:ad282f9b6926286d2ead4750552c8a6142bc4c783fd66b0293547c8fe6ae126a", size = 103418, upload-time = "2025-04-20T18:50:08.518Z" } +sdist = { url = "https://files.pythonhosted.org/packages/6d/e6/21ccce3262dd4889aa3332e5a119a3491a95e8f60939870a3a035aabac0d/soupsieve-2.8.tar.gz", hash = "sha256:e2dd4a40a628cb5f28f6d4b0db8800b8f581b65bb380b97de22ba5ca8d72572f", size = 103472, upload-time = "2025-08-27T15:39:51.78Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e7/9c/0e6afc12c269578be5c0c1c9f4b49a8d32770a080260c333ac04cc1c832d/soupsieve-2.7-py3-none-any.whl", hash = "sha256:6e60cc5c1ffaf1cebcc12e8188320b72071e922c2e897f737cadce79ad5d30c4", size = 36677, upload-time = "2025-04-20T18:50:07.196Z" }, + { url = "https://files.pythonhosted.org/packages/14/a0/bb38d3b76b8cae341dad93a2dd83ab7462e6dbcdd84d43f54ee60a8dc167/soupsieve-2.8-py3-none-any.whl", hash = "sha256:0cc76456a30e20f5d7f2e14a98a4ae2ee4e5abdc7c5ea0aafe795f344bc7984c", size = 36679, upload-time = "2025-08-27T15:39:50.179Z" }, ] [[package]] @@ -622,11 +627,11 @@ wheels = [ [[package]] name = "typing-extensions" -version = "4.13.2" +version = "4.15.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f6/37/23083fcd6e35492953e8d2aaaa68b860eb422b34627b13f2ce3eb6106061/typing_extensions-4.13.2.tar.gz", hash = "sha256:e6c81219bd689f51865d9e372991c540bda33a0379d5573cddb9a3a23f7caaef", size = 106967, upload-time = "2025-04-10T14:19:05.416Z" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8b/54/b1ae86c0973cc6f0210b53d508ca3641fb6d0c56823f288d108bc7ab3cc8/typing_extensions-4.13.2-py3-none-any.whl", hash = "sha256:a439e7c04b49fec3e5d3e2beaa21755cadbbdc391694e28ccdd36ca4a1408f8c", size = 45806, upload-time = "2025-04-10T14:19:03.967Z" }, + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, ] [[package]] @@ -640,32 +645,18 @@ 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" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/6b/11/cc635220681e93a0183390e26485430ca2c7b5f9d33b15c74c2861cb8091/urllib3-2.4.0-py3-none-any.whl", hash = "sha256:4e16665048960a0900c702d4a66415956a584919c03361cac9f1df5c5dd7e813", size = 128680, upload-time = "2025-04-10T15:23:37.377Z" }, -] - -[[package]] -name = "virtualenv" -version = "20.34.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "distlib" }, - { name = "filelock" }, - { name = "platformdirs" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/1c/14/37fcdba2808a6c615681cd216fecae00413c9dab44fb2e57805ecf3eaee3/virtualenv-20.34.0.tar.gz", hash = "sha256:44815b2c9dee7ed86e387b842a84f20b93f7f417f95886ca1996a72a4138eb1a", size = 6003808, upload-time = "2025-08-13T14:24:07.464Z" } +sdist = { url = "https://files.pythonhosted.org/packages/15/22/9ee70a2574a4f4599c47dd506532914ce044817c7752a79b6a51286319bc/urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760", size = 393185, upload-time = "2025-06-18T14:07:41.644Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/76/06/04c8e804f813cf972e3262f3f8584c232de64f0cde9f703b46cf53a45090/virtualenv-20.34.0-py3-none-any.whl", hash = "sha256:341f5afa7eee943e4984a9207c025feedd768baff6753cd660c857ceb3e36026", size = 5983279, upload-time = "2025-08-13T14:24:05.111Z" }, + { url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795, upload-time = "2025-06-18T14:07:40.39Z" }, ] [[package]] name = "wcwidth" -version = "0.2.13" +version = "0.2.14" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6c/63/53559446a878410fc5a5974feb13d31d78d752eb18aeba59c7fef1af7598/wcwidth-0.2.13.tar.gz", hash = "sha256:72ea0c06399eb286d978fdedb6923a9eb47e1c486ce63e9b4e64fc18303972b5", size = 101301, upload-time = "2024-01-06T02:10:57.829Z" } +sdist = { url = "https://files.pythonhosted.org/packages/24/30/6b0809f4510673dc723187aeaf24c7f5459922d01e2f794277a3dfb90345/wcwidth-0.2.14.tar.gz", hash = "sha256:4d478375d31bc5395a3c55c40ccdf3354688364cd61c4f6adacaa9215d0b3605", size = 102293, upload-time = "2025-09-22T16:29:53.023Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fd/84/fd2ba7aafacbad3c4201d395674fc6348826569da3c0937e75505ead3528/wcwidth-0.2.13-py2.py3-none-any.whl", hash = "sha256:3da69048e4540d84af32131829ff948f1e022c1c6bdb8d6102117aac784f6859", size = 34166, upload-time = "2024-01-06T02:10:55.763Z" }, + { url = "https://files.pythonhosted.org/packages/af/b5/123f13c975e9f27ab9c0770f514345bd406d0e8d3b7a0723af9d43f710af/wcwidth-0.2.14-py2.py3-none-any.whl", hash = "sha256:a7bb560c8aee30f9957e5f9895805edd20602f2d7f720186dfd906e82b4982e1", size = 37286, upload-time = "2025-09-22T16:29:51.641Z" }, ] diff --git a/website/celery.py b/website/celery.py index 1ca2ffa..1f030b8 100644 --- a/website/celery.py +++ b/website/celery.py @@ -4,7 +4,6 @@ from celery import Celery from celery.schedules import crontab -from django.conf import settings os.environ.setdefault("DJANGO_SETTINGS_MODULE", "website.settings") app = Celery("website") diff --git a/website/config.py b/website/config.py index 2eab01f..fed93f5 100644 --- a/website/config.py +++ b/website/config.py @@ -1,11 +1,12 @@ -import os -import yaml import collections.abc +import operator +import os +from functools import reduce from pathlib import Path from typing import Any, Callable, TypeVar + +import yaml from django.core.exceptions import ImproperlyConfigured -from functools import reduce -import operator # Generic TypeVar for casting function return values T = TypeVar("T") diff --git a/website/settings.py b/website/settings.py index bbd2668..d281cf3 100644 --- a/website/settings.py +++ b/website/settings.py @@ -1,8 +1,9 @@ from pathlib import Path + import dj_database_url from dotenv import load_dotenv -from .config import Config +from .config import Config BASE_DIR = Path(__file__).resolve().parent.parent load_dotenv(BASE_DIR / ".env") @@ -17,6 +18,10 @@ "COOKIE_AGE": 2592000, # 30 days "SAVE_EVERY_REQUEST": True, }, + "WEB": { + "COURSE": {"PAGE_SIZE": 10}, + "REVIEW": {"PAGE_SIZE": 10, "COMMENT_MIN_LENGTH": 30}, + }, "AUTH": { "OTP_TIMEOUT": 120, "TEMP_TOKEN_TIMEOUT": 600, @@ -25,6 +30,7 @@ "PASSWORD_LENGTH_MIN": 10, "PASSWORD_LENGTH_MAX": 32, "EMAIL_DOMAIN_NAME": "sjtu.edu.cn", + "ACTION_LIST": ["signup", "login", "reset_password"], }, "DATABASE": {"URL": "sqlite:///db.sqlite3"}, "REDIS": {"URL": "redis://localhost:6379/0", "MAX_CONNECTIONS": 100}, @@ -41,7 +47,7 @@ "URL": None, "QUESTIONID": None, }, - "RESET": { + "RESET_PASSWORD": { "API_KEY": None, "URL": None, "QUESTIONID": None, @@ -86,6 +92,7 @@ # --- Application-Specific Settings --- AUTH = config.get("AUTH") +WEB = config.get("WEB") TURNSTILE_SECRET_KEY = config.get("TURNSTILE_SECRET_KEY") AUTO_IMPORT_CRAWLED_DATA = config.get("AUTO_IMPORT_CRAWLED_DATA", cast=bool) diff --git a/website/urls.py b/website/urls.py index 9ca4cfb..707d854 100644 --- a/website/urls.py +++ b/website/urls.py @@ -1,99 +1,12 @@ -import django.contrib.auth.views as authviews from django.contrib import admin -from django.urls import re_path - - -from apps.auth import views as auth_views -from apps.spider import views as spider_views -from apps.web import views +from django.urls import include, re_path urlpatterns = [ - # OAuth - re_path( - r"^api/auth/initiate/$", - auth_views.auth_initiate_api, - name="auth_initiate_api", - ), - re_path( - r"^api/auth/verify/$", - auth_views.verify_callback_api, - name="verify_callback_api", - ), - # Backwards-compatible alias (some front-end code calls verify-callback) - re_path( - r"^api/auth/verify-callback/$", - auth_views.verify_callback_api, - name="verify_callback_api_alias", - ), - re_path( - r"^api/auth/password/$", - auth_views.auth_reset_password_api, - name="auth_reset_password_api", - ), - re_path(r"^api/auth/signup/$", auth_views.auth_signup_api, name="auth_signup_api"), - # email+password login - re_path(r"^api/auth/login/$", auth_views.auth_login_api, name="auth_login_api"), - # log out - re_path(r"^api/auth/logout/?$", auth_views.auth_logout_api, name="auth_logout_api"), # administrative re_path(r"^admin/", admin.site.urls), - re_path(r"^api/user/status/?", views.user_status, name="user_status"), - # spider - re_path(r"^spider/data/$", spider_views.crawled_data_list, name="crawled_datas"), - re_path( - r"^spider/data/(?P[0-9]+)$", - spider_views.crawled_data_detail, - name="crawled_data", - ), - # primary views - re_path(r"^api/landing/$", views.landing_api, name="landing_api"), - re_path( - r"^api/course/(?P[0-9]+)/$", - views.course_detail_api, - name="course_detail_api", - ), - re_path( - r"^api/course/(?P[0-9].*)/instructors?/?", - views.course_instructors, - name="course_instructors", - ), - re_path( - r"^api/course/(?P[0-9].*)/medians", views.medians, name="medians" - ), - re_path( - r"^api/course/(?P[0-9].*)/professors?/?", - views.course_professors, - name="course_professors", - ), - re_path( - r"^api/course/(?P[0-9].*)/vote", - views.course_vote_api, - name="course_vote_api", - ), - re_path( - r"^api/course/(?P[0-9]+)/review/$", - views.delete_review_api, - name="delete_review_api", - ), - re_path( - r"^api/course/(?P[0-9]+)/my-review/$", - views.get_user_review_api, - name="get_user_review_api", - ), - re_path( - r"^api/review/(?P[0-9]+)/vote/$", - views.review_vote_api, - name="review_vote_api", - ), - re_path( - r"^api/departments/$", - views.departments_api, - name="departments_api", - ), - re_path(r"^api/courses/$", views.courses_api, name="courses_api"), - re_path( - r"^api/course/(?P[0-9]+)/review_search/$", - views.course_review_search_api, - name="course_review_search_api", - ), + # API routes + re_path(r"^api/auth/", include("apps.auth.urls")), + re_path(r"^api/", include("apps.web.urls")), + # Spider routes + re_path(r"^spider/", include("apps.spider.urls")), ]