diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..3091903 --- /dev/null +++ b/.env.example @@ -0,0 +1,42 @@ +# .env.example +# Copy this file to .env and fill in the secrets for local development. +# DO NOT COMMIT .env TO VERSION CONTROL. +# This file overrides config.yaml + +# --- Core Security (REQUIRED IN PRODUCTION) --- +# Generate a new one for production! +SECRET_KEY=django-insecure-my-local-dev-secret-key + +# --- Local Overrides --- +# Set to False in production +# DEBUG=True + +# --- Infrastructure (REQUIRED) --- +# Use a single URL for database and Redis connections. +# Format: driver://user:password@host:port/dbname +DATABASE__URL=postgres://admin:test@127.0.0.1:5432/coursereview +REDIS__URL=redis://localhost:6379/0 + +# --- External Services Secrets (REQUIRED) --- +TURNSTILE_SECRET_KEY=dummy0 + +# Use PARENT__CHILD format to override nested settings +# URL and ID may be specified in config.yaml +QUEST__SIGNUP__API_KEY=dummy1 +# QUEST__SIGNUP__URL= +# QUEST__SIGNUP__QUESTIONID= + +QUEST__LOGIN__API_KEY=dummy2 +# QUEST__LOGIN__URL= +# QUEST__LOGIN__QUESTIONID= + +QUEST__RESET__API_KEY=dummy3 +# QUEST__RESET__URL= +# QUEST__RESET__QUESTIONID= + +# --- Other Overrides (Optional) --- +# Example of overriding a nested value in the AUTH dictionary +# AUTH__OTP_TIMEOUT=60 + +# Example of overriding a list with a comma-separated string +# ALLOWED_HOSTS=localhost,127.0.0.1,dev.my-app.com diff --git a/.gitignore b/.gitignore index 8647958..6b28f35 100644 --- a/.gitignore +++ b/.gitignore @@ -1,15 +1,8 @@ -### Project ignores -.venv/ -venv/ -staticfiles -.env -.pyversion -data +config.yaml -### Python ignores (https://github.com/github/gitignore/blob/master/Python.gitignore) # Byte-compiled / optimized / DLL files __pycache__/ -*.py[cod] +*.py[codz] *$py.class # C extensions @@ -17,24 +10,27 @@ __pycache__/ # Distribution / packaging .Python -env/ build/ develop-eggs/ dist/ downloads/ eggs/ .eggs/ +lib/ lib64/ parts/ sdist/ var/ +wheels/ +share/python-wheels/ *.egg-info/ .installed.cfg *.egg +MANIFEST # PyInstaller -# Usually these files are written by a python script from a template -# before PyInstaller builds the exe, so as to inject date/other infos into it. +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. *.manifest *.spec @@ -45,13 +41,17 @@ pip-delete-this-directory.txt # Unit test / coverage reports htmlcov/ .tox/ +.nox/ .coverage .coverage.* .cache nosetests.xml coverage.xml -*,cover +*.cover +*.py.cover .hypothesis/ +.pytest_cache/ +cover/ # Translations *.mo @@ -59,30 +59,162 @@ coverage.xml # Django stuff: *.log +local_settings.py +db.sqlite3 +db.sqlite3-journal +staticfiles + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy # Sphinx documentation docs/_build/ # PyBuilder +.pybuilder/ target/ -package-lock.json -pnpm-lock.yaml -bun.lock +# Jupyter Notebook +.ipynb_checkpoints -node_modules -**/node_modules +# IPython +profile_default/ +ipython_config.py +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version -.DS_Store -**/.DS_Store +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +# Pipfile.lock -# node version compabilities -.nvmrc -course-activity-service-account.json -Layup-List.code-workspace -.vscode/ -db.sqlite3 -*.db +# UV +# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# uv.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +# poetry.lock +# poetry.toml + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python. +# https://pdm-project.org/en/latest/usage/project/#working-with-version-control +# pdm.lock +# pdm.toml +.pdm-python +.pdm-build/ + +# pixi +# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control. +# pixi.lock +# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one +# in the .venv directory. It is recommended not to include this directory in version control. +.pixi + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# Redis *.rdb -.aider* +*.aof +*.pid + +# RabbitMQ +mnesia/ +rabbitmq/ +rabbitmq-data/ + +# ActiveMQ +activemq-data/ + +# SageMath parsed files +*.sage.py + +# Environments +.env +.envrc +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +# .idea/ + +# Abstra +# Abstra is an AI-powered process automation framework. +# Ignore directories containing user credentials, local state, and settings. +# Learn more at https://abstra.io/docs +.abstra/ + +# Visual Studio Code +# 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, +# you could uncomment the following to ignore the entire vscode folder +# .vscode/ + +# Ruff stuff: +.ruff_cache/ + +# PyPI configuration file +.pypirc + +# Marimo +marimo/_static/ +marimo/_lsp/ +__marimo__/ + +# Streamlit +.streamlit/secrets.toml + diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..923e982 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "frontend"] + path = frontend + url = https://github.com/Tech-JI/CourseFront diff --git a/Makefile b/Makefile index 48d26fb..ad8f5f9 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: run clean collect format format-backend format-frontend makemigrations migrate shell createsuperuser dev-frontend help +.PHONY: run dev-frontend clean collect install-frontend format format-backend format-frontend lint lint-backend lint-frontend makemigrations migrate shell createsuperuser help # Default target when 'make' is run without arguments .DEFAULT_GOAL := help @@ -9,9 +9,13 @@ help: @echo " dev-frontend - Starts the frontend development server (formats frontend code first)" @echo " clean - Clears Django session data" @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-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" + @echo " lint-frontend - Lints frontend code using eslint" @echo " makemigrations - Creates new Django model migrations" @echo " migrate - Applies Django database migrations" @echo " shell - Opens a Django shell" @@ -33,6 +37,10 @@ collect: @echo "Collecting Django static files (confirming 'yes')..." echo 'yes' | uv run manage.py collectstatic +install-frontend: + @echo "Installing frontend dependencies with bun..." + cd frontend && bun install + format: format-backend format-frontend @echo "All code formatted successfully!" @@ -42,7 +50,18 @@ format-backend: format-frontend: @echo "Formatting frontend code with prettier..." - cd frontend && bunx prettier . -w + cd frontend && bun run format | grep -v -F '(unchanged)' || true + +lint: lint-backend lint-frontend + @echo "All code linted successfully!" + +lint-backend: format-backend + @echo "Linting backend (Python) code with ruff..." + uvx ruff check + +lint-frontend: format-frontend + @echo "Linting frontend code with eslint..." + cd frontend && bun run lint makemigrations: @echo "Creating Django database migrations..." diff --git a/apps/auth/__init__.py b/apps/auth/__init__.py new file mode 100644 index 0000000..ec9487c --- /dev/null +++ b/apps/auth/__init__.py @@ -0,0 +1 @@ +default_app_config = "apps.auth.apps.OAuthConfig" diff --git a/apps/auth/admin.py b/apps/auth/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/apps/auth/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/apps/auth/apps.py b/apps/auth/apps.py new file mode 100644 index 0000000..f2da6af --- /dev/null +++ b/apps/auth/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class OAuthConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "apps.auth" + label = "oauth" # Unique label to avoid conflict with django.contrib.auth diff --git a/apps/auth/migrations/__init__.py b/apps/auth/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/auth/models.py b/apps/auth/models.py new file mode 100644 index 0000000..71a8362 --- /dev/null +++ b/apps/auth/models.py @@ -0,0 +1,3 @@ +from django.db import models + +# Create your models here. diff --git a/apps/auth/tests.py b/apps/auth/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/apps/auth/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/apps/auth/utils.py b/apps/auth/utils.py new file mode 100644 index 0000000..e2dfa14 --- /dev/null +++ b/apps/auth/utils.py @@ -0,0 +1,285 @@ +import json +import logging +import re + +import httpx +from django.conf import settings +from django.contrib.auth import get_user_model +from django.contrib.auth.models import AbstractUser +from django.contrib.auth.password_validation import validate_password +from django.core.exceptions import ValidationError +from rest_framework.response import Response +from typing import Any + +from apps.web.models import Student + +AUTH_SETTINGS = settings.AUTH +PASSWORD_LENGTH_MIN = AUTH_SETTINGS["PASSWORD_LENGTH_MIN"] +PASSWORD_LENGTH_MAX = AUTH_SETTINGS["PASSWORD_LENGTH_MAX"] +OTP_TIMEOUT = AUTH_SETTINGS["OTP_TIMEOUT"] +EMAIL_DOMAIN_NAME = AUTH_SETTINGS["EMAIL_DOMAIN_NAME"] + +QUEST_SETTINGS = settings.QUEST +QUEST_BASE_URL = QUEST_SETTINGS["BASE_URL"] + + +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". + """ + + action_details = QUEST_SETTINGS.get(action.upper()) + + if not action_details: + logging.error("Invalid quest action requested: %s", action) + return None + + try: + question_id = int(action_details.get("QUESTIONID")) + except (ValueError, TypeError): + logging.error( + "Could not parse 'QUESTIONID' for action '%s'. Check your settings.", action + ) + return None + + return { + "url": action_details.get("URL"), + "api_key": action_details.get("API_KEY"), + "question_id": question_id, + } + + +async def verify_turnstile_token( + turnstile_token, client_ip +) -> tuple[bool, Response | None]: + """Helper function to verify Turnstile token with Cloudflare's API""" + + try: + async with httpx.AsyncClient(timeout=OTP_TIMEOUT) as client: + response = await client.post( + "https://challenges.cloudflare.com/turnstile/v0/siteverify", + data={ + "secret": settings.TURNSTILE_SECRET_KEY, + "response": turnstile_token, + "remoteip": client_ip, + }, + ) + if not response.json().get("success"): + logging.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") + return False, Response( + {"error": "Turnstile verification timed out"}, status=504 + ) + except Exception as e: + logging.error(f"Error verifying Turnstile token: {e}") + return False, Response({"error": "Turnstile verification error"}, status=500) + + +async def get_latest_answer( + action: str, + account: str, +) -> tuple[dict | None, Response | None]: + """Fetch the latest questionnaire answer for a given account from the WJ API(specific api for actions). + Returns a tuple of (filtered_data, error_response). + `filtered_data` contains: id, submitted_at, user.account, and otp. + `error_response` is a DRF Response object if an error occurs, otherwise None. + """ + + details = get_survey_details(action) + if not details: + return None, Response({"error": "Invalid action"}, status=400) + quest_api = details.get("api_key") + if not quest_api: + return None, Response({"error": "Invalid action"}, status=400) + + # Get the target question ID for the verification code + question_id = details.get("question_id") + if not question_id: + return None, Response( + {"error": "Configuration error: question ID not found for action"}, + status=500, + ) + + # Build the 'params' and 'sort' dictionaries + params_dict = { + "account": account, + "current": 1, + "pageSize": 1, + } + sort_dict = {"id": "desc"} + + params_json_str = json.dumps(params_dict, ensure_ascii=False) + sort_json_str = json.dumps(sort_dict) + + # Prepare the final query parameters + final_query_params = {"params": params_json_str, "sort": sort_json_str} + + # Combine to form the full URL path + full_url_path = f"{QUEST_BASE_URL}/{quest_api}/json" + + try: + async with httpx.AsyncClient(timeout=OTP_TIMEOUT) as client: + response = await client.get( + full_url_path, + params=final_query_params, + ) + 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") + return None, Response( + {"error": "Questionnaire API query timed out"}, + status=504, + ) + except httpx.RequestError as e: + logging.exception(f"Error querying questionnaire API: {e}") + return None, Response( + {"error": "Failed to query questionnaire API"}, + status=500, + ) + except Exception as e: + logging.exception(f"An unexpected error occurred: {e}") + return None, Response({"error": "An unexpected error occurred"}, status=500) + + # Filter and return only the required fields from the first row + if ( + full_data.get("success") + and full_data.get("data") + and full_data["data"].get("rows") + and len(full_data["data"]["rows"]) > 0 + ): + # Get the first (latest) row + latest_answer = full_data["data"]["rows"][0] + + # Find the otp by matching the question ID + otp = None + answers = latest_answer.get("answers", []) + for ans in answers: + if str(ans.get("question", {}).get("id")) == str(question_id): + otp = ans.get("answer") + break + + # Extract only the required fields from this row + filtered_data = { + "id": latest_answer.get("id"), + "submitted_at": latest_answer.get("submitted_at"), + "account": latest_answer.get("user", {}).get("account") + if latest_answer.get("user") + else None, + "otp": otp, + } + + # Check if all required fields are present + if not all( + 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") + return None, Response( + {"error": "Missing required field(s) in questionnaire response"}, + status=400, + ) + + return filtered_data, None + + return None, Response( + {"error": "No questionnaire submission found or submission invalid"}, + status=403, + ) + + +def rate_password_strength(password: str) -> int: + """Helper function to rate password strength""" + + if len(password) < PASSWORD_LENGTH_MIN or len(password) > PASSWORD_LENGTH_MAX: + return 0 + + score = 1 + + if re.search(r"[a-z]", password): + score += 1 + if re.search(r"[A-Z]", password): + score += 1 + if re.search(r"\d", password): + score += 1 + if re.search(r"[^a-zA-Z0-9\s]", password): + score += 1 + + length_step = (PASSWORD_LENGTH_MAX - PASSWORD_LENGTH_MIN) // 10 + + score += (len(password) - PASSWORD_LENGTH_MIN) // length_step + + return min(score, 5) + + +def validate_password_strength(password: str) -> tuple[bool, dict | None]: + """Helper function to validate password complexity and strength. + + Returns: A tuple of (is_valid, error_response). + `is_valid` is True if the password is valid, otherwise False. + `error_response` is a dict with a detailed error message if invalid, otherwise None. + """ + + score = rate_password_strength(password) + + if score == 0: + return False, { + "error": "Password is too short or too long.", + } + + if score < 3: + return False, { + "error": "Password is too weak.", + } + + # Use Django's built-in validators for additional checks + try: + validate_password(password) + return True, None + except ValidationError as e: + return False, {"error": list(e.messages)} + + +def create_user_session( + request, + account, +) -> tuple[AbstractUser | None, Response | None]: + """Helper function includes session management, user creation and Student model integration. + Returns a tuple of (user, error_response). + `user` is the user object on success, otherwise None. + `error_response` is a DRF Response object if an error occurs, otherwise None. + """ + + try: + # Ensure session exists - create one if it doesn't exist + if not request.session.session_key: + request.session.create() + + # Get or create user + user_model = get_user_model() + + user, _ = user_model.objects.get_or_create( + username=account, + defaults={"email": f"{account}@{EMAIL_DOMAIN_NAME}"}, + ) + + if not user: + return None, Response( + {"error": "Failed to retrieve or create user"}, status=500 + ) + + # Handle Student model integration + Student.objects.get_or_create(user=user) + + # Update session to use authenticated username + request.session["user_id"] = user.username + return user, None + + except Exception: + return None, Response({"error": "Failed to create user session"}, status=500) diff --git a/apps/auth/views.py b/apps/auth/views.py new file mode 100644 index 0000000..6350dc0 --- /dev/null +++ b/apps/auth/views.py @@ -0,0 +1,485 @@ +import asyncio +import base64 +import hashlib +import json +import logging +import secrets +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_redis import get_redis_connection +from rest_framework.authentication import SessionAuthentication +from rest_framework.decorators import ( + api_view, + authentication_classes, + permission_classes, +) +from rest_framework.permissions import AllowAny +from rest_framework.response import Response + +from apps.auth import utils +from apps.web.models import Student + + +class CsrfExemptSessionAuthentication(SessionAuthentication): + def enforce_csrf(self, request): + return + + +AUTH_SETTINGS = settings.AUTH +OTP_TIMEOUT = AUTH_SETTINGS["OTP_TIMEOUT"] +TEMP_TOKEN_TIMEOUT = AUTH_SETTINGS["TEMP_TOKEN_TIMEOUT"] +ACTION_LIST = ["signup", "login", "reset_password"] +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) + + 1. Receives action and turnstile_token from frontend + 2. Verifies Turnstile token with Cloudflare's API + 3. Generates cryptographically secure OTP and temp_token + 4. Stores OTP->temp_token mapping and temp_token state in Redis + 5. Sets temp_token as HttpOnly cookie and returns OTP and redirect_url + """ + # Get required fields from request data + action = request.data.get("action") + turnstile_token = request.data.get("turnstile_token") + + if not action or not turnstile_token: + return Response({"error": "Missing action or turnstile_token"}, status=400) + + if action not in ACTION_LIST: + return Response({"error": "Invalid action"}, status=400) + + client_ip = ( + request.META.get("HTTP_CF_CONNECTING_IP") + or request.META.get("HTTP_X_FORWARDED_FOR", "").split(",")[0].strip() + or request.META.get("REMOTE_ADDR") + ) + + # Verify Turnstile token + success, error_response = asyncio.run( + utils.verify_turnstile_token(turnstile_token, client_ip) + ) + if not success: + return error_response + + # Generate cryptographically secure OTP and temp_token + otp = "".join([str(secrets.randbelow(10)) for _ in range(8)]) + temp_token = secrets.token_urlsafe(32) + + # Create Redis storage and clean up existing tokens + r = get_redis_connection("default") + + # Clean up any existing temp_token for this client to prevent memory leaks + existing_temp_token = request.COOKIES.get("temp_token") + if existing_temp_token: + try: + existing_hash = hashlib.sha256(existing_temp_token.encode()).hexdigest() + existing_state_key = f"temp_token_state:{existing_hash}" + existing_state_data = r.get(existing_state_key) + 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') + }" + ) + except Exception as e: + logging.warning(f"Error cleaning up existing temp_token: {e}") + + # Store OTP -> temp_token mapping with initiated_at timestamp + current_time = time.time() + otp_data = {"temp_token": temp_token, "initiated_at": current_time} + r.setex(f"otp:{otp}", OTP_TIMEOUT, json.dumps(otp_data)) + + # Store temp_token with SHA256 hash as key, and status of pending as well as action + temp_token_hash = hashlib.sha256(temp_token.encode()).hexdigest() + temp_token_state = {"status": "pending", "action": action} + r.setex( + f"temp_token_state:{temp_token_hash}", + TEMP_TOKEN_TIMEOUT, + json.dumps(temp_token_state), + ) + + logging.info("Created auth intent for action %s with OTP and temp_token", action) + + details = utils.get_survey_details(action) + if not details: + return Response({"error": "Invalid action"}, status=400) + survey_url = details.get("url") + if not survey_url: + return Response( + {"error": "Something went wrong when fetching the survey URL"}, + status=500, + ) + + # Create response and set temp_token as HttpOnly cookie + response = Response({"otp": otp, "redirect_url": survey_url}, status=200) + response.set_cookie( + "temp_token", + temp_token, + max_age=TEMP_TOKEN_TIMEOUT, + httponly=True, + secure=getattr(settings, "SECURE_COOKIES", True), + samesite="Lax", + ) + return response + + +@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. + """ + # 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: + return Response({"error": "Missing account, answer_id, or action"}, status=400) + + if action not in ACTION_LIST: + return Response({"error": "Invalid action"}, status=400) + + # Get temp_token from HttpOnly cookie + temp_token = request.COOKIES.get("temp_token") + if not temp_token: + return Response({"error": "No temp_token found"}, status=401) + + r = get_redis_connection("default") + + # Step 1: Look up temp_token state record + temp_token_hash = hashlib.sha256(temp_token.encode()).hexdigest() + state_key = f"temp_token_state:{temp_token_hash}" + state_data = r.get(state_key) + + if not state_data: + return Response({"error": "Temp token state not found or expired"}, status=401) + + try: + state_data = json.loads(state_data) + except json.JSONDecodeError: + return Response({"error": "Invalid temp token state data"}, status=401) + + # Verify status is pending and action matches + if state_data.get("status") != "pending": + return Response({"error": "Invalid temp token state"}, status=401) + + if state_data.get("action") != action: + return Response({"error": "Action mismatch"}, status=403) + + # Step 2: Apply rate limiting per temp_token to prevent brute-force attempts + rate_limit_key = ( + f"verify_attempts:{hashlib.sha256(temp_token.encode()).hexdigest()}" + ) + + attempts = r.incr(rate_limit_key) + + if attempts == 1: + r.expire(rate_limit_key, TOKEN_RATE_LIMIT_TIME) + + if attempts > TOKEN_RATE_LIMIT: + return Response({"error": "Too many verification attempts"}, status=429) + + # Step 3: Query questionnaire API for latest submission of the specific questionnaire of the action + latest_answer, error_response = asyncio.run( + utils.get_latest_answer(action=action, account=account), + ) + if error_response: + return error_response + + if latest_answer is None: + 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): + return Response({"error": "Answer ID mismatch"}, status=403) + + # Extract OTP and quest_id from submission + submitted_otp = latest_answer.get("otp") + + # Atomically get and delete OTP record to prevent reuse + otp_key = f"otp:{submitted_otp}" + otp_data_raw = r.getdel(otp_key) + + if not otp_data_raw: + return Response({"error": "Invalid or expired OTP"}, status=401) + + try: + otp_data = json.loads(otp_data_raw.decode("utf-8")) + expected_temp_token = otp_data.get("temp_token") + initiated_at = otp_data.get("initiated_at") + except (json.JSONDecodeError, AttributeError): + return Response({"error": "Invalid OTP data format"}, status=401) + + if not expected_temp_token or not initiated_at: + return Response({"error": "Incomplete OTP data"}, status=401) + + # Step 5: StepVerify temp_token matches + if expected_temp_token != temp_token: + return Response({"error": "Invalid temp_token"}, status=401) + + # Step 6: Validate submission timestamp after OTP extraction + try: + submitted_at_str = latest_answer.get("submitted_at") + if submitted_at_str is None: + return Response({"error": "Missing submission timestamp"}, status=400) + + submitted_at = dateutil.parser.parse(submitted_at_str).timestamp() + + # Additional validation: check submission is after initiation and within window + if submitted_at < initiated_at or (submitted_at - initiated_at) > OTP_TIMEOUT: + return Response( + {"error": "Submission timestamp outside validity window"}, + status=401, + ) + + except (ValueError, TypeError) as e: + logging.exception(f"Error parsing submission timestamp: {e}") + return Response({"error": "Invalid submission timestamp"}, status=401) + + # Step 7: Update state to verified and add user details + state_data.update( + { + "status": "verified", + "account": account, + }, + ) + + # Update temp_token_state in Redis with refreshed TTL + r.setex(state_key, TEMP_TOKEN_TIMEOUT, json.dumps(state_data)) + expires_at = int(time.time() + TEMP_TOKEN_TIMEOUT) + + # Clear rate limiting on success + r.delete(rate_limit_key) + + logging.info( + "Successfully verified temp_token for user %s with action %s", account, action + ) + + # For login action, handle immediate session creation and cleanup + is_logged_in = False + if action == "login": + user, error_response = utils.create_user_session(request, account) + if user is None: + if error_response: + logging.error( + "Failed to create session for login: %s", + getattr(error_response, "data", {}).get("error", "Unknown error"), + ) + return error_response + else: + return Response({"error": "Failed to create user session"}, status=500) + if not user.is_active: + logging.warning("Inactive user attempted OAuth login: %s", account) + return Response({"error": "User account is inactive"}, status=403) + try: + # Create Django session + login(request, user) + is_logged_in = True + # Delete temp_token_state after successful login + r.delete(state_key) + except Exception: + logging.exception( + "Error during login session creation or cleanup for user %s", account + ) + return Response({"error": "Failed to finalize login process"}, status=500) + + # Create response + response = Response( + {"action": action, "expires_at": expires_at, "is_logged_in": is_logged_in}, + status=200, + ) + + # Clear temp_token cookie if login succeeded + if is_logged_in: + response.delete_cookie("temp_token") + + return response + + +def verify_token_pwd(request, action: str) -> tuple[dict | None, Response | None]: + # Get temp_token from HttpOnly cookie + temp_token = request.COOKIES.get("temp_token") + if not temp_token: + return None, Response({"error": "No temp_token found"}, status=401) + + r = get_redis_connection("default") + + # Look up temp_token state record + temp_token_hash = hashlib.sha256(temp_token.encode()).hexdigest() + state_key = f"temp_token_state:{temp_token_hash}" + state_data = r.get(state_key) + + if not state_data: + return None, Response( + {"error": "Temp token state not found or expired"}, + status=401, + ) + + try: + state_data = json.loads(state_data) + except json.JSONDecodeError: + return None, Response({"error": "Invalid temp token state data"}, status=401) + + # Verify status is verified and action is signup + if state_data.get("status") != "verified" or state_data.get("action") != action: + return None, Response({"error": "Invalid temp token state"}, status=403) + + # Get password from request data + password = request.data.get("password") + if not password: + return None, Response({"error": "Missing password"}, status=400) + + # Validate password strength + is_valid, error_response = utils.validate_password_strength(password) + if not is_valid: + return None, Response(error_response, status=400) + # Get account from verified state + account = state_data.get("account") + if not account: + return None, Response({"error": "No account in verified state"}, status=401) + return {"account": account, "password": password, "state_key": state_key}, None + + +@api_view(["POST"]) +def auth_signup_api(request) -> Response: + """Signup API (/api/auth/signup) + + Handles user signup using verified temp_token. + """ + try: + verification_data, error_response = verify_token_pwd(request, action="signup") + if verification_data is None: + return error_response or Response( + {"error": "Verification failed"}, status=400 + ) + + account = verification_data.get("account") + password = verification_data.get("password") + state_key = verification_data.get("state_key") + + # Create user session + user, error_response = utils.create_user_session(request, account) + if user is None: + return error_response or Response( + {"error": "Failed to create user session"}, status=500 + ) + if user.password: + return Response({"error": "User already exists with password."}, status=409) + + user.is_active = True + # Set password + user.set_password(password) + user.save() + + # Cleanup: Delete temp_token_state and clear cookie + r = get_redis_connection("default") + r.delete(state_key) + response = Response({"success": True, "username": user.username}, status=200) + response.delete_cookie("temp_token") + return response + + except Exception as e: + logging.error(f"Error in auth_signup_api: {e}") + return Response({"error": "Failed to complete signup"}, status=500) + + +@api_view(["POST"]) +def auth_reset_password_api(request) -> Response: + """Reset Password API (/api/auth/password) + + Handles password reset using verified temp_token. + """ + try: + verification_data, error_response = verify_token_pwd( + request, + action="reset_password", + ) + if verification_data is None: + return error_response or Response( + {"error": "Verification failed"}, status=400 + ) + account = verification_data.get("account") + password = verification_data.get("password") + state_key = verification_data.get("state_key") + + # Get the user object and update password + user_model = get_user_model() + try: + user = user_model.objects.get(username=account) + user.set_password(password) + user.save() + except user_model.DoesNotExist: + return Response({"error": "User does not exist"}, status=404) + + # Cleanup: Delete temp_token_state and clear cookie + r = get_redis_connection("default") + r.delete(state_key) + response = Response({"success": True, "username": user.username}, status=200) + response.delete_cookie("temp_token") + return response + + except Exception as e: + logging.error(f"Error in auth_reset_password_api: {e}") + 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() + password = request.data.get("password", "") + turnstile_token = request.data.get("turnstile_token", "") + + if not account or not password or not turnstile_token: + return Response( + {"error": "Account, password, and Turnstile token are missing"}, status=400 + ) + + client_ip = ( + request.META.get("HTTP_CF_CONNECTING_IP") + or request.META.get("HTTP_X_FORWARDED_FOR", "").split(",")[0].strip() + or request.META.get("REMOTE_ADDR") + ) + + success, error_response = asyncio.run( + utils.verify_turnstile_token(turnstile_token, client_ip) + ) + if not success: + return error_response or Response( + {"error": "Turnstile verification failed"}, status=502 + ) + + user = authenticate(username=account, password=password) + if user is None or not user.is_active: + return Response({"error": "Invalid account or password"}, status=401) + + login(request, user) + Student.objects.get_or_create(user=user) + + return Response({"message": "Login successfully"}, status=200) + + +@api_view(["POST"]) +@authentication_classes([CsrfExemptSessionAuthentication]) +@permission_classes([AllowAny]) +def auth_logout_api(request) -> Response: + """Logout a user.""" + logout(request) + return Response({"message": "Logged out successfully"}, status=200) diff --git a/apps/spider/crawlers/orc.py b/apps/spider/crawlers/orc.py index a9bc782..aba3fdd 100644 --- a/apps/spider/crawlers/orc.py +++ b/apps/spider/crawlers/orc.py @@ -39,8 +39,9 @@ def crawl_program_urls(): + program_urls = set() # Initialize to empty set for orc_url in [UNDERGRAD_URL]: - program_urls = _get_department_urls_from_url(orc_url) + program_urls.update(_get_department_urls_from_url(orc_url)) return program_urls @@ -58,7 +59,11 @@ def _is_department_url(candidate_url): def _crawl_course_data(course_url): soup = retrieve_soup(course_url) - course_heading = soup.find("h2").get_text() + course_heading_element = soup.find("h2") + if course_heading_element is None: + return None # Return early if no h2 element found + + course_heading = course_heading_element.get_text() if course_heading: split_course_heading = course_heading.split(" – ") children = list(soup.find_all(class_="et_pb_text_inner")[3].children) diff --git a/apps/web/migrations/0009_remove_student_confirmation_link_and_more.py b/apps/web/migrations/0009_remove_student_confirmation_link_and_more.py new file mode 100644 index 0000000..cdd93a3 --- /dev/null +++ b/apps/web/migrations/0009_remove_student_confirmation_link_and_more.py @@ -0,0 +1,20 @@ +# Generated by Django 5.2 on 2025-10-02 17:14 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("web", "0008_reviewvote"), + ] + + operations = [ + migrations.RemoveField( + model_name="student", + name="confirmation_link", + ), + migrations.RemoveField( + model_name="student", + name="unauth_session_ids", + ), + ] diff --git a/apps/web/models/forms/__init__.py b/apps/web/models/forms/__init__.py index 9621560..a566c4c 100644 --- a/apps/web/models/forms/__init__.py +++ b/apps/web/models/forms/__init__.py @@ -1,2 +1 @@ from .review_form import ReviewForm -from .signup_form import SignupForm diff --git a/apps/web/models/forms/review_form.py b/apps/web/models/forms/review_form.py index b376945..1d7b69f 100644 --- a/apps/web/models/forms/review_form.py +++ b/apps/web/models/forms/review_form.py @@ -5,7 +5,7 @@ from lib import constants from lib.terms import is_valid_term -REVIEW_MINIMUM_LENGTH = 100 +REVIEW_MINIMUM_LENGTH = 30 class ReviewForm(forms.ModelForm): diff --git a/apps/web/models/forms/signup_form.py b/apps/web/models/forms/signup_form.py deleted file mode 100644 index 54b0c16..0000000 --- a/apps/web/models/forms/signup_form.py +++ /dev/null @@ -1,78 +0,0 @@ -from django import forms -from django.contrib.auth.models import User -from django.contrib.auth.password_validation import validate_password -from django.core.exceptions import ValidationError -from django.db import transaction -from django.db.models import Q - -from apps.web.models import Student - - -class SignupForm(forms.Form): - """ - modified from UserCreationForm: - https://github.com/django/django/blob/master/django/contrib/auth/forms.py - """ - - error_messages = { - "password_mismatch": "The two password fields didn't match.", - } - - email = forms.EmailField(label="SJTU Undergraduate Email") - password1 = forms.CharField(label="Password", widget=forms.PasswordInput) - password2 = forms.CharField( - label="Password confirmation", - widget=forms.PasswordInput, - help_text="Enter the same password as before, for verification.", - ) - - def clean_password1(self): - password1 = self.cleaned_data.get("password1") - email = self.cleaned_data.get("email") - validate_password(password1, User(username=email, email=email)) - return password1 - - def clean_password2(self): - password1 = self.cleaned_data.get("password1") - password2 = self.cleaned_data.get("password2") - if password1 and password2 and password1 != password2: - raise forms.ValidationError( - self.error_messages["password_mismatch"], - code="password_mismatch", - ) - - return password2 - - def clean_email(self): - email = self.cleaned_data.get("email").lower() - username = email.split("@")[0] - - if not Student.objects.is_valid_sjtu_student_email(email): - raise ValidationError( - "Only SJTU student emails are permitted for registration at this time." - ) - - if len(username) > 30: - raise ValidationError("Please use a shorter email.") - - if User.objects.filter(Q(username=username) | Q(email=email)): - raise ValidationError("A user with that email already exists") - - return email - - @transaction.atomic() - def save_and_send_confirmation(self, request): - new_user = User.objects.create_user( - username=self.cleaned_data["email"].split("@")[0], - email=self.cleaned_data["email"], - password=self.cleaned_data["password1"], - is_active=False, - ) - - new_student = Student.objects.create( - user=new_user, - confirmation_link=User.objects.make_random_password(length=16), - ) - new_student.send_confirmation_link(request) - - return new_user diff --git a/apps/web/models/review.py b/apps/web/models/review.py index d4f15da..3da86c2 100644 --- a/apps/web/models/review.py +++ b/apps/web/models/review.py @@ -18,11 +18,15 @@ def get_user_review_for_course(self, user, course): """ 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. """ 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() class Review(models.Model): diff --git a/apps/web/models/student.py b/apps/web/models/student.py index 3b12ce1..a2f952c 100644 --- a/apps/web/models/student.py +++ b/apps/web/models/student.py @@ -1,24 +1,11 @@ from __future__ import unicode_literals -from django.conf import settings from django.contrib.auth.models import User -from django.contrib.postgres.fields import ArrayField -from django.core.mail import send_mail from django.db import models -from django.urls import reverse - -from lib import constants - -from .vote import Vote class StudentManager(models.Manager): - def is_valid_sjtu_student_email(self, email): - email_components = email.split("@") - if len(email_components) != 2: - return False - domain = email_components[1] - return domain == "sjtu.edu.cn" + pass class Student(models.Model): @@ -26,37 +13,8 @@ class Student(models.Model): user = models.OneToOneField(User, on_delete=models.CASCADE) - confirmation_link = models.CharField(max_length=16, unique=True) - created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) - unauth_session_ids = ArrayField( - base_field=models.CharField(max_length=32, unique=True), - default=list, - blank=True, - ) - - def send_confirmation_link(self, request): - full_link = ( - request.build_absolute_uri(reverse("confirmation")) - + "?link=" - + self.confirmation_link - ) - if not settings.DEBUG: - send_mail( - "Your confirmation link", - "Please navigate to the following confirmation link: " + full_link, - constants.SUPPORT_EMAIL, - [self.user.email], - fail_silently=False, - ) - - def can_see_recommendations(self): - return ( - Vote.objects.num_quality_upvotes_for_user(self.user) - >= constants.REC_UPVOTE_REQ - ) - def __unicode__(self): return str(self.user) diff --git a/apps/web/views.py b/apps/web/views.py index 3b67564..c2e3cf7 100644 --- a/apps/web/views.py +++ b/apps/web/views.py @@ -1,57 +1,40 @@ -import datetime -import uuid -import traceback - -import dateutil.parser -from django.contrib.auth import authenticate, login, logout -from django.contrib.auth.decorators import login_required -from django.core.paginator import EmptyPage, PageNotAnInteger, Paginator -from django.db.models import Count -from django.http import ( - HttpResponseBadRequest, - HttpResponseForbidden, - HttpResponseRedirect, - JsonResponse, -) -from django.shortcuts import redirect, render -from django.urls import reverse -from django.views.decorators.http import require_POST, require_safe -from rest_framework.authentication import SessionAuthentication -from rest_framework.decorators import ( - api_view, - authentication_classes, - permission_classes, -) -from rest_framework.permissions import AllowAny, IsAuthenticated -from rest_framework.response import Response - - -class CsrfExemptSessionAuthentication(SessionAuthentication): - def enforce_csrf(self, request): - return - - from apps.web.models import ( Course, CourseMedian, - DistributiveRequirement, Instructor, Review, ReviewVote, - Student, Vote, ) -from apps.web.models.forms import ReviewForm, SignupForm + +from apps.web.models.forms import ReviewForm + from apps.web.serializers import ( CourseSearchSerializer, CourseSerializer, ReviewSerializer, ) + from lib import constants from lib.departments import get_department_name from lib.grades import numeric_value_for_grade from lib.terms import numeric_value_of_term +import datetime +import uuid +import dateutil.parser + +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, @@ -105,113 +88,6 @@ def get_prior_course_id(request, current_course_id): return prior_course_id -def signup(request): - if request.method == "POST": - form = SignupForm(request.POST) - if form.is_valid(): - form.save_and_send_confirmation(request) - return render(request, "instructions.html") - else: - return render(request, "signup.html", {"form": form}) - - else: - return render(request, "signup.html", {"form": SignupForm()}) - - -@api_view(["POST"]) -@authentication_classes([CsrfExemptSessionAuthentication]) -@permission_classes([AllowAny]) -def auth_login_api(request): - email = request.data.get("email", "").lower() - password = request.data.get("password", "") - next_url = request.data.get("next", "/courses") - - if not email or not password: - return Response({"error": "Email and password are required"}, status=400) - - username = email.split("@")[0] - user = authenticate(username=username, password=password) - - if user is not None: - if user.is_active: - login(request, user) - if "user_id" in request.session: - try: - student = Student.objects.get(user=user) - student.unauth_session_ids.append(request.session["user_id"]) - student.save() - except Student.DoesNotExist: - student = Student.objects.create( - user=user, unauth_session_ids=[request.session["user_id"]] - ) - request.session["user_id"] = user.username - - return Response( - {"success": True, "next": next_url, "username": user.username} - ) - else: - return Response( - { - "error": "Please activate your account via the activation link first." - }, - status=403, - ) - else: - return Response({"error": "Invalid email or password"}, status=401) - - -@api_view(["POST"]) -@authentication_classes([CsrfExemptSessionAuthentication]) -@permission_classes([AllowAny]) -def auth_logout_api(request): - """ - API endpoint for user logout. - """ - if request.user.is_authenticated: - try: - student = Student.objects.get(user=request.user) - if "user_id" in request.session: - if request.session["user_id"] in student.unauth_session_ids: - student.unauth_session_ids.remove(request.session["user_id"]) - student.save() - except Student.DoesNotExist: - pass - - logout(request) - request.session["userID"] = uuid.uuid4().hex - return Response({"success": True, "message": "Logged out successfully"}) - else: - return Response( - {"success": False, "message": "User not authenticated"}, status=400 - ) - - -@require_safe -def confirmation(request): - link = request.GET.get("link") - - if link: - try: - student = Student.objects.get(confirmation_link=link) - except Student.DoesNotExist: - return render( - request, - "confirmation.html", - {"error": "Confirmation code expired or does not exist."}, - ) - - if student.user.is_active: - return render(request, "confirmation.html", {"already_confirmed": True}) - - student.user.is_active = True - student.user.save() - return render(request, "confirmation.html", {"already_confirmed": False}) - else: - return render( - request, "confirmation.html", {"error": "Please provide confirmation code."} - ) - - @api_view(["GET"]) @permission_classes([AllowAny]) def courses_api(request): @@ -320,11 +196,8 @@ def course_detail_api(request, course_id): @api_view(["DELETE"]) -@permission_classes([AllowAny]) +@permission_classes([IsAuthenticated]) def delete_review_api(request, course_id): - # Check if user is authenticated - if not request.user.is_authenticated: - return Response({"detail": "Authentication required"}, status=403) 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}) @@ -403,7 +276,7 @@ def course_review_search_api(request, course_id): ) -@require_safe +@api_view(["GET"]) def medians(request, course_id): # retrieve course medians for term, and group by term for averaging medians_by_term = {} @@ -420,7 +293,7 @@ def medians(request, course_id): } ) - return JsonResponse( + return Response( { "medians": sorted( [ @@ -437,13 +310,14 @@ def medians(request, course_id): key=lambda x: numeric_value_of_term(x["term"]), reverse=True, ) - } + }, + status=200, ) -@require_safe +@api_view(["GET"]) def course_professors(request, course_id): - return JsonResponse( + return Response( { "professors": sorted( set( @@ -459,46 +333,40 @@ def course_professors(request, course_id): .distinct() ) ) - } + }, + status=200, ) -@require_safe +@api_view(["GET"]) def course_instructors(request, course_id): try: course = Course.objects.get(pk=course_id) instructors = course.get_instructors() - return JsonResponse( - {"instructors": [instructor.name for instructor in instructors]} + return Response( + {"instructors": [instructor.name for instructor in instructors]}, status=200 ) except Course.DoesNotExist: - return JsonResponse({"error": "Course not found"}, status=404) + return Response({"error": "Course not found"}, status=404) -@require_POST +@api_view(["POST"]) +@permission_classes([IsAuthenticated]) def course_vote_api(request, course_id): - if not request.user.is_authenticated: - return HttpResponseForbidden() - try: - import json - - if request.content_type == "application/json": - data = json.loads(request.body) - value = data["value"] - forLayup = data["forLayup"] - else: - value = request.POST["value"] - forLayup = request.POST["forLayup"] - except (KeyError, json.JSONDecodeError): - return HttpResponseBadRequest() + value = request.data["value"] + forLayup = request.data["forLayup"] + except KeyError: + return Response( + {"detail": "Missing required fields: value, forLayup"}, status=400 + ) 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 ) - return JsonResponse( + return Response( { "new_score": new_score, "was_unvote": is_unvote, @@ -508,7 +376,7 @@ def course_vote_api(request, course_id): @api_view(["POST"]) -@permission_classes([AllowAny]) +@permission_classes([IsAuthenticated]) def review_vote_api(request, review_id): """ API endpoint for voting on reviews (kudos/dislike). @@ -522,8 +390,6 @@ def review_vote_api(request, review_id): - dislike_count: updated dislike count - user_vote: user's current vote (True/False/None) """ - if not request.user.is_authenticated: - return Response({"detail": "Authentication required"}, status=403) try: is_kudos = request.data.get("is_kudos") @@ -558,7 +424,7 @@ def review_vote_api(request, review_id): @api_view(["GET"]) -@permission_classes([AllowAny]) +@permission_classes([IsAuthenticated]) def get_user_review_api(request, course_id): """ API endpoint to get the authenticated user's review for a specific course. @@ -568,8 +434,6 @@ def get_user_review_api(request, course_id): - 404 if no review found - 403 if user is not authenticated """ - if not request.user.is_authenticated: - return Response({"detail": "Authentication required"}, status=403) try: # Get the course diff --git a/config.yaml.example b/config.yaml.example new file mode 100644 index 0000000..27105cd --- /dev/null +++ b/config.yaml.example @@ -0,0 +1,57 @@ +# Please copy this file to config.yaml and fill in +# corresponding fields. +# For non-secret, environment-specific configuration. +# Values here will override DEFAULTS in settings.py. +# Environment variables will override values here. + +DEBUG: true + +# SECRET_KEY: Use env + +ALLOWED_HOSTS: + # - "backend.redacted.com" + - "localhost" + - "127.0.0.1" + +CORS_ALLOWED_ORIGINS: + # - "https://frontend.redacted.com" + - "http://localhost:5173" + - "http://127.0.0.1:5173" + +# SESSION: +# COOKIE_AGE: 2592000 # 30 days +# SAVE_EVERY_REQUEST: true +# +# AUTH: +# OTP_TIMEOUT: 120 +# TEMP_TOKEN_TIMEOUT: 600 +# TOKEN_RATE_LIMIT: 5 +# TOKEN_RATE_LIMIT_TIME: 600 +# PASSWORD_LENGTH_MIN: 10 +# PASSWORD_LENGTH_MAX: 32 +# EMAIL_DOMAIN_NAME: "sjtu.edu.cn" +# +# DATABASE: +# URL: Use env +# +# REDIS: +# URL: Use env +# MAX_CONNECTIONS: 100 +# +# TURNSTILE_SECRET_KEY: Use env + +QUEST: + # BASE_URL: "https://wj.sjtu.edu.cn/api/v1/public/export" + SIGNUP: + # API_KEY: Use env + URL: "https://wj.sjtu.edu.cn/q/dummy0" + QUESTIONID: 10000000 + LOGIN: + # API_KEY: Use env + URL: "https://wj.sjtu.edu.cn/q/dummy1" + QUESTIONID: 10000001 + RESET: + # API_KEY: Use env + URL: "https://wj.sjtu.edu.cn/q/dummy2" + QUESTIONID: 10000002 +# AUTO_IMPORT_CRAWLED_DATA: true diff --git a/docs/auth.md b/docs/auth.md new file mode 100644 index 0000000..1ca3919 --- /dev/null +++ b/docs/auth.md @@ -0,0 +1,162 @@ +# Auth + +## Design Overview + +### Introduction + +This document outlines the authentication mechanism for our website (`frontend.website.com`). The system utilizes the university's official questionnaire platform (`https://wj.sjtu.edu.cn/`) as a proxy for its SSO infrastructure, enabling secure, passwordless authentication. This flow is based on a One-Time Password (OTP) model, providing a robust and user-friendly experience for signup, login, and password recovery within a streamlined single-page application flow. + +### Authentication Flow + +The process is designed as a linear, redirect-based flow that is compatible across all browsers and WebView environments. + +1. **Initiation and Bot Prevention** + + A user on a designated page (e.g., `/signup`) is verified by a Cloudflare Turnstile challenge. Upon success, the frontend sends an `initiate` request to our backend (`api.website.com`), declaring the user's intent (`action`). + +2. **OTP Generation and User Redirection** + + The backend validates the Turnstile response, generates a cryptographically secure 8-digit OTP, and associates it with a short-lived (10-minute) `temp_token`. The backend sets this `temp_token` in a secure, `HttpOnly` cookie and returns the OTP and the relevant pre-configured questionnaire URL. (Use different questionnaires for different actions.) The frontend prominently displays the OTP and a copy button. When the user clicks this button, the OTP is copied to their clipboard, frontend shows "Copied!", and they are automatically redirected to the questionnaire URL within the current tab after 1 second (no countdown or displaying remaining seconds). The OTP is also appended to the URL as a `otp_hint` parameter for user convenience. + +3. **Identity Assertion via Questionnaire** + + The user is now on the university's questionnaire page. If required, they log in with their university credentials, paste or type the OTP into the form, and submit it. + +4. **Callback and State Hand-off** + + The questionnaire platform instantly redirects the user back to our unified callback URL (e.g., `https://frontend.website.com/callback?action=signup&account={{.User}}&answer_id={{.AnswerID}}`), which includes parameters specifying university account, a unique submission ID, and the original intent. The callback page sends this information to our backend for verification. The browser automatically includes the `temp_token` cookie in this request. + +5. **Backend Validation** + + The backend performs a rigorous validation, confirming the submission details against the OTP and the `temp_token` (read from the cookie) records stored in Redis. Upon success, the OTP is consumed, and the `temp_token` is marked as verified, now containing the authenticated user's identity. + +6. **Flow Completion** + + The backend returns a success response to the callback page. The callback page stores a non-sensitive state object (e.g., `{ status: 'verified', action: 'signup', expires_at: ... }`) in `localStorage` and redirects the user back to the original page they started from (e.g., `/signup`). This page, now detecting the verified flow state in `localStorage`, intelligently renders the next step of the flow (e.g., the password creation form) instead of the initial OTP component. + +## Security Analysis + +### Overview + +This authentication flow is hardened by its stateless, token-based design. It relies on short-lived, single-use credentials and verifiable, server-side intent, which mitigates common web application vulnerabilities. + +### Key Security Mechanisms + +- **Bot Prevention**: Cloudflare Turnstile gates all authentication initiation points. +- **Cryptographically Secure Tokens**: The OTP and `temp_token` are generated using a CSPRNG. +- **`HttpOnly` Cookie Storage**: The `temp_token` is stored in a secure, `HttpOnly` cookie, preventing access from client-side JavaScript and mitigating XSS-based token theft. +- **Time-Limited Validity**: OTP is valid for 2 minutes; `temp_token` for 10 minutes. +- **One-Time-Use OTP**: The OTP-to-token link is deleted from Redis immediately after use. +- **Hashed Token Storage**: The `temp_token` is stored in Redis using its SHA256 hash as the key (`temp_token_state:`). +- **Server-Side Intent Enforcement**: The backend maps each `quest_id` to a specific `action` and enforces this link during verification. + +## Implementation Details + +### Components & Stack + +- **Frontend**: Vue.js (`frontend.website.com`) with Vue Router +- **Backend**: Django / Django REST Framework (`api.website.com`) +- **Cache/State Store**: Redis +- **Bot Prevention**: Cloudflare Turnstile +- **Infrastructure**: Cloudflare in front of Nginx. + +### Frontend Architecture (Vue Components) + +#### `AuthInitiate.vue` (Reusable Component) + +- **Props**: `action` (String: `signup`, `login`, `reset_password`). +- **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. + 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. + +#### Page-Level Components + +##### `Signup.vue` (`/signup`) + +On mount, checks `localStorage` for an `auth_flow` state object with `status: 'verified'`, `action: 'signup'`, and a non-expired timestamp. + +- **If not found**: Renders ``. +- **If found**: Renders ``. + +##### `Login.vue` (`/login`) + +Redirect to `/` if already logged in. Multiple login methods, including password login and questionnaire login, may be more in the future. Both requires passing Turnstile. Uncareful handling of Turnstile widget may cause conflicts. If questionnaire, render ``. The flow completes at the callback, which redirects to the homepage directly. + +##### `ResetPassword.vue` (`/reset`) + +On mount, checks `localStorage` for an `auth_flow` state object with `status: 'verified'`, `action: 'reset_password'`, and a non-expired timestamp. + +- **If not found**: Renders ``. +- **If found**: Renders ``. + +#### `AuthCallback.vue` (`/callback`) + +- **Logic**: A transient component. + 1. On mount, parses `account`, `answer_id`, and `action` from the URL query. + 2. Calls `POST /api/auth/verify` with these parameters. The browser automatically sends the `temp_token` cookie. + 3. On success, receives the flow's `action` and `expires_at` timestamp from the backend and writes a state object `{ status: 'verified', action, expires_at }` to `localStorage`. + 4. Redirects: if `action == 'login'`, redirect to `/`. Otherwise, redirect to `/${action}` (e.g., `/signup`). + +#### `SetPasswordForm.vue` (Reusable Component) + +- **Props**: `action` (String: `signup`, `reset_password`). +- **Logic**: Renders password fields. (password and re-type-password only. Backend fetches username and other info directly from questionnaire platform.) On submit, it sends the new password to `POST /api/auth/signup` or `POST /api/auth/password` (for password reset) based on action. The browser automatically attaches the `HttpOnly` `temp_token` cookie to the request. The frontend does not handle the token. Delete OTP and the auth flow state from `localStorage` on success and redirect to `/`. + +### Detailed Backend Process + +#### `POST /api/auth/initiate` + +1. **Input**: Receives the user's intended `action` and the `turnstile_token`. +2. **Validation**: Verifies the `turnstile_token` with Cloudflare's API. +3. **Generation**: Generates a cryptographically secure `otp` and a `temp_token`. +4. **Redis Storage**: + - Links the `otp` to the raw `temp_token` with a 2-minute expiry. + - Creates a state record for the `temp_token` (keyed by its SHA256 hash). This record contains its `status` (`pending`) and the user's `action`, and has a 10-minute expiry. +5. **Response**: `200 OK` with the `otp` and `redirect_url`. The backend also sets a secure, `HttpOnly` cookie containing the `temp_token` with a 10-minute expiry. + +#### `POST /api/auth/verify` + +1. **Input**: Receives the `account`, `answer_id`, and `action` from the callback. The `temp_token` is received via the `HttpOnly` cookie sent by the browser. +2. **Token Pre-Validation**: Extracts the `temp_token` from the cookie. This is the fastest check to reject invalid, expired, or already-used tokens. + - Looks up the state record in Redis using the SHA256 hash of the `temp_token`. + - Verifies the status is `pending`. If it's already `verified` or missing, return an error. +3. **Security**: Applies strict rate limiting per _valid_ `temp_token` to prevent brute-force attempts on a single verification flow. Check and set rate limit (or attempts count) in Redis. +4. **API Query**: Fetches recent submissions from the questionnaire platform for the given `account`. +5. **Find Submission**: Locates the specific submission matching the `answer_id`. If not found, returns an error. +6. **Extract Data**: Extracts the `submitted_otp` and the questionnaire's unique ID (`quest_id`) from the submission. +7. ~~**Intent Verification**: Confirms that the `quest_id` from the submission correctly maps to the `action` specified in the request, preventing cross-flow attacks.~~ Not needed, different quesitonnaires use different API, cross-flow attacks are not possible. +8. **OTP & Token Link Validation**: + - Atomically retrieves the expected `temp_token` associated with the `submitted_otp` and deletes the OTP record to prevent reuse. Use atomic `GETDEL` to prevent race condition. + - If no token is found for the OTP, or if the retrieved token does not exactly match the `temp_token` from the cookie, the request is invalid. Return `401 Unauthorized`. +9. **State Transition**: Updates the state record's status to `verified` and adds the authenticated user details (e.g., `jaccount`, `ip`, time). The record's TTL is preserved. +10. **Action-Specific Logic**: If the `action` is `login`, the user is now fully authenticated. The backend logs them in and immediately deletes the `temp_token_state` record from Redis. +11. **Response**: `200 OK` with the confirmed `action`, an `expires_at` timestamp for the flow state, and a boolean `is_logged_in` flag. This allows the frontend to safely manage its UI state. + +#### `POST /api/auth/signup` + +1. **Input**: Receives the new `password`. The `temp_token` is received via the `HttpOnly` cookie. +2. **Token Validation**: + - Looks up the state record in Redis using the SHA256 hash of the `temp_token` from the cookie. + - Verifies the record exists and its status is `verified`, and its `action` is `signup`. +3. **Password Complexity Check**: Checks the password against complexity and length rules. +4. **Extract Identity**: Retrieves the `jaccount` and `action` from the state record. +5. **Logic**: Creates a new user account, checking first that the `jaccount` does not already exist. +6. **Session Management**: Logs in the user session. +7. **Cleanup**: Deletes the `temp_token_state` record from Redis and sends an instruction to the browser to clear the `temp_token` cookie (set cookie as already expired). +8. **Response**: `200 OK` with a success message confirming the action was completed. + +#### `POST /api/auth/password` + +1. **Input**: Receives the new `password`. The `temp_token` is received via the `HttpOnly` cookie. +2. **Token Validation**: + - Looks up the state record in Redis using the SHA256 hash of the `temp_token` from the cookie. + - Verifies the record exists and its status is `verified`, and its action is `reset_password`. If not, return `403 Forbidden`. +3. **Password Complexity Check**: Checks the password against complexity and length rules. +4. **Extract Identity**: Retrieves the `jaccount` and `action` from the state record. +5. ~~**Identity Verification**: Verifies the `jaccount` is the same with the account of the current user (identified by session).~~ Forget password will fail this. +6. **Password Update**: updates the password of the authenticated user. +7. **Cleanup**: Deletes the `temp_token_state` record from Redis and sends an instruction to the browser to clear the `temp_token` cookie (set cookie as already expired). +8. **Response**: `200 OK` with a success message confirming the action was completed. diff --git a/docs/config.md b/docs/config.md new file mode 100644 index 0000000..b0b73d3 --- /dev/null +++ b/docs/config.md @@ -0,0 +1,195 @@ +# Config + +Use YAML and environment variables for robust and secure configuration. All the customizable fields can be specified in `config.yaml` and environment variables (or `.env` file at local dev). + +## TL;DR + +1. Copy `.env.example` file to `.env`, fill in: + - `SECRET_KEY` + - `TURNSTILE_SECRET_KEY` + - `QUEST__SIGNUP__API_KEY` + - `QUEST__LOGIN__API_KEY` + - `QUEST__RESET__API_KEY` + - (If in production) `DATABASE__URL` and `REDIS__URL` +2. Copy `config.yaml.example` to `config.yaml`, fill in: + - `DEBUG`: `true` if at dev, `false` if in production + - `URL` and `QUESTIONID` in all actions in `QUEST` + - (If in production) backend domains in `ALLOWED_HOSTS`, frontend domains in `CORS_ALLOWED_ORIGINS` +3. That's it! + +## Priority + +env > `config.yaml` > default config + +Every field (including nested ones) can be specified anywhere (i.e. env, `config.yaml`, none/default), and config will be loaded with each field following the above priority order. + +### Environment Variables + +- Environment variables are used to set secrets and credentials. +- Use `.env` file for local development. Directly export environment variables at production. +- Copy this `.env.example` file to `.env` and fill in the secrets for local development. +- `.env` should **NOT** be committed (already git ignored). +- Use `PARENT__CHILD` format to override nested settings. `__` means parental relationship. +- Use `,` as delimiter for lists. + +```env path=.env +# .env.example +# Copy this file to .env and fill in the secrets for local development. +# DO NOT COMMIT .env TO VERSION CONTROL. +# This file overrides config.yaml + +# --- Core Security (REQUIRED IN PRODUCTION) --- +# Generate a new one for production! +SECRET_KEY=django-insecure-my-local-dev-secret-key + +# --- Local Overrides --- +# Set to False in production +# DEBUG=True + +# --- Infrastructure (REQUIRED) --- +# Use a single URL for database and Redis connections. +# Format: driver://user:password@host:port/dbname +DATABASE__URL=postgres://admin:test@127.0.0.1:5432/coursereview +REDIS__URL=redis://localhost:6379/0 + +# --- External Services Secrets (REQUIRED) --- +TURNSTILE_SECRET_KEY=dummy0 + +# Use PARENT__CHILD format to override nested settings +# URL and ID may be specified in config.yaml +QUEST__SIGNUP__API_KEY=dummy1 +# QUEST__SIGNUP__URL= +# QUEST__SIGNUP__QUESTIONID= + +QUEST__LOGIN__API_KEY=dummy2 +# QUEST__LOGIN__URL= +# QUEST__LOGIN__QUESTIONID= + +QUEST__RESET__API_KEY=dummy3 +# QUEST__RESET__URL= +# QUEST__RESET__QUESTIONID= + +# --- Other Overrides (Optional) --- +# Example of overriding a nested value in the AUTH dictionary +# AUTH__OTP_TIMEOUT=60 + +# Example of overriding a list with a comma-separated string +# ALLOWED_HOSTS=localhost,127.0.0.1,dev.my-app.com +``` + +### YAML + +- `config.yaml` is used to set custom but not secret configs (e.g. frontend and backend URLs, questionnaire ID) +- Copy this `config.yaml.example` file to `config.yaml` and fill in the required fields. +- `config.yaml` should **NOT** be committed (already git ignored). + +```yaml path=config.yaml +# Please copy this file to config.yaml and fill in +# corresponding fields. +# For non-secret, environment-specific configuration. +# Values here will override DEFAULTS in settings.py. +# Environment variables will override values here. + +DEBUG: true + +# SECRET_KEY: Use env + +ALLOWED_HOSTS: + # - "backend.redacted.com" + - "localhost" + - "127.0.0.1" + +CORS_ALLOWED_ORIGINS: + # - "https://frontend.redacted.com" + - "http://localhost:5173" + - "http://127.0.0.1:5173" + +# SESSION: +# COOKIE_AGE: 2592000 # 30 days +# SAVE_EVERY_REQUEST: true +# +# AUTH: +# OTP_TIMEOUT: 120 +# TEMP_TOKEN_TIMEOUT: 600 +# TOKEN_RATE_LIMIT: 5 +# TOKEN_RATE_LIMIT_TIME: 600 +# PASSWORD_LENGTH_MIN: 10 +# PASSWORD_LENGTH_MAX: 32 +# EMAIL_DOMAIN_NAME: "sjtu.edu.cn" +# +# DATABASE: +# URL: Use env +# +# REDIS: +# URL: Use env +# MAX_CONNECTIONS: 100 +# +# TURNSTILE_SECRET_KEY: Use env + +QUEST: + # BASE_URL: "https://wj.sjtu.edu.cn/api/v1/public/export" + SIGNUP: + # API_KEY: Use env + URL: "https://wj.sjtu.edu.cn/q/dummy0" + QUESTIONID: 10000000 + LOGIN: + # API_KEY: Use env + URL: "https://wj.sjtu.edu.cn/q/dummy1" + QUESTIONID: 10000001 + RESET: + # API_KEY: Use env + URL: "https://wj.sjtu.edu.cn/q/dummy2" + QUESTIONID: 10000002 +# AUTO_IMPORT_CRAWLED_DATA: true +``` + +### Default Config + +- Just for example. +- `settings.py` should **NOT** be modified by non-developers. +- The fields whose default values are `None` are mandatory, either in env or in `config.yaml`. + +```python path=website/settings.py +# --- Default Configuration --- +DEFAULTS = { + "DEBUG": True, + "SECRET_KEY": None, + "ALLOWED_HOSTS": ["127.0.0.1", "localhost"], + "CORS_ALLOWED_ORIGINS": ["http://localhost:5173", "http://127.0.0.1:5173"], + "SESSION": { + "COOKIE_AGE": 2592000, # 30 days + "SAVE_EVERY_REQUEST": True, + }, + "AUTH": { + "OTP_TIMEOUT": 120, + "TEMP_TOKEN_TIMEOUT": 600, + "TOKEN_RATE_LIMIT": 5, + "TOKEN_RATE_LIMIT_TIME": 600, + "PASSWORD_LENGTH_MIN": 10, + "PASSWORD_LENGTH_MAX": 32, + "EMAIL_DOMAIN_NAME": "sjtu.edu.cn", + }, + "DATABASE": {"URL": "sqlite:///db.sqlite3"}, + "REDIS": {"URL": "redis://localhost:6379/0", "MAX_CONNECTIONS": 100}, + "TURNSTILE_SECRET_KEY": None, + "QUEST": { + "BASE_URL": "https://wj.sjtu.edu.cn/api/v1/public/export", + "SIGNUP": { + "API_KEY": None, + "URL": None, + "QUESTIONID": None, + }, + "LOGIN": { + "API_KEY": None, + "URL": None, + "QUESTIONID": None, + }, + "RESET": { + "API_KEY": None, + "URL": None, + "QUESTIONID": None, + }, + }, + "AUTO_IMPORT_CRAWLED_DATA": True, +} +``` diff --git a/docs/Setup.md b/docs/setup.md similarity index 96% rename from docs/Setup.md rename to docs/setup.md index de64cab..74a4a4c 100644 --- a/docs/Setup.md +++ b/docs/setup.md @@ -22,7 +22,7 @@ Environment: 6. Make directory for builds of static files: `mkdir staticfiles` -7. Create .env file for storing secrets. The contents should be like: +7. cp .env.example and rename it .env at root dir. The contents of PostgreSQL should be like: ```ini # PostgreSQL @@ -36,12 +36,12 @@ Environment: OFFERINGS_THRESHOLD_FOR_TERM_UPDATE=100 ``` + Also cp .env.example in frontend/ and rename it .env. + 8. Build static files: `make collect` 9. Configure database - 1. Install Postgres: - - `sudo apt update` - `sudo apt install postgresql` @@ -55,7 +55,6 @@ Environment: 5. Switch to user postgres: `sudo -iu postgres` 6. `psql` - 1. Initialize coursereview database, user and privileges ```sql @@ -96,7 +95,6 @@ Environment: 11. `make run` and visit 12. Add local admin: - 1. `make createsuperuser`. The email can be blank. Use a strong password in production. 2. Enter interactive python shell: `make shell`. (Different from directly running `python` from shell.) @@ -113,7 +111,6 @@ Environment: ``` 13. Crawl data from JI official website: - 1. Edit `COURSE_DETAIL_URL_PREFIX` in `apps/spider/crawlers/orc.py`: Add a number after url param `id` like this: `...?id=23`, so only course id starting from 23 (e.g. 230-239, 2300) will be crawled, so as to save time during development. Remember not to commit this change. 2. Enter interactive python shell: `make shell`. diff --git a/frontend b/frontend new file mode 160000 index 0000000..cd0bc12 --- /dev/null +++ b/frontend @@ -0,0 +1 @@ +Subproject commit cd0bc129f97ecc3e066a42045759a17953e7eabb diff --git a/lib/constants.py b/lib/constants.py index f26f03a..d8d8aef 100644 --- a/lib/constants.py +++ b/lib/constants.py @@ -1,4 +1,3 @@ -import os from datetime import datetime @@ -21,6 +20,3 @@ def get_current_term(): # CURRENT_TERM = os.environ["CURRENT_TERM"] # e.g. 16S SUPPORT_EMAIL = "support@layuplist.com" REC_UPVOTE_REQ = 2 -OFFERINGS_THRESHOLD_FOR_TERM_UPDATE = int( - os.environ["OFFERINGS_THRESHOLD_FOR_TERM_UPDATE"] -) diff --git a/pyproject.toml b/pyproject.toml index 3f3dfb5..ef49a79 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,15 +3,10 @@ name = "" version = "0.0.1" dependencies = [ "beautifulsoup4>=4.13.3", - "celery>=5.4.0", - "crispy-bootstrap4>=2024.10", "dj-database-url>=2.3.0", "django>=5.1.6", - "django-celery-beat>=2.7.0", - "django-celery-results>=2.5.1", - "django-crispy-forms>=2.3", "django-debug-toolbar>=5.0.1", - "django-pipeline>=4.0.0", + "httpx>=0.28.1", "psycopg2-binary>=2.9.10", "python-dateutil>=2.9.0", "python-dotenv>=1.0.1", @@ -22,6 +17,8 @@ dependencies = [ "ptpython>=3.0.29", "djangorestframework>=3.16.0", "django-cors-headers>=4.7.0", + "django-redis", + "pyyaml>=6.0.2", ] [tool.uv] diff --git a/scripts/__init__.py b/scripts/__init__.py index d73d887..15bc822 100644 --- a/scripts/__init__.py +++ b/scripts/__init__.py @@ -97,7 +97,7 @@ def crawl_and_save_instructors(): break if not instructor_names: - print(f" No instructors found") + print("No instructors found") continue print(f" Found instructors: {', '.join(instructor_names)}") diff --git a/scripts/migrate_votes_to_rating_system.py b/scripts/migrate_votes_to_rating_system.py deleted file mode 100644 index 08ff54f..0000000 --- a/scripts/migrate_votes_to_rating_system.py +++ /dev/null @@ -1,85 +0,0 @@ -#!/usr/bin/env python -""" -Script to migrate existing votes to new rating system. -This script converts old +1/-1 votes to the new 1-5 rating system. -""" - -import os -import sys - -import django - -# Add the project directory to the path dynamically -project_root = os.path.abspath(os.path.join(os.path.dirname(__file__), os.pardir)) -sys.path.insert(0, project_root) - -# Set Django settings -os.environ.setdefault("DJANGO_SETTINGS_MODULE", "website.settings") -django.setup() - -from django.db import transaction - -from apps.web.models import Course, Vote - - -def migrate_votes(): - """ - Migrate existing votes to new rating system: - - Old +1 votes become 4 (good rating) - - Old -1 votes become 2 (poor rating) - - Old 0 votes remain 0 (unvoted) - """ - print("Starting vote migration...") - - with transaction.atomic(): - # Convert +1 votes to 4 (good rating) - positive_votes = Vote.objects.filter(value=1) - positive_count = positive_votes.count() - positive_votes.update(value=4) - print(f"Converted {positive_count} positive votes to rating 4") - - # Convert -1 votes to 2 (poor rating) - negative_votes = Vote.objects.filter(value=-1) - negative_count = negative_votes.count() - negative_votes.update(value=2) - print(f"Converted {negative_count} negative votes to rating 2") - - # Recalculate all course scores - print("Recalculating course scores...") - courses = Course.objects.all() - for course in courses: - # Recalculate quality score - quality_votes = Vote.objects.filter( - course=course, category=Vote.CATEGORIES.QUALITY - ).exclude(value=0) - - if quality_votes.exists(): - avg_quality = ( - sum(vote.value for vote in quality_votes) / quality_votes.count() - ) - course.quality_score = round(avg_quality, 1) - else: - course.quality_score = 0.0 - - # Recalculate difficulty score - difficulty_votes = Vote.objects.filter( - course=course, category=Vote.CATEGORIES.DIFFICULTY - ).exclude(value=0) - - if difficulty_votes.exists(): - avg_difficulty = ( - sum(vote.value for vote in difficulty_votes) - / difficulty_votes.count() - ) - course.difficulty_score = round(avg_difficulty, 1) - else: - course.difficulty_score = 0.0 - - course.save() - - print(f"Updated scores for {courses.count()} courses") - print("Vote migration completed successfully!") - - -if __name__ == "__main__": - migrate_votes() diff --git a/scripts/pre-commit-format.py b/scripts/pre-commit-format.py index 5c9e216..e0e8c90 100644 --- a/scripts/pre-commit-format.py +++ b/scripts/pre-commit-format.py @@ -10,7 +10,7 @@ def main(): try: # Run make format and capture exit code - result = subprocess.run(["make", "format"], check=True) + subprocess.run(["make", "format"], check=True) # Format succeeded, stage the changes subprocess.run(["git", "add", "--update"], check=True) diff --git a/uv.lock b/uv.lock index cf9c4de..5202b8b 100644 --- a/uv.lock +++ b/uv.lock @@ -1,6 +1,6 @@ version = 1 revision = 3 -requires-python = ">=3.12" +requires-python = ">=3.13" [[package]] name = "" @@ -9,22 +9,19 @@ source = { virtual = "." } dependencies = [ { name = "beautifulsoup4" }, { name = "bpython" }, - { name = "celery" }, - { name = "crispy-bootstrap4" }, { name = "dj-database-url" }, { name = "django" }, - { name = "django-celery-beat" }, - { name = "django-celery-results" }, { name = "django-cors-headers" }, - { name = "django-crispy-forms" }, { name = "django-debug-toolbar" }, - { name = "django-pipeline" }, + { 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" }, ] @@ -38,22 +35,19 @@ dev = [ requires-dist = [ { name = "beautifulsoup4", specifier = ">=4.13.3" }, { name = "bpython", specifier = ">=0.25" }, - { name = "celery", specifier = ">=5.4.0" }, - { name = "crispy-bootstrap4", specifier = ">=2024.10" }, { name = "dj-database-url", specifier = ">=2.3.0" }, { name = "django", specifier = ">=5.1.6" }, - { name = "django-celery-beat", specifier = ">=2.7.0" }, - { name = "django-celery-results", specifier = ">=2.5.1" }, { name = "django-cors-headers", specifier = ">=4.7.0" }, - { name = "django-crispy-forms", specifier = ">=2.3" }, { name = "django-debug-toolbar", specifier = ">=5.0.1" }, - { name = "django-pipeline", specifier = ">=4.0.0" }, + { 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" }, ] @@ -62,24 +56,25 @@ requires-dist = [ dev = [{ name = "pre-commit", specifier = ">=4.3.0" }] [[package]] -name = "amqp" -version = "5.3.1" +name = "ansicon" +version = "1.89.0" source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "vine" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/79/fc/ec94a357dfc6683d8c86f8b4cfa5416a4c36b28052ec8260c77aca96a443/amqp-5.3.1.tar.gz", hash = "sha256:cddc00c725449522023bad949f70fff7b48f0b1ade74d170a6f10ab044739432", size = 129013, upload-time = "2024-11-12T19:55:44.051Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b6/e2/1c866404ddbd280efedff4a9f15abfe943cb83cde6e895022370f3a61f85/ansicon-1.89.0.tar.gz", hash = "sha256:e4d039def5768a47e4afec8e89e83ec3ae5a26bf00ad851f914d1240b444d2b1", size = 67312, upload-time = "2019-04-29T20:23:57.314Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/26/99/fc813cd978842c26c82534010ea849eee9ab3a13ea2b74e95cb9c99e747b/amqp-5.3.1-py3-none-any.whl", hash = "sha256:43b3319e1b4e7d1251833a93d672b4af1e40f3d632d479b98661a95f117880a2", size = 50944, upload-time = "2024-11-12T19:55:41.782Z" }, + { url = "https://files.pythonhosted.org/packages/75/f9/f1c10e223c7b56a38109a3f2eb4e7fe9a757ea3ed3a166754fb30f65e466/ansicon-1.89.0-py2.py3-none-any.whl", hash = "sha256:f1def52d17f65c2c9682cf8370c03f541f410c1752d6a14029f97318e4b9dfec", size = 63675, upload-time = "2019-04-29T20:23:53.83Z" }, ] [[package]] -name = "ansicon" -version = "1.89.0" +name = "anyio" +version = "4.10.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b6/e2/1c866404ddbd280efedff4a9f15abfe943cb83cde6e895022370f3a61f85/ansicon-1.89.0.tar.gz", hash = "sha256:e4d039def5768a47e4afec8e89e83ec3ae5a26bf00ad851f914d1240b444d2b1", size = 67312, upload-time = "2019-04-29T20:23:57.314Z" } +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" } wheels = [ - { url = "https://files.pythonhosted.org/packages/75/f9/f1c10e223c7b56a38109a3f2eb4e7fe9a757ea3ed3a166754fb30f65e466/ansicon-1.89.0-py2.py3-none-any.whl", hash = "sha256:f1def52d17f65c2c9682cf8370c03f541f410c1752d6a14029f97318e4b9dfec", size = 63675, upload-time = "2019-04-29T20:23:53.83Z" }, + { 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" }, ] [[package]] @@ -113,15 +108,6 @@ 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" }, ] -[[package]] -name = "billiard" -version = "4.2.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7c/58/1546c970afcd2a2428b1bfafecf2371d8951cc34b46701bea73f4280989e/billiard-4.2.1.tar.gz", hash = "sha256:12b641b0c539073fc8d3f5b8b7be998956665c4233c7c1fcd66a7e677c4fb36f", size = 155031, upload-time = "2024-09-21T13:40:22.491Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/30/da/43b15f28fe5f9e027b41c539abc5469052e9d48fd75f8ff094ba2a0ae767/billiard-4.2.1-py3-none-any.whl", hash = "sha256:40b59a4ac8806ba2c2369ea98d876bc6108b051c227baffd928c644d15d8f3cb", size = 86766, upload-time = "2024-09-21T13:40:20.188Z" }, -] - [[package]] name = "blessed" version = "1.21.0" @@ -152,25 +138,6 @@ 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" }, ] -[[package]] -name = "celery" -version = "5.5.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "billiard" }, - { name = "click" }, - { name = "click-didyoumean" }, - { name = "click-plugins" }, - { name = "click-repl" }, - { name = "kombu" }, - { name = "python-dateutil" }, - { name = "vine" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/bf/03/5d9c6c449248958f1a5870e633a29d7419ff3724c452a98ffd22688a1a6a/celery-5.5.2.tar.gz", hash = "sha256:4d6930f354f9d29295425d7a37261245c74a32807c45d764bedc286afd0e724e", size = 1666892, upload-time = "2025-04-25T20:10:04.695Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/04/94/8e825ac1cf59d45d20c4345d4461e6b5263ae475f708d047c3dad0ac6401/celery-5.5.2-py3-none-any.whl", hash = "sha256:54425a067afdc88b57cd8d94ed4af2ffaf13ab8c7680041ac2c4ac44357bdf4c", size = 438626, upload-time = "2025-04-25T20:10:01.383Z" }, -] - [[package]] name = "certifi" version = "2025.4.26" @@ -195,19 +162,6 @@ 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/0a/9a/dd1e1cdceb841925b7798369a09279bd1cf183cef0f9ddf15a3a6502ee45/charset_normalizer-3.4.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:73d94b58ec7fecbc7366247d3b0b10a21681004153238750bb67bd9012414545", size = 196105, upload-time = "2024-12-24T18:10:38.83Z" }, - { url = "https://files.pythonhosted.org/packages/d3/8c/90bfabf8c4809ecb648f39794cf2a84ff2e7d2a6cf159fe68d9a26160467/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dad3e487649f498dd991eeb901125411559b22e8d7ab25d3aeb1af367df5efd7", size = 140404, upload-time = "2024-12-24T18:10:44.272Z" }, - { url = "https://files.pythonhosted.org/packages/ad/8f/e410d57c721945ea3b4f1a04b74f70ce8fa800d393d72899f0a40526401f/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c30197aa96e8eed02200a83fba2657b4c3acd0f0aa4bdc9f6c1af8e8962e0757", size = 150423, upload-time = "2024-12-24T18:10:45.492Z" }, - { url = "https://files.pythonhosted.org/packages/f0/b8/e6825e25deb691ff98cf5c9072ee0605dc2acfca98af70c2d1b1bc75190d/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2369eea1ee4a7610a860d88f268eb39b95cb588acd7235e02fd5a5601773d4fa", size = 143184, upload-time = "2024-12-24T18:10:47.898Z" }, - { url = "https://files.pythonhosted.org/packages/3e/a2/513f6cbe752421f16d969e32f3583762bfd583848b763913ddab8d9bfd4f/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc2722592d8998c870fa4e290c2eec2c1569b87fe58618e67d38b4665dfa680d", size = 145268, upload-time = "2024-12-24T18:10:50.589Z" }, - { url = "https://files.pythonhosted.org/packages/74/94/8a5277664f27c3c438546f3eb53b33f5b19568eb7424736bdc440a88a31f/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ffc9202a29ab3920fa812879e95a9e78b2465fd10be7fcbd042899695d75e616", size = 147601, upload-time = "2024-12-24T18:10:52.541Z" }, - { url = "https://files.pythonhosted.org/packages/7c/5f/6d352c51ee763623a98e31194823518e09bfa48be2a7e8383cf691bbb3d0/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:804a4d582ba6e5b747c625bf1255e6b1507465494a40a2130978bda7b932c90b", size = 141098, upload-time = "2024-12-24T18:10:53.789Z" }, - { url = "https://files.pythonhosted.org/packages/78/d4/f5704cb629ba5ab16d1d3d741396aec6dc3ca2b67757c45b0599bb010478/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:0f55e69f030f7163dffe9fd0752b32f070566451afe180f99dbeeb81f511ad8d", size = 149520, upload-time = "2024-12-24T18:10:55.048Z" }, - { url = "https://files.pythonhosted.org/packages/c5/96/64120b1d02b81785f222b976c0fb79a35875457fa9bb40827678e54d1bc8/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c4c3e6da02df6fa1410a7680bd3f63d4f710232d3139089536310d027950696a", size = 152852, upload-time = "2024-12-24T18:10:57.647Z" }, - { url = "https://files.pythonhosted.org/packages/84/c9/98e3732278a99f47d487fd3468bc60b882920cef29d1fa6ca460a1fdf4e6/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:5df196eb874dae23dcfb968c83d4f8fdccb333330fe1fc278ac5ceeb101003a9", size = 150488, upload-time = "2024-12-24T18:10:59.43Z" }, - { url = "https://files.pythonhosted.org/packages/13/0e/9c8d4cb99c98c1007cc11eda969ebfe837bbbd0acdb4736d228ccaabcd22/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e358e64305fe12299a08e08978f51fc21fac060dcfcddd95453eabe5b93ed0e1", size = 146192, upload-time = "2024-12-24T18:11:00.676Z" }, - { url = "https://files.pythonhosted.org/packages/b2/21/2b6b5b860781a0b49427309cb8670785aa543fb2178de875b87b9cc97746/charset_normalizer-3.4.1-cp312-cp312-win32.whl", hash = "sha256:9b23ca7ef998bc739bf6ffc077c2116917eabcc901f88da1b9856b210ef63f35", size = 95550, upload-time = "2024-12-24T18:11:01.952Z" }, - { url = "https://files.pythonhosted.org/packages/21/5b/1b390b03b1d16c7e382b561c5329f83cc06623916aab983e8ab9239c7d5c/charset_normalizer-3.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:6ff8a4a60c227ad87030d76e99cd1698345d4491638dfa6673027c48b3cd395f", size = 102785, upload-time = "2024-12-24T18:11:03.142Z" }, { 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" }, @@ -224,86 +178,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0e/f6/65ecc6878a89bb1c23a086ea335ad4bf21a588990c3f535a227b9eea9108/charset_normalizer-3.4.1-py3-none-any.whl", hash = "sha256:d98b1668f06378c6dbefec3b92299716b931cd4e6061f3c875a71ced1780ab85", size = 49767, upload-time = "2024-12-24T18:12:32.852Z" }, ] -[[package]] -name = "click" -version = "8.1.8" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/b9/2e/0090cbf739cee7d23781ad4b89a9894a41538e4fcf4c31dcdd705b78eb8b/click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a", size = 226593, upload-time = "2024-12-21T18:38:44.339Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7e/d4/7ebdbd03970677812aac39c869717059dbb71a4cfc033ca6e5221787892c/click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2", size = 98188, upload-time = "2024-12-21T18:38:41.666Z" }, -] - -[[package]] -name = "click-didyoumean" -version = "0.3.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "click" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/30/ce/217289b77c590ea1e7c24242d9ddd6e249e52c795ff10fac2c50062c48cb/click_didyoumean-0.3.1.tar.gz", hash = "sha256:4f82fdff0dbe64ef8ab2279bd6aa3f6a99c3b28c05aa09cbfc07c9d7fbb5a463", size = 3089, upload-time = "2024-03-24T08:22:07.499Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1b/5b/974430b5ffdb7a4f1941d13d83c64a0395114503cc357c6b9ae4ce5047ed/click_didyoumean-0.3.1-py3-none-any.whl", hash = "sha256:5c4bb6007cfea5f2fd6583a2fb6701a22a41eb98957e63d0fac41c10e7c3117c", size = 3631, upload-time = "2024-03-24T08:22:06.356Z" }, -] - -[[package]] -name = "click-plugins" -version = "1.1.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "click" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/5f/1d/45434f64ed749540af821fd7e42b8e4d23ac04b1eda7c26613288d6cd8a8/click-plugins-1.1.1.tar.gz", hash = "sha256:46ab999744a9d831159c3411bb0c79346d94a444df9a3a3742e9ed63645f264b", size = 8164, upload-time = "2019-04-04T04:27:04.82Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e9/da/824b92d9942f4e472702488857914bdd50f73021efea15b4cad9aca8ecef/click_plugins-1.1.1-py2.py3-none-any.whl", hash = "sha256:5d262006d3222f5057fd81e1623d4443e41dcda5dc815c06b442aa3c02889fc8", size = 7497, upload-time = "2019-04-04T04:27:03.36Z" }, -] - -[[package]] -name = "click-repl" -version = "0.3.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "click" }, - { name = "prompt-toolkit" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/cb/a2/57f4ac79838cfae6912f997b4d1a64a858fb0c86d7fcaae6f7b58d267fca/click-repl-0.3.0.tar.gz", hash = "sha256:17849c23dba3d667247dc4defe1757fff98694e90fe37474f3feebb69ced26a9", size = 10449, upload-time = "2023-06-15T12:43:51.141Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/52/40/9d857001228658f0d59e97ebd4c346fe73e138c6de1bce61dc568a57c7f8/click_repl-0.3.0-py3-none-any.whl", hash = "sha256:fb7e06deb8da8de86180a33a9da97ac316751c094c6899382da7feeeeb51b812", size = 10289, upload-time = "2023-06-15T12:43:48.626Z" }, -] - -[[package]] -name = "colorama" -version = "0.4.6" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, -] - -[[package]] -name = "crispy-bootstrap4" -version = "2024.10" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "django" }, - { name = "django-crispy-forms" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/69/0b/6a3e2ab27d9eab3fd95628e45212454ac486b2c501def355f3c425cf4ae3/crispy-bootstrap4-2024.10.tar.gz", hash = "sha256:503e8922b0f3b5262a6fdf303a3a94eb2a07514812f1ca130b88f7c02dd25e2b", size = 35301, upload-time = "2024-10-05T15:41:50.457Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/de/a9/2a22c0e6b72323205a6780f9a93e8121bc2c81338d34a0ddc1f6d1a958e7/crispy_bootstrap4-2024.10-py3-none-any.whl", hash = "sha256:138a97884044ae4c4799c80595b36c42066e4e933431e2e971611e251c84f96c", size = 23060, upload-time = "2024-10-05T15:41:48.907Z" }, -] - -[[package]] -name = "cron-descriptor" -version = "1.4.5" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/02/83/70bd410dc6965e33a5460b7da84cf0c5a7330a68d6d5d4c3dfdb72ca117e/cron_descriptor-1.4.5.tar.gz", hash = "sha256:f51ce4ffc1d1f2816939add8524f206c376a42c87a5fca3091ce26725b3b1bca", size = 30666, upload-time = "2024-08-24T18:16:48.654Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/88/20/2cfe598ead23a715a00beb716477cfddd3e5948cf203c372d02221e5b0c6/cron_descriptor-1.4.5-py3-none-any.whl", hash = "sha256:736b3ae9d1a99bc3dbfc5b55b5e6e7c12031e7ba5de716625772f8b02dcd6013", size = 50370, upload-time = "2024-08-24T18:16:46.783Z" }, -] - [[package]] name = "curtsies" version = "0.4.2" @@ -323,13 +197,6 @@ 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/87/28/8e2ab81f0116bfcec22069e4c92fda9d05b0512605ccef00b62d93719ded/cwcwidth-0.1.10-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1d2b21ff2eb60c6793349b7fb161c40a8583a57ec32e61f47aab7938177bfdec", size = 23031, upload-time = "2025-02-09T21:14:59.01Z" }, - { url = "https://files.pythonhosted.org/packages/3a/a4/5adc535e2a714ecc926ea701e821a9abbe14f65cae4d615d20059b9b52a5/cwcwidth-0.1.10-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e0316488349c3e5ca4b20de7daa1cb8e96a05d1d14d040d46e87a495da655f4a", size = 101219, upload-time = "2025-02-09T21:15:00.079Z" }, - { url = "https://files.pythonhosted.org/packages/78/4c/18a5a06aa8db3cc28712ab957671e7718aedfc73403d84b0c2cb5cfcbc27/cwcwidth-0.1.10-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:848b6ffca1e32e28d2ccbb2cd395ccd3c38a7c4ec110728cd9d828eaf609b09e", size = 106565, upload-time = "2025-02-09T21:15:02.081Z" }, - { url = "https://files.pythonhosted.org/packages/06/40/801cba5ccb9551c862ad210eba22031e4655cd74711e32756b7ce24fc751/cwcwidth-0.1.10-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:c3a7bfe1da478c0c27c549f68c6e28a583413da3ee451854ec2d983497bd18b8", size = 102244, upload-time = "2025-02-09T21:15:04.003Z" }, - { url = "https://files.pythonhosted.org/packages/e4/ed/60f61274fcfd0621a45e9403502e8f46968d562810a4424e5ff8d6bd50b0/cwcwidth-0.1.10-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:cff03100f49170bc50fc399d05a31b8fcb7b0cef26df1a8068fa943387107f6c", size = 105634, upload-time = "2025-02-09T21:15:06.005Z" }, - { url = "https://files.pythonhosted.org/packages/b1/27/8179cecd688fef894dda601455d35066adfa3d58af4e97c5ab112893b5f6/cwcwidth-0.1.10-cp312-cp312-win32.whl", hash = "sha256:2dd9a92fdfbc53fc79f0953f39708dcf743fd27450c374985f419e3d47eb89d4", size = 23507, upload-time = "2025-02-09T21:15:07.968Z" }, - { url = "https://files.pythonhosted.org/packages/b2/b4/b7fe652a4d96f03ef051fff8313dfe827bc31578f7e67f1c98d5a5813f66/cwcwidth-0.1.10-cp312-cp312-win_amd64.whl", hash = "sha256:734d764281e3d87c40d0265543f00a653409145fa9f48a93bc0fbf9a8e7932ca", size = 26100, upload-time = "2025-02-09T21:15:09.186Z" }, { 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" }, @@ -375,36 +242,6 @@ 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" }, ] -[[package]] -name = "django-celery-beat" -version = "2.8.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "celery" }, - { name = "cron-descriptor" }, - { name = "django" }, - { name = "django-timezone-field" }, - { name = "python-crontab" }, - { name = "tzdata" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/d5/c8/f29ec081372fb1d800933a3effebbd19b97a93be27a88a5ab100f01babc2/django_celery_beat-2.8.0.tar.gz", hash = "sha256:955bfb3c4b8f1026a8d20144d0da39c941e1eb23acbaee9e12a7e7cc1f74959a", size = 172052, upload-time = "2025-04-16T07:15:40.122Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2e/d6/d83c9664b03f41281a0ae975756ce23fb8da7c6844f400f2d13c840ea941/django_celery_beat-2.8.0-py3-none-any.whl", hash = "sha256:f8fd2e1ffbfa8e570ab9439383b2cd15a6642b347662d0de79c62ba6f68d4b38", size = 103583, upload-time = "2025-04-16T07:15:38.196Z" }, -] - -[[package]] -name = "django-celery-results" -version = "2.6.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "celery" }, - { name = "django" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/a6/b5/9966c28e31014c228305e09d48b19b35522a8f941fe5af5f81f40dc8fa80/django_celery_results-2.6.0.tar.gz", hash = "sha256:9abcd836ae6b61063779244d8887a88fe80bbfaba143df36d3cb07034671277c", size = 83985, upload-time = "2025-04-10T08:23:52.677Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2c/da/70f0f3c5364735344c4bc89e53413bcaae95b4fc1de4e98a7a3b9fb70c88/django_celery_results-2.6.0-py3-none-any.whl", hash = "sha256:b9ccdca2695b98c7cbbb8dea742311ba9a92773d71d7b4944a676e69a7df1c73", size = 38351, upload-time = "2025-04-10T08:23:49.965Z" }, -] - [[package]] name = "django-cors-headers" version = "4.7.0" @@ -418,18 +255,6 @@ 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" }, ] -[[package]] -name = "django-crispy-forms" -version = "2.4" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "django" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/88/a1/ffd7b0e160296121d88e3e173165370000ee4de7328f5c4f4b266638dcd9/django_crispy_forms-2.4.tar.gz", hash = "sha256:915e1ffdeb2987d78b33fabfeff8e5203c8776aa910a3a659a2c514ca125f3bd", size = 278932, upload-time = "2025-04-13T07:25:00.176Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1d/ec/a25f81e56a674e63cf6c3dd8e36b1b3fecc238fecd6098504adc0cc61402/django_crispy_forms-2.4-py3-none-any.whl", hash = "sha256:5a4b99876cfb1bdd3e47727731b6d4197c51c0da502befbfbec6a93010b02030", size = 31446, upload-time = "2025-04-13T07:24:58.516Z" }, -] - [[package]] name = "django-debug-toolbar" version = "5.1.0" @@ -444,28 +269,16 @@ wheels = [ ] [[package]] -name = "django-pipeline" -version = "4.0.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "setuptools" }, - { name = "wheel" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/49/f5/12c83c33f0d6cd93a7b1498a1b3ae9bd86828f1f739998f59c5249d7504e/django_pipeline-4.0.0.tar.gz", hash = "sha256:0ab1190d9dc64e2f7b72be3f7b023c06aca7cc1cc61e7dc9f0343838e29bbc88", size = 71780, upload-time = "2024-12-06T12:11:11.795Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2d/05/7258f4b27839d186dfc29f9416475d236c2a64f25ff64a22e77d700eb753/django_pipeline-4.0.0-py3-none-any.whl", hash = "sha256:90e50c15387a6e051ee1a6ce2aaca333823ccfb23695028790f74412bde7d7db", size = 75384, upload-time = "2024-12-06T12:10:08.162Z" }, -] - -[[package]] -name = "django-timezone-field" -version = "7.1" +name = "django-redis" +version = "6.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "django" }, + { name = "redis" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ba/5b/0dbe271fef3c2274b83dbcb1b19fa3dacf1f7e542382819294644e78ea8b/django_timezone_field-7.1.tar.gz", hash = "sha256:b3ef409d88a2718b566fabe10ea996f2838bc72b22d3a2900c0aa905c761380c", size = 13727, upload-time = "2025-01-11T17:49:54.486Z" } +sdist = { url = "https://files.pythonhosted.org/packages/08/53/dbcfa1e528e0d6c39947092625b2c89274b5d88f14d357cee53c4d6dbbd4/django_redis-6.0.0.tar.gz", hash = "sha256:2d9cb12a20424a4c4dde082c6122f486628bae2d9c2bee4c0126a4de7fda00dd", size = 56904, upload-time = "2025-06-17T18:15:46.376Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ec/09/7a808392a751a24ffa62bec00e3085a9c1a151d728c323a5bab229ea0e58/django_timezone_field-7.1-py3-none-any.whl", hash = "sha256:93914713ed882f5bccda080eda388f7006349f25930b6122e9b07bf8db49c4b4", size = 13177, upload-time = "2025-01-11T17:49:52.142Z" }, + { url = "https://files.pythonhosted.org/packages/7e/79/055dfcc508cfe9f439d9f453741188d633efa9eab90fc78a67b0ab50b137/django_redis-6.0.0-py3-none-any.whl", hash = "sha256:20bf0063a8abee567eb5f77f375143c32810c8700c0674ced34737f8de4e36c0", size = 33687, upload-time = "2025-06-17T18:15:34.165Z" }, ] [[package]] @@ -495,15 +308,6 @@ 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/f0/d1/e4777b188a04726f6cf69047830d37365b9191017f54caf2f7af336a6f18/greenlet-3.2.1-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:0ba2811509a30e5f943be048895a983a8daf0b9aa0ac0ead526dfb5d987d80ea", size = 270381, upload-time = "2025-04-22T14:25:43.69Z" }, - { url = "https://files.pythonhosted.org/packages/59/e7/b5b738f5679247ddfcf2179c38945519668dced60c3164c20d55c1a7bb4a/greenlet-3.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4245246e72352b150a1588d43ddc8ab5e306bef924c26571aafafa5d1aaae4e8", size = 637195, upload-time = "2025-04-22T14:53:44.563Z" }, - { url = "https://files.pythonhosted.org/packages/6c/9f/57968c88a5f6bc371364baf983a2e5549cca8f503bfef591b6dd81332cbc/greenlet-3.2.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7abc0545d8e880779f0c7ce665a1afc3f72f0ca0d5815e2b006cafc4c1cc5840", size = 651381, upload-time = "2025-04-22T14:54:59.439Z" }, - { url = "https://files.pythonhosted.org/packages/40/81/1533c9a458e9f2ebccb3ae22f1463b2093b0eb448a88aac36182f1c2cd3d/greenlet-3.2.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6dcc6d604a6575c6225ac0da39df9335cc0c6ac50725063fa90f104f3dbdb2c9", size = 646110, upload-time = "2025-04-22T15:04:35.739Z" }, - { url = "https://files.pythonhosted.org/packages/06/66/25f7e4b1468ebe4a520757f2e41c2a36a2f49a12e963431b82e9f98df2a0/greenlet-3.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2273586879affca2d1f414709bb1f61f0770adcabf9eda8ef48fd90b36f15d12", size = 648070, upload-time = "2025-04-22T14:27:05.976Z" }, - { url = "https://files.pythonhosted.org/packages/d7/4c/49d366565c4c4d29e6f666287b9e2f471a66c3a3d8d5066692e347f09e27/greenlet-3.2.1-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ff38c869ed30fff07f1452d9a204ece1ec6d3c0870e0ba6e478ce7c1515acf22", size = 603816, upload-time = "2025-04-22T14:25:57.224Z" }, - { url = "https://files.pythonhosted.org/packages/04/15/1612bb61506f44b6b8b6bebb6488702b1fe1432547e95dda57874303a1f5/greenlet-3.2.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:e934591a7a4084fa10ee5ef50eb9d2ac8c4075d5c9cf91128116b5dca49d43b1", size = 1119572, upload-time = "2025-04-22T14:58:58.277Z" }, - { url = "https://files.pythonhosted.org/packages/cc/2f/002b99dacd1610e825876f5cbbe7f86740aa2a6b76816e5eca41c8457e85/greenlet-3.2.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:063bcf7f8ee28eb91e7f7a8148c65a43b73fbdc0064ab693e024b5a940070145", size = 1147442, upload-time = "2025-04-22T14:28:11.243Z" }, - { url = "https://files.pythonhosted.org/packages/c0/ba/82a2c3b9868644ee6011da742156247070f30e952f4d33f33857458450f2/greenlet-3.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:7132e024ebeeeabbe661cf8878aac5d2e643975c4feae833142592ec2f03263d", size = 296207, upload-time = "2025-04-22T14:54:40.531Z" }, { 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" }, @@ -523,6 +327,43 @@ wheels = [ { 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" }, ] +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +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" @@ -565,20 +406,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/27/e3/0e0014d6ab159d48189e92044ace13b1e1fe9aa3024ba9f4e8cf172aa7c2/jinxed-1.3.0-py2.py3-none-any.whl", hash = "sha256:b993189f39dc2d7504d802152671535b06d380b26d78070559551cbf92df4fc5", size = 33085, upload-time = "2024-07-31T22:39:17.426Z" }, ] -[[package]] -name = "kombu" -version = "5.5.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "amqp" }, - { name = "tzdata" }, - { name = "vine" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/60/0a/128b65651ed8120460fc5af754241ad595eac74993115ec0de4f2d7bc459/kombu-5.5.3.tar.gz", hash = "sha256:021a0e11fcfcd9b0260ef1fb64088c0e92beb976eb59c1dfca7ddd4ad4562ea2", size = 461784, upload-time = "2025-04-16T12:46:17.014Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/5d/35/1407fb0b2f5b07b50cbaf97fce09ad87d3bfefbf64f7171a8651cd8d2f68/kombu-5.5.3-py3-none-any.whl", hash = "sha256:5b0dbceb4edee50aa464f59469d34b97864be09111338cfb224a10b6a163909b", size = 209921, upload-time = "2025-04-16T12:46:15.139Z" }, -] - [[package]] name = "nodeenv" version = "1.9.1" @@ -640,18 +467,6 @@ version = "2.9.10" 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" } wheels = [ - { url = "https://files.pythonhosted.org/packages/49/7d/465cc9795cf76f6d329efdafca74693714556ea3891813701ac1fee87545/psycopg2_binary-2.9.10-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:880845dfe1f85d9d5f7c412efea7a08946a46894537e4e5d091732eb1d34d9a0", size = 3044771, upload-time = "2024-10-16T11:20:35.234Z" }, - { url = "https://files.pythonhosted.org/packages/8b/31/6d225b7b641a1a2148e3ed65e1aa74fc86ba3fee850545e27be9e1de893d/psycopg2_binary-2.9.10-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:9440fa522a79356aaa482aa4ba500b65f28e5d0e63b801abf6aa152a29bd842a", size = 3275336, upload-time = "2024-10-16T11:20:38.742Z" }, - { url = "https://files.pythonhosted.org/packages/30/b7/a68c2b4bff1cbb1728e3ec864b2d92327c77ad52edcd27922535a8366f68/psycopg2_binary-2.9.10-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e3923c1d9870c49a2d44f795df0c889a22380d36ef92440ff618ec315757e539", size = 2851637, upload-time = "2024-10-16T11:20:42.145Z" }, - { url = "https://files.pythonhosted.org/packages/0b/b1/cfedc0e0e6f9ad61f8657fd173b2f831ce261c02a08c0b09c652b127d813/psycopg2_binary-2.9.10-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7b2c956c028ea5de47ff3a8d6b3cc3330ab45cf0b7c3da35a2d6ff8420896526", size = 3082097, upload-time = "2024-10-16T11:20:46.185Z" }, - { url = "https://files.pythonhosted.org/packages/18/ed/0a8e4153c9b769f59c02fb5e7914f20f0b2483a19dae7bf2db54b743d0d0/psycopg2_binary-2.9.10-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f758ed67cab30b9a8d2833609513ce4d3bd027641673d4ebc9c067e4d208eec1", size = 3264776, upload-time = "2024-10-16T11:20:50.879Z" }, - { url = "https://files.pythonhosted.org/packages/10/db/d09da68c6a0cdab41566b74e0a6068a425f077169bed0946559b7348ebe9/psycopg2_binary-2.9.10-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8cd9b4f2cfab88ed4a9106192de509464b75a906462fb846b936eabe45c2063e", size = 3020968, upload-time = "2024-10-16T11:20:56.819Z" }, - { url = "https://files.pythonhosted.org/packages/94/28/4d6f8c255f0dfffb410db2b3f9ac5218d959a66c715c34cac31081e19b95/psycopg2_binary-2.9.10-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6dc08420625b5a20b53551c50deae6e231e6371194fa0651dbe0fb206452ae1f", size = 2872334, upload-time = "2024-10-16T11:21:02.411Z" }, - { url = "https://files.pythonhosted.org/packages/05/f7/20d7bf796593c4fea95e12119d6cc384ff1f6141a24fbb7df5a668d29d29/psycopg2_binary-2.9.10-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:d7cd730dfa7c36dbe8724426bf5612798734bff2d3c3857f36f2733f5bfc7c00", size = 2822722, upload-time = "2024-10-16T11:21:09.01Z" }, - { url = "https://files.pythonhosted.org/packages/4d/e4/0c407ae919ef626dbdb32835a03b6737013c3cc7240169843965cada2bdf/psycopg2_binary-2.9.10-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:155e69561d54d02b3c3209545fb08938e27889ff5a10c19de8d23eb5a41be8a5", size = 2920132, upload-time = "2024-10-16T11:21:16.339Z" }, - { url = "https://files.pythonhosted.org/packages/2d/70/aa69c9f69cf09a01da224909ff6ce8b68faeef476f00f7ec377e8f03be70/psycopg2_binary-2.9.10-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c3cc28a6fd5a4a26224007712e79b81dbaee2ffb90ff406256158ec4d7b52b47", size = 2959312, upload-time = "2024-10-16T11:21:25.584Z" }, - { url = "https://files.pythonhosted.org/packages/d3/bd/213e59854fafe87ba47814bf413ace0dcee33a89c8c8c814faca6bc7cf3c/psycopg2_binary-2.9.10-cp312-cp312-win32.whl", hash = "sha256:ec8a77f521a17506a24a5f626cb2aee7850f9b69a0afe704586f63a464f3cd64", size = 1025191, upload-time = "2024-10-16T11:21:29.912Z" }, - { url = "https://files.pythonhosted.org/packages/92/29/06261ea000e2dc1e22907dbbc483a1093665509ea586b29b8986a0e56733/psycopg2_binary-2.9.10-cp312-cp312-win_amd64.whl", hash = "sha256:18c5ee682b9c6dd3696dad6e54cc7ff3a1a9020df6a5c0f861ef8bfd338c3ca0", size = 1164031, upload-time = "2024-10-16T11:21:34.211Z" }, { 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" }, @@ -689,18 +504,6 @@ 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" }, ] -[[package]] -name = "python-crontab" -version = "3.2.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "python-dateutil" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/e2/f0/25775565c133d4e29eeb607bf9ddba0075f3af36041a1844dd207881047f/python_crontab-3.2.0.tar.gz", hash = "sha256:40067d1dd39ade3460b2ad8557c7651514cd3851deffff61c5c60e1227c5c36b", size = 57001, upload-time = "2024-07-01T22:29:10.903Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3b/91/832fb3b3a1f62bd2ab4924f6be0c7736c9bc4f84d3b153b74efcf6d4e4a1/python_crontab-3.2.0-py3-none-any.whl", hash = "sha256:82cb9b6a312d41ff66fd3caf3eed7115c28c195bfb50711bc2b4b9592feb9fe5", size = 27351, upload-time = "2024-07-01T22:29:08.549Z" }, -] - [[package]] name = "python-dateutil" version = "2.9.0.post0" @@ -746,15 +549,6 @@ 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/86/0c/c581167fc46d6d6d7ddcfb8c843a4de25bdd27e4466938109ca68492292c/PyYAML-6.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab", size = 183873, upload-time = "2024-08-06T20:32:25.131Z" }, - { url = "https://files.pythonhosted.org/packages/a8/0c/38374f5bb272c051e2a69281d71cba6fdb983413e6758b84482905e29a5d/PyYAML-6.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725", size = 173302, upload-time = "2024-08-06T20:32:26.511Z" }, - { url = "https://files.pythonhosted.org/packages/c3/93/9916574aa8c00aa06bbac729972eb1071d002b8e158bd0e83a3b9a20a1f7/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5", size = 739154, upload-time = "2024-08-06T20:32:28.363Z" }, - { url = "https://files.pythonhosted.org/packages/95/0f/b8938f1cbd09739c6da569d172531567dbcc9789e0029aa070856f123984/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b22676e8097e9e22e36d6b7bda33190d0d400f345f23d4065d48f4ca7ae0425", size = 766223, upload-time = "2024-08-06T20:32:30.058Z" }, - { url = "https://files.pythonhosted.org/packages/b9/2b/614b4752f2e127db5cc206abc23a8c19678e92b23c3db30fc86ab731d3bd/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80bab7bfc629882493af4aa31a4cfa43a4c57c83813253626916b8c7ada83476", size = 767542, upload-time = "2024-08-06T20:32:31.881Z" }, - { url = "https://files.pythonhosted.org/packages/d4/00/dd137d5bcc7efea1836d6264f049359861cf548469d18da90cd8216cf05f/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48", size = 731164, upload-time = "2024-08-06T20:32:37.083Z" }, - { url = "https://files.pythonhosted.org/packages/c9/1f/4f998c900485e5c0ef43838363ba4a9723ac0ad73a9dc42068b12aaba4e4/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b", size = 756611, upload-time = "2024-08-06T20:32:38.898Z" }, - { url = "https://files.pythonhosted.org/packages/df/d1/f5a275fdb252768b7a11ec63585bc38d0e87c9e05668a139fea92b80634c/PyYAML-6.0.2-cp312-cp312-win32.whl", hash = "sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4", size = 140591, upload-time = "2024-08-06T20:32:40.241Z" }, - { url = "https://files.pythonhosted.org/packages/0c/e8/4f648c598b17c3d06e8753d7d13d57542b30d56e6c2dedf9c331ae56312e/PyYAML-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8", size = 156338, upload-time = "2024-08-06T20:32:41.93Z" }, { 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" }, @@ -768,11 +562,11 @@ wheels = [ [[package]] name = "redis" -version = "5.2.1" +version = "6.2.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/47/da/d283a37303a995cd36f8b92db85135153dc4f7a8e4441aa827721b442cfb/redis-5.2.1.tar.gz", hash = "sha256:16f2e22dff21d5125e8481515e386711a34cbec50f0e44413dd7d9c060a54e0f", size = 4608355, upload-time = "2024-12-06T09:50:41.956Z" } +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" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3c/5f/fa26b9b2672cbe30e07d9a5bdf39cf16e3b80b42916757c5f92bca88e4ba/redis-5.2.1-py3-none-any.whl", hash = "sha256:ee7e1056b9aea0f04c6c2ed59452947f34c4940ee025f5dd83e6a6418b6989e4", size = 261502, upload-time = "2024-12-06T09:50:39.656Z" }, + { 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" }, ] [[package]] @@ -791,21 +585,21 @@ wheels = [ ] [[package]] -name = "setuptools" -version = "79.0.1" +name = "six" +version = "1.17.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/bb/71/b6365e6325b3290e14957b2c3a804a529968c77a049b2ed40c095f749707/setuptools-79.0.1.tar.gz", hash = "sha256:128ce7b8f33c3079fd1b067ecbb4051a66e8526e7b65f6cec075dfc650ddfa88", size = 1367909, upload-time = "2025-04-23T22:20:59.241Z" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0d/6d/b4752b044bf94cb802d88a888dc7d288baaf77d7910b7dedda74b5ceea0c/setuptools-79.0.1-py3-none-any.whl", hash = "sha256:e147c0549f27767ba362f9da434eab9c5dc0045d5304feb602a0af001089fc51", size = 1256281, upload-time = "2025-04-23T22:20:56.768Z" }, + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, ] [[package]] -name = "six" -version = "1.17.0" +name = "sniffio" +version = "1.3.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, ] [[package]] @@ -853,15 +647,6 @@ 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 = "vine" -version = "5.1.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/bd/e4/d07b5f29d283596b9727dd5275ccbceb63c44a1a82aa9e4bfd20426762ac/vine-5.1.0.tar.gz", hash = "sha256:8b62e981d35c41049211cf62a0a1242d8c1ee9bd15bb196ce38aefd6799e61e0", size = 48980, upload-time = "2023-11-05T08:46:53.857Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/03/ff/7c0c86c43b3cbb927e0ccc0255cb4057ceba4799cd44ae95174ce8e8b5b2/vine-5.1.0-py3-none-any.whl", hash = "sha256:40fdf3c48b2cfe1c38a49e9ae2da6fda88e4794c810050a728bd7413811fb1dc", size = 9636, upload-time = "2023-11-05T08:46:51.205Z" }, -] - [[package]] name = "virtualenv" version = "20.34.0" @@ -884,12 +669,3 @@ sdist = { url = "https://files.pythonhosted.org/packages/6c/63/53559446a878410fc 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" }, ] - -[[package]] -name = "wheel" -version = "0.45.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/8a/98/2d9906746cdc6a6ef809ae6338005b3f21bb568bea3165cfc6a243fdc25c/wheel-0.45.1.tar.gz", hash = "sha256:661e1abd9198507b1409a20c02106d9670b2576e916d58f520316666abca6729", size = 107545, upload-time = "2024-11-23T00:18:23.513Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0b/2c/87f3254fd8ffd29e4c02732eee68a83a1d3c346ae39bc6822dcbcb697f2b/wheel-0.45.1-py3-none-any.whl", hash = "sha256:708e7481cc80179af0e556bbf0cc00b8444c7321e2700b8d8580231d13017248", size = 72494, upload-time = "2024-11-23T00:18:21.207Z" }, -] diff --git a/website/__init__.py b/website/__init__.py index e83cb86..e69de29 100644 --- a/website/__init__.py +++ b/website/__init__.py @@ -1,7 +0,0 @@ -# website/__init__.py -from __future__ import absolute_import - -# This will make sure the app is always imported when Django starts so that shared_task will use this app. -from .celery import app as celery_app - -__all__ = ("celery_app",) diff --git a/website/config.py b/website/config.py new file mode 100644 index 0000000..2eab01f --- /dev/null +++ b/website/config.py @@ -0,0 +1,139 @@ +import os +import yaml +import collections.abc +from pathlib import Path +from typing import Any, Callable, TypeVar +from django.core.exceptions import ImproperlyConfigured +from functools import reduce +import operator + +# Generic TypeVar for casting function return values +T = TypeVar("T") + + +class Config: + """ + A robust, nested configuration loader that deeply merges settings from three sources: + 1. Environment Variables (using `PARENT__CHILD` for nesting) + 2. YAML Configuration File (`config.yaml`) + 3. Hardcoded Default Values + + Raises ImproperlyConfigured if a required setting is not found in any source. + This class respects case-sensitivity for setting keys to align with Django conventions. + """ + + def __init__(self, config_path: Path, defaults: dict[str, Any] | None = None): + # 1. Load all sources of configuration. + default_config = defaults or {} + yaml_config = self._load_yaml(config_path) + env_config = self._load_from_env() + + # 2. Build the final config by merging sources in the correct order of priority. + # Start with an empty dict, merge defaults, then yaml, then env. + self._final_config = {} + self._deep_merge(self._final_config, default_config) + self._deep_merge(self._final_config, yaml_config) + self._deep_merge(self._final_config, env_config) + + def _deep_merge(self, base: dict, new: dict) -> None: + """Recursively merges `new` dict into `base` dict in place.""" + + for key, value in new.items(): + base_value = base.get(key) + if isinstance(base_value, dict) and isinstance( + value, collections.abc.Mapping + ): + # If both the base and new values for a key are dicts, recurse. + self._deep_merge(base_value, value) + else: + # Otherwise, the new value overwrites the base value. + base[key] = value + + def _load_yaml(self, config_path: Path) -> dict[str, Any]: + """Loads the YAML config file if it exists, otherwise returns an empty dict.""" + + if not config_path.exists(): + return {} + try: + with open(config_path, "r", encoding="utf-8") as f: + return yaml.safe_load(f) or {} + except (yaml.YAMLError, IOError) as e: + raise ImproperlyConfigured( + f"Error reading YAML config at {config_path}: {e}" + ) + + def _load_from_env(self) -> dict[str, Any]: + """Parses environment variables like `AUTH__OTP_TIMEOUT` into a nested dict.""" + + env_config = {} + for key, value in os.environ.items(): + # Skip keys that don't contain our nesting separator to avoid noise + if "__" not in key: + # Handle simple top-level keys + env_config[key] = value + continue + + path = key.split("__") + target = env_config + for part in path[:-1]: # Iterate through the path to create nested dicts + target = target.setdefault(part, {}) + if not isinstance(target, dict): + raise ImproperlyConfigured( + f"Environment variable conflict. '{key}' implies '{ + part + }' is a dictionary, " + "but it was previously defined as a scalar value." + ) + + target[path[-1]] = value + + return env_config + + def get( + self, key: str, *, cast: Callable[[Any], T] | None = None, required: bool = True + ) -> T | Any | None: + """ + Retrieves a configuration value for a given key, supporting dot notation for nesting. + + Example: `config.get("DATABASE.PORT", cast=int)` + + Args: + key: The name of the setting, using '.' for nesting (e.g., "AUTH.OTP_TIMEOUT"). + cast: An optional callable (e.g., int, bool) to cast the final value. + required: If True (default), raises ImproperlyConfigured if the key is not found. + If False, returns None if the key is not found. + + Returns: + The configuration value, or None if not found and not required. + + Raises: + ImproperlyConfigured: If the key is required and not found in any source, + or if casting fails. + """ + + path = key.split(".") + + try: + value = reduce(operator.getitem, path, self._final_config) + except (KeyError, TypeError): + if required: + raise ImproperlyConfigured( + f"Required setting '{key}' is not defined in any source." + ) + return None + + if cast is not None: + if cast is bool and isinstance(value, str): + return value.lower() in ("true", "1", "yes") + if cast is list and isinstance(value, str): + return [item.strip() for item in value.split(",")] + try: + return cast(value) + except (ValueError, TypeError) as e: + raise ImproperlyConfigured( + f"Failed to cast setting '{key}' with value {value!r} to { + cast.__name__ + }. Error: {e}" + ) + + return value diff --git a/website/settings.py b/website/settings.py index 1985ae6..bbd2668 100644 --- a/website/settings.py +++ b/website/settings.py @@ -1,59 +1,118 @@ -""" -Django settings for CourseReview project. +from pathlib import Path +import dj_database_url +from dotenv import load_dotenv +from .config import Config -Generated by 'django-admin startproject' using Django 5.0.8. -For more information on this file, see -https://docs.djangoproject.com/en/5.0/topics/settings/ +BASE_DIR = Path(__file__).resolve().parent.parent +load_dotenv(BASE_DIR / ".env") -For the full list of settings and their values, see -https://docs.djangoproject.com/en/5.0/ref/settings/ -""" +# --- Default Configuration --- +DEFAULTS = { + "DEBUG": True, + "SECRET_KEY": None, + "ALLOWED_HOSTS": ["127.0.0.1", "localhost"], + "CORS_ALLOWED_ORIGINS": ["http://localhost:5173", "http://127.0.0.1:5173"], + "SESSION": { + "COOKIE_AGE": 2592000, # 30 days + "SAVE_EVERY_REQUEST": True, + }, + "AUTH": { + "OTP_TIMEOUT": 120, + "TEMP_TOKEN_TIMEOUT": 600, + "TOKEN_RATE_LIMIT": 5, + "TOKEN_RATE_LIMIT_TIME": 600, + "PASSWORD_LENGTH_MIN": 10, + "PASSWORD_LENGTH_MAX": 32, + "EMAIL_DOMAIN_NAME": "sjtu.edu.cn", + }, + "DATABASE": {"URL": "sqlite:///db.sqlite3"}, + "REDIS": {"URL": "redis://localhost:6379/0", "MAX_CONNECTIONS": 100}, + "TURNSTILE_SECRET_KEY": None, + "QUEST": { + "BASE_URL": "https://wj.sjtu.edu.cn/api/v1/public/export", + "SIGNUP": { + "API_KEY": None, + "URL": None, + "QUESTIONID": None, + }, + "LOGIN": { + "API_KEY": None, + "URL": None, + "QUESTIONID": None, + }, + "RESET": { + "API_KEY": None, + "URL": None, + "QUESTIONID": None, + }, + }, + "AUTO_IMPORT_CRAWLED_DATA": True, +} -import os +config = Config(config_path=BASE_DIR / "config.yaml", defaults=DEFAULTS) -from dotenv import load_dotenv -# Load environment variables from .env file -load_dotenv() +# ============================================================================== +# MANAGED SETTINGS (env > config.yaml > defaults) +# ============================================================================== -# Build paths inside the project like this: BASE_DIR / 'subdir'. -BASE_DIR = os.path.dirname(os.path.abspath(__file__)) +# --- Core Security & Behavior --- +SECRET_KEY = config.get("SECRET_KEY") +DEBUG = config.get("DEBUG", cast=bool) +ALLOWED_HOSTS = config.get("ALLOWED_HOSTS", cast=list) +CORS_ALLOWED_ORIGINS = config.get("CORS_ALLOWED_ORIGINS", cast=list) -# Quick-start development settings - unsuitable for production -# See https://docs.djangoproject.com/en/5.0/howto/deployment/checklist/ +# --- Infrastructure --- +DATABASES = {"default": dj_database_url.parse(config.get("DATABASE.URL"))} +CACHES = { + "default": { + "BACKEND": "django_redis.cache.RedisCache", + "LOCATION": config.get("REDIS.URL"), + "OPTIONS": { + "CLIENT_CLASS": "django_redis.client.DefaultClient", + "CONNECTION_POOL_KWARGS": { + "max_connections": config.get("REDIS.MAX_CONNECTIONS", cast=int) + }, + }, + "KEY_PREFIX": "coursereview", + } +} + +# --- Session Management --- +SESSION_COOKIE_AGE = config.get("SESSION.COOKIE_AGE", cast=int) +SESSION_SAVE_EVERY_REQUEST = config.get("SESSION.SAVE_EVERY_REQUEST", cast=bool) +SESSION_COOKIE_SECURE = not DEBUG -# SECURITY WARNING: keep the secret key used in production secret! -SECRET_KEY = os.getenv("SECRET_KEY") +# --- Application-Specific Settings --- +AUTH = config.get("AUTH") +TURNSTILE_SECRET_KEY = config.get("TURNSTILE_SECRET_KEY") +AUTO_IMPORT_CRAWLED_DATA = config.get("AUTO_IMPORT_CRAWLED_DATA", cast=bool) -# SECURITY WARNING: don't run with debug turned on in production! -DEBUG = os.getenv("DEBUG") == "True" +QUEST = config.get("QUEST") -ALLOWED_HOSTS = [] -# Application definition +# ============================================================================== +# DJANGO FRAMEWORK SETTINGS +# ============================================================================== +# These settings define the application's structure and are not meant to be +# configured. INSTALLED_APPS = [ "django.contrib.admin", "django.contrib.auth", "django.contrib.contenttypes", + # django admin requires django.contrib.staticfiles to be in INSTALLED_APPS + "django.contrib.staticfiles", "django.contrib.sessions", "django.contrib.messages", - "django.contrib.staticfiles", "django.contrib.humanize", "debug_toolbar", - "pipeline", - "crispy_forms", - "crispy_bootstrap4", - # "hijack", # hijack-admin (relies on compact) deprecated and merged into hijack - "django_celery_beat", - "django_celery_results", "rest_framework", "corsheaders", - "apps.analytics", - "apps.recommendations", "apps.spider", "apps.web", + "apps.auth", ] MIDDLEWARE = [ @@ -69,7 +128,7 @@ ] ROOT_URLCONF = "website.urls" - +WSGI_APPLICATION = "website.wsgi.application" TEMPLATES = [ { "BACKEND": "django.template.backends.django.DjangoTemplates", @@ -81,132 +140,32 @@ "django.template.context_processors.request", "django.contrib.auth.context_processors.auth", "django.contrib.messages.context_processors.messages", - ], + ] }, - }, -] - -CRISPY_TEMPLATE_PACK = "bootstrap4" - -WSGI_APPLICATION = "website.wsgi.application" - - -# Rest Framework - -if DEBUG: - CORS_ALLOW_ALL_ORIGINS = True -else: - CORS_ALLOWED_ORIGINS = [ - os.getenv("FRONTEND_URL"), - "http://127.0.0.1:8080", - "http://localhost:8080", - ] - - -# Database -# https://docs.djangoproject.com/en/5.0/ref/settings/#databases - -DATABASES = { - "default": { - "ENGINE": "django.db.backends.postgresql", - "NAME": os.getenv("DB_NAME", "coursereview"), - "USER": os.getenv("DB_USER"), - "PASSWORD": os.getenv("DB_PASSWORD"), - "HOST": os.getenv("DB_HOST", "127.0.0.1"), - "PORT": os.getenv("DB_PORT", "5432"), } -} - -# Password validation -# https://docs.djangoproject.com/en/5.0/ref/settings/#auth-password-validators - -AUTH_PASSWORD_VALIDATORS = [ - { - "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", - }, - { - "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", - }, - { - "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", - }, - { - "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", - }, ] -CELERY_BROKER_URL = os.environ["REDIS_URL"] -CELERY_RESULT_BACKEND = "django-db" -CELERY_TIMEZONE = "Asia/Shanghai" - -# Spider - -AUTO_IMPORT_CRAWLED_DATA = "True" - -# Internationalization -# https://docs.djangoproject.com/en/5.0/topics/i18n/ +STATIC_URL = "/dummy/" # Required by Django staticfiles but not used in this setup +DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" LANGUAGE_CODE = "en-us" - -TIME_ZONE = "UTC" - +TIME_ZONE = "Asia/Shanghai" USE_I18N = True - USE_TZ = True -# Static files (CSS, JavaScript, Images) -# https://docs.djangoproject.com/en/5.0/howto/static-files/ - - -STATIC_ROOT = "staticfiles" -STATIC_URL = "/static/" -STATICFILES_DIRS = (os.path.join(BASE_DIR, "static"),) -STATICFILES_STORAGE = "pipeline.storage.ManifestStaticFilesStorage" -STATICFILES_FINDERS = ( - "django.contrib.staticfiles.finders.FileSystemFinder", - "django.contrib.staticfiles.finders.AppDirectoriesFinder", - "pipeline.finders.PipelineFinder", -) -ROOT_ASSETS_DIR = os.path.join(BASE_DIR, "root_assets") -PIPELINE = { - # 'COMPILERS': ('react.utils.pipeline.JSXCompiler', ), - "JAVASCRIPT": { - "app": { - "source_filenames": ( - "js/plugins.js", - "js/vendor/jquery.highlight-5.js", - "js/web/base.jsx", - "js/web/common.jsx", - "js/web/landing.jsx", - "js/web/current_term.jsx", - "js/web/course_detail.jsx", - "js/web/course_review_search.jsx", - ), - "output_filename": "js/app.js", - } - }, - "STYLESHEETS": { - "app": { - "source_filenames": ( - "css/web/base.css", - "css/web/current_term.css", - "css/web/course_detail.css", - "css/web/course_review_search.css", - "css/web/landing.css", - "css/web/auth.css", - ), - "output_filename": "css/app.css", - "extra_context": { - "media": "screen,projection", - }, - } - }, -} +SESSION_ENGINE = "django.contrib.sessions.backends.cache" +SESSION_CACHE_ALIAS = "default" -# Default primary key field type -# https://docs.djangoproject.com/en/5.0/ref/settings/#default-auto-field - -DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" +AUTH_PASSWORD_VALIDATORS = [ + { + "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator" + }, + {"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator"}, + {"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"}, + {"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"}, +] -SESSION_COOKIE_AGE = 3153600000 # 100 years -SESSION_COOKIE_SECURE = not DEBUG +if DEBUG: + CORS_ALLOW_ALL_ORIGINS = True +else: + CORS_ALLOW_ALL_ORIGINS = False diff --git a/website/urls.py b/website/urls.py index 6df3173..9ca4cfb 100644 --- a/website/urls.py +++ b/website/urls.py @@ -1,43 +1,43 @@ -"""layup_list URL Configuration - -The `urlpatterns` list routes URLs to views. For more information please see: - https://docs.djangoproject.com/en/dev/topics/http/urls/ -Examples: -Function views - 1. Add an import: from my_app import views - 2. Add a URL to urlpatterns: path('', views.home, name='home') -Class-based views - 1. Add an import: from other_app.views import Home - 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') -Including another URLconf - 1. Add an import: from blog import urls as blog_urls - 2. Import the include() function: from django.urls import path, include - 3. Add a URL to urlpatterns: path('blog/', include(blog_urls)) -""" - import django.contrib.auth.views as authviews from django.contrib import admin from django.urls import re_path -from apps.analytics import views as aviews -from apps.recommendations import views as rviews + +from apps.auth import views as auth_views from apps.spider import views as spider_views from apps.web import views urlpatterns = [ - # administrative - re_path(r"^admin/", admin.site.urls), - re_path(r"^api/user/status/?", views.user_status, name="user_status"), - re_path(r"^api/accounts/login/$", views.auth_login_api, name="auth_login_api"), - re_path(r"^analytics/$", aviews.home, name="analytics_home"), + # OAuth re_path( - r"^eligible_for_recommendations/$", - aviews.eligible_for_recommendations, - name="eligible_for_recommendations", + r"^api/auth/initiate/$", + auth_views.auth_initiate_api, + name="auth_initiate_api", ), re_path( - r"^sentiment_labeler/$", aviews.sentiment_labeler, name="sentiment_labeler" + 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( @@ -96,40 +96,4 @@ views.course_review_search_api, name="course_review_search_api", ), - # recommendations - re_path(r"^recommendations/?", rviews.recommendations, name="recommendations"), - # authentication - re_path(r"^accounts/signup$", views.signup, name="signup"), - re_path(r"^api/auth/logout/?$", views.auth_logout_api, name="auth_logout_api"), - re_path(r"^accounts/confirmation$", views.confirmation, name="confirmation"), - # password resets - re_path( - r"^accounts/password/reset/$", - authviews.PasswordResetView.as_view( - template_name="password_reset_form.html", - html_email_template_name="password_reset_email.html", - email_template_name="password_reset_email.html", - ), - {"post_reset_redirect": "/accounts/password/reset/done/"}, - name="password_reset", - ), - re_path( - r"^accounts/password/reset/done/$", - authviews.PasswordResetDoneView.as_view( - template_name="password_reset_done.html" - ), - ), - re_path( - r"^accounts/password/reset/(?P[0-9A-Za-z]+)-(?P.+)/$", - authviews.PasswordResetConfirmView.as_view( - template_name="password_reset_confirm.html" - ), - name="password_reset_confirm", - ), - re_path( - r"^accounts/password/done/$", - authviews.PasswordResetCompleteView.as_view( - template_name="password_reset_complete.html" - ), - ), ]