From d3a23a7ef9658d4b397d84ff41dc5ce4a9a16e71 Mon Sep 17 00:00:00 2001 From: alexis Date: Sun, 28 Sep 2025 01:53:52 +0800 Subject: [PATCH 01/31] chore: load env in settings.py instead of using yaml --- .env.example | 26 +++++- apps/auth/utils.py | 6 +- apps/auth/views.py | 12 ++- website/development.yaml | 134 ------------------------------ website/settings.py | 174 +++++++++++++++++++++++++++++++++++---- 5 files changed, 195 insertions(+), 157 deletions(-) delete mode 100644 website/development.yaml diff --git a/.env.example b/.env.example index e0f5cdd..15ea0c8 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,5 @@ # PostgreSQL +DB_NAME=coursereview DB_USER=admin DB_PASSWORD=test DB_HOST=127.0.0.1 @@ -23,4 +24,27 @@ RESET_QUEST_URL= RESET_QUEST_QUESTIONID= # Turnstile -TURNSTILE_SECRET_KEY= \ No newline at end of file +TURNSTILE_SECRET_KEY= + +# (comma-separated values) +ALLOWED_HOSTS=localhost,127.0.0.1,compass.gcers.org + +# Authentication Settings +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 +QUEST_BASE_URL=https://wj.sjtu.edu.cn/api/v1/public/export + +# Session Settings +SESSION_COOKIE_AGE=2592000 +SESSION_SAVE_EVERY_REQUEST=True + +# Development Settings +AUTO_IMPORT_CRAWLED_DATA=True + +# CORS Settings +CORS_ALLOWED_ORIGINS= \ No newline at end of file diff --git a/apps/auth/utils.py b/apps/auth/utils.py index 0ca4a8b..8061534 100644 --- a/apps/auth/utils.py +++ b/apps/auth/utils.py @@ -14,7 +14,7 @@ PASSWORD_LENGTH_MIN = settings.AUTH["PASSWORD_LENGTH_MIN"] PASSWORD_LENGTH_MAX = settings.AUTH["PASSWORD_LENGTH_MAX"] -OTP_TIME_OUT = settings.AUTH["OTP_TIME_OUT"] +OTP_TIMEOUT = settings.AUTH["OTP_TIMEOUT"] QUEST_BASE_URL = settings.AUTH["QUEST_BASE_URL"] EMAIL_DOMAIN_NAME = settings.AUTH["EMAIL_DOMAIN_NAME"] @@ -65,7 +65,7 @@ async def verify_turnstile_token( """Helper function to verify Turnstile token with Cloudflare's API""" try: - async with httpx.AsyncClient(timeout=OTP_TIME_OUT) as client: + async with httpx.AsyncClient(timeout=OTP_TIMEOUT) as client: response = await client.post( "https://challenges.cloudflare.com/turnstile/v0/siteverify", data={ @@ -129,7 +129,7 @@ async def get_latest_answer( full_url_path = f"{QUEST_BASE_URL}/{quest_api}/json" try: - async with httpx.AsyncClient(timeout=OTP_TIME_OUT) as client: + async with httpx.AsyncClient(timeout=OTP_TIMEOUT) as client: response = await client.get( full_url_path, params=final_query_params, diff --git a/apps/auth/views.py b/apps/auth/views.py index cf20b0f..8232ba8 100644 --- a/apps/auth/views.py +++ b/apps/auth/views.py @@ -29,9 +29,13 @@ def enforce_csrf(self, request): return -OTP_TIME_OUT = settings.AUTH["OTP_TIME_OUT"] +OTP_TIMEOUT = settings.AUTH["OTP_TIMEOUT"] TEMP_TOKEN_TIMEOUT = settings.AUTH["TEMP_TOKEN_TIMEOUT"] -ACTION_LIST = settings.AUTH["ACTION_LIST"] +ACTION_LIST = [ + "signup", + "login", + "reset_password", +] TOKEN_RATE_LIMIT = settings.AUTH["TOKEN_RATE_LIMIT"] TOKEN_RATE_LIMIT_TIME = settings.AUTH["TOKEN_RATE_LIMIT_TIME"] @@ -97,7 +101,7 @@ def auth_initiate_api(request): # 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_TIME_OUT, json.dumps(otp_data)) + 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() @@ -236,7 +240,7 @@ def verify_callback_api(request): 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_TIME_OUT: + if submitted_at < initiated_at or (submitted_at - initiated_at) > OTP_TIMEOUT: return Response( {"error": "Submission timestamp outside validity window"}, status=401, diff --git a/website/development.yaml b/website/development.yaml deleted file mode 100644 index 14cd0b8..0000000 --- a/website/development.yaml +++ /dev/null @@ -1,134 +0,0 @@ -# website/development.yaml - -INSTALLED_APPS: - - "django.contrib.admin" - - "django.contrib.auth" - - "django.contrib.contenttypes" - - "django.contrib.sessions" - - "django.contrib.messages" - - "django.contrib.staticfiles" - - "django.contrib.humanize" - - "debug_toolbar" - - "pipeline" - - "crispy_forms" - - "crispy_bootstrap4" - - "django_celery_beat" - - "django_celery_results" - - "rest_framework" - - "corsheaders" - - "apps.analytics" - - "apps.recommendations" - - "apps.spider" - - "apps.web" - - "apps.auth" - -MIDDLEWARE: - - "corsheaders.middleware.CorsMiddleware" - - "django.middleware.security.SecurityMiddleware" - - "django.contrib.sessions.middleware.SessionMiddleware" - - "django.middleware.common.CommonMiddleware" - - "django.middleware.csrf.CsrfViewMiddleware" - - "django.contrib.auth.middleware.AuthenticationMiddleware" - - "django.contrib.messages.middleware.MessageMiddleware" - - "django.middleware.clickjacking.XFrameOptionsMiddleware" - - "debug_toolbar.middleware.DebugToolbarMiddleware" - -ROOT_URLCONF: "website.urls" - -TEMPLATES: - - BACKEND: "django.template.backends.django.DjangoTemplates" - DIRS: [] - APP_DIRS: True - OPTIONS: - context_processors: - - "django.template.context_processors.debug" - - "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" - -# 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_RESULT_BACKEND: "django-db" -CELERY_TIMEZONE: "Asia/Shanghai" - -AUTO_IMPORT_CRAWLED_DATA: "True" -# Internationalization -# https://docs.djangoproject.com/en/5.0/topics/i18n/ -LANGUAGE_CODE: "en-us" -TIME_ZONE: "UTC" -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_STORAGE: "pipeline.storage.ManifestStaticFilesStorage" -STATICFILES_FINDERS: - - "django.contrib.staticfiles.finders.FileSystemFinder" - - "django.contrib.staticfiles.finders.AppDirectoriesFinder" - - "pipeline.finders.PipelineFinder" - -PIPELINE: - 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" - -# Default primary key field type -# https://docs.djangoproject.com/en/5.0/ref/settings/#default-auto-field -DEFAULT_AUTO_FIELD: "django.db.models.BigAutoField" - -SESSION_COOKIE_AGE: 2592000 # 30 days -SESSION_SAVE_EVERY_REQUEST: True -SESSION_ENGINE: "django.contrib.sessions.backends.cache" -SESSION_CACHE_ALIAS: "default" - -ALLOWED_HOSTS: - - "localhost" - - "127.0.0.1" - - "0.0.0.0" - -# OAuth -AUTH: - OTP_TIME_OUT: 120 # 2 min - TEMP_TOKEN_TIMEOUT: 600 # 10 min - ACTION_LIST: - - "signup" - - "login" - - "reset_password" - TOKEN_RATE_LIMIT: 5 # max 5 callback attempts per temp_token - TOKEN_RATE_LIMIT_TIME: 600 # 10 minutes window - PASSWORD_LENGTH_MIN: 10 - PASSWORD_LENGTH_MAX: 32 - QUEST_BASE_URL: "https://wj.sjtu.edu.cn/api/v1/public/export" - EMAIL_DOMAIN_NAME: "sjtu.edu.cn" diff --git a/website/settings.py b/website/settings.py index b0295bf..b386753 100644 --- a/website/settings.py +++ b/website/settings.py @@ -1,12 +1,9 @@ import os -import yaml -from pathlib import Path from dotenv import load_dotenv load_dotenv() TURNSTILE_SECRET_KEY = os.getenv("TURNSTILE_SECRET_KEY") -# url and api for wj platform SIGNUP_QUEST_API_KEY = os.getenv("SIGNUP_QUEST_API_KEY") SIGNUP_QUEST_URL = os.getenv("SIGNUP_QUEST_URL") SIGNUP_QUEST_QUESTIONID = os.getenv("SIGNUP_QUEST_QUESTIONID") @@ -17,18 +14,161 @@ RESET_QUEST_URL = os.getenv("RESET_QUEST_URL") RESET_QUEST_QUESTIONID = os.getenv("RESET_QUEST_QUESTIONID") -FRONTEND_URL = os.getenv("FRONTEND_URL", "http://localhost:5173") +FRONTEND_URL = os.getenv("FRONTEND_URL") # Build paths inside the project like this: BASE_DIR / 'subdir'. BASE_DIR = os.path.dirname(os.path.abspath(__file__)) +# INSTALLED_APPS +INSTALLED_APPS = [ + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", + "django.contrib.humanize", + "debug_toolbar", + "pipeline", + "crispy_forms", + "crispy_bootstrap4", + "django_celery_beat", + "django_celery_results", + "rest_framework", + "corsheaders", + "apps.analytics", + "apps.recommendations", + "apps.spider", + "apps.web", + "apps.auth", +] + +# MIDDLEWARE +MIDDLEWARE = [ + "corsheaders.middleware.CorsMiddleware", + "django.middleware.security.SecurityMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", + "debug_toolbar.middleware.DebugToolbarMiddleware", +] + +ROOT_URLCONF = "website.urls" + +# TEMPLATES +TEMPLATES = [ + { + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [], + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.debug", + "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" + +# Password validation +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_RESULT_BACKEND = "django-db" +CELERY_TIMEZONE = "Asia/Shanghai" + +AUTO_IMPORT_CRAWLED_DATA = os.getenv("AUTO_IMPORT_CRAWLED_DATA", "True") == "True" + +# Internationalization +LANGUAGE_CODE = "en-us" +TIME_ZONE = "UTC" +USE_I18N = True +USE_TZ = True + +# Static files +STATIC_ROOT = "staticfiles" +STATIC_URL = "/static/" +STATICFILES_STORAGE = "pipeline.storage.ManifestStaticFilesStorage" +STATICFILES_FINDERS = [ + "django.contrib.staticfiles.finders.FileSystemFinder", + "django.contrib.staticfiles.finders.AppDirectoriesFinder", + "pipeline.finders.PipelineFinder", +] + +# Pipeline configuration +PIPELINE = { + "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", + }, + } + }, +} -# Load development config -with open(Path(BASE_DIR) / "development.yaml") as f: - config = yaml.safe_load(f) - -for key, value in config.items(): - globals()[key] = value +# Default primary key field type +DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" + +# Session settings +SESSION_COOKIE_AGE = int(os.getenv("SESSION_COOKIE_AGE", "2592000")) +SESSION_SAVE_EVERY_REQUEST = os.getenv("SESSION_SAVE_EVERY_REQUEST", "True") == "True" +SESSION_ENGINE = "django.contrib.sessions.backends.cache" +SESSION_CACHE_ALIAS = "default" + +ALLOWED_HOSTS = os.getenv("ALLOWED_HOSTS").split(",") + +# OAuth settings +AUTH = { + "OTP_TIMEOUT": int(os.getenv("OTP_TIMEOUT", "120")), + "TEMP_TOKEN_TIMEOUT": int(os.getenv("TEMP_TOKEN_TIMEOUT", "600")), + "TOKEN_RATE_LIMIT": int(os.getenv("TOKEN_RATE_LIMIT", "5")), + "TOKEN_RATE_LIMIT_TIME": int(os.getenv("TOKEN_RATE_LIMIT_TIME", "600")), + "PASSWORD_LENGTH_MIN": int(os.getenv("PASSWORD_LENGTH_MIN", "10")), + "PASSWORD_LENGTH_MAX": int(os.getenv("PASSWORD_LENGTH_MAX", "32")), + "QUEST_BASE_URL": os.getenv( + "QUEST_BASE_URL", "https://wj.sjtu.edu.cn/api/v1/public/export" + ), + "EMAIL_DOMAIN_NAME": os.getenv("EMAIL_DOMAIN_NAME", "sjtu.edu.cn"), +} # Quick-start development settings - unsuitable for production # See https://docs.djangoproject.com/en/5.0/howto/deployment/checklist/ @@ -45,11 +185,15 @@ if DEBUG: CORS_ALLOW_ALL_ORIGINS = True else: + CORS_ALLOWED_ORIGINS = os.getenv("CORS_ALLOWED_ORIGINS", "").split(",") CORS_ALLOWED_ORIGINS = [ - FRONTEND_URL, - "http://127.0.0.1:8080", - "http://localhost:8080", + origin.strip() for origin in CORS_ALLOWED_ORIGINS if origin.strip() ] + CORS_ALLOWED_ORIGINS.extend( + [ + FRONTEND_URL, + ] + ) # Database @@ -67,7 +211,7 @@ } -CELERY_BROKER_URL = os.environ["REDIS_URL"] +CELERY_BROKER_URL = os.getenv("REDIS_URL") # Static files (CSS, JavaScript, Images) @@ -81,7 +225,7 @@ CACHES = { "default": { "BACKEND": "django_redis.cache.RedisCache", - "LOCATION": os.getenv("REDIS_URL", "redis://127.0.0.1:6379/1"), + "LOCATION": os.getenv("REDIS_URL"), "OPTIONS": { "CLIENT_CLASS": "django_redis.client.DefaultClient", "CONNECTION_POOL_KWARGS": {"max_connections": 100}, From cd4c2dd6e123aabcb1a58ca9dada9c66cf7905d8 Mon Sep 17 00:00:00 2001 From: alexis Date: Mon, 29 Sep 2025 00:51:20 +0800 Subject: [PATCH 02/31] chore: use json load for list config --- .env.example | 5 ++--- website/settings.py | 22 ++++++++++++---------- 2 files changed, 14 insertions(+), 13 deletions(-) diff --git a/.env.example b/.env.example index 15ea0c8..7216b19 100644 --- a/.env.example +++ b/.env.example @@ -26,8 +26,7 @@ RESET_QUEST_QUESTIONID= # Turnstile TURNSTILE_SECRET_KEY= -# (comma-separated values) -ALLOWED_HOSTS=localhost,127.0.0.1,compass.gcers.org +ALLOWED_HOSTS=["localhost", "127.0.0.1"] # Authentication Settings OTP_TIMEOUT=120 @@ -47,4 +46,4 @@ SESSION_SAVE_EVERY_REQUEST=True AUTO_IMPORT_CRAWLED_DATA=True # CORS Settings -CORS_ALLOWED_ORIGINS= \ No newline at end of file +CORS_ALLOWED_ORIGINS=["http://localhost:5173"] \ No newline at end of file diff --git a/website/settings.py b/website/settings.py index b386753..a692000 100644 --- a/website/settings.py +++ b/website/settings.py @@ -1,4 +1,6 @@ import os +import json +import logging from dotenv import load_dotenv load_dotenv() @@ -154,7 +156,11 @@ SESSION_ENGINE = "django.contrib.sessions.backends.cache" SESSION_CACHE_ALIAS = "default" -ALLOWED_HOSTS = os.getenv("ALLOWED_HOSTS").split(",") +try: + ALLOWED_HOSTS = json.loads(os.getenv("ALLOWED_HOSTS", "[]")) +except (json.JSONDecodeError, TypeError): + logging.error("Failed to parse ALLOWED_HOSTS.") + ALLOWED_HOSTS = [] # OAuth settings AUTH = { @@ -185,15 +191,11 @@ if DEBUG: CORS_ALLOW_ALL_ORIGINS = True else: - CORS_ALLOWED_ORIGINS = os.getenv("CORS_ALLOWED_ORIGINS", "").split(",") - CORS_ALLOWED_ORIGINS = [ - origin.strip() for origin in CORS_ALLOWED_ORIGINS if origin.strip() - ] - CORS_ALLOWED_ORIGINS.extend( - [ - FRONTEND_URL, - ] - ) + try: + CORS_ALLOWED_ORIGINS = json.loads(os.getenv("CORS_ALLOWED_ORIGINS", "[]")) + except (json.JSONDecodeError, TypeError): + logging.error("Failed to parse CORS_ALLOWED_ORIGINS.") + CORS_ALLOWED_ORIGINS = [] # Database From 9f8a03b1fde0c840c86038442d01bc5e11da4a44 Mon Sep 17 00:00:00 2001 From: Pachakutiq <101460915+PACHAKUTlQ@users.noreply.github.com> Date: Mon, 29 Sep 2025 14:38:20 +0800 Subject: [PATCH 03/31] fix(settings)!: Remove legacy static file and celery settings --- website/settings.py | 73 ++++----------------------------------------- 1 file changed, 5 insertions(+), 68 deletions(-) diff --git a/website/settings.py b/website/settings.py index a692000..c0dab12 100644 --- a/website/settings.py +++ b/website/settings.py @@ -26,20 +26,14 @@ "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", - "django_celery_beat", - "django_celery_results", "rest_framework", "corsheaders", - "apps.analytics", - "apps.recommendations", "apps.spider", "apps.web", "apps.auth", @@ -77,10 +71,11 @@ } ] -CRISPY_TEMPLATE_PACK = "bootstrap4" - WSGI_APPLICATION = "website.wsgi.application" +# django.contrib.staticfiles requires STATIC_URL to be set. Not actually used. +STATIC_URL = "/dummy/" + # Password validation AUTH_PASSWORD_VALIDATORS = [ { @@ -91,9 +86,6 @@ {"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"}, ] -CELERY_RESULT_BACKEND = "django-db" -CELERY_TIMEZONE = "Asia/Shanghai" - AUTO_IMPORT_CRAWLED_DATA = os.getenv("AUTO_IMPORT_CRAWLED_DATA", "True") == "True" # Internationalization @@ -102,51 +94,6 @@ USE_I18N = True USE_TZ = True -# Static files -STATIC_ROOT = "staticfiles" -STATIC_URL = "/static/" -STATICFILES_STORAGE = "pipeline.storage.ManifestStaticFilesStorage" -STATICFILES_FINDERS = [ - "django.contrib.staticfiles.finders.FileSystemFinder", - "django.contrib.staticfiles.finders.AppDirectoriesFinder", - "pipeline.finders.PipelineFinder", -] - -# Pipeline configuration -PIPELINE = { - "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", - }, - } - }, -} - # Default primary key field type DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" @@ -212,16 +159,6 @@ } } - -CELERY_BROKER_URL = os.getenv("REDIS_URL") - - -# Static files (CSS, JavaScript, Images) -# https://docs.djangoproject.com/en/5.0/howto/static-files/ -STATICFILES_DIRS = (os.path.join(BASE_DIR, "static"),) - -ROOT_ASSETS_DIR = os.path.join(BASE_DIR, "root_assets") - SESSION_COOKIE_SECURE = not DEBUG CACHES = { From 7c980f7d50b361ce0267c81aad102ed5232167d5 Mon Sep 17 00:00:00 2001 From: Pachakutiq <101460915+PACHAKUTlQ@users.noreply.github.com> Date: Mon, 29 Sep 2025 14:38:56 +0800 Subject: [PATCH 04/31] fix(routing)!: Remove legacy analytics and recommendations apps --- website/urls.py | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/website/urls.py b/website/urls.py index f533d93..9ca4cfb 100644 --- a/website/urls.py +++ b/website/urls.py @@ -4,8 +4,6 @@ from apps.auth import views as auth_views -from apps.analytics import views as aviews -from apps.recommendations import views as rviews from apps.spider import views as spider_views from apps.web import views @@ -40,15 +38,6 @@ # administrative re_path(r"^admin/", admin.site.urls), re_path(r"^api/user/status/?", views.user_status, name="user_status"), - re_path(r"^analytics/$", aviews.home, name="analytics_home"), - re_path( - r"^eligible_for_recommendations/$", - aviews.eligible_for_recommendations, - name="eligible_for_recommendations", - ), - re_path( - r"^sentiment_labeler/$", aviews.sentiment_labeler, name="sentiment_labeler" - ), # spider re_path(r"^spider/data/$", spider_views.crawled_data_list, name="crawled_datas"), re_path( @@ -107,6 +96,4 @@ views.course_review_search_api, name="course_review_search_api", ), - # recommendations - re_path(r"^recommendations/?", rviews.recommendations, name="recommendations"), ] From b6148aaeda7f2ec883b44f83d7c3d76f7a18fdac Mon Sep 17 00:00:00 2001 From: Pachakutiq <101460915+PACHAKUTlQ@users.noreply.github.com> Date: Mon, 29 Sep 2025 14:40:01 +0800 Subject: [PATCH 05/31] fix(Makefile)!: Run server without processing static files --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index ad8f5f9..8522443 100644 --- a/Makefile +++ b/Makefile @@ -23,7 +23,7 @@ help: run: format-backend @echo "Starting Django development server..." - uv run manage.py runserver + uv run manage.py runserver --nostatic dev-frontend: format-frontend @echo "Starting frontend dev server from frontend/ folder..." From cda37c4ada83871477d1a5d4996d381348d02d1e Mon Sep 17 00:00:00 2001 From: Pachakutiq <101460915+PACHAKUTlQ@users.noreply.github.com> Date: Mon, 29 Sep 2025 14:41:35 +0800 Subject: [PATCH 06/31] fix(chore)!: Remove legacy static files and celery dependencies --- pyproject.toml | 6 - uv.lock | 305 +------------------------------------------------ 2 files changed, 1 insertion(+), 310 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index d099abc..ef49a79 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,15 +3,9 @@ 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", diff --git a/uv.lock b/uv.lock index 7e5ca33..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,16 +9,10 @@ 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" }, @@ -41,16 +35,10 @@ 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" }, @@ -67,18 +55,6 @@ requires-dist = [ [package.metadata.requires-dev] dev = [{ name = "pre-commit", specifier = ">=4.3.0" }] -[[package]] -name = "amqp" -version = "5.3.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "vine" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/79/fc/ec94a357dfc6683d8c86f8b4cfa5416a4c36b28052ec8260c77aca96a443/amqp-5.3.1.tar.gz", hash = "sha256:cddc00c725449522023bad949f70fff7b48f0b1ade74d170a6f10ab044739432", size = 129013, upload-time = "2024-11-12T19:55:44.051Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/26/99/fc813cd978842c26c82534010ea849eee9ab3a13ea2b74e95cb9c99e747b/amqp-5.3.1-py3-none-any.whl", hash = "sha256:43b3319e1b4e7d1251833a93d672b4af1e40f3d632d479b98661a95f117880a2", size = 50944, upload-time = "2024-11-12T19:55:41.782Z" }, -] - [[package]] name = "ansicon" version = "1.89.0" @@ -95,7 +71,6 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "idna" }, { name = "sniffio" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] 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 = [ @@ -133,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" @@ -172,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" @@ -215,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" }, @@ -244,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" @@ -343,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" }, @@ -395,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" @@ -438,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" @@ -463,19 +268,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/62/ce/39831ce0a946979fdf19c32e6dcd1754a70e3280815aa7a377f61d5e021c/django_debug_toolbar-5.1.0-py3-none-any.whl", hash = "sha256:c0591e338ee9603bdfce5aebf8d18ca7341fdbb69595e2b0b34869be5857180e", size = 261531, upload-time = "2025-03-20T16:17:05.812Z" }, ] -[[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-redis" version = "6.0.0" @@ -489,18 +281,6 @@ wheels = [ { 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]] -name = "django-timezone-field" -version = "7.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "django" }, -] -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" } -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" }, -] - [[package]] name = "djangorestframework" version = "3.16.0" @@ -528,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" }, @@ -635,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" @@ -710,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" }, @@ -759,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" @@ -816,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" }, @@ -860,15 +584,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f9/9b/335f9764261e915ed497fcdeb11df5dfd6f7bf257d4a6a2a686d80da4d54/requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6", size = 64928, upload-time = "2024-05-29T15:37:47.027Z" }, ] -[[package]] -name = "setuptools" -version = "79.0.1" -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" } -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" }, -] - [[package]] name = "six" version = "1.17.0" @@ -932,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" @@ -963,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" }, -] From 32c008a2ae6d6e31d5ae9c8233b175d4e90cb499 Mon Sep 17 00:00:00 2001 From: Pachakutiq <101460915+PACHAKUTlQ@users.noreply.github.com> Date: Mon, 29 Sep 2025 14:42:44 +0800 Subject: [PATCH 07/31] fix(django)!: Remove legacy celery initialization --- website/__init__.py | 7 ------- 1 file changed, 7 deletions(-) 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",) From 8c491a62951db59aae45a89016c5c4caa1277cd7 Mon Sep 17 00:00:00 2001 From: Pachakutiq <101460915+PACHAKUTlQ@users.noreply.github.com> Date: Mon, 29 Sep 2025 16:41:25 +0800 Subject: [PATCH 08/31] fix(Makefile): Remove nostatic flags --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 8522443..ad8f5f9 100644 --- a/Makefile +++ b/Makefile @@ -23,7 +23,7 @@ help: run: format-backend @echo "Starting Django development server..." - uv run manage.py runserver --nostatic + uv run manage.py runserver dev-frontend: format-frontend @echo "Starting frontend dev server from frontend/ folder..." From 1fc0124a174a95c8ac003f16c59f9b0bb7fa05ee Mon Sep 17 00:00:00 2001 From: Pachakutiq <101460915+PACHAKUTlQ@users.noreply.github.com> Date: Mon, 29 Sep 2025 16:42:22 +0800 Subject: [PATCH 09/31] feat(config)!: Add config class to auto handle env and yaml config --- website/config.py | 109 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 109 insertions(+) create mode 100644 website/config.py diff --git a/website/config.py b/website/config.py new file mode 100644 index 0000000..76cfbd0 --- /dev/null +++ b/website/config.py @@ -0,0 +1,109 @@ +import os +import yaml +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 centralized, nested configuration loader with a defined priority order: + 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. + """ + + def __init__(self, config_path: Path, defaults: dict[str, Any] | None = None): + self._yaml_config = self._load_yaml(config_path) + self._defaults = defaults or {} + + 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 _get_nested_val( + self, source_dict: dict[str, Any], path: list[str] + ) -> Any | None: + """Safely retrieves a nested value from a dictionary using a path list.""" + + try: + return reduce(operator.getitem, path, source_dict) + except (KeyError, TypeError): + return None + + 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. + """ + + # 1. Check Environment Variables (e.g., AUTH.OTP_TIMEOUT -> AUTH__OTP_TIMEOUT) + env_key = key.upper().replace(".", "__") + if (env_val := os.environ.get(env_key)) is not None: + value, source = env_val, f"environment variable ({env_key})" + else: + path = key.split(".") + # 2. Check YAML Config + if (yaml_val := self._get_nested_val(self._yaml_config, path)) is not None: + value, source = yaml_val, "config.yaml" + # 3. Check Defaults + elif ( + default_val := self._get_nested_val(self._defaults, path) + ) is not None: + value, source = default_val, "default value" + else: + if required: + raise ImproperlyConfigured( + f"Required setting '{key}' is not defined in any source." + ) + return None + + if cast is None: + return value + + if source.startswith("environment"): + if cast is bool: + return value.lower() in ("true", "1", "yes") + if cast is list: + 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}' from {source} with value '{ + value!r + }' to {cast.__name__}. Error: {e}" + ) From 27731c2149900117d088e4ae676cc967f29d5057 Mon Sep 17 00:00:00 2001 From: Pachakutiq <101460915+PACHAKUTlQ@users.noreply.github.com> Date: Mon, 29 Sep 2025 16:43:14 +0800 Subject: [PATCH 10/31] feat(config)!: Use new config supporting env, yaml and default --- website/settings.py | 246 ++++++++++++++++++++++---------------------- 1 file changed, 123 insertions(+), 123 deletions(-) diff --git a/website/settings.py b/website/settings.py index c0dab12..3b648ab 100644 --- a/website/settings.py +++ b/website/settings.py @@ -1,27 +1,106 @@ -import os -import json -import logging +from pathlib import Path +import dj_database_url from dotenv import load_dotenv +from .config import Config + + +BASE_DIR = Path(__file__).resolve().parent.parent +load_dotenv(BASE_DIR / ".env") + +# --- Default Configuration --- +DEFAULTS = { + "DEBUG": True, + "SECRET_KEY": "a-default-secret-key-for-development-only", + "ALLOWED_HOSTS": ["127.0.0.1", "localhost"], + "CORS_ALLOWED_ORIGINS": ["http://localhost:5173", "http://127.0.0.1:5173"], + "FRONTEND_URL": "http://localhost: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}, + "QUEST": { + "BASE_URL": "https://wj.sjtu.edu.cn/api/v1/public/export", + }, + "AUTO_IMPORT_CRAWLED_DATA": True, +} + +config = Config(config_path=BASE_DIR / "config.yaml", defaults=DEFAULTS) + + +# ============================================================================== +# MANAGED SETTINGS (env > config.yaml > defaults) +# ============================================================================== + +# --- 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) +FRONTEND_URL = config.get("FRONTEND_URL") + +# --- 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", + } +} -load_dotenv() +# --- 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 -TURNSTILE_SECRET_KEY = os.getenv("TURNSTILE_SECRET_KEY") -SIGNUP_QUEST_API_KEY = os.getenv("SIGNUP_QUEST_API_KEY") -SIGNUP_QUEST_URL = os.getenv("SIGNUP_QUEST_URL") -SIGNUP_QUEST_QUESTIONID = os.getenv("SIGNUP_QUEST_QUESTIONID") -LOGIN_QUEST_API_KEY = os.getenv("LOGIN_QUEST_API_KEY") -LOGIN_QUEST_URL = os.getenv("LOGIN_QUEST_URL") -LOGIN_QUEST_QUESTIONID = os.getenv("LOGIN_QUEST_QUESTIONID") -RESET_QUEST_API_KEY = os.getenv("RESET_QUEST_API_KEY") -RESET_QUEST_URL = os.getenv("RESET_QUEST_URL") -RESET_QUEST_QUESTIONID = os.getenv("RESET_QUEST_QUESTIONID") +# --- 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) + +QUEST = { + "BASE_URL": config.get("QUEST.BASE_URL"), + "SIGNUP": { + "API_KEY": config.get("QUEST.SIGNUP.API_KEY"), + "URL": config.get("QUEST.SIGNUP.URL"), + "QUESTIONID": config.get("QUEST.SIGNUP.QUESTIONID", cast=int), + }, + "LOGIN": { + "API_KEY": config.get("QUEST.LOGIN.API_KEY"), + "URL": config.get("QUEST.LOGIN.URL"), + "QUESTIONID": config.get("QUEST.LOGIN.QUESTIONID", cast=int), + }, + "RESET": { + "API_KEY": config.get("QUEST.RESET.API_KEY"), + "URL": config.get("QUEST.RESET.URL"), + "QUESTIONID": config.get("QUEST.RESET.QUESTIONID", cast=int), + }, +} -FRONTEND_URL = os.getenv("FRONTEND_URL") -# Build paths inside the project like this: BASE_DIR / 'subdir'. -BASE_DIR = os.path.dirname(os.path.abspath(__file__)) +# ============================================================================== +# DJANGO FRAMEWORK SETTINGS +# ============================================================================== +# These settings define the application's structure and are not meant to be +# configured. -# INSTALLED_APPS INSTALLED_APPS = [ "django.contrib.admin", "django.contrib.auth", @@ -39,7 +118,6 @@ "apps.auth", ] -# MIDDLEWARE MIDDLEWARE = [ "corsheaders.middleware.CorsMiddleware", "django.middleware.security.SecurityMiddleware", @@ -53,30 +131,34 @@ ] ROOT_URLCONF = "website.urls" - -# TEMPLATES -TEMPLATES = [ - { - "BACKEND": "django.template.backends.django.DjangoTemplates", - "DIRS": [], - "APP_DIRS": True, - "OPTIONS": { - "context_processors": [ - "django.template.context_processors.debug", - "django.template.context_processors.request", - "django.contrib.auth.context_processors.auth", - "django.contrib.messages.context_processors.messages", - ], - }, - } -] - WSGI_APPLICATION = "website.wsgi.application" +# TEMPLATES = [ +# { +# "BACKEND": "django.template.backends.django.DjangoTemplates", +# "DIRS": [], +# "APP_DIRS": True, +# "OPTIONS": { +# "context_processors": [ +# "django.template.context_processors.debug", +# "django.template.context_processors.request", +# "django.contrib.auth.context_processors.auth", +# "django.contrib.messages.context_processors.messages", +# ] +# }, +# } +# ] + +STATIC_URL = "/dummy/" # Required by Django staticfiles but not used in this setup +DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" -# django.contrib.staticfiles requires STATIC_URL to be set. Not actually used. -STATIC_URL = "/dummy/" +LANGUAGE_CODE = "en-us" +TIME_ZONE = "Asia/Shanghai" +USE_I18N = True +USE_TZ = True + +SESSION_ENGINE = "django.contrib.sessions.backends.cache" +SESSION_CACHE_ALIAS = "default" -# Password validation AUTH_PASSWORD_VALIDATORS = [ { "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator" @@ -86,89 +168,7 @@ {"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"}, ] -AUTO_IMPORT_CRAWLED_DATA = os.getenv("AUTO_IMPORT_CRAWLED_DATA", "True") == "True" - -# Internationalization -LANGUAGE_CODE = "en-us" -TIME_ZONE = "UTC" -USE_I18N = True -USE_TZ = True - -# Default primary key field type -DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" - -# Session settings -SESSION_COOKIE_AGE = int(os.getenv("SESSION_COOKIE_AGE", "2592000")) -SESSION_SAVE_EVERY_REQUEST = os.getenv("SESSION_SAVE_EVERY_REQUEST", "True") == "True" -SESSION_ENGINE = "django.contrib.sessions.backends.cache" -SESSION_CACHE_ALIAS = "default" - -try: - ALLOWED_HOSTS = json.loads(os.getenv("ALLOWED_HOSTS", "[]")) -except (json.JSONDecodeError, TypeError): - logging.error("Failed to parse ALLOWED_HOSTS.") - ALLOWED_HOSTS = [] - -# OAuth settings -AUTH = { - "OTP_TIMEOUT": int(os.getenv("OTP_TIMEOUT", "120")), - "TEMP_TOKEN_TIMEOUT": int(os.getenv("TEMP_TOKEN_TIMEOUT", "600")), - "TOKEN_RATE_LIMIT": int(os.getenv("TOKEN_RATE_LIMIT", "5")), - "TOKEN_RATE_LIMIT_TIME": int(os.getenv("TOKEN_RATE_LIMIT_TIME", "600")), - "PASSWORD_LENGTH_MIN": int(os.getenv("PASSWORD_LENGTH_MIN", "10")), - "PASSWORD_LENGTH_MAX": int(os.getenv("PASSWORD_LENGTH_MAX", "32")), - "QUEST_BASE_URL": os.getenv( - "QUEST_BASE_URL", "https://wj.sjtu.edu.cn/api/v1/public/export" - ), - "EMAIL_DOMAIN_NAME": os.getenv("EMAIL_DOMAIN_NAME", "sjtu.edu.cn"), -} - -# Quick-start development settings - unsuitable for production -# See https://docs.djangoproject.com/en/5.0/howto/deployment/checklist/ - -# SECURITY WARNING: keep the secret key used in production secret! -SECRET_KEY = os.getenv("SECRET_KEY") - -# SECURITY WARNING: don't run with debug turned on in production! -DEBUG = os.getenv("DEBUG") == "True" - - -# Rest Framework - if DEBUG: CORS_ALLOW_ALL_ORIGINS = True else: - try: - CORS_ALLOWED_ORIGINS = json.loads(os.getenv("CORS_ALLOWED_ORIGINS", "[]")) - except (json.JSONDecodeError, TypeError): - logging.error("Failed to parse CORS_ALLOWED_ORIGINS.") - CORS_ALLOWED_ORIGINS = [] - - -# 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"), - } -} - -SESSION_COOKIE_SECURE = not DEBUG - -CACHES = { - "default": { - "BACKEND": "django_redis.cache.RedisCache", - "LOCATION": os.getenv("REDIS_URL"), - "OPTIONS": { - "CLIENT_CLASS": "django_redis.client.DefaultClient", - "CONNECTION_POOL_KWARGS": {"max_connections": 100}, - }, - "KEY_PREFIX": "coursereview", - } -} + CORS_ALLOW_ALL_ORIGINS = False From 0f388c2a98a7ffc3776a8407d5580f68e0372c47 Mon Sep 17 00:00:00 2001 From: Pachakutiq <101460915+PACHAKUTlQ@users.noreply.github.com> Date: Mon, 29 Sep 2025 16:43:49 +0800 Subject: [PATCH 11/31] feat(config)!: Add example yaml config and .env file --- .env.example | 80 +++++++++++++++++++++++++--------------------------- config.yaml | 16 +++++++++++ 2 files changed, 54 insertions(+), 42 deletions(-) create mode 100644 config.yaml diff --git a/.env.example b/.env.example index 7216b19..4fd7f21 100644 --- a/.env.example +++ b/.env.example @@ -1,49 +1,45 @@ -# PostgreSQL -DB_NAME=coursereview -DB_USER=admin -DB_PASSWORD=test -DB_HOST=127.0.0.1 -DB_PORT=5432 -REDIS_URL=redis://localhost:6379/0 -SECRET_KEY=02247f40-a769-4c49-9178-4c038048e7ad +# .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 -OFFERINGS_THRESHOLD_FOR_TERM_UPDATE=100 - -# Frontend -FRONTEND_URL=http://localhost:5173 - -# wj platform -SIGNUP_QUEST_API_KEY= -SIGNUP_QUEST_URL= -SIGNUP_QUEST_QUESTIONID= -LOGIN_QUEST_API_KEY= -LOGIN_QUEST_URL= -LOGIN_QUEST_QUESTIONID= -RESET_QUEST_API_KEY= -RESET_QUEST_URL= -RESET_QUEST_QUESTIONID= - -# Turnstile + + +# --- 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= -ALLOWED_HOSTS=["localhost", "127.0.0.1"] +# Use PARENT__CHILD format to override nested settings +QUEST__SIGNUP__API_KEY= +QUEST__SIGNUP__URL= +QUEST__SIGNUP__QUESTIONID= + +QUEST__LOGIN__API_KEY= +QUEST__LOGIN__URL= +QUEST__LOGIN__QUESTIONID= -# Authentication Settings -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 -QUEST_BASE_URL=https://wj.sjtu.edu.cn/api/v1/public/export +QUEST__RESET__API_KEY= +QUEST__RESET__URL= +QUEST__RESET__QUESTIONID= -# Session Settings -SESSION_COOKIE_AGE=2592000 -SESSION_SAVE_EVERY_REQUEST=True -# Development Settings -AUTO_IMPORT_CRAWLED_DATA=True +# --- Other Overrides (Optional) --- +# Example of overriding a nested value in the AUTH dictionary +# AUTH__OTP_TIMEOUT=60 -# CORS Settings -CORS_ALLOWED_ORIGINS=["http://localhost:5173"] \ No newline at end of file +# Example of overriding a list with a comma-separated string +# ALLOWED_HOSTS=localhost,127.0.0.1,dev.my-app.com diff --git a/config.yaml b/config.yaml new file mode 100644 index 0000000..9fd50fc --- /dev/null +++ b/config.yaml @@ -0,0 +1,16 @@ +# config.yaml +# For non-secret, environment-specific configuration. +# Values here will override DEFAULTS in settings.py. +# Environment variables will override values here. + +ALLOWED_HOSTS: + - "staging.my-app.com" + +CORS_ALLOWED_ORIGINS: + - "https://staging-frontend.my-app.com" + +AUTH: + TEMP_TOKEN_TIMEOUT: 300 + +QUEST: + BASE_URL: "https://wj-staging.sjtu.edu.cn/api/v1/public/export" From 0a5838941c0f5b4149dc7f8e19b46dcdef3585c6 Mon Sep 17 00:00:00 2001 From: Pachakutiq <101460915+PACHAKUTlQ@users.noreply.github.com> Date: Mon, 29 Sep 2025 16:44:25 +0800 Subject: [PATCH 12/31] fix(auth)!: Use new config system format --- apps/auth/utils.py | 28 +++++++++++++++++----------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/apps/auth/utils.py b/apps/auth/utils.py index 8061534..938ecfb 100644 --- a/apps/auth/utils.py +++ b/apps/auth/utils.py @@ -15,41 +15,44 @@ PASSWORD_LENGTH_MIN = settings.AUTH["PASSWORD_LENGTH_MIN"] PASSWORD_LENGTH_MAX = settings.AUTH["PASSWORD_LENGTH_MAX"] OTP_TIMEOUT = settings.AUTH["OTP_TIMEOUT"] -QUEST_BASE_URL = settings.AUTH["QUEST_BASE_URL"] EMAIL_DOMAIN_NAME = settings.AUTH["EMAIL_DOMAIN_NAME"] +QUEST_BASE_URL = settings.QUEST["BASE_URL"] def get_survey_url(action: str) -> str | None: """Helper function to get the survey URL based on action type""" + if action == "signup": - return settings.SIGNUP_QUEST_URL + return settings.QUEST["SIGNUP"]["URL"] if action == "login": - return settings.LOGIN_QUEST_URL + return settings.QUEST["LOGIN"]["URL"] if action == "reset_password": - return settings.RESET_QUEST_URL + return settings.QUEST["RESET"]["URL"] return None def get_survey_api_key(action: str) -> str | None: """Helper function to get the survey API key based on action type""" + if action == "signup": - return settings.SIGNUP_QUEST_API_KEY + return settings.QUEST["SIGNUP"]["API_KEY"] if action == "login": - return settings.LOGIN_QUEST_API_KEY + return settings.QUEST["LOGIN"]["API_KEY"] if action == "reset_password": - return settings.RESET_QUEST_API_KEY + return settings.QUEST["RESET"]["API_KEY"] return None def get_survey_questionid(action: str) -> int | None: """Helper function to get the survey question ID for the verification code based on action type""" + question_id_str = None if action == "signup": - question_id_str = settings.SIGNUP_QUEST_QUESTIONID + question_id_str = settings.QUEST["SIGNUP"]["QUESTIONID"] elif action == "login": - question_id_str = settings.LOGIN_QUEST_QUESTIONID + question_id_str = settings.QUEST["LOGIN"]["QUESTIONID"] elif action == "reset_password": - question_id_str = settings.RESET_QUEST_QUESTIONID + question_id_str = settings.QUEST["RESET"]["QUESTIONID"] if question_id_str: try: @@ -99,6 +102,7 @@ async def get_latest_answer( `filtered_data` contains: id, submitted_at, user.account, and otp. `error_response` is a DRF Response object if an error occurs, otherwise None. """ + quest_api = get_survey_api_key(action) if not quest_api: return None, Response({"error": "Invalid action"}, status=400) @@ -159,7 +163,8 @@ async def get_latest_answer( and full_data["data"].get("rows") and len(full_data["data"]["rows"]) > 0 ): - latest_answer = full_data["data"]["rows"][0] # Get the first (latest) row + # Get the first (latest) row + latest_answer = full_data["data"]["rows"][0] # Find the otp by matching the question ID otp = None @@ -259,6 +264,7 @@ def create_user_session( `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: From e7736434512b72374a8ac447d7a4ed5ade9ec7e4 Mon Sep 17 00:00:00 2001 From: Pachakutiq <101460915+PACHAKUTlQ@users.noreply.github.com> Date: Mon, 29 Sep 2025 17:40:23 +0800 Subject: [PATCH 13/31] fix(lib)!: Remove legacy offering threshold env variable to avoid breaking new config --- lib/constants.py | 4 ---- 1 file changed, 4 deletions(-) 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"] -) From 79c0711c1e8c91e2633188a03b6594f88df54cdc Mon Sep 17 00:00:00 2001 From: Pachakutiq <101460915+PACHAKUTlQ@users.noreply.github.com> Date: Mon, 29 Sep 2025 17:44:47 +0800 Subject: [PATCH 14/31] feat(config)!: Use deep merge to adapt to configs of all types for every field --- website/config.py | 109 +++++++++++++++++++++++++++++----------------- 1 file changed, 70 insertions(+), 39 deletions(-) diff --git a/website/config.py b/website/config.py index 76cfbd0..0d4baa4 100644 --- a/website/config.py +++ b/website/config.py @@ -5,6 +5,7 @@ from django.core.exceptions import ImproperlyConfigured from functools import reduce import operator +import collections.abc # Generic TypeVar for casting function return values T = TypeVar("T") @@ -12,7 +13,7 @@ class Config: """ - A centralized, nested configuration loader with a defined priority order: + 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 @@ -21,8 +22,42 @@ class Config: """ def __init__(self, config_path: Path, defaults: dict[str, Any] | None = None): - self._yaml_config = self._load_yaml(config_path) - self._defaults = defaults or {} + # 1. Start with normalized defaults + self._final_config = self._normalize_keys_to_lower(defaults or {}) + + # 2. Load and merge normalized YAML config + yaml_config = self._normalize_keys_to_lower(self._load_yaml(config_path)) + self._final_config = self._deep_merge(self._final_config, yaml_config) + + # 3. Load and merge environment variables (already produces lowercase keys) + env_config = self._load_from_env() + self._final_config = self._deep_merge(self._final_config, env_config) + + def _normalize_keys_to_lower(self, obj: Any) -> Any: + """Recursively converts all dictionary keys in an object to lowercase.""" + + if isinstance(obj, dict): + return { + str(key).lower(): self._normalize_keys_to_lower(value) + for key, value in obj.items() + } + if isinstance(obj, list): + return [self._normalize_keys_to_lower(item) for item in obj] + + return obj + + def _deep_merge(self, base_dict: dict, new_dict: dict) -> dict: + """Recursively merges new_dict into base_dict.""" + + for key, value in new_dict.items(): + if isinstance(base_dict.get(key), dict) and isinstance( + value, collections.abc.Mapping + ): + base_dict[key] = self._deep_merge(base_dict[key], value) + else: + base_dict[key] = value + + return base_dict def _load_yaml(self, config_path: Path) -> dict[str, Any]: """Loads the YAML config file if it exists, otherwise returns an empty dict.""" @@ -37,15 +72,22 @@ def _load_yaml(self, config_path: Path) -> dict[str, Any]: f"Error reading YAML config at {config_path}: {e}" ) - def _get_nested_val( - self, source_dict: dict[str, Any], path: list[str] - ) -> Any | None: - """Safely retrieves a nested value from a dictionary using a path list.""" + def _load_from_env(self) -> dict[str, Any]: + """Parses environment variables like `AUTH__OTP_TIMEOUT` into a nested dict.""" - try: - return reduce(operator.getitem, path, source_dict) - except (KeyError, TypeError): - return None + env_config = {} + for key, value in os.environ.items(): + # Split by '__' to create a path for the nested dictionary + path = key.lower().split("__") + target = env_config + # Traverse/create the nested dict structure + for i, part in enumerate(path): + if i == len(path) - 1: + target[part] = value + else: + target = target.setdefault(part, {}) + + return env_config def get( self, key: str, *, cast: Callable[[Any], T] | None = None, required: bool = True @@ -69,41 +111,30 @@ def get( or if casting fails. """ - # 1. Check Environment Variables (e.g., AUTH.OTP_TIMEOUT -> AUTH__OTP_TIMEOUT) - env_key = key.upper().replace(".", "__") - if (env_val := os.environ.get(env_key)) is not None: - value, source = env_val, f"environment variable ({env_key})" - else: - path = key.split(".") - # 2. Check YAML Config - if (yaml_val := self._get_nested_val(self._yaml_config, path)) is not None: - value, source = yaml_val, "config.yaml" - # 3. Check Defaults - elif ( - default_val := self._get_nested_val(self._defaults, path) - ) is not None: - value, source = default_val, "default value" - else: - if required: - raise ImproperlyConfigured( - f"Required setting '{key}' is not defined in any source." - ) - return None + path = key.lower().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 None: return value - if source.startswith("environment"): - if cast is bool: - return value.lower() in ("true", "1", "yes") - if cast is list: - return [item.strip() for item in value.split(",")] + # Since env vars are strings, perform smart casting + 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}' from {source} with value '{ - value!r - }' to {cast.__name__}. Error: {e}" + f"Failed to cast setting '{key}' with value {value!r} to { + cast.__name__ + }. Error: {e}" ) From 2db98b5237e7a749924e4eb20aa35ffcf5371885 Mon Sep 17 00:00:00 2001 From: Pachakutiq <101460915+PACHAKUTlQ@users.noreply.github.com> Date: Mon, 29 Sep 2025 17:46:15 +0800 Subject: [PATCH 15/31] fix(settings)!: Use complete default config to avoid errors --- website/settings.py | 34 ++++++++++++++++------------------ 1 file changed, 16 insertions(+), 18 deletions(-) diff --git a/website/settings.py b/website/settings.py index 3b648ab..8532d81 100644 --- a/website/settings.py +++ b/website/settings.py @@ -31,6 +31,21 @@ "REDIS": {"URL": "redis://localhost:6379/0", "MAX_CONNECTIONS": 100}, "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, } @@ -75,24 +90,7 @@ TURNSTILE_SECRET_KEY = config.get("TURNSTILE_SECRET_KEY") AUTO_IMPORT_CRAWLED_DATA = config.get("AUTO_IMPORT_CRAWLED_DATA", cast=bool) -QUEST = { - "BASE_URL": config.get("QUEST.BASE_URL"), - "SIGNUP": { - "API_KEY": config.get("QUEST.SIGNUP.API_KEY"), - "URL": config.get("QUEST.SIGNUP.URL"), - "QUESTIONID": config.get("QUEST.SIGNUP.QUESTIONID", cast=int), - }, - "LOGIN": { - "API_KEY": config.get("QUEST.LOGIN.API_KEY"), - "URL": config.get("QUEST.LOGIN.URL"), - "QUESTIONID": config.get("QUEST.LOGIN.QUESTIONID", cast=int), - }, - "RESET": { - "API_KEY": config.get("QUEST.RESET.API_KEY"), - "URL": config.get("QUEST.RESET.URL"), - "QUESTIONID": config.get("QUEST.RESET.QUESTIONID", cast=int), - }, -} +QUEST = config.get("QUEST") # ============================================================================== From 25502d3b31c044abc4289575595ef3720a56db89 Mon Sep 17 00:00:00 2001 From: Pachakutiq <101460915+PACHAKUTlQ@users.noreply.github.com> Date: Mon, 29 Sep 2025 17:46:52 +0800 Subject: [PATCH 16/31] fix(settings)!: Uncomment template parts used by django admin --- website/settings.py | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/website/settings.py b/website/settings.py index 8532d81..c979955 100644 --- a/website/settings.py +++ b/website/settings.py @@ -130,21 +130,21 @@ ROOT_URLCONF = "website.urls" WSGI_APPLICATION = "website.wsgi.application" -# TEMPLATES = [ -# { -# "BACKEND": "django.template.backends.django.DjangoTemplates", -# "DIRS": [], -# "APP_DIRS": True, -# "OPTIONS": { -# "context_processors": [ -# "django.template.context_processors.debug", -# "django.template.context_processors.request", -# "django.contrib.auth.context_processors.auth", -# "django.contrib.messages.context_processors.messages", -# ] -# }, -# } -# ] +TEMPLATES = [ + { + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [], + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.debug", + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", + ] + }, + } +] STATIC_URL = "/dummy/" # Required by Django staticfiles but not used in this setup DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" From f20f6115905ee4066c75aa8c9c1469fb0bacf5bb Mon Sep 17 00:00:00 2001 From: Pachakutiq <101460915+PACHAKUTlQ@users.noreply.github.com> Date: Mon, 29 Sep 2025 18:17:26 +0800 Subject: [PATCH 17/31] fix(auth)!: Use new config system --- apps/auth/utils.py | 13 ++++++++----- apps/auth/views.py | 15 ++++++--------- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/apps/auth/utils.py b/apps/auth/utils.py index 938ecfb..460d6d4 100644 --- a/apps/auth/utils.py +++ b/apps/auth/utils.py @@ -12,11 +12,14 @@ from apps.web.models import Student -PASSWORD_LENGTH_MIN = settings.AUTH["PASSWORD_LENGTH_MIN"] -PASSWORD_LENGTH_MAX = settings.AUTH["PASSWORD_LENGTH_MAX"] -OTP_TIMEOUT = settings.AUTH["OTP_TIMEOUT"] -EMAIL_DOMAIN_NAME = settings.AUTH["EMAIL_DOMAIN_NAME"] -QUEST_BASE_URL = settings.QUEST["BASE_URL"] +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_url(action: str) -> str | None: diff --git a/apps/auth/views.py b/apps/auth/views.py index 8232ba8..2135843 100644 --- a/apps/auth/views.py +++ b/apps/auth/views.py @@ -29,15 +29,12 @@ def enforce_csrf(self, request): return -OTP_TIMEOUT = settings.AUTH["OTP_TIMEOUT"] -TEMP_TOKEN_TIMEOUT = settings.AUTH["TEMP_TOKEN_TIMEOUT"] -ACTION_LIST = [ - "signup", - "login", - "reset_password", -] -TOKEN_RATE_LIMIT = settings.AUTH["TOKEN_RATE_LIMIT"] -TOKEN_RATE_LIMIT_TIME = settings.AUTH["TOKEN_RATE_LIMIT_TIME"] +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"]) From 7e867b34f098667a64812038fd2299f093e8ca2e Mon Sep 17 00:00:00 2001 From: Pachakutiq <101460915+PACHAKUTlQ@users.noreply.github.com> Date: Mon, 29 Sep 2025 18:23:41 +0800 Subject: [PATCH 18/31] fix(auth)!: Use unified helper function to get quest details --- apps/auth/utils.py | 65 +++++++++++++++++++--------------------------- apps/auth/views.py | 19 +++++++++++--- 2 files changed, 41 insertions(+), 43 deletions(-) diff --git a/apps/auth/utils.py b/apps/auth/utils.py index 460d6d4..6706bce 100644 --- a/apps/auth/utils.py +++ b/apps/auth/utils.py @@ -22,47 +22,31 @@ QUEST_BASE_URL = QUEST_SETTINGS["base_url"] -def get_survey_url(action: str) -> str | None: - """Helper function to get the survey URL based on action type""" - - if action == "signup": - return settings.QUEST["SIGNUP"]["URL"] - if action == "login": - return settings.QUEST["LOGIN"]["URL"] - if action == "reset_password": - return settings.QUEST["RESET"]["URL"] - return None - - -def get_survey_api_key(action: str) -> str | None: - """Helper function to get the survey API key based on action type""" - - if action == "signup": - return settings.QUEST["SIGNUP"]["API_KEY"] - if action == "login": - return settings.QUEST["LOGIN"]["API_KEY"] - if action == "reset_password": - return settings.QUEST["RESET"]["API_KEY"] - return None +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.lower()) -def get_survey_questionid(action: str) -> int | None: - """Helper function to get the survey question ID for the verification code based on action type""" + if not action_details: + logging.error(f"Invalid quest action requested: {action}") + return None - question_id_str = None - if action == "signup": - question_id_str = settings.QUEST["SIGNUP"]["QUESTIONID"] - elif action == "login": - question_id_str = settings.QUEST["LOGIN"]["QUESTIONID"] - elif action == "reset_password": - question_id_str = settings.QUEST["RESET"]["QUESTIONID"] + try: + question_id = int(action_details.get("questionid")) + except (ValueError, TypeError): + logging.error( + f"Could not parse 'questionid' for action '{action}'. Check your settings." + ) + return None - if question_id_str: - try: - return int(question_id_str) - except (ValueError, TypeError): - return None - return None + return { + "url": action_details.get("url"), + "api_key": action_details.get("api_key"), + "question_id": question_id, + } async def verify_turnstile_token( @@ -106,12 +90,15 @@ async def get_latest_answer( `error_response` is a DRF Response object if an error occurs, otherwise None. """ - quest_api = get_survey_api_key(action) + 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 = get_survey_questionid(action) + question_id = details.get("question_id") if not question_id: return None, Response( {"error": "Configuration error: question ID not found for action"}, diff --git a/apps/auth/views.py b/apps/auth/views.py index 2135843..a7afef3 100644 --- a/apps/auth/views.py +++ b/apps/auth/views.py @@ -90,7 +90,9 @@ def auth_initiate_api(request): 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')}" + 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}") @@ -111,7 +113,10 @@ def auth_initiate_api(request): logging.info(f"Created auth intent for action {action} with OTP and temp_token") - survey_url = utils.get_survey_url(action) + survey_details = utils.get_survey_details(action) + if not survey_details: + return Response({"error": "Invalid action"}, status=400) + survey_url = survey_details.get("url") if not survey_url: return Response( {"error": "Something went wrong when fetching the survey URL"}, @@ -273,7 +278,11 @@ def verify_callback_api(request): if user is None: if error_response: logging.error( - f"Failed to create session for login: {getattr(error_response, 'data', {}).get('error', 'Unknown error')}", + f"Failed to create session for login: { + getattr(error_response, 'data', {}).get( + 'error', 'Unknown error' + ) + }", ) return error_response else: @@ -289,7 +298,9 @@ def verify_callback_api(request): r.delete(state_key) except Exception as e: logging.exception( - f"Error during login session creation or cleanup for user {account}: {e}", + f"Error during login session creation or cleanup for user {account}: { + e + }", ) return Response({"error": "Failed to finalize login process"}, status=500) From cb87a8858fb2da9e4968d5349282b5567d988168 Mon Sep 17 00:00:00 2001 From: Pachakutiq <101460915+PACHAKUTlQ@users.noreply.github.com> Date: Mon, 29 Sep 2025 19:28:51 +0800 Subject: [PATCH 19/31] fix(config)!: Fix of failed migrations (DB not connected due to wrong env name) --- .env.example | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.env.example b/.env.example index 4fd7f21..cc6f113 100644 --- a/.env.example +++ b/.env.example @@ -16,8 +16,8 @@ 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 +DATABASE__URL=postgres://admin:test@127.0.0.1:5432/coursereview +REDIS__URL=redis://localhost:6379/0 # --- External Services Secrets (REQUIRED) --- From d4907bc1cabfb4411b21ae8957fef0fa4d016f25 Mon Sep 17 00:00:00 2001 From: Pachakutiq <101460915+PACHAKUTlQ@users.noreply.github.com> Date: Mon, 29 Sep 2025 19:43:20 +0800 Subject: [PATCH 20/31] fix(auth)!: Use new config system, change keys to upper cases --- apps/auth/utils.py | 20 ++++++++++---------- apps/auth/views.py | 14 +++++++------- 2 files changed, 17 insertions(+), 17 deletions(-) diff --git a/apps/auth/utils.py b/apps/auth/utils.py index 6706bce..e28d3b6 100644 --- a/apps/auth/utils.py +++ b/apps/auth/utils.py @@ -13,13 +13,13 @@ 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"] +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"] +QUEST_BASE_URL = QUEST_SETTINGS["BASE_URL"] def get_survey_details(action: str) -> dict[str, any] | None: @@ -28,23 +28,23 @@ def get_survey_details(action: str) -> dict[str, any] | None: Valid actions: "signup", "login", "reset". """ - action_details = QUEST_SETTINGS.get(action.lower()) + action_details = QUEST_SETTINGS.get(action.upper()) if not action_details: logging.error(f"Invalid quest action requested: {action}") return None try: - question_id = int(action_details.get("questionid")) + question_id = int(action_details.get("QUESTIONID")) except (ValueError, TypeError): logging.error( - f"Could not parse 'questionid' for action '{action}'. Check your settings." + f"Could not parse 'QUESTIONID' for action '{action}'. Check your settings." ) return None return { - "url": action_details.get("url"), - "api_key": action_details.get("api_key"), + "url": action_details.get("URL"), + "api_key": action_details.get("API_KEY"), "question_id": question_id, } diff --git a/apps/auth/views.py b/apps/auth/views.py index a7afef3..ce797ac 100644 --- a/apps/auth/views.py +++ b/apps/auth/views.py @@ -30,11 +30,11 @@ def enforce_csrf(self, request): AUTH_SETTINGS = settings.AUTH -OTP_TIMEOUT = AUTH_SETTINGS["otp_timeout"] -TEMP_TOKEN_TIMEOUT = AUTH_SETTINGS["temp_token_timeout"] +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"] +TOKEN_RATE_LIMIT = AUTH_SETTINGS["TOKEN_RATE_LIMIT"] +TOKEN_RATE_LIMIT_TIME = AUTH_SETTINGS["TOKEN_RATE_LIMIT_TIME"] @api_view(["POST"]) @@ -113,10 +113,10 @@ def auth_initiate_api(request): logging.info(f"Created auth intent for action {action} with OTP and temp_token") - survey_details = utils.get_survey_details(action) - if not survey_details: + details = utils.get_survey_details(action) + if not details: return Response({"error": "Invalid action"}, status=400) - survey_url = survey_details.get("url") + survey_url = details.get("URL") if not survey_url: return Response( {"error": "Something went wrong when fetching the survey URL"}, From fce4292443eed3fd12df94eac7653b2a4cc4d7fe Mon Sep 17 00:00:00 2001 From: Pachakutiq <101460915+PACHAKUTlQ@users.noreply.github.com> Date: Mon, 29 Sep 2025 19:44:03 +0800 Subject: [PATCH 21/31] refactor(config)!: Refactor config parsing, use upper case for keys --- website/config.py | 86 +++++++++++++++++++++++++++-------------------- 1 file changed, 49 insertions(+), 37 deletions(-) diff --git a/website/config.py b/website/config.py index 0d4baa4..891c512 100644 --- a/website/config.py +++ b/website/config.py @@ -1,11 +1,12 @@ import os import yaml +import collections.abc +from copy import deepcopy from pathlib import Path from typing import Any, Callable, TypeVar from django.core.exceptions import ImproperlyConfigured from functools import reduce import operator -import collections.abc # Generic TypeVar for casting function return values T = TypeVar("T") @@ -19,45 +20,26 @@ class Config: 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. Start with normalized defaults - self._final_config = self._normalize_keys_to_lower(defaults or {}) - - # 2. Load and merge normalized YAML config - yaml_config = self._normalize_keys_to_lower(self._load_yaml(config_path)) - self._final_config = self._deep_merge(self._final_config, yaml_config) - - # 3. Load and merge environment variables (already produces lowercase keys) - env_config = self._load_from_env() - self._final_config = self._deep_merge(self._final_config, env_config) + self._defaults = defaults or {} + self._yaml_config = self._load_yaml(config_path) + self._env_config = self._load_from_env() - def _normalize_keys_to_lower(self, obj: Any) -> Any: - """Recursively converts all dictionary keys in an object to lowercase.""" + def _deep_merge(self, base: dict, new: dict) -> dict: + """Recursively merges `new` dict into `base` dict.""" - if isinstance(obj, dict): - return { - str(key).lower(): self._normalize_keys_to_lower(value) - for key, value in obj.items() - } - if isinstance(obj, list): - return [self._normalize_keys_to_lower(item) for item in obj] - - return obj - - def _deep_merge(self, base_dict: dict, new_dict: dict) -> dict: - """Recursively merges new_dict into base_dict.""" - - for key, value in new_dict.items(): - if isinstance(base_dict.get(key), dict) and isinstance( + for key, value in new.items(): + if isinstance(base.get(key), dict) and isinstance( value, collections.abc.Mapping ): - base_dict[key] = self._deep_merge(base_dict[key], value) + base[key] = self._deep_merge(base.get(key, {}), value) else: - base_dict[key] = value + base[key] = value - return base_dict + return base def _load_yaml(self, config_path: Path) -> dict[str, Any]: """Loads the YAML config file if it exists, otherwise returns an empty dict.""" @@ -78,7 +60,7 @@ def _load_from_env(self) -> dict[str, Any]: env_config = {} for key, value in os.environ.items(): # Split by '__' to create a path for the nested dictionary - path = key.lower().split("__") + path = key.split("__") target = env_config # Traverse/create the nested dict structure for i, part in enumerate(path): @@ -89,6 +71,14 @@ def _load_from_env(self) -> dict[str, Any]: return env_config + def _get_from_path(self, source: dict, path: list[str]) -> Any | None: + """Safely retrieves a value from a nested dict using a path list.""" + + try: + return reduce(operator.getitem, path, source) + except (KeyError, TypeError): + return None + def get( self, key: str, *, cast: Callable[[Any], T] | None = None, required: bool = True ) -> T | Any | None: @@ -111,10 +101,32 @@ def get( or if casting fails. """ - path = key.lower().split(".") - try: - value = reduce(operator.getitem, path, self._final_config) - except (KeyError, TypeError): + path = key.split(".") + + default_val = self._get_from_path(self._defaults, path) + yaml_val = self._get_from_path(self._yaml_config, path) + env_val = self._get_from_path(self._env_config, path) + + # Determine final value based on priority and type + value = None + if isinstance(default_val, dict): + # For dictionaries, perform a deep merge + merged_val = deepcopy(default_val) + if isinstance(yaml_val, dict): + self._deep_merge(merged_val, yaml_val) + if isinstance(env_val, dict): + self._deep_merge(merged_val, env_val) + value = merged_val + else: + # For scalar values, prioritize env > yaml > default + if env_val is not None: + value = env_val + elif yaml_val is not None: + value = yaml_val + else: + value = default_val + + if value is None: if required: raise ImproperlyConfigured( f"Required setting '{key}' is not defined in any source." @@ -124,7 +136,7 @@ def get( if cast is None: return value - # Since env vars are strings, perform smart casting + # Perform smart casting for values that are likely strings (from env) if cast is bool and isinstance(value, str): return value.lower() in ("true", "1", "yes") if cast is list and isinstance(value, str): From 482eedca5124a38c9919655504c3b59ad8688759 Mon Sep 17 00:00:00 2001 From: Pachakutiq <101460915+PACHAKUTlQ@users.noreply.github.com> Date: Mon, 29 Sep 2025 19:44:25 +0800 Subject: [PATCH 22/31] fix(settings)!: Add missing TURNSTILE_SECRET_KEY to make default config complete --- website/settings.py | 1 + 1 file changed, 1 insertion(+) diff --git a/website/settings.py b/website/settings.py index c979955..ef748b5 100644 --- a/website/settings.py +++ b/website/settings.py @@ -29,6 +29,7 @@ }, "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": { From 62970e62032817f543240d13cf0760bc7766fc6a Mon Sep 17 00:00:00 2001 From: Pachakutiq <101460915+PACHAKUTlQ@users.noreply.github.com> Date: Mon, 29 Sep 2025 20:52:08 +0800 Subject: [PATCH 23/31] fix(auth)!: Use lower-case url as this is from utils but not settings.py --- apps/auth/views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/auth/views.py b/apps/auth/views.py index ce797ac..19815da 100644 --- a/apps/auth/views.py +++ b/apps/auth/views.py @@ -116,7 +116,7 @@ def auth_initiate_api(request): details = utils.get_survey_details(action) if not details: return Response({"error": "Invalid action"}, status=400) - survey_url = details.get("URL") + survey_url = details.get("url") if not survey_url: return Response( {"error": "Something went wrong when fetching the survey URL"}, From 1c2ef28f758a39a6070e07a2b067f05c41f22f3c Mon Sep 17 00:00:00 2001 From: Pachakutiq <101460915+PACHAKUTlQ@users.noreply.github.com> Date: Tue, 30 Sep 2025 00:00:01 +0800 Subject: [PATCH 24/31] fix(config)!: Correctly implement deep merge --- website/config.py | 117 +++++++++++++++++++++------------------------- 1 file changed, 52 insertions(+), 65 deletions(-) diff --git a/website/config.py b/website/config.py index 891c512..2eab01f 100644 --- a/website/config.py +++ b/website/config.py @@ -1,7 +1,6 @@ import os import yaml import collections.abc -from copy import deepcopy from pathlib import Path from typing import Any, Callable, TypeVar from django.core.exceptions import ImproperlyConfigured @@ -24,23 +23,32 @@ class Config: """ def __init__(self, config_path: Path, defaults: dict[str, Any] | None = None): - self._defaults = defaults or {} - self._yaml_config = self._load_yaml(config_path) - self._env_config = self._load_from_env() + # 1. Load all sources of configuration. + default_config = defaults or {} + yaml_config = self._load_yaml(config_path) + env_config = self._load_from_env() - def _deep_merge(self, base: dict, new: dict) -> dict: - """Recursively merges `new` dict into `base` dict.""" + # 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(): - if isinstance(base.get(key), dict) and isinstance( + base_value = base.get(key) + if isinstance(base_value, dict) and isinstance( value, collections.abc.Mapping ): - base[key] = self._deep_merge(base.get(key, {}), value) + # 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 - return base - def _load_yaml(self, config_path: Path) -> dict[str, Any]: """Loads the YAML config file if it exists, otherwise returns an empty dict.""" @@ -59,26 +67,28 @@ def _load_from_env(self) -> dict[str, Any]: env_config = {} for key, value in os.environ.items(): - # Split by '__' to create a path for the nested dictionary + # 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 - # Traverse/create the nested dict structure - for i, part in enumerate(path): - if i == len(path) - 1: - target[part] = value - else: - target = target.setdefault(part, {}) + 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_from_path(self, source: dict, path: list[str]) -> Any | None: - """Safely retrieves a value from a nested dict using a path list.""" - - try: - return reduce(operator.getitem, path, source) - except (KeyError, TypeError): - return None - def get( self, key: str, *, cast: Callable[[Any], T] | None = None, required: bool = True ) -> T | Any | None: @@ -103,50 +113,27 @@ def get( path = key.split(".") - default_val = self._get_from_path(self._defaults, path) - yaml_val = self._get_from_path(self._yaml_config, path) - env_val = self._get_from_path(self._env_config, path) - - # Determine final value based on priority and type - value = None - if isinstance(default_val, dict): - # For dictionaries, perform a deep merge - merged_val = deepcopy(default_val) - if isinstance(yaml_val, dict): - self._deep_merge(merged_val, yaml_val) - if isinstance(env_val, dict): - self._deep_merge(merged_val, env_val) - value = merged_val - else: - # For scalar values, prioritize env > yaml > default - if env_val is not None: - value = env_val - elif yaml_val is not None: - value = yaml_val - else: - value = default_val - - if value is None: + 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 None: - return value - - # Perform smart casting for values that are likely strings (from env) - 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(",")] + 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}" + ) - 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 From 0d2264a859141902b6c88365eb4044414f722d00 Mon Sep 17 00:00:00 2001 From: Pachakutiq <101460915+PACHAKUTlQ@users.noreply.github.com> Date: Mon, 29 Sep 2025 23:19:36 +0800 Subject: [PATCH 25/31] fix(settings)!: Remove unused FRONTEND_URL --- website/settings.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/website/settings.py b/website/settings.py index ef748b5..379143d 100644 --- a/website/settings.py +++ b/website/settings.py @@ -13,7 +13,6 @@ "SECRET_KEY": "a-default-secret-key-for-development-only", "ALLOWED_HOSTS": ["127.0.0.1", "localhost"], "CORS_ALLOWED_ORIGINS": ["http://localhost:5173", "http://127.0.0.1:5173"], - "FRONTEND_URL": "http://localhost:5173", "SESSION": { "COOKIE_AGE": 2592000, # 30 days "SAVE_EVERY_REQUEST": True, @@ -63,7 +62,6 @@ DEBUG = config.get("DEBUG", cast=bool) ALLOWED_HOSTS = config.get("ALLOWED_HOSTS", cast=list) CORS_ALLOWED_ORIGINS = config.get("CORS_ALLOWED_ORIGINS", cast=list) -FRONTEND_URL = config.get("FRONTEND_URL") # --- Infrastructure --- DATABASES = {"default": dj_database_url.parse(config.get("DATABASE.URL"))} From a8351475e716bb953606de25c5274b04fc493808 Mon Sep 17 00:00:00 2001 From: Pachakutiq <101460915+PACHAKUTlQ@users.noreply.github.com> Date: Tue, 30 Sep 2025 00:03:12 +0800 Subject: [PATCH 26/31] feat(config)!: Add example .env and config.yaml files --- .env.example | 31 +++++++++++------------- config.yaml.example | 57 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+), 17 deletions(-) create mode 100644 config.yaml.example diff --git a/.env.example b/.env.example index cc6f113..3091903 100644 --- a/.env.example +++ b/.env.example @@ -7,11 +7,9 @@ # Generate a new one for production! SECRET_KEY=django-insecure-my-local-dev-secret-key - # --- Local Overrides --- # Set to False in production -DEBUG=True - +# DEBUG=True # --- Infrastructure (REQUIRED) --- # Use a single URL for database and Redis connections. @@ -19,23 +17,22 @@ DEBUG=True DATABASE__URL=postgres://admin:test@127.0.0.1:5432/coursereview REDIS__URL=redis://localhost:6379/0 - # --- External Services Secrets (REQUIRED) --- -TURNSTILE_SECRET_KEY= +TURNSTILE_SECRET_KEY=dummy0 # Use PARENT__CHILD format to override nested settings -QUEST__SIGNUP__API_KEY= -QUEST__SIGNUP__URL= -QUEST__SIGNUP__QUESTIONID= - -QUEST__LOGIN__API_KEY= -QUEST__LOGIN__URL= -QUEST__LOGIN__QUESTIONID= - -QUEST__RESET__API_KEY= -QUEST__RESET__URL= -QUEST__RESET__QUESTIONID= - +# 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 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 From 4ccf11528d850967315a017c4670e52c87a8761e Mon Sep 17 00:00:00 2001 From: Pachakutiq <101460915+PACHAKUTlQ@users.noreply.github.com> Date: Tue, 30 Sep 2025 00:10:35 +0800 Subject: [PATCH 27/31] feat(chore)!: Update .gitignore and ignore config.yaml --- .gitignore | 187 +++++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 159 insertions(+), 28 deletions(-) diff --git a/.gitignore b/.gitignore index 8647958..24ea18b 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,161 @@ coverage.xml # Django stuff: *.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# 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 + From 03436ab41bc6e40d542f641f6b04c886601a6cb4 Mon Sep 17 00:00:00 2001 From: Pachakutiq <101460915+PACHAKUTlQ@users.noreply.github.com> Date: Tue, 30 Sep 2025 00:13:51 +0800 Subject: [PATCH 28/31] fix(chore)!: Ignore config.yaml --- config.yaml | 16 ---------------- 1 file changed, 16 deletions(-) delete mode 100644 config.yaml diff --git a/config.yaml b/config.yaml deleted file mode 100644 index 9fd50fc..0000000 --- a/config.yaml +++ /dev/null @@ -1,16 +0,0 @@ -# config.yaml -# For non-secret, environment-specific configuration. -# Values here will override DEFAULTS in settings.py. -# Environment variables will override values here. - -ALLOWED_HOSTS: - - "staging.my-app.com" - -CORS_ALLOWED_ORIGINS: - - "https://staging-frontend.my-app.com" - -AUTH: - TEMP_TOKEN_TIMEOUT: 300 - -QUEST: - BASE_URL: "https://wj-staging.sjtu.edu.cn/api/v1/public/export" From 25423c879cc2e059a897cf7c3a369461cb018a78 Mon Sep 17 00:00:00 2001 From: Pachakutiq <101460915+PACHAKUTlQ@users.noreply.github.com> Date: Tue, 30 Sep 2025 00:14:55 +0800 Subject: [PATCH 29/31] fix(settings)!: Remove default SECRET_KEY to fail if not setting it --- website/settings.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/settings.py b/website/settings.py index 379143d..bbd2668 100644 --- a/website/settings.py +++ b/website/settings.py @@ -10,7 +10,7 @@ # --- Default Configuration --- DEFAULTS = { "DEBUG": True, - "SECRET_KEY": "a-default-secret-key-for-development-only", + "SECRET_KEY": None, "ALLOWED_HOSTS": ["127.0.0.1", "localhost"], "CORS_ALLOWED_ORIGINS": ["http://localhost:5173", "http://127.0.0.1:5173"], "SESSION": { From 454823397987274c38af623fb25ab806b395f6ec Mon Sep 17 00:00:00 2001 From: Pachakutiq <101460915+PACHAKUTlQ@users.noreply.github.com> Date: Tue, 30 Sep 2025 00:16:31 +0800 Subject: [PATCH 30/31] docs(config): Add docs for config --- docs/config.md | 195 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 195 insertions(+) create mode 100644 docs/config.md diff --git a/docs/config.md b/docs/config.md new file mode 100644 index 0000000..6ae944b --- /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 at 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` indicates the fields are required 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, +} +``` From 484a2ed11186a3932f1f8a82fa73ba676e6ccea6 Mon Sep 17 00:00:00 2001 From: Pachakutiq <101460915+PACHAKUTlQ@users.noreply.github.com> Date: Tue, 30 Sep 2025 00:19:10 +0800 Subject: [PATCH 31/31] fix(docs): Fix typo --- docs/config.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/config.md b/docs/config.md index 6ae944b..b0b73d3 100644 --- a/docs/config.md +++ b/docs/config.md @@ -2,7 +2,7 @@ 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: +## TL;DR 1. Copy `.env.example` file to `.env`, fill in: - `SECRET_KEY` @@ -14,7 +14,7 @@ Use YAML and environment variables for robust and secure configuration. All the 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 at production) backend domains in `ALLOWED_HOSTS`, frontend domains in `CORS_ALLOWED_ORIGINS` + - (If in production) backend domains in `ALLOWED_HOSTS`, frontend domains in `CORS_ALLOWED_ORIGINS` 3. That's it! ## Priority @@ -147,7 +147,7 @@ QUEST: - Just for example. - `settings.py` should **NOT** be modified by non-developers. -- The fields whose default values are `None` indicates the fields are required either in env or in `config.yaml`. +- The fields whose default values are `None` are mandatory, either in env or in `config.yaml`. ```python path=website/settings.py # --- Default Configuration ---