diff --git a/.containerignore b/.containerignore
new file mode 100644
index 0000000..19f0285
--- /dev/null
+++ b/.containerignore
@@ -0,0 +1,21 @@
+config.yaml
+run.py
+.git
+.venv
+.env
+.ruff_cache
+.pytest_cache
+frontend
+.github
+docs
+Makefile
+.pre-commit-config.yaml
+LICENSE
+.gitignore
+.containerignore
+.gitmodules
+podman-compose.yaml
+__pycache__
+*.py[codz]
+**/__pycache__
+**/*.py[codz]
diff --git a/.env.example b/.env.example
index 93883f9..0473e9f 100644
--- a/.env.example
+++ b/.env.example
@@ -14,8 +14,8 @@ SECRET_KEY=django-insecure-my-local-dev-secret-key
# --- 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@db:5432/coursereview
+REDIS__URL=redis://cache:6379/0
# --- External Services Secrets (REQUIRED) ---
TURNSTILE_SECRET_KEY=dummy0
diff --git a/.github/workflows/bot.yaml b/.github/workflows/bot.yaml
index 3a11154..3937c25 100644
--- a/.github/workflows/bot.yaml
+++ b/.github/workflows/bot.yaml
@@ -36,7 +36,7 @@ on:
public:
pull_request:
branches:
- - '*'
+ - "*"
types: [opened, reopened]
pull_request_review:
types: [edited, dismissed, submitted]
@@ -46,7 +46,7 @@ on:
types: [assigned, opened, synchronize, reopened]
push:
branches:
- - '*'
+ - "*"
registry_package:
types: [published]
release:
diff --git a/.github/workflows/build-image.yml b/.github/workflows/build-image.yml
new file mode 100644
index 0000000..0b4dde5
--- /dev/null
+++ b/.github/workflows/build-image.yml
@@ -0,0 +1,54 @@
+name: Publish container image
+
+on:
+ workflow_dispatch:
+
+env:
+ REGISTRY: ghcr.io
+ IMAGE_NAME: ghcr.io/tech-ji/coursereview
+
+jobs:
+ publish:
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ packages: write
+ attestations: write
+ id-token: write
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v6
+
+ - name: Log in to GHCR
+ uses: docker/login-action@v4
+ with:
+ registry: ${{ env.REGISTRY }}
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Extract Docker metadata
+ id: meta
+ uses: docker/metadata-action@v6
+ with:
+ images: ${{ env.IMAGE_NAME }}
+ tags: |
+ type=sha,prefix=sha-
+ type=ref,event=tag
+ labels: |
+ org.opencontainers.image.source=https://github.com/Tech-JI/CourseReview
+ org.opencontainers.image.description=CourseReview Django application
+
+ - name: Build and push image
+ id: push
+ uses: docker/build-push-action@v7
+ with:
+ context: .
+ file: ./Containerfile
+ push: true
+ tags: ${{ steps.meta.outputs.tags }}
+ labels: ${{ steps.meta.outputs.labels }}
+
+ - name: Show digest
+ run: |
+ echo "Digest: ${{ steps.push.outputs.digest }}"
diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml
new file mode 100644
index 0000000..fcf390c
--- /dev/null
+++ b/.github/workflows/pytest.yml
@@ -0,0 +1,78 @@
+name: pytest
+
+on:
+ push:
+ branches: ["dev", "main"]
+ pull_request:
+ branches: ["dev", "main"]
+
+permissions:
+ contents: read
+
+jobs:
+ test:
+ runs-on: ubuntu-latest
+
+ services:
+ db:
+ image: postgres:18-alpine
+ env:
+ POSTGRES_DB: coursereview
+ POSTGRES_USER: admin
+ POSTGRES_PASSWORD: test
+ ports:
+ - "5432:5432"
+ options: >-
+ --health-cmd "pg_isready -U admin -d coursereview"
+ --health-interval 10s
+ --health-timeout 5s
+ --health-retries 5
+
+ cache:
+ image: valkey/valkey:9-alpine
+ ports:
+ - "6379:6379"
+ options: >-
+ --health-cmd "valkey-cli ping"
+ --health-interval 10s
+ --health-timeout 5s
+ --health-retries 5
+
+ env:
+ SECRET_KEY: 02247f40-a769-4c49-9178-4c038048e7ad
+ DATABASE__URL: postgres://admin:test@db:5432/coursereview
+ REDIS__URL: redis://cache:6379/0
+
+ TURNSTILE_SECRET_KEY: dummy0
+
+ QUEST__SIGNUP__API_KEY: dummy1
+ QUEST__SIGNUP__URL: https://wj.sjtu.edu.cn/q/dummy0
+ QUEST__SIGNUP__QUESTIONID: "10000000"
+
+ QUEST__LOGIN__API_KEY: dummy2
+ QUEST__LOGIN__URL: https://wj.sjtu.edu.cn/q/dummy1
+ QUEST__LOGIN__QUESTIONID: "10000001"
+
+ QUEST__RESET__API_KEY: dummy3
+ QUEST__RESET__URL: https://wj.sjtu.edu.cn/q/dummy2
+ QUEST__RESET__QUESTIONID: "10000002"
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v6
+
+ - name: Set up Python
+ uses: actions/setup-python@v6
+ with:
+ python-version-file: pyproject.toml
+
+ - name: Set up uv
+ uses: astral-sh/setup-uv@v7
+ with:
+ enable-cache: true
+
+ - name: Install dependencies
+ run: uv sync --frozen --group dev
+
+ - name: Run pytest
+ run: python run.py test
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 87696b3..eb5e991 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -1,15 +1,28 @@
-repos:
+default_language_version:
+ python: 3.14
+repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v6.0.0
hooks:
- - id: trailing-whitespace
- - id: end-of-file-fixer
- - id: check-yaml
- id: check-added-large-files
+ - id: check-ast
+ - id: check-builtin-literals
+ - id: check-case-conflict
+ - id: check-illegal-windows-names
+ - id: check-json
+ - id: check-merge-conflict
+ - id: check-toml
+ - id: check-yaml
+ - id: detect-private-key
+ - id: end-of-file-fixer
+ - id: no-commit-to-branch
+ - id: pretty-format-json
+ - id: requirements-txt-fixer
+ - id: trailing-whitespace
- repo: https://github.com/astral-sh/ruff-pre-commit
- rev: v0.14.5
+ rev: v0.15.2
hooks:
- id: ruff-check
types_or: [python, pyi]
diff --git a/Containerfile b/Containerfile
new file mode 100644
index 0000000..cb6ee1c
--- /dev/null
+++ b/Containerfile
@@ -0,0 +1,20 @@
+FROM ghcr.io/astral-sh/uv:python3.14-trixie-slim AS builder
+
+WORKDIR /app
+
+COPY . /app
+
+RUN UV_PROJECT_ENVIRONMENT=/usr/local \
+ uv sync --project=/app --frozen --compile-bytecode --no-dev --no-editable --no-managed-python
+
+FROM gcr.io/distroless/base-debian13:nonroot
+
+COPY --from=builder /usr/local /usr/local
+
+COPY --from=builder /app /app
+
+WORKDIR /app
+
+USER nonroot
+
+ENTRYPOINT ["python", "scripts/entrypoint.py"]
diff --git a/Makefile b/Makefile
deleted file mode 100644
index 8622be3..0000000
--- a/Makefile
+++ /dev/null
@@ -1,81 +0,0 @@
-.PHONY: run dev-frontend clean collect install-frontend format format-backend format-frontend lint lint-backend lint-frontend makemigrations migrate shell createsuperuser help
-
-# Default target when 'make' is run without arguments
-.DEFAULT_GOAL := help
-
-help:
- @echo "Available commands:"
- @echo " run - Starts the Django development server (formats backend code first)"
- @echo " dev-frontend - Starts the frontend development server (formats frontend code first)"
- @echo " clean - Clears Django session data"
- @echo " collect - Collects Django static files"
- @echo " install-frontend - Installs frontend dependencies using bun"
- @echo " format - Formats both backend (Python) and frontend (JS/TS/CSS) code"
- @echo " format-backend - Formats Python code using ruff check and format"
- @echo " format-frontend - Formats frontend code using prettier"
- @echo " lint - Lints both backend (Python) and frontend (JS/TS/CSS) code"
- @echo " lint-backend - Lints Python code using ruff"
- @echo " lint-frontend - Lints frontend code using eslint"
- @echo " makemigrations - Creates new Django model migrations"
- @echo " migrate - Applies Django database migrations"
- @echo " shell - Opens a Django shell"
- @echo " createsuperuser - Creates a Django superuser account"
-
-run: format-backend
- @echo "Starting Django development server..."
- uv run manage.py runserver
-
-dev-frontend: format-frontend
- @echo "Starting frontend dev server from frontend/ folder..."
- cd frontend && bun run dev
-
-clean:
- @echo "Clearing Django session data..."
- uv run manage.py clearsession
-
-collect:
- @echo "Collecting Django static files (confirming 'yes')..."
- echo 'yes' | uv run manage.py collectstatic
-
-install-frontend:
- @echo "Installing frontend dependencies with bun..."
- cd frontend && bun install
-
-format: format-backend format-frontend
- @echo "All code formatted successfully!"
-
-format-backend:
- @echo "Formatting backend (Python) code with ruff check and format..."
- uv run ruff check --select I . --fix && \
- uv run ruff format
-
-format-frontend:
- @echo "Formatting frontend code with prettier..."
- cd frontend && bun run format | grep -v -F '(unchanged)' || true
-
-lint: lint-backend lint-frontend
- @echo "All code linted successfully!"
-
-lint-backend: format-backend
- @echo "Linting backend (Python) code with ruff..."
- uv run ruff check
-
-lint-frontend: format-frontend
- @echo "Linting frontend code with eslint..."
- cd frontend && bun run lint
-
-makemigrations:
- @echo "Creating Django database migrations..."
- uv run manage.py makemigrations
-
-migrate:
- @echo "Applying Django database migrations..."
- uv run manage.py migrate
-
-shell:
- @echo "Opening Django shell..."
- uv run manage.py shell
-
-createsuperuser:
- @echo "Creating Django superuser account..."
- uv run manage.py createsuperuser
diff --git a/apps/auth/utils.py b/apps/auth/utils.py
index 4c60c36..3e63695 100644
--- a/apps/auth/utils.py
+++ b/apps/auth/utils.py
@@ -46,7 +46,7 @@ def get_survey_details(action: str) -> dict[str, Any] | None:
try:
question_id = int(action_details.get("QUESTIONID"))
- except (ValueError, TypeError):
+ except ValueError, TypeError:
logger.error(
"Could not parse 'QUESTIONID' for action '%s'. Check your settings.", action
)
diff --git a/apps/auth/views.py b/apps/auth/views.py
index df1672d..01aafb0 100644
--- a/apps/auth/views.py
+++ b/apps/auth/views.py
@@ -240,7 +240,7 @@ def verify_callback_api(request):
otp_data = json.loads(otp_data_raw.decode("utf-8"))
expected_temp_token = otp_data.get("temp_token")
initiated_at = otp_data.get("initiated_at")
- except (json.JSONDecodeError, AttributeError):
+ except json.JSONDecodeError, AttributeError:
logger.error("Invalid OTP data format in verify_callback_api")
return Response({"error": "Invalid OTP data format"}, status=401)
@@ -268,7 +268,7 @@ def verify_callback_api(request):
status=401,
)
- except (ValueError, TypeError):
+ except ValueError, TypeError:
logger.error("Error parsing submission timestamp")
return Response({"error": "Invalid submission timestamp"}, status=401)
diff --git a/apps/web/migrations/0012_vote_web_vote_course__b117a9_idx.py b/apps/web/migrations/0012_vote_web_vote_course__b117a9_idx.py
new file mode 100644
index 0000000..d032ebc
--- /dev/null
+++ b/apps/web/migrations/0012_vote_web_vote_course__b117a9_idx.py
@@ -0,0 +1,21 @@
+# Generated by Django 5.2.8 on 2026-01-19 02:11
+
+from django.conf import settings
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("web", "0011_remove_course_difficulty_score_and_more"),
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ]
+
+ operations = [
+ migrations.AddIndex(
+ model_name="vote",
+ index=models.Index(
+ fields=["course", "category", "value"],
+ name="web_vote_course__b117a9_idx",
+ ),
+ ),
+ ]
diff --git a/apps/web/models/course.py b/apps/web/models/course.py
index dd2e8a4..da03f31 100644
--- a/apps/web/models/course.py
+++ b/apps/web/models/course.py
@@ -41,7 +41,7 @@ def search(self, query):
elif len(department_or_query) not in self.DEPARTMENT_LENGTHS:
# must be query, too long to be department. ignore numbers we may
# have. e.g. "Introduction"
- return Course.objects.filter(title__icontains=department_or_query)
+ return Course.objects.filter(course_title__icontains=department_or_query)
# elif number and subnumber:
# # course with number and subnumber
# # e.g. COSC 089.01
@@ -140,7 +140,7 @@ class Meta:
]
def __unicode__(self):
- return "{}: {}".format(self.short_name(), self.title)
+ return "{}: {}".format(self.short_name(), self.course_title)
def get_absolute_url(self):
return reverse("course_detail", args=[self.id])
diff --git a/apps/web/tests/conftest.py b/apps/web/tests/conftest.py
new file mode 100644
index 0000000..f543ec8
--- /dev/null
+++ b/apps/web/tests/conftest.py
@@ -0,0 +1,134 @@
+import pytest
+from django.conf import settings
+from django.urls import reverse
+from rest_framework.test import APIClient
+from apps.web.tests import factories
+
+# -------------------------------------------------------------------------
+# 1. Clients & Authentication
+# -------------------------------------------------------------------------
+
+
+@pytest.fixture
+def base_client():
+ """Returns an unauthenticated API client."""
+ return APIClient()
+
+
+@pytest.fixture
+def user(db):
+ """Returns a saved user instance."""
+ return factories.UserFactory()
+
+
+@pytest.fixture
+def auth_client(user, base_client):
+ """Returns an API client authenticated as the 'user' fixture."""
+ base_client.force_authenticate(user=user)
+ return base_client
+
+
+# -------------------------------------------------------------------------
+# 2. Data Fixtures (Models)
+# -------------------------------------------------------------------------
+
+
+@pytest.fixture
+def course(db):
+ """Returns a saved course instance."""
+ return factories.CourseFactory()
+
+
+@pytest.fixture
+def course_batch(db):
+ """Returns a batch of 3 general courses."""
+ return factories.CourseFactory.create_batch(3)
+
+
+@pytest.fixture
+def department_mixed_courses(db):
+ """Returns a mixed set of courses for filtering/sorting tests."""
+ # Note: Using course_title to match current course model field
+ return [
+ factories.CourseFactory(
+ department="MATH",
+ course_title="Honors Calculus II",
+ course_code="MATH1560J",
+ ),
+ factories.CourseFactory(
+ department="MATH", course_title="Calculus II", course_code="MATH1160J"
+ ),
+ factories.CourseFactory(
+ department="CHEM", course_title="Chemistry", course_code="CHEM2100J"
+ ),
+ ]
+
+
+@pytest.fixture
+def review(db, course, user, min_len):
+ """Returns a saved review instance belonging to 'user'."""
+ return factories.ReviewFactory(course=course, user=user, comments="a" * min_len)
+
+
+@pytest.fixture
+def other_review(db):
+ """Returns a review belonging to a different user for security testing."""
+ from apps.web.tests.factories import UserFactory, ReviewFactory
+
+ return ReviewFactory(user=UserFactory())
+
+
+@pytest.fixture
+def course_factory(db):
+ """Access the factory class directly for custom batch creation."""
+ return factories.CourseFactory
+
+
+# -------------------------------------------------------------------------
+# 3. Validation & Payloads
+# -------------------------------------------------------------------------
+
+
+@pytest.fixture
+def min_len():
+ """Retrieves the minimum comment length from project settings."""
+ return settings.WEB["REVIEW"]["COMMENT_MIN_LENGTH"]
+
+
+@pytest.fixture
+def valid_review_data(min_len):
+ """Generates a valid payload for review creation/update tests."""
+ return {
+ "term": "23F",
+ "professor": "Dr. Testing",
+ "comments": "a" * min_len,
+ }
+
+
+# -------------------------------------------------------------------------
+# 4. URL Fixtures (Routing)
+# -------------------------------------------------------------------------
+
+
+@pytest.fixture
+def course_reviews_url(course):
+ """URL for listing/posting reviews for a specific course."""
+ return reverse("course_review_api", kwargs={"course_id": course.id})
+
+
+@pytest.fixture
+def personal_reviews_list_url():
+ """URL for the current user's personal review list."""
+ return reverse("user_reviews_api")
+
+
+@pytest.fixture
+def personal_review_detail_url(review):
+ """URL for GET/PUT/DELETE a specific review owned by the user."""
+ return reverse("user_review_api", kwargs={"review_id": review.id})
+
+
+@pytest.fixture
+def other_review_detail_url(other_review):
+ """URL for a review NOT owned by the current user (used for 404/Security)."""
+ return reverse("user_review_api", kwargs={"review_id": other_review.id})
diff --git a/apps/web/tests/factories.py b/apps/web/tests/factories.py
index f501948..53c32ee 100644
--- a/apps/web/tests/factories.py
+++ b/apps/web/tests/factories.py
@@ -1,88 +1,79 @@
import factory
+import factory.fuzzy
from django.contrib.auth.models import User
-from apps.web import models
-from lib import constants
+# Import models from their individual files
+from apps.web.models.course import Course
+from apps.web.models.review import Review
+from apps.web.models.student import Student
+from apps.web.models.course_offering import CourseOffering
class UserFactory(factory.django.DjangoModelFactory):
class Meta:
model = User
- username = factory.Faker("first_name")
- email = factory.Faker("email")
- first_name = factory.Faker("first_name")
- last_name = factory.Faker("last_name")
- is_active = True
+ username = factory.Sequence(lambda n: f"user_{n}")
+ email = factory.LazyAttribute(lambda o: f"{o.username}@example.com")
@classmethod
- def _prepare(cls, create, **kwargs):
- # thanks: https://gist.github.com/mbrochh/2433411
- password = factory.Faker("password")
- if "password" in kwargs:
- password = kwargs.pop("password")
- user = super(UserFactory, cls)._prepare(create, **kwargs)
- user.set_password(password)
- if create:
- user.save()
- return user
+ def _create(cls, model_class, *args, **kwargs):
+ """Ensure password is hashed correctly so auth_client can log in"""
+ password = kwargs.pop("password", "password123")
+ obj = model_class(*args, **kwargs)
+ obj.set_password(password)
+ obj.save()
+ return obj
class CourseFactory(factory.django.DjangoModelFactory):
class Meta:
- model = models.Course
+ model = Course
- title = factory.Faker("words")
- department = "COSC"
- number = factory.Faker("random_number")
- url = factory.Faker("url")
- description = factory.Faker("text")
+ course_title = factory.Faker("sentence", nb_words=3)
+ department = factory.fuzzy.FuzzyChoice(["MATH", "PHYS", "EECS"])
+ number = factory.Sequence(lambda n: 100 + n)
+
+ @factory.lazy_attribute
+ def course_code(self):
+ """Generates unique MATH100, PHYS101, etc."""
+ return f"{self.department}{str(self.number):<04}J"
+
+ description = factory.Faker("paragraph")
class CourseOfferingFactory(factory.django.DjangoModelFactory):
class Meta:
- model = models.CourseOffering
+ model = CourseOffering
course = factory.SubFactory(CourseFactory)
-
- term = constants.CURRENT_TERM
- section = factory.Faker("random_number")
+ term = "23F"
+ section = factory.Sequence(lambda n: n)
period = "2A"
class ReviewFactory(factory.django.DjangoModelFactory):
class Meta:
- model = models.Review
+ model = Review
course = factory.SubFactory(CourseFactory)
user = factory.SubFactory(UserFactory)
+ term = "23F"
professor = factory.Faker("name")
- term = constants.CURRENT_TERM
comments = factory.Faker("paragraph")
-class DistributiveRequirementFactory(factory.django.DjangoModelFactory):
- class Meta:
- model = models.DistributiveRequirement
-
- name = "ART"
- distributive_type = models.DistributiveRequirement.DISTRIBUTIVE
-
-
class StudentFactory(factory.django.DjangoModelFactory):
class Meta:
- model = models.Student
+ model = Student
user = factory.SubFactory(UserFactory)
- confirmation_link = User.objects.make_random_password(length=16)
-class VoteFactory(factory.django.DjangoModelFactory):
+class DistributiveRequirementFactory(factory.django.DjangoModelFactory):
class Meta:
- model = models.Vote
+ # Using string reference for potential distributive requirements model
+ model = "web.DistributiveRequirement"
- value = 0
- course = factory.SubFactory(CourseFactory)
- user = factory.SubFactory(UserFactory)
- category = models.Vote.CATEGORIES.QUALITY
+ name = factory.Sequence(lambda n: f"Dist{n}")
diff --git a/apps/web/tests/lib_tests/test_terms.py b/apps/web/tests/lib_tests/test_terms.py
index 662deee..baffc6f 100644
--- a/apps/web/tests/lib_tests/test_terms.py
+++ b/apps/web/tests/lib_tests/test_terms.py
@@ -34,6 +34,12 @@ def test_term_regex_allows_for_lower_and_upper_terms(self):
and term_data.group("year") == "16"
and term_data.group("term") == "w"
)
+ term_data = terms.term_regex.match("16F")
+ self.assertTrue(
+ term_data
+ and term_data.group("year") == "16"
+ and term_data.group("term") == "F"
+ )
def test_term_regex_allows_for_current_term(self):
term_data = terms.term_regex.match(constants.CURRENT_TERM)
@@ -52,7 +58,7 @@ def test_numeric_value_of_term_returns_0_if_bad_term(self):
self.assertEqual(terms.numeric_value_of_term("fall"), 0)
def test_numeric_value_of_term_ranks_terms_in_correct_order(self):
- correct_order = ["", "09w", "09S", "09X", "12F", "14x", "15W", "16S", "20x"]
+ correct_order = ["", "09w", "09S", "09X", "12F", "14x", "15w", "16S", "20x"]
shuffled_data = list(correct_order)
while correct_order == shuffled_data:
random.shuffle(shuffled_data)
@@ -66,9 +72,10 @@ def test_numeric_value_of_term_gives_expected_numeric_value(self):
self.assertEqual(terms.numeric_value_of_term("16W"), 161)
def test_is_valid_term_returns_false_if_in_future(self):
- next_year = (
- int(terms.term_regex.match(constants.CURRENT_TERM).group("year")) + 1
- )
+ term_data = terms.term_regex.match(constants.CURRENT_TERM)
+ if term_data is None:
+ raise AssertionError("CURRENT_TERM did not match term_regex")
+ next_year = int(term_data.group("year")) + 1
self.assertFalse(terms.is_valid_term("{}f".format(next_year)))
def test_is_valid_term_returns_false_if_no_term(self):
diff --git a/apps/web/tests/model_tests/__init__.py b/apps/web/tests/model_tests/__init__.py
deleted file mode 100644
index e69de29..0000000
diff --git a/apps/web/tests/model_tests/test_course.py b/apps/web/tests/model_tests/test_course.py
deleted file mode 100644
index 9cba58b..0000000
--- a/apps/web/tests/model_tests/test_course.py
+++ /dev/null
@@ -1,161 +0,0 @@
-from django.test import TestCase
-
-from apps.web.models import Course, CourseOffering
-from apps.web.tests import factories
-
-
-class CourseTestCase(TestCase):
- TEST_TERM = "16W"
-
- def setUp(self):
- self.distrib = factories.DistributiveRequirementFactory()
- self.c1 = factories.CourseFactory()
- self.c2 = factories.CourseFactory()
- self.c1o = factories.CourseOfferingFactory(term=self.TEST_TERM, course=self.c1)
- self.c2o = factories.CourseOfferingFactory(term=self.TEST_TERM, course=self.c2)
-
- def test_for_term_retrieves_courses_for_term(self):
- self.assertEqual(len(Course.objects.for_term(self.TEST_TERM)), 2)
- CourseOffering.objects.all().delete()
- self.assertEqual(len(Course.objects.for_term(self.TEST_TERM)), 0)
-
- def test_for_term_filters_by_distrib_correctly(self):
- self.assertEqual(
- len(Course.objects.for_term(self.TEST_TERM, self.distrib.name)), 0
- )
- self.c1.distribs.add(self.distrib)
- self.c1.save()
- self.assertEqual(
- len(Course.objects.for_term(self.TEST_TERM, self.distrib.name)), 1
- )
-
- def test_review_search_retrieves_review_by_comments(self):
- c1r = factories.ReviewFactory(
- course=self.c1, comments="this class was not very good"
- )
- self.assertEqual(self.c1.search_reviews("CLASS")[0], c1r)
- self.assertEqual(len(self.c1.search_reviews("asdf")), 0)
-
- def test_review_search_retrieves_review_by_professor(self):
- c1r = factories.ReviewFactory(course=self.c1, professor="Layup List")
- self.assertEqual(self.c1.search_reviews("layup")[0], c1r)
- self.assertEqual(len(self.c1.search_reviews("easy")), 0)
-
- def test_review_search_retrieves_review_by_both_comments_and_professor(self):
- factories.ReviewFactory(course=self.c1, comments="this class is a layup")
- factories.ReviewFactory(course=self.c1, professor="Layup List")
- self.assertEqual(len(self.c1.search_reviews("lay")), 2)
-
- def test_is_offered_returns_true_if_offered_on_specified_term(self):
- self.assertTrue(self.c1.is_offered(self.TEST_TERM))
- self.c1o.term = "00X"
- self.c1o.save()
- self.assertFalse(self.c1.is_offered(self.TEST_TERM))
-
- def test_offered_times_retrieves_times_offered_for_term(self):
- offered_times = ("2A", "3B")
- self.c1o.period, self.c2o.period = offered_times
- self.c2o.course = self.c1
- self.c1o.save()
- self.c2o.save()
- times = self.c1.offered_times(self.TEST_TERM)
- self.assertEqual(len(times), len(offered_times))
- for time in times:
- self.assertTrue(time in offered_times)
-
- def test_offered_times_ignores_duplicates(self):
- offered_time = "2A"
- self.c1o.period, self.c2o.period = offered_time, offered_time
- self.c2o.course = self.c1
- self.c1o.save()
- self.c2o.save()
- times = self.c1.offered_times(self.TEST_TERM)
- self.assertEqual(len(times), 1)
- self.assertEqual(times[0], offered_time)
-
- def test_offered_times_redacts_unordinary_times(self):
- offered_times = ("2A", "F 4:00 PM-6:00 PM")
- self.c1o.period, self.c2o.period = offered_times
- self.c2o.course = self.c1
- self.c1o.save()
- self.c2o.save()
- times = self.c1.offered_times(self.TEST_TERM)
- self.assertEqual(len(times), len(offered_times))
- for time in times:
- self.assertTrue(time in offered_times or time == "other")
- self.assertTrue("other" in times)
-
-
-class CourseSearchTestCase(TestCase):
- DEPARTMENT_4 = "COSC"
- DEPARTMENT_3 = "REL"
-
- def setUp(self):
- self.c1 = factories.CourseFactory(department=self.DEPARTMENT_4, number=1)
- self.c2 = factories.CourseFactory(department=self.DEPARTMENT_4, number=2)
- self.c3 = factories.CourseFactory(department=self.DEPARTMENT_3, number=3)
- self.c4 = factories.CourseFactory(department=self.DEPARTMENT_3, number=4)
-
- def test_search_returns_nothing_if_no_query(self):
- self.assertEqual(len(Course.objects.search("")), 0)
-
- def test_searches_by_four_letter_department(self):
- # e.g. HIST
- self.c4.title = "something something {}".format(self.DEPARTMENT_4)
- self.c4.save()
- self.assertEqual(len(Course.objects.search(self.DEPARTMENT_4)), 2)
-
- def test_searches_by_three_letter_department(self):
- # e.g. REL
- self.c2.title = "something something {}".format(self.DEPARTMENT_3)
- self.c2.save()
- self.assertEqual(len(Course.objects.search(self.DEPARTMENT_3)), 2)
-
- def test_searches_as_query_if_dpt_length_but_not_valid_department(self):
- # e.g. war, boom
- self.c1.title = "the art of war"
- self.c2.title = "World War II"
- self.c1.save()
- self.c2.save()
- self.assertEqual(len(Course.objects.search("war")), 2)
-
- self.c1.title = "the art of boomerangs"
- self.c2.title = "World BOOM II"
- self.c1.save()
- self.c2.save()
- self.assertEqual(len(Course.objects.search("war")), 0)
- self.assertEqual(len(Course.objects.search("boom")), 2)
-
- def test_searches_by_number_and_subnumber(self):
- # e.g. COSC 089.01, COSC089.01
- self.assertEqual(
- len(Course.objects.search("{}1.5".format(self.DEPARTMENT_4))), 0
- )
- self.assertEqual(
- len(Course.objects.search("{} 1.5".format(self.DEPARTMENT_4))), 0
- )
- self.c1.subnumber = 5
- self.c1.save()
- self.assertEqual(
- len(Course.objects.search("{}1.5".format(self.DEPARTMENT_4))), 1
- )
- self.assertEqual(
- len(Course.objects.search("{} 1.5".format(self.DEPARTMENT_4))), 1
- )
-
- def test_searches_by_number_and_no_subnumber(self):
- # e.g. COSC 1
- self.assertEqual(len(Course.objects.search("{}1".format(self.DEPARTMENT_4))), 1)
- self.assertEqual(
- len(Course.objects.search("{} 1".format(self.DEPARTMENT_4))), 1
- )
- self.c1.delete()
- self.assertEqual(len(Course.objects.search("{}1".format(self.DEPARTMENT_4))), 0)
- self.assertEqual(
- len(Course.objects.search("{} 1".format(self.DEPARTMENT_4))), 0
- )
-
- def test_search_is_case_insensitive(self):
- self.c1.title = "The Art of War"
- self.c1.save()
- self.assertEqual(len(Course.objects.search("art of war")), 1)
diff --git a/apps/web/tests/model_tests/test_student.py b/apps/web/tests/model_tests/test_student.py
deleted file mode 100644
index 0cf902f..0000000
--- a/apps/web/tests/model_tests/test_student.py
+++ /dev/null
@@ -1,33 +0,0 @@
-from django.test import TestCase
-
-from apps.web.models import Vote
-from apps.web.tests import factories
-from lib import constants
-
-
-class StudentTestCase(TestCase):
- def test_can_see_recommendations(self):
- s = factories.StudentFactory()
- self.assertFalse(s.can_see_recommendations())
-
- # create sufficient votes of wrong type
- for _ in range(constants.REC_UPVOTE_REQ):
- factories.VoteFactory(
- user=s.user, category=Vote.CATEGORIES.DIFFICULTY, value=1
- )
- for value in [-1, 0]:
- for category in [c[0] for c in Vote.CATEGORIES.CHOICES]:
- factories.VoteFactory(user=s.user, category=category, value=value)
-
- # cannot view if does not reach vote count
- Vote.objects.all().delete()
- factories.ReviewFactory(user=s.user)
- for _ in range(constants.REC_UPVOTE_REQ - 1):
- factories.VoteFactory(
- user=s.user, category=Vote.CATEGORIES.QUALITY, value=1
- )
- self.assertFalse(s.can_see_recommendations())
-
- # can view
- factories.VoteFactory(user=s.user, category=Vote.CATEGORIES.QUALITY, value=1)
- self.assertTrue(s.can_see_recommendations())
diff --git a/apps/web/tests/model_tests/test_vote.py b/apps/web/tests/model_tests/test_vote.py
deleted file mode 100644
index da65912..0000000
--- a/apps/web/tests/model_tests/test_vote.py
+++ /dev/null
@@ -1,95 +0,0 @@
-from django.test import TestCase
-
-from apps.web.models import Course, Vote
-from apps.web.tests import factories
-
-
-class VoteTestCase(TestCase):
- def test_vote(self):
- gv = factories.VoteFactory(category=Vote.CATEGORIES.QUALITY)
- lv = factories.VoteFactory(
- course=gv.course, user=gv.user, category=Vote.CATEGORIES.DIFFICULTY
- )
- c = gv.course
- u = gv.user
-
- # doesn't work if value > 5
- self.assertEqual(
- (
- None,
- False,
- ),
- Vote.objects.vote(6, c.id, Vote.CATEGORIES.DIFFICULTY, u),
- )
-
- self.assertEqual(gv.value, 0)
- self.assertEqual(lv.value, 0)
- self.assertEqual(c.difficulty_score, 0)
- self.assertEqual(c.quality_score, 0)
-
- # can rate 1-5
- self.assertEqual(
- (
- 5.0,
- False,
- ),
- Vote.objects.vote(5, c.id, Vote.CATEGORIES.QUALITY, u.id),
- )
- self.assertEqual(
- (
- 2.0,
- False,
- ),
- Vote.objects.vote(2, c.id, Vote.CATEGORIES.DIFFICULTY, u.id),
- )
-
- gv.refresh_from_db()
- lv.refresh_from_db()
- c.refresh_from_db()
- self.assertEqual(gv.value, 5)
- self.assertEqual(lv.value, 2)
- self.assertEqual(c.quality_score, 5.0)
- self.assertEqual(c.difficulty_score, 2.0)
-
- # can unvote
- self.assertEqual(
- (
- 0.0,
- True,
- ),
- Vote.objects.vote(5, c.id, Vote.CATEGORIES.QUALITY, u.id),
- )
- self.assertEqual(
- (
- 0.0,
- True,
- ),
- Vote.objects.vote(2, c.id, Vote.CATEGORIES.DIFFICULTY, u.id),
- )
-
- gv.refresh_from_db()
- lv.refresh_from_db()
- c.refresh_from_db()
- self.assertEqual(gv.value, 0)
- self.assertEqual(lv.value, 0)
- self.assertEqual(c.quality_score, 0.0)
- self.assertEqual(c.difficulty_score, 0.0)
-
- def test_group_courses_with_votes(self):
- factories.CourseFactory()
- factories.CourseFactory()
- v = factories.VoteFactory()
- factories.CourseFactory()
-
- results = Vote.objects.group_courses_with_votes(
- Course.objects.all(),
- v.category,
- v.user,
- )
-
- self.assertEqual(4, len(results))
- for course, vote in results:
- if course == v.course:
- self.assertEqual(vote, v)
- else:
- self.assertEqual(vote, None)
diff --git a/apps/web/tests/test_auth.py b/apps/web/tests/test_auth.py
new file mode 100644
index 0000000..ffef89c
--- /dev/null
+++ b/apps/web/tests/test_auth.py
@@ -0,0 +1,56 @@
+import pytest
+from django.urls import reverse
+
+from apps.web.tests.factories import ReviewFactory
+
+
+@pytest.mark.django_db
+class TestUserStatusAPI:
+ def test_user_status_anonymous(self, base_client):
+ """Test that unauthenticated users get isAuthenticated=False"""
+ url = reverse("user_status")
+ response = base_client.get(url)
+
+ assert response.status_code == 200
+ assert response.data["isAuthenticated"] is False
+ assert "username" not in response.data
+
+ def test_user_status_authenticated(self, auth_client, user):
+ """Test that authenticated users get isAuthenticated=True and their username"""
+ url = reverse("user_status")
+ # auth_client is already logged in via the fixture in conftest.py
+ response = auth_client.get(url)
+
+ assert response.status_code == 200
+ assert response.data["isAuthenticated"] is True
+ assert response.data["username"] == user.username
+
+
+@pytest.mark.django_db
+class TestLandingPageAPI:
+ def test_landing_page_review_count(self, base_client, review):
+ """Verify landing page shows correct review statistics."""
+ url = reverse("landing_api")
+ response = base_client.get(url)
+ assert response.status_code == 200
+ # Should be at least 1 due to the 'review' fixture
+ assert response.data["review_count"] == 1
+
+ def test_landing_page_review_count_empty(self, base_client, db):
+ """Verify review count is 0 when no reviews exist in the database."""
+ url = reverse("landing_api")
+ response = base_client.get(url)
+
+ assert response.status_code == 200
+ assert response.data["review_count"] == 0
+
+ def test_landing_page_review_count_multiple(self, base_client, db):
+ """Verify review count returns the correct total when multiple reviews exist."""
+ # Create 5 reviews across different courses/users
+ ReviewFactory.create_batch(5)
+
+ url = reverse("landing_api")
+ response = base_client.get(url)
+
+ assert response.status_code == 200
+ assert response.data["review_count"] == 5
diff --git a/apps/web/tests/test_course.py b/apps/web/tests/test_course.py
new file mode 100644
index 0000000..dfccafc
--- /dev/null
+++ b/apps/web/tests/test_course.py
@@ -0,0 +1,307 @@
+import pytest
+from django.urls import reverse
+
+from apps.web.models import Review, Vote
+
+
+@pytest.mark.django_db
+class TestCourseAPIUnauthenticated:
+ def test_list_courses_anonymous(self, base_client, course_factory):
+ """Verify that any user can list courses with pagination."""
+ # Create 3 courses using the factory
+ course_factory.create_batch(3)
+
+ url = reverse("courses_api")
+ response = base_client.get(url)
+
+ assert response.status_code == 200
+ assert response.data["count"] == 3
+ assert "results" in response.data
+
+ def test_filter_courses_by_department(self, base_client, course_factory):
+ """Verify filtering courses by department code."""
+ # Create specific courses
+ course_factory(department="MATH", course_code="MATH101J")
+ course_factory(department="PHYS", course_code="PHYS101J")
+
+ url = reverse("courses_api")
+ # Test filtering for MATH department
+ response = base_client.get(url, {"department": "MATH"})
+
+ assert response.status_code == 200
+ # Check that filtering worked (only 1 course returned)
+ assert response.data["count"] == 1
+
+ # Check course_code instead of department key
+ # Since 'department' is not in the response, we verify 'MATH101J'
+ assert response.data["results"][0]["course_code"] == "MATH101J"
+
+ def test_filter_courses_by_code(self, base_client, course_factory):
+ course_factory(course_code="PHYS101J")
+ course_factory(course_code="MATH102J")
+ course_factory(course_code="MATH101J")
+
+ url = reverse("courses_api")
+
+ response = base_client.get(url, {"code": "MATH"})
+ assert response.status_code == 200
+ assert response.data["count"] == 2
+
+ response = base_client.get(url, {"code": "101"})
+ assert response.data["count"] == 2
+
+ def test_sort_courses_by_review_count_anonymous(
+ self, base_client, user, course_factory
+ ):
+ c1 = course_factory(course_code="MATH101J")
+ c2 = course_factory(course_code="MATH102J")
+ Review.objects.create(
+ course=c1, user=user, term="23S", professor="Prof X", comments="Great!"
+ )
+
+ url = reverse("courses_api")
+
+ response = base_client.get(
+ url, {"sort_by": "review_count", "sort_order": "desc"}
+ )
+ assert response.status_code == 200
+ assert response.data["results"][0]["course_code"] == c1.course_code
+ assert response.data["results"][1]["course_code"] == c2.course_code
+
+ def test_sort_courses_by_score_anonymous(self, base_client, user, course_factory):
+ c1 = course_factory(course_code="MATH101J")
+ Vote.objects.create(
+ user=user, course=c1, value=5, category=Vote.CATEGORIES.QUALITY
+ )
+
+ c2 = course_factory(course_code="MATH102J")
+ Vote.objects.create(
+ user=user, course=c2, value=1, category=Vote.CATEGORIES.QUALITY
+ )
+
+ url = reverse("courses_api")
+
+ response = base_client.get(
+ url, {"sort_by": "quality_score", "sort_order": "desc"}
+ )
+ assert response.status_code == 200
+ assert response.data["results"][0]["course_code"] == "MATH102J"
+ assert response.data["results"][1]["course_code"] == "MATH101J"
+
+ def test_filter_courses_by_score_anonymous(self, base_client, user, course_factory):
+ c1 = course_factory(course_code="MATH101J")
+ Vote.objects.create(
+ user=user, course=c1, value=5, category=Vote.CATEGORIES.QUALITY
+ )
+
+ c2 = course_factory(course_code="MATH102J")
+ Vote.objects.create(
+ user=user, course=c2, value=1, category=Vote.CATEGORIES.QUALITY
+ )
+
+ url = reverse("courses_api")
+
+ response = base_client.get(url, {"min_quality": 4})
+ assert response.status_code == 200
+ assert response.data["count"] == 2
+
+ def test_filter_courses_by_difficulty_anonymous(
+ self, base_client, user, course_factory
+ ):
+ c1 = course_factory(course_code="MATH101J")
+ Vote.objects.create(
+ user=user, course=c1, value=5, category=Vote.CATEGORIES.DIFFICULTY
+ )
+
+ c2 = course_factory(course_code="MATH102J")
+ Vote.objects.create(
+ user=user, course=c2, value=1, category=Vote.CATEGORIES.DIFFICULTY
+ )
+
+ url = reverse("courses_api")
+
+ response = base_client.get(url, {"min_difficulty": 4})
+ assert response.status_code == 200
+ assert response.data["count"] == 2
+
+ def test_course_detail_retrieval(self, base_client, course):
+ """Verify retrieving details for a specific course using its ID."""
+ url = reverse("course_detail_api", kwargs={"course_id": course.id})
+ response = base_client.get(url)
+
+ assert response.status_code == 200
+ # Verify the title matches the fixture-created course
+ assert response.data["course_title"] == course.course_title
+
+ def test_course_detail_fields_unauthenticated(self, base_client, course):
+ url = reverse("course_detail_api", kwargs={"course_id": course.id})
+ response = base_client.get(url)
+ assert response.status_code == 200
+
+ hidden_fields = [
+ "quality_score",
+ "difficulty_score",
+ "difficulty_vote",
+ "quality_vote",
+ "quality_vote_count",
+ "difficulty_vote_count",
+ ]
+ for field in hidden_fields:
+ assert field not in response.data
+
+ def test_department_listings(self, base_client, course_factory):
+ """Verify the endpoint that lists all departments and their course counts."""
+ course_factory(department="MATH")
+ course_factory(department="MATH")
+ course_factory(department="EECS")
+
+ url = reverse("departments_api")
+ response = base_client.get(url)
+
+ assert response.status_code == 200
+ assert isinstance(response.data, list)
+
+ # Find MATH department in the list
+ math_dept = next(item for item in response.data if item["code"] == "MATH")
+ assert math_dept["count"] == 2
+
+ def test_department_api_empty(self, base_client, db):
+ url = reverse("departments_api")
+ response = base_client.get(url)
+ assert response.status_code == 200
+ assert response.data == []
+
+ def test_department_api_sorting(self, base_client, course_factory):
+ course_factory(department="ENGL", course_code="ENGL1000J")
+ course_factory(department="MATH", course_code="MATH1560J")
+ response = base_client.get(reverse("departments_api"))
+ assert response.data[0]["code"] == "ENGL"
+
+
+@pytest.mark.django_db
+class TestCourseAPIAuthenticated:
+ def test_sort_by_review_count(self, auth_client, user, course_factory):
+ c_hot = course_factory(course_code="ENGR101J")
+ course_factory(course_code="ENGR100J")
+ Review.objects.create(
+ course=c_hot, user=user, term="23S", professor="Prof X", comments="Great!"
+ )
+ url = reverse("courses_api")
+
+ response = auth_client.get(
+ url, {"sort_by": "review_count", "sort_order": "desc"}
+ )
+
+ results = response.data["results"]
+ assert results[0]["course_code"] == "ENGR101J"
+ assert results[1]["course_code"] == "ENGR100J"
+
+ def test_filter_courses_by_quality(self, auth_client, user, course_factory):
+ c1 = course_factory(course_code="MATH101J")
+ Vote.objects.create(
+ user=user, course=c1, value=5, category=Vote.CATEGORIES.QUALITY
+ )
+
+ c2 = course_factory(course_code="MATH102J")
+ Vote.objects.create(
+ user=user, course=c2, value=1, category=Vote.CATEGORIES.QUALITY
+ )
+
+ url = reverse("courses_api")
+
+ response = auth_client.get(url, {"min_quality": 4})
+
+ assert response.status_code == 200
+ assert response.data["count"] == 1
+ assert response.data["results"][0]["course_code"] == "MATH101J"
+
+ def test_filter_courses_by_difficulty(self, auth_client, user, course_factory):
+ c1 = course_factory(course_code="MATH101J")
+ Vote.objects.create(
+ user=user, course=c1, value=5, category=Vote.CATEGORIES.DIFFICULTY
+ )
+
+ c2 = course_factory(course_code="MATH102J")
+ Vote.objects.create(
+ user=user, course=c2, value=1, category=Vote.CATEGORIES.DIFFICULTY
+ )
+
+ url = reverse("courses_api")
+
+ response = auth_client.get(url, {"min_difficulty": 4})
+
+ assert response.status_code == 200
+ assert response.data["count"] == 1
+ assert response.data["results"][0]["course_code"] == "MATH101J"
+
+ def test_sort_courses_by_quality_score(self, auth_client, user, course_factory):
+ c1 = course_factory(course_code="MATH101J")
+ Vote.objects.create(
+ user=user, course=c1, value=5, category=Vote.CATEGORIES.QUALITY
+ )
+
+ c2 = course_factory(course_code="MATH102J")
+ Vote.objects.create(
+ user=user, course=c2, value=1, category=Vote.CATEGORIES.QUALITY
+ )
+
+ url = reverse("courses_api")
+
+ response = auth_client.get(
+ url, {"sort_by": "quality_score", "sort_order": "desc"}
+ )
+ assert response.status_code == 200
+ assert response.data["results"][0]["course_code"] == "MATH101J"
+
+ def test_sort_courses_by_difficulty_score(self, auth_client, user, course_factory):
+ c1 = course_factory(course_code="MATH101J")
+ Vote.objects.create(
+ user=user, course=c1, value=5, category=Vote.CATEGORIES.DIFFICULTY
+ )
+
+ c2 = course_factory(course_code="MATH102J")
+ Vote.objects.create(
+ user=user, course=c2, value=1, category=Vote.CATEGORIES.DIFFICULTY
+ )
+
+ url = reverse("courses_api")
+
+ response = auth_client.get(
+ url, {"sort_by": "difficulty_score", "sort_order": "desc"}
+ )
+ assert response.status_code == 200
+ assert response.data["results"][0]["course_code"] == "MATH101J"
+
+ def test_sort_order_asc_and_desc(self, auth_client, course_factory):
+ course_factory(course_code="MATH101J")
+ course_factory(course_code="PHY101J")
+
+ url = reverse("courses_api")
+
+ # case 1: Ascending
+ res_asc = auth_client.get(url, {"sort_by": "course_code", "sort_order": "asc"})
+ assert res_asc.data["results"][0]["course_code"] == "MATH101J"
+ assert res_asc.data["results"][1]["course_code"] == "PHY101J"
+
+ # case 2: Descending
+ res_desc = auth_client.get(
+ url, {"sort_by": "course_code", "sort_order": "desc"}
+ )
+ assert res_desc.data["results"][0]["course_code"] == "PHY101J"
+ assert res_desc.data["results"][1]["course_code"] == "MATH101J"
+
+ def test_course_detail_fields_authenticated(self, auth_client, course):
+ url = reverse("course_detail_api", kwargs={"course_id": course.id})
+ response = auth_client.get(url)
+ assert response.status_code == 200
+
+ required_fields = [
+ "quality_score",
+ "difficulty_score",
+ "difficulty_vote",
+ "quality_vote",
+ "quality_vote_count",
+ "difficulty_vote_count",
+ ]
+ for field in required_fields:
+ assert field in response.data
diff --git a/apps/web/tests/test_review.py b/apps/web/tests/test_review.py
new file mode 100644
index 0000000..d9f7cb8
--- /dev/null
+++ b/apps/web/tests/test_review.py
@@ -0,0 +1,164 @@
+import pytest
+from django.urls import reverse
+from rest_framework import status
+
+from apps.web.models import Review
+from apps.web.tests.factories import ReviewFactory
+
+
+@pytest.mark.django_db
+class TestReviewAPIUnauthenticated:
+ def test_get_course_reviews_anonymous(self, base_client, course_reviews_url):
+ """1. Verify anonymous users cannot list course reviews."""
+ response = base_client.get(course_reviews_url)
+ assert response.status_code in [
+ status.HTTP_401_UNAUTHORIZED,
+ status.HTTP_403_FORBIDDEN,
+ ]
+
+ def test_get_personal_reviews_anonymous(
+ self, base_client, personal_reviews_list_url
+ ):
+ """2. Verify anonymous users cannot access personal review list."""
+ response = base_client.get(personal_reviews_list_url)
+ assert response.status_code in [
+ status.HTTP_401_UNAUTHORIZED,
+ status.HTTP_403_FORBIDDEN,
+ ]
+
+ def test_delete_review_anonymous_forbidden(self, base_client, review):
+ """3. Verify that unauthenticated users are forbidden from deleting reviews."""
+ url = reverse("user_review_api", kwargs={"review_id": review.id})
+ response = base_client.delete(url)
+ assert response.status_code in [
+ status.HTTP_401_UNAUTHORIZED,
+ status.HTTP_403_FORBIDDEN,
+ ]
+ assert Review.objects.filter(id=review.id).exists()
+
+ def test_review_detail_anonymous_forbidden(
+ self, base_client, personal_review_detail_url
+ ):
+ response = base_client.get(personal_review_detail_url)
+ assert response.status_code in [
+ status.HTTP_401_UNAUTHORIZED,
+ status.HTTP_403_FORBIDDEN,
+ ]
+
+
+@pytest.mark.django_db
+class TestReviewAPIAuthenticated:
+ def test_create_review_success(
+ self, auth_client, course_reviews_url, course, valid_review_data
+ ):
+ """4. Verify successful review creation with valid data."""
+ response = auth_client.post(
+ course_reviews_url, valid_review_data, format="json"
+ )
+ assert response.status_code == status.HTTP_201_CREATED
+ assert Review.objects.filter(course=course).count() == 1
+
+ def test_list_personal_reviews(
+ self, auth_client, personal_reviews_list_url, review, other_review
+ ):
+ """5. Verify user can list their own reviews."""
+ response = auth_client.get(personal_reviews_list_url)
+ assert response.status_code == status.HTTP_200_OK
+ assert any(r["id"] == review.id for r in response.data)
+ assert all(r["id"] != other_review.id for r in response.data)
+
+ def test_retrieve_review_detail(
+ self, auth_client, personal_review_detail_url, review
+ ):
+ """6. Verify user can retrieve their own review details."""
+ response = auth_client.get(personal_review_detail_url)
+ assert response.status_code == status.HTTP_200_OK
+ assert response.data["id"] == review.id
+
+ def test_filter_reviews_by_author_me(self, auth_client, course_reviews_url, review):
+ """7. Verify 'author=me' filters reviews for a specific course."""
+ ReviewFactory(course=review.course)
+ response = auth_client.get(course_reviews_url, {"author": "me"})
+ assert response.status_code == status.HTTP_200_OK
+ assert [r["id"] for r in response.data] == [review.id]
+
+ def test_search_reviews_by_professor(
+ self, auth_client, course_reviews_url, course, min_len
+ ):
+ """8. Verify search 'q' works for professor names."""
+ ReviewFactory(course=course, professor="UniqueProf", comments="c" * min_len)
+ ReviewFactory(course=course, professor="OtherProf", comments="c" * min_len)
+ response = auth_client.get(course_reviews_url, {"q": "UniqueProf"})
+ assert all(r["professor"] == "UniqueProf" for r in response.data)
+
+ def test_update_review_success(
+ self, auth_client, personal_review_detail_url, review, valid_review_data
+ ):
+ """9. Verify successful update of user's own review."""
+ valid_review_data["comments"] = "b" * len(valid_review_data["comments"])
+ response = auth_client.put(
+ personal_review_detail_url, valid_review_data, format="json"
+ )
+ assert response.status_code == status.HTTP_200_OK
+ review.refresh_from_db()
+ assert review.comments == valid_review_data["comments"]
+
+ def test_delete_review_success(
+ self, auth_client, personal_review_detail_url, review
+ ):
+ """10. Verify successful deletion of user's own review."""
+ response = auth_client.delete(personal_review_detail_url)
+ assert response.status_code == status.HTTP_204_NO_CONTENT
+ assert not Review.objects.filter(id=review.id).exists()
+
+ def test_create_validation_length_error(
+ self, auth_client, course_reviews_url, valid_review_data, min_len
+ ):
+ """13. Verify rejection of comments shorter than min_length."""
+ valid_review_data["comments"] = "a" * (min_len - 1)
+ response = auth_client.post(
+ course_reviews_url, valid_review_data, format="json"
+ )
+ assert response.status_code == status.HTTP_400_BAD_REQUEST
+
+ def test_update_validation_missing_field(
+ self, auth_client, personal_review_detail_url, min_len
+ ):
+ """14. Verify PUT fails if required fields (professor) are missing."""
+ response = auth_client.put(
+ personal_review_detail_url,
+ {"comments": "b" * min_len},
+ format="json",
+ )
+ assert response.status_code == status.HTTP_400_BAD_REQUEST
+
+ def test_duplicate_review_denied(self, auth_client, review, valid_review_data):
+ """15. Verify user cannot review the same course twice (403)."""
+ url = reverse("course_review_api", kwargs={"course_id": review.course.id})
+ response = auth_client.post(url, valid_review_data, format="json")
+ assert response.status_code == status.HTTP_403_FORBIDDEN
+
+ def test_access_other_user_review_404(self, auth_client, other_review_detail_url):
+ """16. Security: Verify user cannot access someone else's review ID."""
+ response = auth_client.get(other_review_detail_url)
+ assert response.status_code == status.HTTP_404_NOT_FOUND
+
+ def test_delete_non_existent_id(self, auth_client):
+ """17. Verify deletion of non-existent review ID returns 404."""
+ url = reverse("user_review_api", kwargs={"review_id": 99999})
+ response = auth_client.delete(url)
+ assert response.status_code == status.HTTP_404_NOT_FOUND
+
+ def test_post_to_invalid_course_id(self, auth_client, valid_review_data):
+ """18. Verify posting to non-existent course ID returns 404."""
+ url = reverse("course_review_api", kwargs={"course_id": 88888})
+ response = auth_client.post(url, valid_review_data, format="json")
+ assert response.status_code == status.HTTP_404_NOT_FOUND
+
+ def test_review_response_contains_votes(
+ self, auth_client, personal_review_detail_url
+ ):
+ """19. Verify vote statistics are included in the response."""
+ response = auth_client.get(personal_review_detail_url)
+ assert "kudos_count" in response.data
+ assert "dislike_count" in response.data
diff --git a/apps/web/tests/test_vote.py b/apps/web/tests/test_vote.py
new file mode 100644
index 0000000..f77b190
--- /dev/null
+++ b/apps/web/tests/test_vote.py
@@ -0,0 +1,105 @@
+import pytest
+from django.urls import reverse
+from rest_framework import status
+
+
+@pytest.mark.django_db
+class TestCourseVoteAPIAuthenticated:
+ def test_course_vote_quality_success(self, auth_client, course):
+ """Test authenticated user voting for course quality."""
+ url = reverse("course_vote_api", kwargs={"course_id": course.id})
+ data = {"value": 5, "forLayup": False}
+ response = auth_client.post(url, data, format="json")
+
+ assert response.status_code == status.HTTP_200_OK
+ assert "new_score" in response.data
+ assert response.data["new_vote_count"] == 1
+ assert response.data["was_unvote"] is False
+
+ def test_course_vote_change_value(self, auth_client, course):
+ """Verify user can change their vote value (e.g., from 5 to 3)."""
+ url = reverse("course_vote_api", kwargs={"course_id": course.id})
+
+ # Initial vote
+ auth_client.post(url, {"value": 5, "forLayup": False}, format="json")
+ # Change vote
+ response = auth_client.post(url, {"value": 3, "forLayup": False}, format="json")
+
+ assert response.status_code == status.HTTP_200_OK
+ assert response.data["new_score"] == 3.0
+ assert response.data["new_vote_count"] == 1 # Count stays same
+
+ def test_course_vote_cancel(self, auth_client, course):
+ """Verify voting the same value twice cancels (unvotes) the vote."""
+ url = reverse("course_vote_api", kwargs={"course_id": course.id})
+
+ auth_client.post(url, {"value": 5, "forLayup": False}, format="json")
+ # Vote same value again to toggle off
+ response = auth_client.post(url, {"value": 5, "forLayup": False}, format="json")
+
+ assert response.status_code == status.HTTP_200_OK
+ assert response.data["was_unvote"] is True
+ assert response.data["new_vote_count"] == 0
+
+ def test_course_vote_invalid_range_400(self, auth_client, course):
+ """Verify 400 error for scores outside 1-5."""
+ url = reverse("course_vote_api", kwargs={"course_id": course.id})
+ response = auth_client.post(
+ url, {"value": 10, "forLayup": False}, format="json"
+ )
+ assert response.status_code == status.HTTP_400_BAD_REQUEST
+
+
+@pytest.mark.django_db
+class TestCourseVoteAPIUnauthenticated:
+ def test_course_vote_anonymous_denied(self, base_client, course):
+ """Verify unauthenticated users cannot vote."""
+ url = reverse("course_vote_api", kwargs={"course_id": course.id})
+ response = base_client.post(url, {"value": 5, "forLayup": False}, format="json")
+ assert response.status_code in [
+ status.HTTP_401_UNAUTHORIZED,
+ status.HTTP_403_FORBIDDEN,
+ ]
+
+
+@pytest.mark.django_db
+class TestReviewVoteAPIAuthenticated:
+ def test_review_vote_kudos_success(self, auth_client, review):
+ """Test authenticated user giving kudos to a review."""
+ url = reverse("review_vote_api", kwargs={"review_id": review.id})
+ data = {"is_kudos": True}
+ response = auth_client.post(url, data, format="json")
+
+ assert response.status_code == status.HTTP_200_OK
+ assert response.data["kudos_count"] == 1
+ assert response.data["user_vote"] is True
+
+ def test_review_vote_toggle_off(self, auth_client, review):
+ """Verify that clicking kudos twice cancels the vote."""
+ url = reverse("review_vote_api", kwargs={"review_id": review.id})
+
+ auth_client.post(url, {"is_kudos": True}, format="json")
+ # Second click
+ response = auth_client.post(url, {"is_kudos": True}, format="json")
+
+ assert response.status_code == status.HTTP_200_OK
+ assert response.data["kudos_count"] == 0
+ assert response.data["user_vote"] is None
+
+ def test_review_vote_not_found_404(self, auth_client):
+ """Verify 404 for non-existent review ID."""
+ url = reverse("review_vote_api", kwargs={"review_id": 99999})
+ response = auth_client.post(url, {"is_kudos": True}, format="json")
+ assert response.status_code == status.HTTP_404_NOT_FOUND
+
+
+@pytest.mark.django_db
+class TestReviewVoteAPIUnauthenticated:
+ def test_review_vote_anonymous_denied(self, base_client, review):
+ """Verify unauthenticated users cannot vote on reviews."""
+ url = reverse("review_vote_api", kwargs={"review_id": review.id})
+ response = base_client.post(url, {"is_kudos": True}, format="json")
+ assert response.status_code in [
+ status.HTTP_401_UNAUTHORIZED,
+ status.HTTP_403_FORBIDDEN,
+ ]
diff --git a/apps/web/views.py b/apps/web/views.py
index 47e177e..8e55d9e 100644
--- a/apps/web/views.py
+++ b/apps/web/views.py
@@ -134,7 +134,7 @@ def _filter_by_score(self, queryset):
try:
threshold = int(param_value)
queryset = queryset.filter(**{f"{field_name}__gte": threshold})
- except (ValueError, TypeError):
+ except ValueError, TypeError:
pass
return queryset
diff --git a/compose.dev.yaml b/compose.dev.yaml
new file mode 100644
index 0000000..440bc30
--- /dev/null
+++ b/compose.dev.yaml
@@ -0,0 +1,34 @@
+services:
+ db:
+ ports:
+ - "5432:5432"
+
+ cache:
+ ports:
+ - "6379:6379"
+
+ backend:
+ build:
+ context: .
+ dockerfile: Containerfile
+ ports:
+ - "8000:8000"
+ env_file:
+ - .env
+ environment:
+ DATABASE__URL: ${DATABASE__URL:-postgres://admin:test@db:5432/coursereview}
+ REDIS__URL: ${REDIS__URL:-redis://cache:6379/0}
+ volumes:
+ - ./config.yaml:/app/config.yaml:ro
+
+ migrate:
+ build:
+ context: .
+ dockerfile: Containerfile
+ env_file:
+ - .env
+ environment:
+ DATABASE__URL: ${DATABASE__URL:-postgres://admin:test@db:5432/coursereview}
+ REDIS__URL: ${REDIS__URL:-redis://cache:6379/0}
+ volumes:
+ - ./config.yaml:/app/config.yaml:ro
diff --git a/compose.prod.yaml b/compose.prod.yaml
new file mode 100644
index 0000000..ba0c787
--- /dev/null
+++ b/compose.prod.yaml
@@ -0,0 +1,20 @@
+services:
+ backend:
+ ports:
+ - "8000:8000"
+ env_file:
+ - /etc/coursereview/secrets.env
+ environment:
+ DATABASE__URL: ${DATABASE__URL:-postgres://admin:test@db:5432/coursereview}
+ REDIS__URL: ${REDIS__URL:-redis://cache:6379/0}
+ volumes:
+ - /etc/coursereview/config.yaml:/app/config.yaml:ro
+
+ migrate:
+ env_file:
+ - /etc/coursereview/secrets.env
+ environment:
+ DATABASE__URL: ${DATABASE__URL:-postgres://admin:test@db:5432/coursereview}
+ REDIS__URL: ${REDIS__URL:-redis://cache:6379/0}
+ volumes:
+ - /etc/coursereview/config.yaml:/app/config.yaml:ro
diff --git a/compose.yaml b/compose.yaml
new file mode 100644
index 0000000..a20016d
--- /dev/null
+++ b/compose.yaml
@@ -0,0 +1,61 @@
+services:
+ db:
+ image: postgres:18-alpine
+ volumes:
+ - postgres18_data:/var/lib/postgresql/data
+ environment:
+ POSTGRES_DB: ${POSTGRES_DB:-coursereview}
+ POSTGRES_USER: ${POSTGRES_USER:-admin}
+ POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-test}
+ healthcheck:
+ test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-admin} -d ${POSTGRES_DB:-coursereview}"]
+ interval: 10s
+ timeout: 5s
+ retries: 5
+ restart: unless-stopped
+
+ cache:
+ image: valkey/valkey:9-alpine
+ healthcheck:
+ test: ["CMD", "valkey-cli", "ping"]
+ interval: 10s
+ timeout: 5s
+ retries: 5
+ restart: unless-stopped
+
+ backend:
+ image: coursereview-backend
+ depends_on:
+ db:
+ condition: service_healthy
+ cache:
+ condition: service_healthy
+ environment:
+ PYTHONUNBUFFERED: "1"
+ GUNICORN_CMD_ARGS: "--control-socket /tmp/gunicorn.ctl --worker-tmp-dir /tmp"
+ healthcheck:
+ test:
+ [
+ "CMD",
+ "python",
+ "-c",
+ "__import__('urllib.request').request.urlopen('http://127.0.0.1:8000/api/user/status/',timeout=5)",
+ ]
+ interval: 15s
+ timeout: 5s
+ retries: 5
+ start_period: 20s
+ restart: unless-stopped
+
+ migrate:
+ image: coursereview-backend
+ depends_on:
+ db:
+ condition: service_healthy
+ environment:
+ PYTHONUNBUFFERED: "1"
+ command: ["python", "django_manage.py", "migrate"]
+ restart: "no"
+
+volumes:
+ postgres18_data:
diff --git a/manage.py b/django_manage.py
similarity index 100%
rename from manage.py
rename to django_manage.py
diff --git a/docs/config.md b/docs/config.md
index b0b73d3..e68f003 100644
--- a/docs/config.md
+++ b/docs/config.md
@@ -1,38 +1,101 @@
# 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).
+CourseReview uses three configuration sources, merged in this order:
-## TL;DR
+```text
+environment variables > config.yaml > built-in defaults
+```
+
+Use:
+
+- environment variables for secrets and credentials
+- `config.yaml` for non-secret, environment-specific settings
+- built-in defaults for sane local defaults
+
+For local development, copy:
+
+- `.env.example` to `.env`
+- `config.yaml.example` to `config.yaml`
+
+Neither file should be committed.
+
+## Quick start
+
+1. Copy `.env.example` to `.env`
+2. Copy `config.yaml.example` to `config.yaml`
+3. Fill in required values
+4. Run the app with `python run.py dev`
+
+## Required values
+
+### Usually set in `.env`
+
+These are typically secrets:
+
+- `SECRET_KEY`
+- `TURNSTILE_SECRET_KEY`
+- `QUEST__SIGNUP__API_KEY`
+- `QUEST__LOGIN__API_KEY`
+- `QUEST__RESET_PASSWORD__API_KEY`
+
+Infrastructure URLs are also commonly set in `.env`:
+
+- `DATABASE__URL`
+- `REDIS__URL`
+
+### Usually set in `config.yaml`
+
+These are typically non-secret and environment-specific:
+
+- `DEBUG`
+- `ALLOWED_HOSTS`
+- `CORS_ALLOWED_ORIGINS`
+- `QUEST.SIGNUP.URL`
+- `QUEST.SIGNUP.QUESTIONID`
+- `QUEST.LOGIN.URL`
+- `QUEST.LOGIN.QUESTIONID`
+- `QUEST.RESET_PASSWORD.URL`
+- `QUEST.RESET_PASSWORD.QUESTIONID`
+
+## Environment variables
-1. Copy `.env.example` file to `.env`, fill in:
- - `SECRET_KEY`
- - `TURNSTILE_SECRET_KEY`
- - `QUEST__SIGNUP__API_KEY`
- - `QUEST__LOGIN__API_KEY`
- - `QUEST__RESET__API_KEY`
- - (If in production) `DATABASE__URL` and `REDIS__URL`
-2. Copy `config.yaml.example` to `config.yaml`, fill in:
- - `DEBUG`: `true` if at dev, `false` if in production
- - `URL` and `QUESTIONID` in all actions in `QUEST`
- - (If in production) backend domains in `ALLOWED_HOSTS`, frontend domains in `CORS_ALLOWED_ORIGINS`
-3. That's it!
+Environment variables use `__` to represent nesting.
-## Priority
+Examples:
-env > `config.yaml` > default config
+```env
+DATABASE__URL=postgres://admin:test@db:5432/coursereview
+REDIS__URL=redis://cache:6379/0
+AUTH__OTP_TIMEOUT=60
+WEB__COURSE__PAGE_SIZE=5
+QUEST__RESET_PASSWORD__QUESTIONID=10000002
+```
+
+Lists can be overridden with comma-separated strings:
+
+```env
+ALLOWED_HOSTS=localhost,127.0.0.1,api.example.com
+```
+
+### Local development note
+
+Keep the URLs in `.env` container-friendly:
+
+```env
+DATABASE__URL=postgres://admin:test@db:5432/coursereview
+REDIS__URL=redis://cache:6379/0
+```
+
+This is intentional.
-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.
+- Podman Compose needs `db` and `cache`
+- host-side commands such as `python run.py dev`, `python run.py django ...`, and `python run.py test` automatically rewrite them to `127.0.0.1`
-### Environment Variables
+So do **not** change `.env` to `localhost` just for host-side development.
-- 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.example`
-```env path=.env
+```env
# .env.example
# Copy this file to .env and fill in the secrets for local development.
# DO NOT COMMIT .env TO VERSION CONTROL.
@@ -49,8 +112,8 @@ SECRET_KEY=django-insecure-my-local-dev-secret-key
# --- 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@db:5432/coursereview
+REDIS__URL=redis://cache:6379/0
# --- External Services Secrets (REQUIRED) ---
TURNSTILE_SECRET_KEY=dummy0
@@ -65,25 +128,25 @@ QUEST__LOGIN__API_KEY=dummy2
# QUEST__LOGIN__URL=
# QUEST__LOGIN__QUESTIONID=
-QUEST__RESET__API_KEY=dummy3
-# QUEST__RESET__URL=
-# QUEST__RESET__QUESTIONID=
+QUEST__RESET_PASSWORD__API_KEY=dummy3
+# QUEST__RESET_PASSWORD__URL=
+# QUEST__RESET_PASSWORD__QUESTIONID=
# --- Other Overrides (Optional) ---
# Example of overriding a nested value in the AUTH dictionary
# AUTH__OTP_TIMEOUT=60
+# Example of overriding web size constraints
+# WEB__COURSE__PAGE_SIZE=5
+# WEB__REVIEW__PAGE_SIZE=10
+# WEB__REVIEW__COMMENT_MIN_LENGTH=30
# Example of overriding a list with a comma-separated string
# ALLOWED_HOSTS=localhost,127.0.0.1,dev.my-app.com
```
-### YAML
+## `config.yaml.example`
-- `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
+```yaml
# Please copy this file to config.yaml and fill in
# corresponding fields.
# For non-secret, environment-specific configuration.
@@ -108,6 +171,13 @@ CORS_ALLOWED_ORIGINS:
# COOKIE_AGE: 2592000 # 30 days
# SAVE_EVERY_REQUEST: true
#
+# WEB:
+# COURSE:
+# PAGE_SIZE: 5
+# REVIEW:
+# PAGE_SIZE: 10
+# COMMENT_MIN_LENGTH: 30
+#
# AUTH:
# OTP_TIMEOUT: 120
# TEMP_TOKEN_TIMEOUT: 600
@@ -116,6 +186,10 @@ CORS_ALLOWED_ORIGINS:
# PASSWORD_LENGTH_MIN: 10
# PASSWORD_LENGTH_MAX: 32
# EMAIL_DOMAIN_NAME: "sjtu.edu.cn"
+# ACTION_LIST:
+# - "signup"
+# - "login"
+# - "reset_password"
#
# DATABASE:
# URL: Use env
@@ -136,21 +210,19 @@ QUEST:
# API_KEY: Use env
URL: "https://wj.sjtu.edu.cn/q/dummy1"
QUESTIONID: 10000001
- RESET:
+ RESET_PASSWORD:
# API_KEY: Use env
URL: "https://wj.sjtu.edu.cn/q/dummy2"
QUESTIONID: 10000002
+
# AUTO_IMPORT_CRAWLED_DATA: true
```
-### Default Config
+## Built-in defaults
-- Just for example.
-- `settings.py` should **NOT** be modified by non-developers.
-- The fields whose default values are `None` are mandatory, either in env or in `config.yaml`.
+Current built-in defaults are:
-```python path=website/settings.py
-# --- Default Configuration ---
+```python
DEFAULTS = {
"DEBUG": True,
"SECRET_KEY": None,
@@ -160,6 +232,10 @@ DEFAULTS = {
"COOKIE_AGE": 2592000, # 30 days
"SAVE_EVERY_REQUEST": True,
},
+ "WEB": {
+ "COURSE": {"PAGE_SIZE": 10},
+ "REVIEW": {"PAGE_SIZE": 10, "COMMENT_MIN_LENGTH": 30},
+ },
"AUTH": {
"OTP_TIMEOUT": 120,
"TEMP_TOKEN_TIMEOUT": 600,
@@ -168,6 +244,7 @@ DEFAULTS = {
"PASSWORD_LENGTH_MIN": 10,
"PASSWORD_LENGTH_MAX": 32,
"EMAIL_DOMAIN_NAME": "sjtu.edu.cn",
+ "ACTION_LIST": ["signup", "login", "reset_password"],
},
"DATABASE": {"URL": "sqlite:///db.sqlite3"},
"REDIS": {"URL": "redis://localhost:6379/0", "MAX_CONNECTIONS": 100},
@@ -184,7 +261,7 @@ DEFAULTS = {
"URL": None,
"QUESTIONID": None,
},
- "RESET": {
+ "RESET_PASSWORD": {
"API_KEY": None,
"URL": None,
"QUESTIONID": None,
@@ -193,3 +270,19 @@ DEFAULTS = {
"AUTO_IMPORT_CRAWLED_DATA": True,
}
```
+
+## Production notes
+
+For production, usually:
+
+- set `DEBUG: false`
+- set real backend domains in `ALLOWED_HOSTS`
+- set real frontend domains in `CORS_ALLOWED_ORIGINS`
+- use a strong `SECRET_KEY`
+- use real Postgres and Valkey URLs
+- keep secrets in environment variables, not in `config.yaml`
+
+Typical production split:
+
+- `/etc/coursereview/secrets.env` for secrets
+- `/etc/coursereview/config.yaml` for non-secret config
diff --git a/docs/setup.md b/docs/setup.md
deleted file mode 100644
index 48a252b..0000000
--- a/docs/setup.md
+++ /dev/null
@@ -1,125 +0,0 @@
-# Development
-
-Environment:
-
-- Ubuntu Linux (most modern Linux distros and MacOS are supposedly supported.)
-
-- Use your corresponding package manager. This guide uses ubuntu/debian's `apt`, python `uv`, modern javascript runtime and package manager `bun`.
-
-- python 3.10 to 3.13
-
----
-
-1. `git clone git@github.com:TechJI-2023/CourseReview.git`
-
-2. `cd CourseReview`
-
-3. `git checkout dev`
-
-4. `uv sync --all-groups`
-
-5. `uv run prek install` (for installing git hook in .git)
-
-6. Make directory for builds of static files: `mkdir staticfiles`
-
-7. cp .env.example and rename it .env at root dir. The contents of PostgreSQL should be like:
-
- ```ini
- # PostgreSQL
- 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
- DEBUG=True
- OFFERINGS_THRESHOLD_FOR_TERM_UPDATE=100
- ```
-
- Also cp .env.example in frontend/ and rename it .env.
-
-8. Build static files: `make collect`
-
-9. Configure database
- 1. Install Postgres:
- - `sudo apt update`
-
- - `sudo apt install postgresql`
-
- 2. Create user postgres: `sudo -iu postgres`
-
- 3. Initialize database: `initdb -D /var/lib/postgres/data`
-
- 4. Start postgresql service: `sudo systemctl start postgresql`. Run `sudo systemctl enable postgresql` to auto-start postgresql service on start-up.
-
- 5. Switch to user postgres: `sudo -iu postgres`
-
- 6. `psql`
- 1. Initialize coursereview database, user and privileges
-
- ```sql
- CREATE DATABASE coursereview;
- CREATE USER admin WITH PASSWORD 'test'; -- This is the same password of admin in .env file above.
- GRANT ALL PRIVILEGES ON DATABASE coursereview TO admin;
- ALTER DATABASE coursereview OWNER TO admin;
- ```
-
- 2. Get the path of config file:
-
- ```sql
- SHOW config_file;
- ```
-
- 3. Copy the path (Ctrl+Shift+C by default)
-
- 4. Exit `psql` and switch back to normal user: `\q`, `exit`
-
- 7. Configure postgres to listen on all interfaces (DO NOT do this in production): `sudo vim {Path to your config file}`, example: `sudo vim /etc/postgresql/14/main/postgresql.conf`. Find the line `listen_addresses`, modify it to:
-
- ```ini
- listen_addresses = '0.0.0.0'
- ```
-
- 8. Grant permission to connect to postgres from any IP (DO NOT do this in production): `sudo vim /etc/postgresql/14/main/pg_hba.conf` (maybe differ from your path, just change the command according to the copied path) and add a line:
-
- ```ini
- host all all 0.0.0.0/0 md5
- ```
-
- 9. Restart postgres service: `sudo systemctl restart postgresql`
-
- 10. Auto setup database connection and static file routes in Django: `make migrate`, `make makemigrations`
-
-10. Install cache database valkey: `sudo apt install valkey`, `sudo systemctl start valkey`. Run `sudo systemctl enable valkey` to auto-start valkey service on start-up.
-
-11. `make run` and visit
-
-12. Add local admin:
- 1. `make createsuperuser`. The email can be blank. Use a strong password in production.
-
- 2. Enter interactive python shell: `make shell`. (Different from directly running `python` from shell.)
-
- 3. Run following python codes in interactive shell:
-
- ```python
- from django.contrib.auth.models import User
- u = User.objects.last()
- u.is_active = True
- u.is_staff = True
- u.is_admin = True
- u.save()
- ```
-
-13. Crawl data from JI official website:
- 1. Edit `COURSE_DETAIL_URL_PREFIX` in `apps/spider/crawlers/orc.py`: Add a number after url param `id` like this: `...?id=23`, so only course id starting from 23 (e.g. 230-239, 2300) will be crawled, so as to save time during development. Remember not to commit this change.
-
- 2. Enter interactive python shell: `make shell`.
-
- 3. Run following python codes in interactive shell:
-
- ```python
- from scripts import crawl_and_import_data
- crawl_and_import_data()
- ```
-
-14. Run frontend (dev mode): `make dev-frontend` and visit http://127.0.0.1:5173/
diff --git a/docs/usage.md b/docs/usage.md
new file mode 100644
index 0000000..ac86635
--- /dev/null
+++ b/docs/usage.md
@@ -0,0 +1,283 @@
+# Setup and Operations
+
+This project has:
+
+- a Django backend
+- a separate frontend repo deployed to Cloudflare Pages
+
+The backend does **not** build or serve the frontend app.
+It only serves the API and Django admin.
+
+## Tech stack
+
+- Python 3.14
+- Django 6
+- PostgreSQL 18
+- Valkey 9
+- Podman
+- uv
+- ruff
+- ty
+
+## Prerequisites
+
+Install:
+
+- `uv`
+- `podman`
+- `podman-compose` (required by `podman compose`)
+
+## Repository setup
+
+```bash
+git clone git@github.com:Tech-JI/CourseReview.git
+cd CourseReview
+./run.py init
+```
+
+`./run.py init` will:
+
+- create `.venv`
+- install dependencies
+- install hooks
+- create `.env` from `.env.example` if missing
+- create `config.yaml` from `config.yaml.example` if missing
+
+Then edit:
+
+- `.env`
+- `config.yaml`
+
+See `docs/config.md` for details.
+
+## Local development
+
+Recommended workflow:
+
+- run Postgres and Valkey in containers
+- run Django on the host
+
+Start everything:
+
+```bash
+./run.py dev
+```
+
+This will:
+
+- start `db` and `cache`
+- run migrations
+- start Django at `127.0.0.1:8000`
+
+### Common commands
+
+- Start only infra:
+
+ ```bash
+ ./run.py infra up
+ ```
+
+- Stop infra:
+
+ ```bash
+ ./run.py infra stop
+ ```
+
+- Destroy infra:
+
+ ```bash
+ ./run.py infra destroy
+ ```
+
+- Run migrations:
+
+ ```bash
+ ./run.py django migrate
+ ```
+
+- Create migrations:
+
+ ```bash
+ ./run.py django makemigrations
+ ```
+
+- Create superuser:
+
+ ```bash
+ ./run.py django createsuperuser
+ ```
+
+- Open Django shell:
+
+ ```bash
+ ./run.py django shell
+ ```
+
+- Run tests:
+
+ ```bash
+ ./run.py test
+ ```
+
+- Pass extra pytest arguments:
+
+ ```bash
+ ./run.py test -- -q
+ ```
+
+### Why `.env` uses `db` and `cache`
+
+Keep this in `.env`:
+
+```env
+DATABASE__URL=postgres://admin:test@db:5432/coursereview
+REDIS__URL=redis://cache:6379/0
+```
+
+This works for both cases:
+
+- Podman Compose uses `db` and `cache` directly
+- host-side commands automatically rewrite them to `127.0.0.1`
+
+So do not switch `.env` back and forth between `db` and `localhost`.
+
+## Full container stack
+
+If you want to run the entire backend stack in containers:
+
+```bash
+./run.py stack up --mode dev --build
+```
+
+Run migrations in the stack:
+
+```bash
+./run.py stack migrate --mode dev
+```
+
+Inspect services:
+
+```bash
+./run.py stack ps --mode dev
+./run.py stack logs --mode dev
+```
+
+Stop the stack:
+
+```bash
+./run.py stack down --mode dev
+```
+
+## CI testing
+
+Tests are run with `pytest`.
+
+Locally:
+
+```bash
+./run.py test
+```
+
+In GitHub Actions, the workflow uses service containers for:
+
+- `db`
+- `cache`
+
+and runs tests through `./run.py test`, so the same env normalization logic is used in CI and locally.
+
+## Production deployment
+
+Recommended production model:
+
+- build the image in GitHub Actions
+- publish it to `ghcr.io`
+- pull it on the server with Podman
+- run containers with Quadlet
+- keep secrets on the server
+- deploy by image digest
+
+### Registry
+
+Image name:
+
+```text
+ghcr.io/tech-ji/coursereview
+```
+
+Use GitHub Actions to publish images manually for now, instead of building on every push.
+
+### Server user
+
+Create a dedicated service user and group:
+
+- user: `coursereview`
+- group: `coursereview`
+
+Enable lingering so rootless user services can run without login:
+
+```bash
+sudo loginctl enable-linger coursereview
+```
+
+### Server config layout
+
+Recommended:
+
+```text
+/etc/coursereview/
+ secrets.env
+ config.yaml
+```
+
+Suggested ownership and permissions:
+
+- directory owned by `root:coursereview`
+- `secrets.env` readable by group `coursereview`
+- `config.yaml` readable by group `coursereview`
+
+### Quadlet
+
+Use rootless Quadlet units under:
+
+```text
+~/.config/containers/systemd/
+```
+
+for the `coursereview` user.
+
+Typical units:
+
+- one network
+- one Postgres container
+- one Valkey container
+- one Django container
+
+### Deployment workflow
+
+Recommended deploy flow:
+
+1. trigger GitHub Actions to publish image
+2. get the image digest
+3. pull that exact image on the server
+4. run migrations using that image
+5. restart the backend service
+
+Prefer:
+
+```text
+ghcr.io/tech-ji/coursereview@sha256:...
+```
+
+over floating tags like `latest`.
+
+### Migrations
+
+Run migrations explicitly during deploy, before restarting the backend.
+
+Do not rely on container startup to hide migration failures.
+
+## Static files and Django admin
+
+The Vue frontend is separate, but Django admin still needs Django static assets.
+
+So production still needs a static-files strategy for admin, typically `collectstatic`
diff --git a/frontend b/frontend
index 464bd99..4f5081a 160000
--- a/frontend
+++ b/frontend
@@ -1 +1 @@
-Subproject commit 464bd99514f4ae80a36107c4efc9663b526cc9bb
+Subproject commit 4f5081afd82023924911f42b7f0e6b3763a372d9
diff --git a/pyproject.toml b/pyproject.toml
index 90395bd..bdba2ab 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -3,24 +3,23 @@ name = "course-review"
requires-python = ">=3.14, <3.15"
version = "0.0.1"
dependencies = [
- "beautifulsoup4==4.14.2",
- "dj-database-url==3.0.1",
- "django==5.2.8",
- "django-debug-toolbar==6.1.0",
- "httpx==0.28.1",
- "psycopg2-binary==2.9.11",
- "python-dateutil==2.9.0",
- "python-dotenv==1.2.1",
- "pytz==2025.2",
- "redis==7.1.0",
- "requests==2.32.5",
- "bpython==0.26",
- "greenlet==3.2.4",
- "ptpython==3.0.31",
- "djangorestframework==3.16.1",
- "django-cors-headers==4.9.0",
- "django-redis==6.0.0",
- "pyyaml==6.0.3",
+ "beautifulsoup4>=4.14.0",
+ "bpython>=0.26",
+ "dj-database-url>=3.1.0",
+ "django>=6.0.0",
+ "django-cors-headers>=4.9.0",
+ "django-debug-toolbar>=6.3.0",
+ "django-redis>=6.0.0",
+ "djangorestframework>=3.17.0",
+ "gunicorn>=26.0.0",
+ "httpx>=0.28.0",
+ "psycopg[binary]>=3.3.0",
+ "python-dateutil>=2.9.0.post0",
+ "python-dotenv>=1.2.0",
+ "pytz>=2026.2",
+ "pyyaml>=6.0.0",
+ "redis>=7.4.0",
+ "requests>=2.34.0",
]
[tool.uv]
@@ -28,8 +27,17 @@ package = false
[dependency-groups]
dev = [
- "prek>=0.2.24",
+ "factory-boy>=3.3.0",
+ "prek>=0.4.0",
+ "pytest>=9.0.0",
+ "pytest-django>=4.12.0",
]
lint = [
- "ruff==0.14.5",
+ "ruff>=0.15.0",
]
+[tool.pytest.ini_options]
+DJANGO_SETTINGS_MODULE = "website.settings"
+python_files = ["tests.py", "test_*.py", "*_tests.py"]
+addopts = "--reuse-db --strict-markers --ignore=apps/web/tests/lib_tests"
+testpaths = ["apps"]
+pythonpath = ["."]
diff --git a/run.py b/run.py
new file mode 100755
index 0000000..0bf46a9
--- /dev/null
+++ b/run.py
@@ -0,0 +1,640 @@
+#!/usr/bin/env python
+
+from __future__ import annotations
+
+import argparse
+import os
+import shlex
+import shutil
+import subprocess
+import sys
+
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Final
+from urllib.parse import quote, unquote, urlparse, urlunparse
+
+
+PROJECT_ROOT: Final[Path] = Path(__file__).resolve().parent
+
+COMPOSE_BASE: Final[Path] = PROJECT_ROOT / "compose.yaml"
+COMPOSE_DEV: Final[Path] = PROJECT_ROOT / "compose.dev.yaml"
+COMPOSE_PROD: Final[Path] = PROJECT_ROOT / "compose.prod.yaml"
+
+ENV_EXAMPLE: Final[Path] = PROJECT_ROOT / ".env.example"
+ENV_DEV: Final[Path] = PROJECT_ROOT / ".env"
+CONFIG_EXAMPLE: Final[Path] = PROJECT_ROOT / "config.yaml.example"
+CONFIG_DEV: Final[Path] = PROJECT_ROOT / "config.yaml"
+
+
+class AppError(RuntimeError):
+ pass
+
+
+@dataclass(frozen=True)
+class Exec:
+ podman: str
+ compose: str
+ uv: str | None
+
+
+def _which(cmd: str) -> str | None:
+ return shutil.which(cmd)
+
+
+def _require(cmd: str) -> str:
+ p = _which(cmd)
+ if p is None:
+ raise AppError(f"Required executable not found on PATH: {cmd!r}")
+
+ return p
+
+
+def _detect_exec() -> Exec:
+ podman = _require("podman")
+ # On Debian, `podman compose` is often provided by podman-compose anyway,
+ # but the real, common entrypoint is `podman-compose`.
+ compose = _which("podman-compose") or "podman compose"
+ uv = _which("uv")
+
+ return Exec(podman=podman, compose=compose, uv=uv)
+
+
+def _run(
+ argv: list[str],
+ *,
+ cwd: Path = PROJECT_ROOT,
+ env: dict[str, str] | None = None,
+ check: bool = True,
+) -> None:
+ pretty = shlex.join(argv)
+ print(f"[run] {pretty}")
+ subprocess.run(argv, cwd=str(cwd), env=env, check=check)
+
+
+def _parse_env_file(path: Path) -> dict[str, str]:
+ """
+ Minimal .env parser:
+ - ignores blank lines and lines starting with '#'
+ - parses KEY=VALUE
+ - strips surrounding single/double quotes from VALUE
+ """
+
+ if not path.exists():
+ return {}
+
+ out: dict[str, str] = {}
+ for raw in path.read_text(encoding="utf-8").splitlines():
+ line = raw.strip()
+ if not line or line.startswith("#"):
+ continue
+ if "=" not in line:
+ continue
+ k, v = line.split("=", 1)
+ key = k.strip()
+ val = v.strip()
+
+ if (val.startswith('"') and val.endswith('"')) or (
+ val.startswith("'") and val.endswith("'")
+ ):
+ val = val[1:-1]
+
+ if key:
+ out[key] = val
+
+ return out
+
+
+def _effective_env(*, env_file: Path | None) -> dict[str, str]:
+ """
+ Merge env sources with the priority OS env > env_file
+ """
+
+ merged: dict[str, str] = {}
+ if env_file is not None:
+ merged.update(_parse_env_file(env_file))
+ merged.update(os.environ) # OS env overrides
+
+ return merged
+
+
+def _build_netloc(
+ *, username: str | None, password: str | None, host: str, port: int | None
+) -> str:
+ """
+ Re-encode user/pass safely for the URL
+ """
+
+ userinfo = ""
+ if username:
+ userinfo = quote(username, safe="")
+ if password is not None:
+ userinfo += f":{quote(password, safe='')}"
+ userinfo += "@"
+
+ hostport = host
+ if port is not None:
+ hostport = f"{host}:{port}"
+
+ return f"{userinfo}{hostport}"
+
+
+def _normalize_env_for_host(env: dict[str, str]) -> dict[str, str]:
+ """
+ Rewrite db -> 127.0.0.1 and cache -> 127.0.0.1
+ for running Django on the host.
+ """
+ out = dict(env)
+
+ db_url = out.get("DATABASE__URL")
+ if db_url:
+ p = urlparse(db_url)
+ if p.hostname == "db":
+ out["DATABASE__URL"] = _rewrite_url_host(db_url, new_host="127.0.0.1")
+ print("[info] Rewrote DATABASE__URL host db -> 127.0.0.1 for host commands")
+
+ redis_url = out.get("REDIS__URL")
+ if redis_url:
+ p = urlparse(redis_url)
+ if p.hostname == "cache":
+ out["REDIS__URL"] = _rewrite_url_host(redis_url, new_host="127.0.0.1")
+ print("[info] Rewrote REDIS__URL host cache -> 127.0.0.1 for host commands")
+
+ return out
+
+
+def _normalize_env_for_compose(env: dict[str, str]) -> dict[str, str]:
+ """
+ Rewrite localhost/127.0.0.1 -> db/cache
+ for running inside the container network.
+ """
+
+ out = dict(env)
+
+ db_url = out.get("DATABASE__URL")
+ if db_url:
+ p = urlparse(db_url)
+ if p.hostname in {"127.0.0.1", "localhost"}:
+ out["DATABASE__URL"] = _rewrite_url_host(db_url, new_host="db")
+ print(
+ "[info] Rewrote DATABASE__URL host localhost -> db for container runs"
+ )
+
+ redis_url = out.get("REDIS__URL")
+ if redis_url:
+ p = urlparse(redis_url)
+ if p.hostname in {"127.0.0.1", "localhost"}:
+ out["REDIS__URL"] = _rewrite_url_host(redis_url, new_host="cache")
+ print(
+ "[info] Rewrote REDIS__URL host localhost -> cache for container runs"
+ )
+
+ return out
+
+
+def _rewrite_url_host(url: str, *, new_host: str) -> str:
+ parsed = urlparse(url)
+ if not parsed.scheme or not parsed.hostname:
+ return url
+
+ netloc = _build_netloc(
+ username=parsed.username,
+ password=parsed.password,
+ host=new_host,
+ port=parsed.port,
+ )
+ return urlunparse(
+ (
+ parsed.scheme,
+ netloc,
+ parsed.path,
+ parsed.params,
+ parsed.query,
+ parsed.fragment,
+ )
+ )
+
+
+def _derive_postgres_env(env: dict[str, str]) -> dict[str, str]:
+ """
+ If POSTGRES_* variables are absent, derive them from DATABASE__URL.
+ """
+
+ db_url = env.get("DATABASE__URL")
+ if not db_url:
+ return env
+
+ parsed = urlparse(db_url)
+ if parsed.scheme not in {"postgres", "postgresql"}:
+ return env
+
+ # path is "/dbname"
+ db_name = parsed.path.lstrip("/") if parsed.path else ""
+ user = unquote(parsed.username) if parsed.username else ""
+ password = unquote(parsed.password) if parsed.password else ""
+ host = parsed.hostname or ""
+ port = str(parsed.port) if parsed.port is not None else ""
+
+ derived: dict[str, str] = {}
+ if "POSTGRES_DB" not in env and db_name:
+ derived["POSTGRES_DB"] = db_name
+ if "POSTGRES_USER" not in env and user:
+ derived["POSTGRES_USER"] = user
+ if "POSTGRES_PASSWORD" not in env and password:
+ derived["POSTGRES_PASSWORD"] = password
+ if "POSTGRES_HOST" not in env and host:
+ derived["POSTGRES_HOST"] = host
+ if "POSTGRES_PORT" not in env and port:
+ derived["POSTGRES_PORT"] = port
+
+ if not derived:
+ return env
+
+ # Do not print secrets; just say what we derived.
+ safe_keys = ", ".join(sorted(derived.keys()))
+ print(f"[info] Derived from DATABASE__URL: {safe_keys}")
+
+ out = dict(env)
+ out.update(derived)
+
+ return out
+
+
+def _compose_argv(exec_: Exec, *, mode: str, args: list[str]) -> list[str]:
+ files = [COMPOSE_BASE]
+ if mode == "dev":
+ files.append(COMPOSE_DEV)
+ elif mode == "prod":
+ files.append(COMPOSE_PROD)
+ else:
+ raise AppError(f"Unknown mode: {mode!r}")
+
+ for f in files:
+ if not f.exists():
+ raise AppError(f"Compose file missing: {f}")
+
+ if exec_.compose == "podman compose":
+ argv = ["podman", "compose"]
+ else:
+ argv = [exec_.compose]
+
+ for f in files:
+ argv += ["-f", str(f)]
+
+ argv += args
+
+ return argv
+
+
+def _warn_localhost_db_url_if_starting_backend(
+ env: dict[str, str], *, starting_backend: bool
+) -> None:
+ if not starting_backend:
+ return
+
+ db_url = env.get("DATABASE__URL", "")
+ if "@127.0.0.1" in db_url or "@localhost" in db_url:
+ print(
+ "[warn] DATABASE__URL points to localhost, but you are starting the backend container.\n"
+ " Inside containers, localhost refers to the container itself.\n"
+ " Use a URL with host 'db' (e.g. postgres://user:pass@db:5432/name) for container runs."
+ )
+
+
+def cmd_init(ns: argparse.Namespace) -> None:
+ exec_ = _detect_exec()
+ if exec_.uv is None:
+ raise AppError(
+ "uv is required for init/dev workflows but was not found on PATH."
+ )
+
+ # Copy templates if missing
+ if ns.create_files:
+ if ENV_EXAMPLE.exists() and not ENV_DEV.exists():
+ ENV_DEV.write_text(
+ ENV_EXAMPLE.read_text(encoding="utf-8"), encoding="utf-8"
+ )
+ print("[info] Created .env from .env.example (edit secrets as needed).")
+ if CONFIG_EXAMPLE.exists() and not CONFIG_DEV.exists():
+ CONFIG_DEV.write_text(
+ CONFIG_EXAMPLE.read_text(encoding="utf-8"), encoding="utf-8"
+ )
+ print(
+ "[info] Created config.yaml from config.yaml.example (edit as needed)."
+ )
+
+ # Create venv + sync deps
+ if ns.sync:
+ _run([exec_.uv, "venv", ".venv"])
+ _run([exec_.uv, "sync", "--all-groups"])
+ if ns.install_prek:
+ _run([exec_.uv, "run", "prek", "install"])
+
+
+def cmd_infra(ns: argparse.Namespace) -> None:
+ exec_ = _detect_exec()
+
+ env_file = ENV_DEV if ns.env_file is None else Path(ns.env_file)
+ env = _effective_env(env_file=env_file)
+ env = _normalize_env_for_compose(env)
+ env = _derive_postgres_env(env)
+
+ action = ns.action
+ if action == "down":
+ # 'down' is destructive in compose-land; for dev infra we want non-destructive.
+ print(
+ "[info] 'infra down' is treated as 'infra stop' (keeps DB volume). Use 'infra destroy' for a fresh DB."
+ )
+ action = "stop"
+
+ elif action == "up":
+ argv = _compose_argv(exec_, mode="dev", args=["up", "-d", "db", "cache"])
+ _run(argv, env=env)
+
+ elif action == "stop":
+ argv = _compose_argv(exec_, mode="dev", args=["stop", "db", "cache"])
+ _run(argv, env=env)
+
+ elif action == "start":
+ # Start existing containers; if they don't exist yet, fall back to up.
+ argv = _compose_argv(exec_, mode="dev", args=["start", "db", "cache"])
+ try:
+ _run(argv, env=env)
+ except subprocess.CalledProcessError:
+ argv = _compose_argv(exec_, mode="dev", args=["up", "-d", "db", "cache"])
+ _run(argv, env=env)
+
+ elif action == "restart":
+ # Restart existing containers; if they don't exist yet, fall back to up.
+ argv = _compose_argv(exec_, mode="dev", args=["restart", "db", "cache"])
+ try:
+ _run(argv, env=env)
+ except subprocess.CalledProcessError:
+ argv = _compose_argv(exec_, mode="dev", args=["up", "-d", "db", "cache"])
+ _run(argv, env=env)
+
+ elif action == "destroy":
+ # Full teardown (may result in a fresh DB depending on podman-compose behavior/config).
+ argv = _compose_argv(exec_, mode="dev", args=["down"])
+ _run(argv, env=env)
+
+ elif action == "ps":
+ argv = _compose_argv(exec_, mode="dev", args=["ps"])
+ _run(argv, env=env)
+
+ else:
+ raise AppError(f"Unknown infra action: {ns.action!r}")
+
+
+def _uv_run_manage(exec_: Exec, args: list[str], *, env_file: Path | None) -> None:
+ if exec_.uv is None:
+ raise AppError(
+ "uv is required for host Django commands but was not found on PATH."
+ )
+
+ env = _effective_env(env_file=env_file)
+ env = _normalize_env_for_host(env)
+ env = _derive_postgres_env(env)
+ _run([exec_.uv, "run", "django_manage.py", *args], env=env)
+
+
+def _uv_run_pytest(
+ exec_: Exec, pytest_args: list[str], *, env_file: Path | None
+) -> None:
+ if exec_.uv is None:
+ raise AppError("uv is required for host tests but was not found on PATH.")
+
+ env = _effective_env(env_file=env_file)
+ env = _normalize_env_for_host(env)
+ env = _derive_postgres_env(env)
+ _run([exec_.uv, "run", "pytest", *pytest_args], env=env)
+
+
+def cmd_django(ns: argparse.Namespace) -> None:
+ exec_ = _detect_exec()
+ env_file = ENV_DEV if ns.env_file is None else Path(ns.env_file)
+
+ match ns.action:
+ case "migrate":
+ _uv_run_manage(exec_, ["migrate"], env_file=env_file)
+ case "makemigrations":
+ _uv_run_manage(exec_, ["makemigrations"], env_file=env_file)
+ case "shell":
+ _uv_run_manage(exec_, ["shell"], env_file=env_file)
+ case "createsuperuser":
+ _uv_run_manage(exec_, ["createsuperuser"], env_file=env_file)
+ case _:
+ raise AppError(f"Unknown django action: {ns.action!r}")
+
+
+def cmd_test(ns: argparse.Namespace) -> None:
+ exec_ = _detect_exec()
+ env_file = ENV_DEV if ns.env_file is None else Path(ns.env_file)
+
+ pytest_args = list(ns.pytest_args)
+ if pytest_args and pytest_args[0] == "--":
+ pytest_args = pytest_args[1:]
+
+ _uv_run_pytest(exec_, pytest_args, env_file=env_file)
+
+
+def cmd_dev(ns: argparse.Namespace) -> None:
+ exec_ = _detect_exec()
+ env_file = ENV_DEV if ns.env_file is None else Path(ns.env_file)
+
+ if ns.infra:
+ cmd_infra(argparse.Namespace(action="up", env_file=str(env_file)))
+
+ if ns.migrate:
+ cmd_django(argparse.Namespace(action="migrate", env_file=str(env_file)))
+
+ if exec_.uv is None:
+ raise AppError("uv is required for dev server but was not found on PATH.")
+
+ _uv_run_manage(exec_, ["runserver", ns.addr], env_file=env_file)
+
+
+def cmd_hooks(ns: argparse.Namespace) -> None:
+ exec_ = _detect_exec()
+ if exec_.uv is None:
+ raise AppError("uv is required to run prek but was not found on PATH.")
+
+ _run([exec_.uv, "run", "prek", "-a"])
+
+
+def cmd_image(ns: argparse.Namespace) -> None:
+ exec_ = _detect_exec()
+ tag = ns.tag
+ _run(
+ [exec_.podman, "build", "-f", "Containerfile", "-t", tag, "."], cwd=PROJECT_ROOT
+ )
+
+
+def cmd_stack(ns: argparse.Namespace) -> None:
+ exec_ = _detect_exec()
+
+ env_file: Path | None
+ if ns.env_file is None:
+ env_file = ENV_DEV if ns.mode == "dev" else None
+ else:
+ env_file = Path(ns.env_file)
+
+ env = _effective_env(env_file=env_file)
+ env = _normalize_env_for_compose(env)
+ env = _derive_postgres_env(env)
+
+ starting_backend = ns.action in {"up", "restart"} and not ns.only_infra
+ _warn_localhost_db_url_if_starting_backend(env, starting_backend=starting_backend)
+
+ if ns.action == "up":
+ args = ["up", "-d"]
+ if ns.build:
+ args.append("--build")
+ if ns.only_infra:
+ args += ["db", "cache"]
+ argv = _compose_argv(exec_, mode=ns.mode, args=args)
+ _run(argv, env=env)
+
+ elif ns.action == "down":
+ argv = _compose_argv(exec_, mode=ns.mode, args=["down"])
+ _run(argv, env=env)
+
+ elif ns.action == "migrate":
+ argv = _compose_argv(exec_, mode=ns.mode, args=["run", "--rm", "migrate"])
+ _run(argv, env=env)
+
+ elif ns.action == "ps":
+ argv = _compose_argv(exec_, mode=ns.mode, args=["ps"])
+ _run(argv, env=env)
+
+ elif ns.action == "logs":
+ argv = _compose_argv(exec_, mode=ns.mode, args=["logs", "-f"])
+ _run(argv, env=env, check=False)
+
+ else:
+ raise AppError(f"Unknown stack action: {ns.action!r}")
+
+
+def _build_parser() -> argparse.ArgumentParser:
+ p = argparse.ArgumentParser(
+ prog="run.py", description="Project management utility (dev/prod)."
+ )
+ sub = p.add_subparsers(dest="cmd", required=True)
+
+ p_init = sub.add_parser(
+ "init", help="Bootstrap local dev environment (.venv, deps, hooks, templates)."
+ )
+ p_init.add_argument(
+ "--no-sync", dest="sync", action="store_false", help="Skip uv venv/sync."
+ )
+ p_init.add_argument(
+ "--no-prek",
+ dest="install_prek",
+ action="store_false",
+ help="Skip prek install.",
+ )
+ p_init.add_argument(
+ "--no-create-files",
+ dest="create_files",
+ action="store_false",
+ help="Skip copying example files.",
+ )
+ p_init.set_defaults(sync=True, install_prek=True, create_files=True, func=cmd_init)
+
+ p_infra = sub.add_parser(
+ "infra", help="Manage dev infra containers (db/cache only)."
+ )
+ p_infra.add_argument(
+ "action", choices=["up", "start", "stop", "restart", "down", "destroy", "ps"]
+ )
+ p_infra.add_argument(
+ "--env-file",
+ default=None,
+ help="Env file for substitution/derivation (default: .env).",
+ )
+ p_infra.set_defaults(func=cmd_infra)
+
+ p_dj = sub.add_parser(
+ "django", help="Run Django management commands on host (via uv)."
+ )
+ p_dj.add_argument(
+ "action", choices=["migrate", "makemigrations", "shell", "createsuperuser"]
+ )
+ p_dj.add_argument(
+ "--env-file", default=None, help="Env file used for Django (default: .env)."
+ )
+ p_dj.set_defaults(func=cmd_django)
+
+ p_test = sub.add_parser(
+ "test",
+ help="Run pytest on host (via uv) with host env normalization.",
+ )
+ p_test.add_argument(
+ "--env-file", default=None, help="Env file used for tests (default: .env)."
+ )
+ p_test.add_argument(
+ "pytest_args",
+ nargs=argparse.REMAINDER,
+ help="Arguments passed through to pytest. Use '--' before pytest flags.",
+ )
+ p_test.set_defaults(func=cmd_test)
+
+ p_dev = sub.add_parser(
+ "dev",
+ help="Run Django dev server on host (optionally start infra and migrate).",
+ )
+ p_dev.add_argument("--addr", default="127.0.0.1:8000")
+ p_dev.add_argument("--no-infra", dest="infra", action="store_false")
+ p_dev.add_argument("--no-migrate", dest="migrate", action="store_false")
+ p_dev.add_argument(
+ "--env-file", default=None, help="Env file used for Django (default: .env)."
+ )
+ p_dev.set_defaults(infra=True, migrate=True, func=cmd_dev)
+
+ p_hooks = sub.add_parser("hooks", help="Run all prek hooks on all files.")
+ p_hooks.set_defaults(func=cmd_hooks)
+
+ p_img = sub.add_parser("image", help="Container image operations.")
+ img_sub = p_img.add_subparsers(dest="action", required=True)
+ p_build = img_sub.add_parser("build", help="Build the backend container image.")
+ p_build.add_argument("--tag", default="coursereview-backend")
+ p_build.set_defaults(func=cmd_image)
+
+ p_stack = sub.add_parser("stack", help="Manage full container stack (dev/prod).")
+ p_stack.add_argument("action", choices=["up", "down", "migrate", "ps", "logs"])
+ p_stack.add_argument("--mode", choices=["dev", "prod"], default="dev")
+ p_stack.add_argument(
+ "--build",
+ action="store_true",
+ help="Build images when bringing up the stack (dev only usually).",
+ )
+ p_stack.add_argument(
+ "--only-infra",
+ action="store_true",
+ help="Only start db/cache (ignore backend).",
+ )
+ p_stack.add_argument(
+ "--env-file",
+ default=None,
+ help="Env file to load into the *compose process* for interpolation/derivation "
+ "(dev default: .env; prod default: none).",
+ )
+ p_stack.set_defaults(func=cmd_stack)
+
+ return p
+
+
+def main(argv: list[str]) -> int:
+ try:
+ ns = _build_parser().parse_args(argv)
+ ns.func(ns)
+ return 0
+ except AppError as e:
+ print(f"[error] {e}", file=sys.stderr)
+ return 2
+ except subprocess.CalledProcessError as e:
+ return e.returncode
+
+
+if __name__ == "__main__":
+ raise SystemExit(main(sys.argv[1:]))
diff --git a/scripts/entrypoint.py b/scripts/entrypoint.py
new file mode 100644
index 0000000..5982b09
--- /dev/null
+++ b/scripts/entrypoint.py
@@ -0,0 +1,25 @@
+#!/usr/bin/env python
+
+import shutil
+import subprocess
+import sys
+
+
+def main() -> None:
+ # If a command is provided (e.g. via compose `command:`), run it and exit.
+ # This enables: `command: ["python", "django_manage.py", "migrate"]`
+ if len(sys.argv) > 1:
+ subprocess.run(sys.argv[1:], check=True)
+ return
+
+ if shutil.which("gunicorn"):
+ subprocess.run(
+ ["gunicorn", "website.wsgi:application", "--bind", "0.0.0.0:8000"],
+ check=True,
+ )
+ else:
+ print("gunicorn not found.")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/uv.lock b/uv.lock
index 01be51a..dc4218e 100644
--- a/uv.lock
+++ b/uv.lock
@@ -13,59 +13,49 @@ wheels = [
[[package]]
name = "anyio"
-version = "4.11.0"
+version = "4.13.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "idna" },
- { name = "sniffio" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/c6/78/7d432127c41b50bccba979505f272c16cbcadcc33645d5fa3a738110ae75/anyio-4.11.0.tar.gz", hash = "sha256:82a8d0b81e318cc5ce71a5f1f8b5c4e63619620b63141ef8c995fa0db95a57c4", size = 219094, upload-time = "2025-09-23T09:19:12.58Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/15/b3/9b1a8074496371342ec1e796a96f99c82c945a339cd81a8e73de28b4cf9e/anyio-4.11.0-py3-none-any.whl", hash = "sha256:0287e96f4d26d4149305414d4e3bc32f0dcd0862365a4bddea19d7a1ec38c4fc", size = 109097, upload-time = "2025-09-23T09:19:10.601Z" },
-]
-
-[[package]]
-name = "appdirs"
-version = "1.4.4"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/d7/d8/05696357e0311f5b5c316d7b95f46c669dd9c15aaeecbb48c7d0aeb88c40/appdirs-1.4.4.tar.gz", hash = "sha256:7d5d0167b2b1ba821647616af46a749d1c653740dd0d2415100fe26e27afdf41", size = 13470, upload-time = "2020-05-11T07:59:51.037Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/3b/00/2344469e2084fb287c2e0b57b72910309874c3245463acd6cf5e3db69324/appdirs-1.4.4-py2.py3-none-any.whl", hash = "sha256:a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128", size = 9566, upload-time = "2020-05-11T07:59:49.499Z" },
+ { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" },
]
[[package]]
name = "asgiref"
-version = "3.11.0"
+version = "3.11.1"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/76/b9/4db2509eabd14b4a8c71d1b24c8d5734c52b8560a7b1e1a8b56c8d25568b/asgiref-3.11.0.tar.gz", hash = "sha256:13acff32519542a1736223fb79a715acdebe24286d98e8b164a73085f40da2c4", size = 37969, upload-time = "2025-11-19T15:32:20.106Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/63/40/f03da1264ae8f7cfdbf9146542e5e7e8100a4c66ab48e791df9a03d3f6c0/asgiref-3.11.1.tar.gz", hash = "sha256:5f184dc43b7e763efe848065441eac62229c9f7b0475f41f80e207a114eda4ce", size = 38550, upload-time = "2026-02-03T13:30:14.33Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/91/be/317c2c55b8bbec407257d45f5c8d1b6867abc76d12043f2d3d58c538a4ea/asgiref-3.11.0-py3-none-any.whl", hash = "sha256:1db9021efadb0d9512ce8ffaf72fcef601c7b73a8807a1bb2ef143dc6b14846d", size = 24096, upload-time = "2025-11-19T15:32:19.004Z" },
+ { url = "https://files.pythonhosted.org/packages/5c/0a/a72d10ed65068e115044937873362e6e32fab1b7dce0046aeb224682c989/asgiref-3.11.1-py3-none-any.whl", hash = "sha256:e8667a091e69529631969fd45dc268fa79b99c92c5fcdda727757e52146ec133", size = 24345, upload-time = "2026-02-03T13:30:13.039Z" },
]
[[package]]
name = "beautifulsoup4"
-version = "4.14.2"
+version = "4.14.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "soupsieve" },
{ name = "typing-extensions" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/77/e9/df2358efd7659577435e2177bfa69cba6c33216681af51a707193dec162a/beautifulsoup4-4.14.2.tar.gz", hash = "sha256:2a98ab9f944a11acee9cc848508ec28d9228abfd522ef0fad6a02a72e0ded69e", size = 625822, upload-time = "2025-09-29T10:05:42.613Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/c3/b0/1c6a16426d389813b48d95e26898aff79abbde42ad353958ad95cc8c9b21/beautifulsoup4-4.14.3.tar.gz", hash = "sha256:6292b1c5186d356bba669ef9f7f051757099565ad9ada5dd630bd9de5fa7fb86", size = 627737, upload-time = "2025-11-30T15:08:26.084Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/94/fe/3aed5d0be4d404d12d36ab97e2f1791424d9ca39c2f754a6285d59a3b01d/beautifulsoup4-4.14.2-py3-none-any.whl", hash = "sha256:5ef6fa3a8cbece8488d66985560f97ed091e22bbc4e9c2338508a9d5de6d4515", size = 106392, upload-time = "2025-09-29T10:05:43.771Z" },
+ { url = "https://files.pythonhosted.org/packages/1a/39/47f9197bdd44df24d67ac8893641e16f386c984a0619ef2ee4c51fbbc019/beautifulsoup4-4.14.3-py3-none-any.whl", hash = "sha256:0918bfe44902e6ad8d57732ba310582e98da931428d231a5ecb9e7c703a735bb", size = 107721, upload-time = "2025-11-30T15:08:24.087Z" },
]
[[package]]
name = "blessed"
-version = "1.25.0"
+version = "1.42.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "jinxed", marker = "sys_platform == 'win32'" },
+ { name = "jinxed" },
{ name = "wcwidth" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/33/cd/eed8b82f1fabcb817d84b24d0780b86600b5c3df7ec4f890bcbb2371b0ad/blessed-1.25.0.tar.gz", hash = "sha256:606aebfea69f85915c7ca6a96eb028e0031d30feccc5688e13fd5cec8277b28d", size = 6746381, upload-time = "2025-11-18T18:43:52.71Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/ef/48/27ed9ee1a574c96453a20f2024d385ec4c33ffeef180faab744c666e7dcd/blessed-1.42.0.tar.gz", hash = "sha256:34b460b77562ed21f807cfd7c527b983b0cc300c98810c8076f283b7bcd45ba7", size = 14025805, upload-time = "2026-05-20T16:03:18.293Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/7c/2c/e9b6dd824fb6e76dbd39a308fc6f497320afd455373aac8518ca3eba7948/blessed-1.25.0-py3-none-any.whl", hash = "sha256:e52b9f778b9e10c30b3f17f6b5f5d2208d1e9b53b270f1d94fc61a243fc4708f", size = 95646, upload-time = "2025-11-18T18:43:50.924Z" },
+ { url = "https://files.pythonhosted.org/packages/d2/46/c41f906f2488e14ab65ac77101f9511779fb28c653484bd79ac13f2f21ee/blessed-1.42.0-py3-none-any.whl", hash = "sha256:f96c4a6dc664b48e0b832fa732acc16df67abd30f0ec35babf99025982f21852", size = 129863, upload-time = "2026-05-20T16:03:15.622Z" },
]
[[package]]
@@ -87,36 +77,61 @@ wheels = [
[[package]]
name = "certifi"
-version = "2025.11.12"
+version = "2026.5.20"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/a2/8c/58f469717fa48465e4a50c014a0400602d3c437d7c0c468e17ada824da3a/certifi-2025.11.12.tar.gz", hash = "sha256:d8ab5478f2ecd78af242878415affce761ca6bc54a22a27e026d7c25357c3316", size = 160538, upload-time = "2025-11-12T02:54:51.517Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/f3/ce/ee2ecad540810a79593028e88299baeae54d346cc7a0d94b6199988b89b1/certifi-2026.5.20.tar.gz", hash = "sha256:69dea482ab64caa7b9f6aba1c6bf48bb6a5448d1c0f1b17ab42ad8c763a5344d", size = 135422, upload-time = "2026-05-20T11:46:50.073Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/70/7d/9bc192684cea499815ff478dfcdc13835ddf401365057044fb721ec6bddb/certifi-2025.11.12-py3-none-any.whl", hash = "sha256:97de8790030bbd5c2d96b7ec782fc2f7820ef8dba6db909ccf95449f2d062d4b", size = 159438, upload-time = "2025-11-12T02:54:49.735Z" },
+ { url = "https://files.pythonhosted.org/packages/59/8c/57e832b7af6d7c5abe66eb3fbe3a3a32f4d11ea23a1aa7131371035be991/certifi-2026.5.20-py3-none-any.whl", hash = "sha256:3c52e209ba0a4ad7aebe60436a4ab349c39e1e602e8c134221e546902ad25897", size = 134134, upload-time = "2026-05-20T11:46:48.578Z" },
]
[[package]]
name = "charset-normalizer"
-version = "3.4.4"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/2a/35/7051599bd493e62411d6ede36fd5af83a38f37c4767b92884df7301db25d/charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd", size = 207746, upload-time = "2025-10-14T04:41:33.773Z" },
- { url = "https://files.pythonhosted.org/packages/10/9a/97c8d48ef10d6cd4fcead2415523221624bf58bcf68a802721a6bc807c8f/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb", size = 147889, upload-time = "2025-10-14T04:41:34.897Z" },
- { url = "https://files.pythonhosted.org/packages/10/bf/979224a919a1b606c82bd2c5fa49b5c6d5727aa47b4312bb27b1734f53cd/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e", size = 143641, upload-time = "2025-10-14T04:41:36.116Z" },
- { url = "https://files.pythonhosted.org/packages/ba/33/0ad65587441fc730dc7bd90e9716b30b4702dc7b617e6ba4997dc8651495/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14", size = 160779, upload-time = "2025-10-14T04:41:37.229Z" },
- { url = "https://files.pythonhosted.org/packages/67/ed/331d6b249259ee71ddea93f6f2f0a56cfebd46938bde6fcc6f7b9a3d0e09/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191", size = 159035, upload-time = "2025-10-14T04:41:38.368Z" },
- { url = "https://files.pythonhosted.org/packages/67/ff/f6b948ca32e4f2a4576aa129d8bed61f2e0543bf9f5f2b7fc3758ed005c9/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838", size = 152542, upload-time = "2025-10-14T04:41:39.862Z" },
- { url = "https://files.pythonhosted.org/packages/16/85/276033dcbcc369eb176594de22728541a925b2632f9716428c851b149e83/charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6", size = 149524, upload-time = "2025-10-14T04:41:41.319Z" },
- { url = "https://files.pythonhosted.org/packages/9e/f2/6a2a1f722b6aba37050e626530a46a68f74e63683947a8acff92569f979a/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e", size = 150395, upload-time = "2025-10-14T04:41:42.539Z" },
- { url = "https://files.pythonhosted.org/packages/60/bb/2186cb2f2bbaea6338cad15ce23a67f9b0672929744381e28b0592676824/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c", size = 143680, upload-time = "2025-10-14T04:41:43.661Z" },
- { url = "https://files.pythonhosted.org/packages/7d/a5/bf6f13b772fbb2a90360eb620d52ed8f796f3c5caee8398c3b2eb7b1c60d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090", size = 162045, upload-time = "2025-10-14T04:41:44.821Z" },
- { url = "https://files.pythonhosted.org/packages/df/c5/d1be898bf0dc3ef9030c3825e5d3b83f2c528d207d246cbabe245966808d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152", size = 149687, upload-time = "2025-10-14T04:41:46.442Z" },
- { url = "https://files.pythonhosted.org/packages/a5/42/90c1f7b9341eef50c8a1cb3f098ac43b0508413f33affd762855f67a410e/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828", size = 160014, upload-time = "2025-10-14T04:41:47.631Z" },
- { url = "https://files.pythonhosted.org/packages/76/be/4d3ee471e8145d12795ab655ece37baed0929462a86e72372fd25859047c/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec", size = 154044, upload-time = "2025-10-14T04:41:48.81Z" },
- { url = "https://files.pythonhosted.org/packages/b0/6f/8f7af07237c34a1defe7defc565a9bc1807762f672c0fde711a4b22bf9c0/charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9", size = 99940, upload-time = "2025-10-14T04:41:49.946Z" },
- { url = "https://files.pythonhosted.org/packages/4b/51/8ade005e5ca5b0d80fb4aff72a3775b325bdc3d27408c8113811a7cbe640/charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c", size = 107104, upload-time = "2025-10-14T04:41:51.051Z" },
- { url = "https://files.pythonhosted.org/packages/da/5f/6b8f83a55bb8278772c5ae54a577f3099025f9ade59d0136ac24a0df4bde/charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2", size = 100743, upload-time = "2025-10-14T04:41:52.122Z" },
- { url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" },
+version = "3.4.7"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", size = 309234, upload-time = "2026-04-02T09:27:07.194Z" },
+ { url = "https://files.pythonhosted.org/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", size = 208042, upload-time = "2026-04-02T09:27:08.749Z" },
+ { url = "https://files.pythonhosted.org/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", size = 228706, upload-time = "2026-04-02T09:27:09.951Z" },
+ { url = "https://files.pythonhosted.org/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41", size = 224727, upload-time = "2026-04-02T09:27:11.175Z" },
+ { url = "https://files.pythonhosted.org/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e", size = 215882, upload-time = "2026-04-02T09:27:12.446Z" },
+ { url = "https://files.pythonhosted.org/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae", size = 200860, upload-time = "2026-04-02T09:27:13.721Z" },
+ { url = "https://files.pythonhosted.org/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18", size = 211564, upload-time = "2026-04-02T09:27:15.272Z" },
+ { url = "https://files.pythonhosted.org/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b", size = 211276, upload-time = "2026-04-02T09:27:16.834Z" },
+ { url = "https://files.pythonhosted.org/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356", size = 201238, upload-time = "2026-04-02T09:27:18.229Z" },
+ { url = "https://files.pythonhosted.org/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab", size = 230189, upload-time = "2026-04-02T09:27:19.445Z" },
+ { url = "https://files.pythonhosted.org/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46", size = 211352, upload-time = "2026-04-02T09:27:20.79Z" },
+ { url = "https://files.pythonhosted.org/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44", size = 227024, upload-time = "2026-04-02T09:27:22.063Z" },
+ { url = "https://files.pythonhosted.org/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72", size = 217869, upload-time = "2026-04-02T09:27:23.486Z" },
+ { url = "https://files.pythonhosted.org/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10", size = 148541, upload-time = "2026-04-02T09:27:25.146Z" },
+ { url = "https://files.pythonhosted.org/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", size = 159634, upload-time = "2026-04-02T09:27:26.642Z" },
+ { url = "https://files.pythonhosted.org/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246", size = 148384, upload-time = "2026-04-02T09:27:28.271Z" },
+ { url = "https://files.pythonhosted.org/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24", size = 330133, upload-time = "2026-04-02T09:27:29.474Z" },
+ { url = "https://files.pythonhosted.org/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79", size = 216257, upload-time = "2026-04-02T09:27:30.793Z" },
+ { url = "https://files.pythonhosted.org/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960", size = 234851, upload-time = "2026-04-02T09:27:32.44Z" },
+ { url = "https://files.pythonhosted.org/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4", size = 233393, upload-time = "2026-04-02T09:27:34.03Z" },
+ { url = "https://files.pythonhosted.org/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e", size = 223251, upload-time = "2026-04-02T09:27:35.369Z" },
+ { url = "https://files.pythonhosted.org/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1", size = 206609, upload-time = "2026-04-02T09:27:36.661Z" },
+ { url = "https://files.pythonhosted.org/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44", size = 220014, upload-time = "2026-04-02T09:27:38.019Z" },
+ { url = "https://files.pythonhosted.org/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e", size = 218979, upload-time = "2026-04-02T09:27:39.37Z" },
+ { url = "https://files.pythonhosted.org/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3", size = 209238, upload-time = "2026-04-02T09:27:40.722Z" },
+ { url = "https://files.pythonhosted.org/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0", size = 236110, upload-time = "2026-04-02T09:27:42.33Z" },
+ { url = "https://files.pythonhosted.org/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e", size = 219824, upload-time = "2026-04-02T09:27:43.924Z" },
+ { url = "https://files.pythonhosted.org/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb", size = 233103, upload-time = "2026-04-02T09:27:45.348Z" },
+ { url = "https://files.pythonhosted.org/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe", size = 225194, upload-time = "2026-04-02T09:27:46.706Z" },
+ { url = "https://files.pythonhosted.org/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0", size = 159827, upload-time = "2026-04-02T09:27:48.053Z" },
+ { url = "https://files.pythonhosted.org/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c", size = 174168, upload-time = "2026-04-02T09:27:49.795Z" },
+ { url = "https://files.pythonhosted.org/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d", size = 153018, upload-time = "2026-04-02T09:27:51.116Z" },
+ { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" },
+]
+
+[[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]]
@@ -132,10 +147,9 @@ dependencies = [
{ name = "django-debug-toolbar" },
{ name = "django-redis" },
{ name = "djangorestframework" },
- { name = "greenlet" },
+ { name = "gunicorn" },
{ name = "httpx" },
- { name = "psycopg2-binary" },
- { name = "ptpython" },
+ { name = "psycopg", extra = ["binary"] },
{ name = "python-dateutil" },
{ name = "python-dotenv" },
{ name = "pytz" },
@@ -146,7 +160,10 @@ dependencies = [
[package.dev-dependencies]
dev = [
+ { name = "factory-boy" },
{ name = "prek" },
+ { name = "pytest" },
+ { name = "pytest-django" },
]
lint = [
{ name = "ruff" },
@@ -154,29 +171,33 @@ lint = [
[package.metadata]
requires-dist = [
- { name = "beautifulsoup4", specifier = "==4.14.2" },
- { name = "bpython", specifier = "==0.26" },
- { name = "dj-database-url", specifier = "==3.0.1" },
- { name = "django", specifier = "==5.2.8" },
- { name = "django-cors-headers", specifier = "==4.9.0" },
- { name = "django-debug-toolbar", specifier = "==6.1.0" },
- { name = "django-redis", specifier = "==6.0.0" },
- { name = "djangorestframework", specifier = "==3.16.1" },
- { name = "greenlet", specifier = "==3.2.4" },
- { name = "httpx", specifier = "==0.28.1" },
- { name = "psycopg2-binary", specifier = "==2.9.11" },
- { name = "ptpython", specifier = "==3.0.31" },
- { name = "python-dateutil", specifier = "==2.9.0" },
- { name = "python-dotenv", specifier = "==1.2.1" },
- { name = "pytz", specifier = "==2025.2" },
- { name = "pyyaml", specifier = "==6.0.3" },
- { name = "redis", specifier = "==7.1.0" },
- { name = "requests", specifier = "==2.32.5" },
+ { name = "beautifulsoup4", specifier = ">=4.14.0" },
+ { name = "bpython", specifier = ">=0.26" },
+ { name = "dj-database-url", specifier = ">=3.1.0" },
+ { name = "django", specifier = ">=6.0.0" },
+ { name = "django-cors-headers", specifier = ">=4.9.0" },
+ { name = "django-debug-toolbar", specifier = ">=6.3.0" },
+ { name = "django-redis", specifier = ">=6.0.0" },
+ { name = "djangorestframework", specifier = ">=3.17.0" },
+ { name = "gunicorn", specifier = ">=26.0.0" },
+ { name = "httpx", specifier = ">=0.28.0" },
+ { name = "psycopg", extras = ["binary"], specifier = ">=3.3.0" },
+ { name = "python-dateutil", specifier = ">=2.9.0.post0" },
+ { name = "python-dotenv", specifier = ">=1.2.0" },
+ { name = "pytz", specifier = ">=2026.2" },
+ { name = "pyyaml", specifier = ">=6.0.0" },
+ { name = "redis", specifier = ">=7.4.0" },
+ { name = "requests", specifier = ">=2.34.0" },
]
[package.metadata.requires-dev]
-dev = [{ name = "prek", specifier = ">=0.2.24" }]
-lint = [{ name = "ruff", specifier = "==0.14.5" }]
+dev = [
+ { name = "factory-boy", specifier = ">=3.3.0" },
+ { name = "prek", specifier = ">=0.4.0" },
+ { name = "pytest", specifier = ">=9.0.0" },
+ { name = "pytest-django", specifier = ">=4.12.0" },
+]
+lint = [{ name = "ruff", specifier = ">=0.15.0" }]
[[package]]
name = "curtsies"
@@ -222,28 +243,28 @@ wheels = [
[[package]]
name = "dj-database-url"
-version = "3.0.1"
+version = "3.1.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "django" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/75/05/2ec51009f4ce424877dbd8ad95868faec0c3494ed0ff1635f9ab53d9e0ee/dj_database_url-3.0.1.tar.gz", hash = "sha256:8994961efb888fc6bf8c41550870c91f6f7691ca751888ebaa71442b7f84eff8", size = 12556, upload-time = "2025-07-02T09:40:11.424Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/03/f6/00b625e9d371b980aa261011d0dc906a16444cb688f94215e0dc86996eb5/dj_database_url-3.1.2.tar.gz", hash = "sha256:63c20e4bbaa51690dfd4c8d189521f6bf6bc9da9fcdb23d95d2ee8ee87f9ec62", size = 11490, upload-time = "2026-02-19T15:30:23.638Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/aa/5e/86a43c6fdaa41c12d58e4ff3ebbfd6b71a7cb0360a08614e3754ef2e9afb/dj_database_url-3.0.1-py3-none-any.whl", hash = "sha256:43950018e1eeea486bf11136384aec0fe55b29fe6fd8a44553231b85661d9383", size = 8808, upload-time = "2025-07-02T09:40:26.326Z" },
+ { url = "https://files.pythonhosted.org/packages/cf/a9/57c66006373381f1d3e5bd94216f1d371228a89f443d3030e010f73dd198/dj_database_url-3.1.2-py3-none-any.whl", hash = "sha256:544e015fee3efa5127a1eb1cca465f4ace578265b3671fe61d0ed7dbafb5ec8a", size = 8953, upload-time = "2026-02-19T15:30:39.37Z" },
]
[[package]]
name = "django"
-version = "5.2.8"
+version = "6.0.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "asgiref" },
{ name = "sqlparse" },
{ name = "tzdata", marker = "sys_platform == 'win32'" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/05/a2/933dbbb3dd9990494960f6e64aca2af4c0745b63b7113f59a822df92329e/django-5.2.8.tar.gz", hash = "sha256:23254866a5bb9a2cfa6004e8b809ec6246eba4b58a7589bc2772f1bcc8456c7f", size = 10849032, upload-time = "2025-11-05T14:07:32.778Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/5e/f1/bf85f0d29ef76abf901f193fe8fef4769d3da7794197832bc30151c071d8/django-6.0.5.tar.gz", hash = "sha256:bc6d6872e98a2864c836e42edd644b362db311147dd5aa8d5b82ba7a032f5269", size = 10924131, upload-time = "2026-05-05T13:54:39.329Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/5e/3d/a035a4ee9b1d4d4beee2ae6e8e12fe6dee5514b21f62504e22efcbd9fb46/django-5.2.8-py3-none-any.whl", hash = "sha256:37e687f7bd73ddf043e2b6b97cfe02fcbb11f2dbb3adccc6a2b18c6daa054d7f", size = 8289692, upload-time = "2025-11-05T14:07:28.761Z" },
+ { url = "https://files.pythonhosted.org/packages/94/5b/1328f8b84fce040c404f76822bf8c57d254e368e8cbd8bd67ec2b26d75f5/django-6.0.5-py3-none-any.whl", hash = "sha256:9d58a7cb49244e74c8e161d5e403a46d6209f1009ba40f5a66d6aa0d0786a8f0", size = 8368680, upload-time = "2026-05-05T13:54:33.532Z" },
]
[[package]]
@@ -261,15 +282,15 @@ wheels = [
[[package]]
name = "django-debug-toolbar"
-version = "6.1.0"
+version = "6.3.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "django" },
{ name = "sqlparse" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/c0/50/acae2dd379164f6f4c6b6b36fd48a4d21b02095a03f4df7c30a8d1f1a62c/django_debug_toolbar-6.1.0.tar.gz", hash = "sha256:e962ec350c9be8bdba918138e975a9cdb193f60ec396af2bb71b769e8e165519", size = 309141, upload-time = "2025-10-30T19:50:39.458Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/d8/ea/b62673424dd72d2dbf5adf4145281a421d5792f47380d9bc8e3b11e1a769/django_debug_toolbar-6.3.0.tar.gz", hash = "sha256:f830a86fe02e17f625a22cfbed24a5bd1500762e201ec959c50efb0f9327282b", size = 334079, upload-time = "2026-04-02T16:07:01.385Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/6d/72/685c978af45ad08257e2c69687a873eda6b6531c79b6e6091794c41c5ff6/django_debug_toolbar-6.1.0-py3-none-any.whl", hash = "sha256:e214dea4494087e7cebdcea84223819c5eb97f9de3110a3665ad673f0ba98413", size = 269069, upload-time = "2025-10-30T19:50:37.71Z" },
+ { url = "https://files.pythonhosted.org/packages/7d/9e/d8c3c845f4b5ccac7377c19f4049e7e00c6f121846a81f69a497b45734df/django_debug_toolbar-6.3.0-py3-none-any.whl", hash = "sha256:a199ce3d0f884739a9096835ad417479fede05f3b3c4824bc8b354721ba8f629", size = 298304, upload-time = "2026-04-02T16:06:59.617Z" },
]
[[package]]
@@ -287,31 +308,77 @@ wheels = [
[[package]]
name = "djangorestframework"
-version = "3.16.1"
+version = "3.17.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "django" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/8a/95/5376fe618646fde6899b3cdc85fd959716bb67542e273a76a80d9f326f27/djangorestframework-3.16.1.tar.gz", hash = "sha256:166809528b1aced0a17dc66c24492af18049f2c9420dbd0be29422029cfc3ff7", size = 1089735, upload-time = "2025-08-06T17:50:53.251Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/ca/d7/c016e69fac19ff8afdc89db9d31d9ae43ae031e4d1993b20aca179b8301a/djangorestframework-3.17.1.tar.gz", hash = "sha256:a6def5f447fe78ff853bff1d47a3c59bf38f5434b031780b351b0c73a62db1a5", size = 905742, upload-time = "2026-03-24T16:58:33.705Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/5a/e1/2c516bdc83652b1a60c6119366ac2c0607b479ed05cd6093f916ca8928f8/djangorestframework-3.17.1-py3-none-any.whl", hash = "sha256:c3c74dd3e83a5a3efc37b3c18d92bd6f86a6791c7b7d4dff62bb068500e76457", size = 898844, upload-time = "2026-03-24T16:58:31.845Z" },
+]
+
+[[package]]
+name = "factory-boy"
+version = "3.3.3"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "faker" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/ba/98/75cacae9945f67cfe323829fc2ac451f64517a8a330b572a06a323997065/factory_boy-3.3.3.tar.gz", hash = "sha256:866862d226128dfac7f2b4160287e899daf54f2612778327dd03d0e2cb1e3d03", size = 164146, upload-time = "2025-02-03T09:49:04.433Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/27/8d/2bc5f5546ff2ccb3f7de06742853483ab75bf74f36a92254702f8baecc79/factory_boy-3.3.3-py2.py3-none-any.whl", hash = "sha256:1c39e3289f7e667c4285433f305f8d506efc2fe9c73aaea4151ebd5cdea394fc", size = 37036, upload-time = "2025-02-03T09:49:01.659Z" },
+]
+
+[[package]]
+name = "faker"
+version = "40.18.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "tzdata", marker = "sys_platform == 'win32'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/18/06/70886e82d8f1d2b73454f3a7c1b7405300128df22e70d85a828951366932/faker-40.18.0.tar.gz", hash = "sha256:2207575c0e8f90e6ccd6dbef764de875c614d16d3db4eee9712d9a00087f2e70", size = 1968243, upload-time = "2026-05-14T16:43:04.834Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/b0/ce/bf8b9d3f415be4ac5588545b5fcdbbb841977db1c1d923f7568eeabe1689/djangorestframework-3.16.1-py3-none-any.whl", hash = "sha256:33a59f47fb9c85ede792cbf88bde71893bcda0667bc573f784649521f1102cec", size = 1080442, upload-time = "2025-08-06T17:50:50.667Z" },
+ { url = "https://files.pythonhosted.org/packages/84/0b/5c0b2d3a4b7a715f1835dd3f963bfbe841a02ae5cad1df8ee0325dfad235/faker-40.18.0-py3-none-any.whl", hash = "sha256:61a6b94b74605ddb090a065deb197a1c585ae7a874c094cf6693671d271e6083", size = 2006355, upload-time = "2026-05-14T16:43:02.489Z" },
]
[[package]]
name = "greenlet"
-version = "3.2.4"
+version = "3.5.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/6d/6e/802acd792aebb2256fbbee8cacf2727faaeb6f240ac11008f09eae4414bc/greenlet-3.5.1.tar.gz", hash = "sha256:5a56aeb7d5d9cc4b3a735efb5095bd4b4f6f0e4f93e5ca876d0e2315137b7829", size = 197356, upload-time = "2026-05-20T15:05:03.917Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/8a/cb/c62454606daf5640369c94d8a9dd540599b1bfc090e2d2180cb77f4038d2/greenlet-3.5.1-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:d8ab31c9de8651a2facdd5c5bb0011f2380dd1a7af78ce2adf4b56095294fc07", size = 285579, upload-time = "2026-05-20T13:08:56.396Z" },
+ { url = "https://files.pythonhosted.org/packages/ec/71/c4270398c2eba968a6071af1dfbdcaeee6ec1c24bc8b435b8cc452700da6/greenlet-3.5.1-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5e300185139abc337ade480c327183adf42a875ac7181bfe66d7d4efea31fbea", size = 651106, upload-time = "2026-05-20T14:00:09.448Z" },
+ { url = "https://files.pythonhosted.org/packages/1a/ab/71e34b78a44ec271fb5f550c17bc46d301ddc5953890d935f270b0dcdb5a/greenlet-3.5.1-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7ffdb990dcaa0234cf9845aead5df2e3c3a8b6507d409274dd87e0d5ab05ffc2", size = 663478, upload-time = "2026-05-20T14:05:45.88Z" },
+ { url = "https://files.pythonhosted.org/packages/c6/2d/2d80842910da44f78c286532d084b8a5c3717c844ae80ceb3858738ae89a/greenlet-3.5.1-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6c09df69dc1712d131332054a858a3e5cca400967fa3a672e2324fbb0971448c", size = 667767, upload-time = "2026-05-20T14:09:12.15Z" },
+ { url = "https://files.pythonhosted.org/packages/77/96/4efd6fa5c62c85426a0c19077a586258ebc3a2a146ff2493e4312a697a22/greenlet-3.5.1-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2f82b3597e9d83b63408affed0b48fd0f54935edac4302237b9a837be0dae33c", size = 660800, upload-time = "2026-05-20T13:14:29.129Z" },
+ { url = "https://files.pythonhosted.org/packages/e9/d3/dad2eecedfbb1ed7050a20dcfae40c1442b74bc7423608be2c7e03ee7133/greenlet-3.5.1-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:a4764e0bfc6a4d114c865b32520805c16a990ef5f286a514413b05d5ecd6a23d", size = 470786, upload-time = "2026-05-20T14:01:42.064Z" },
+ { url = "https://files.pythonhosted.org/packages/7a/e0/6c71401a25cac7000261304e866a2f2cc04dc74810d40e2f118aa4799495/greenlet-3.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c0141e37414c10164e702b8fb1473304221ad98f71600850c6ef7ff4880feba0", size = 1617518, upload-time = "2026-05-20T14:02:28.662Z" },
+ { url = "https://files.pythonhosted.org/packages/41/26/c5c06643e8c0af9e7bf18e16cb51d0ab7625155f0392e1c9015d66d556cd/greenlet-3.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:50ae25a67bea74ea41fb14b960bc532df73eb713417b2d61892dced82fe8d3bc", size = 1681593, upload-time = "2026-05-20T13:14:39.417Z" },
+ { url = "https://files.pythonhosted.org/packages/8a/bd/e11a108317485075e68af9d23039619b86b28130c3b50d227d42edece64b/greenlet-3.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:8a17c42330e261299766b75ac1ea32caa437a9453c8f65d16a13140db378ecd3", size = 239800, upload-time = "2026-05-20T13:09:30.128Z" },
+ { url = "https://files.pythonhosted.org/packages/47/f8/8e8e8417b7bf28639a5a56356ef934d0375e1d0c70a57e04d7701e870ffe/greenlet-3.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:7b5f5fae05b8ac6d176a61b60c394a8cbdc2b5b91b81793066e68745cf165e54", size = 236862, upload-time = "2026-05-20T13:09:10.498Z" },
+ { url = "https://files.pythonhosted.org/packages/90/12/41bf27fde4d3605d3773ae57751eda182b8be2f5398011c041173b1d9534/greenlet-3.5.1-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:ea8da1e900d758d078810d4255d8c6aa572181896a31ec79d779eb79c3adc9ad", size = 293637, upload-time = "2026-05-20T13:12:35.529Z" },
+ { url = "https://files.pythonhosted.org/packages/44/44/ba14b23e9757707050c2f397d305bbcae62e5d7cad122f8b6baec5ae4a1f/greenlet-3.5.1-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a19570c52a21420dcbc94e661994bc325c0b5b11304540fed514586da5dc8f2e", size = 650840, upload-time = "2026-05-20T14:00:11.079Z" },
+ { url = "https://files.pythonhosted.org/packages/a8/37/5ddc2b686a6844f91abecef43411842426da2e1573f60b49ecf2547f4ae1/greenlet-3.5.1-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3d955c89b75eeca4723d7cc14135f393cd47c32e2a6cb4a8e4c6e760a26b0986", size = 656416, upload-time = "2026-05-20T14:05:47.118Z" },
+ { url = "https://files.pythonhosted.org/packages/8c/46/5987dcd1a2570ba84f3b187536b2ca3ae97613387e57f5cfa99df068fe5e/greenlet-3.5.1-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ea37d5a157eb9493820d3792ac4ece28619a394391d2b9f2f78057d396ff0f0f", size = 656607, upload-time = "2026-05-20T14:09:13.949Z" },
+ { url = "https://files.pythonhosted.org/packages/e1/f0/d17510297c35a2992712f0bf84de3779749999f7d3d63aa1f09db7c62dbe/greenlet-3.5.1-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:de2daaaebd1a5aa88c49045b6baf9310b3263796bd88db713edf37cf53e7bb4e", size = 654397, upload-time = "2026-05-20T13:14:30.696Z" },
+ { url = "https://files.pythonhosted.org/packages/2c/c1/6da0a9ddcc29d7e51ef14883fa3dc1e53b3f4ffba00582106c7bf55da1d8/greenlet-3.5.1-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:8d8a23250ea3ec7b36de8fa4b541e9e2db3ee82915cc060ab0631609ad8b28de", size = 488287, upload-time = "2026-05-20T14:01:43.143Z" },
+ { url = "https://files.pythonhosted.org/packages/37/eb/147387705bb89092645b012586e7273cb5ed3c90ef7eaf3a69173eaf0209/greenlet-3.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3bfbd69cc349e43bf3a8ae1c85548ff0718efc887615c2db16c3833d7b0b072d", size = 1614469, upload-time = "2026-05-20T14:02:30.192Z" },
+ { url = "https://files.pythonhosted.org/packages/a6/4e/37ee0da7732b7aa9896f17e15579a9df34b9fcb9dd494f0adfa749af6623/greenlet-3.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:4378720dd888136c27215a0214d32a4d37c3852765d45bc37aad0623423cfd78", size = 1675115, upload-time = "2026-05-20T13:14:40.972Z" },
+ { url = "https://files.pythonhosted.org/packages/57/f3/97dfcf4a6eb5077f8a672234216fb5923eb89f2cab7081cb10b2cf75b605/greenlet-3.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:45718441607f9325d948db98cbc691276059316d0358c188c246da4e1d4d23d2", size = 245246, upload-time = "2026-05-20T13:12:22.646Z" },
+]
+
+[[package]]
+name = "gunicorn"
+version = "26.0.0"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/03/b8/704d753a5a45507a7aab61f18db9509302ed3d0a27ac7e0359ec2905b1a6/greenlet-3.2.4.tar.gz", hash = "sha256:0dca0d95ff849f9a364385f36ab49f50065d76964944638be9691e1832e9f86d", size = 188260, upload-time = "2025-08-07T13:24:33.51Z" }
+dependencies = [
+ { name = "packaging" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/6d/b7/a4a3f632f823e432ce6bc65f62961b7980c898c77f075a2f7118cb3846fe/gunicorn-26.0.0.tar.gz", hash = "sha256:ca9346f85e3a4aeeb64d491045c16b9a35647abd37ea15efe53080eb8b090baf", size = 727286, upload-time = "2026-05-05T06:38:25.529Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/22/5c/85273fd7cc388285632b0498dbbab97596e04b154933dfe0f3e68156c68c/greenlet-3.2.4-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:49a30d5fda2507ae77be16479bdb62a660fa51b1eb4928b524975b3bde77b3c0", size = 273586, upload-time = "2025-08-07T13:16:08.004Z" },
- { url = "https://files.pythonhosted.org/packages/d1/75/10aeeaa3da9332c2e761e4c50d4c3556c21113ee3f0afa2cf5769946f7a3/greenlet-3.2.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:299fd615cd8fc86267b47597123e3f43ad79c9d8a22bebdce535e53550763e2f", size = 686346, upload-time = "2025-08-07T13:42:59.944Z" },
- { url = "https://files.pythonhosted.org/packages/c0/aa/687d6b12ffb505a4447567d1f3abea23bd20e73a5bed63871178e0831b7a/greenlet-3.2.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:c17b6b34111ea72fc5a4e4beec9711d2226285f0386ea83477cbb97c30a3f3a5", size = 699218, upload-time = "2025-08-07T13:45:30.969Z" },
- { url = "https://files.pythonhosted.org/packages/dc/8b/29aae55436521f1d6f8ff4e12fb676f3400de7fcf27fccd1d4d17fd8fecd/greenlet-3.2.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b4a1870c51720687af7fa3e7cda6d08d801dae660f75a76f3845b642b4da6ee1", size = 694659, upload-time = "2025-08-07T13:53:17.759Z" },
- { url = "https://files.pythonhosted.org/packages/92/2e/ea25914b1ebfde93b6fc4ff46d6864564fba59024e928bdc7de475affc25/greenlet-3.2.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:061dc4cf2c34852b052a8620d40f36324554bc192be474b9e9770e8c042fd735", size = 695355, upload-time = "2025-08-07T13:18:34.517Z" },
- { url = "https://files.pythonhosted.org/packages/72/60/fc56c62046ec17f6b0d3060564562c64c862948c9d4bc8aa807cf5bd74f4/greenlet-3.2.4-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44358b9bf66c8576a9f57a590d5f5d6e72fa4228b763d0e43fee6d3b06d3a337", size = 657512, upload-time = "2025-08-07T13:18:33.969Z" },
- { url = "https://files.pythonhosted.org/packages/23/6e/74407aed965a4ab6ddd93a7ded3180b730d281c77b765788419484cdfeef/greenlet-3.2.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2917bdf657f5859fbf3386b12d68ede4cf1f04c90c3a6bc1f013dd68a22e2269", size = 1612508, upload-time = "2025-11-04T12:42:23.427Z" },
- { url = "https://files.pythonhosted.org/packages/0d/da/343cd760ab2f92bac1845ca07ee3faea9fe52bee65f7bcb19f16ad7de08b/greenlet-3.2.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:015d48959d4add5d6c9f6c5210ee3803a830dce46356e3bc326d6776bde54681", size = 1680760, upload-time = "2025-11-04T12:42:25.341Z" },
- { url = "https://files.pythonhosted.org/packages/e3/a5/6ddab2b4c112be95601c13428db1d8b6608a8b6039816f2ba09c346c08fc/greenlet-3.2.4-cp314-cp314-win_amd64.whl", hash = "sha256:e37ab26028f12dbb0ff65f29a8d3d44a765c61e729647bf2ddfbbed621726f01", size = 303425, upload-time = "2025-08-07T13:32:27.59Z" },
+ { url = "https://files.pythonhosted.org/packages/e6/40/9c2384fc2be4ad25dd4a49decd5ad9ea5a3639814c11bd40ab77cb9f0a14/gunicorn-26.0.0-py3-none-any.whl", hash = "sha256:40233d26a5f0d1872916188c276e21641155111c2853f0c2cd55260aec0d24fc", size = 212009, upload-time = "2026-05-05T06:38:23.007Z" },
]
[[package]]
@@ -353,155 +420,176 @@ wheels = [
[[package]]
name = "idna"
-version = "3.11"
+version = "3.16"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/1a/88/bcf9709822fe69d02c2a6a77956c98ce6ea8ca8767a9aadcedc7eb6a2390/idna-3.16.tar.gz", hash = "sha256:d7a6da03db833450fca25d2358ac9ff06cd624577a4aea3a596d5c0f77b8e03d", size = 203770, upload-time = "2026-05-22T00:16:18.781Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
+ { url = "https://files.pythonhosted.org/packages/94/16/70255075a9859a0e3adb789b68ceb0e210dec03934245fd98d248226572f/idna-3.16-py3-none-any.whl", hash = "sha256:cc246e3a3f89580c3a951b5ad298ca4638078b2cdd4f115654332b5c26daded5", size = 74165, upload-time = "2026-05-22T00:16:16.698Z" },
]
[[package]]
-name = "jedi"
-version = "0.19.2"
+name = "iniconfig"
+version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "parso" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/72/3a/79a912fbd4d8dd6fbb02bf69afd3bb72cf0c729bb3063c6f4498603db17a/jedi-0.19.2.tar.gz", hash = "sha256:4770dc3de41bde3966b02eb84fbcf557fb33cce26ad23da12c742fb50ecb11f0", size = 1231287, upload-time = "2024-11-11T01:41:42.873Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/c0/5a/9cac0c82afec3d09ccd97c8b6502d48f165f9124db81b4bcb90b4af974ee/jedi-0.19.2-py2.py3-none-any.whl", hash = "sha256:a8ef22bde8490f57fe5c7681a3c83cb58874daf72b4784de3cce5b6ef6edb5b9", size = 1572278, upload-time = "2024-11-11T01:41:40.175Z" },
+ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
]
[[package]]
name = "jinxed"
-version = "1.3.0"
+version = "2.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "ansicon", marker = "sys_platform == 'win32'" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/20/d0/59b2b80e7a52d255f9e0ad040d2e826342d05580c4b1d7d7747cfb8db731/jinxed-1.3.0.tar.gz", hash = "sha256:1593124b18a41b7a3da3b078471442e51dbad3d77b4d4f2b0c26ab6f7d660dbf", size = 80981, upload-time = "2024-07-31T22:39:18.854Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/cb/eb/8821ce6e7386e96355f2c6be83944925b4a0870572896fd33a4e61b8aa5a/jinxed-2.0.0.tar.gz", hash = "sha256:64b960b8f8d9966e1b2e7cb57ea2b1aa0a8d7e68045b96dc7ef58201e43a1209", size = 127118, upload-time = "2026-05-08T21:25:25.917Z" }
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" },
+ { url = "https://files.pythonhosted.org/packages/b3/00/b61668fd3b1e43b445979ec9a9e0af4781bf06884937d1e906f6a1be6dff/jinxed-2.0.0-py2.py3-none-any.whl", hash = "sha256:b3df1be5262a37145ef42875a8bbf918f1a563fbd035359650dd9fc0bb2b9294", size = 95364, upload-time = "2026-05-08T21:25:24.536Z" },
]
[[package]]
-name = "parso"
-version = "0.8.5"
+name = "packaging"
+version = "26.2"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/d4/de/53e0bcf53d13e005bd8c92e7855142494f41171b34c2536b86187474184d/parso-0.8.5.tar.gz", hash = "sha256:034d7354a9a018bdce352f48b2a8a450f05e9d6ee85db84764e9b6bd96dafe5a", size = 401205, upload-time = "2025-08-23T15:15:28.028Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/16/32/f8e3c85d1d5250232a5d3477a2a28cc291968ff175caeadaf3cc19ce0e4a/parso-0.8.5-py2.py3-none-any.whl", hash = "sha256:646204b5ee239c396d040b90f9e272e9a8017c630092bf59980beb62fd033887", size = 106668, upload-time = "2025-08-23T15:15:25.663Z" },
+ { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" },
+]
+
+[[package]]
+name = "pluggy"
+version = "1.6.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
]
[[package]]
name = "prek"
-version = "0.2.24"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/cd/67/33ff75b530d8f189f18a06b38dc8f684d07ffca045e043293bf043dd963b/prek-0.2.24.tar.gz", hash = "sha256:f7588b9aa0763baf3b2e2bd1b9f103f43e74e494e3e3e12c71270118f56b3f3e", size = 273552, upload-time = "2025-12-23T03:59:10.059Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/27/bc/e67414efd29b81626016a16b7d9f33bb67f4adf47ea8554ae11b7fcb46e3/prek-0.2.24-py3-none-linux_armv6l.whl", hash = "sha256:2b36f04353cf0bbee35b510c83bf2a071682745be0d5265e821934a94869a7f7", size = 4793435, upload-time = "2025-12-23T03:59:19.779Z" },
- { url = "https://files.pythonhosted.org/packages/3f/66/9a724e7b3e3a389e1e0cbacf0f4707ee056c83361925cadef43489b5012d/prek-0.2.24-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:8149aa03eb993ba7c0a7509abccdf30665455db2405eb941c1c4174e3441c6b3", size = 4890722, upload-time = "2025-12-23T03:59:18.299Z" },
- { url = "https://files.pythonhosted.org/packages/2d/cf/ee4c057f08a137ec85cc525f4170c3b930d8edd0a8ead20952c8079199c7/prek-0.2.24-py3-none-macosx_11_0_arm64.whl", hash = "sha256:100bf066669834876f87af11c79bdd4a3c8c1e8abf49aa047bc9c52265f5f544", size = 4615935, upload-time = "2025-12-23T03:59:20.947Z" },
- { url = "https://files.pythonhosted.org/packages/c4/71/a84ae24a82814896220fa3a03f07a62fb2e3f3ed6aa9c3952aaedb008b12/prek-0.2.24-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:656295670b7646936d5d738a708b310900870f47757375214dfaa592786702be", size = 4812259, upload-time = "2025-12-23T03:59:26.671Z" },
- { url = "https://files.pythonhosted.org/packages/55/9a/a009873b954f726f8f43be8d660095c76d47208c6e9397d75f916f52b8fc/prek-0.2.24-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3b79fe57f59fa2649d8a727152af742353de8d537ade75285bedf49b66bf8768", size = 4713078, upload-time = "2025-12-23T03:59:29.51Z" },
- { url = "https://files.pythonhosted.org/packages/37/b3/daf4a1da6f009f4413ca6302b6f6480f824be2447dc74606981c47958ad1/prek-0.2.24-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f02a79c76a84339eecc2d01b1e5f81eb4e8769629e9a62343a8e4089778db956", size = 5034136, upload-time = "2025-12-23T03:59:06.775Z" },
- { url = "https://files.pythonhosted.org/packages/49/17/2b754198c7444f9b8f09c60280e601659afb6a4d6ce9fc5553e15218800b/prek-0.2.24-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:cbd9b7b568a5cdcb9ccce8c8b186b52c6547dfd2e70d0a2438e3cb17a37affb4", size = 5445865, upload-time = "2025-12-23T03:59:12.684Z" },
- { url = "https://files.pythonhosted.org/packages/67/61/d54c7db0f6ff1a12b0b7211b32b7b2685fcee81dd51fb1a139e757b648cd/prek-0.2.24-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fc788a1bb3dba31c9ad864ee73fc6320c07fd0f0a3d9652995dfee5d62ccc4f8", size = 5401392, upload-time = "2025-12-23T03:59:24.181Z" },
- { url = "https://files.pythonhosted.org/packages/5a/61/cd7e78e2f371a6603c6ac323ad2306c6793d39f4f6ee2723682b25d65478/prek-0.2.24-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1ee8d1293755f6b42e7fa4bbc7122781e7c3880ca06ed2f85f0ed40c0df14c9b", size = 5492942, upload-time = "2025-12-23T03:59:14.367Z" },
- { url = "https://files.pythonhosted.org/packages/10/ff/657c6269d65dbe682f82113620823c65e002c3ae4fd417f25adaa390179e/prek-0.2.24-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:933a49f0b22abc2baca378f02b4b1b6d9522800a2ccc9e247aa51ebe421fc6dc", size = 5083804, upload-time = "2025-12-23T03:59:28.213Z" },
- { url = "https://files.pythonhosted.org/packages/bc/d9/8929b12dd8849d4d00b6c8e22db1fec22fef4b1e7356c0812107eb0a4f6c/prek-0.2.24-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:f88defe48704eea1391e29b18b363fcd22ef5490af619b6328fece8092c9d24b", size = 4819786, upload-time = "2025-12-23T03:59:32.053Z" },
- { url = "https://files.pythonhosted.org/packages/db/a4/d9e0f7d445621a5c416a8883a33b079cf2c6f7e35a360d15c074f9b353fb/prek-0.2.24-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:3fd336eb13489460da3476bfb1bd185d6bd0f9d3f9bff7780b32d2c801026578", size = 4829112, upload-time = "2025-12-23T03:59:22.546Z" },
- { url = "https://files.pythonhosted.org/packages/10/da/4fdcd158268c337ad3fe4dad3fcb0716f46bba2fe202ee03a473e3eda9b9/prek-0.2.24-py3-none-musllinux_1_1_armv7l.whl", hash = "sha256:9eb952540fd17d540373eb4239ccdcc1e060ca1c33a7ec5d27f6ec03838848c5", size = 4698341, upload-time = "2025-12-23T03:59:11.184Z" },
- { url = "https://files.pythonhosted.org/packages/71/82/c9dd71e5c40c075314b6e3584067084dfbf56d9d1d74baea217d7581a5bf/prek-0.2.24-py3-none-musllinux_1_1_i686.whl", hash = "sha256:7168d6d86576704cddb7c38aff1b62c305312700492c85ff981dfa986013c265", size = 4917027, upload-time = "2025-12-23T03:59:30.751Z" },
- { url = "https://files.pythonhosted.org/packages/ef/05/0559b0504d39dc97f71d74f270918d043f3259fff4cbe11beccfdbb586e6/prek-0.2.24-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:4e500beb902c524b48d084deabc687cb344226ce91f926c6ab8a65a6754d8a9a", size = 5192231, upload-time = "2025-12-23T03:59:16.775Z" },
- { url = "https://files.pythonhosted.org/packages/1f/b3/e740f52236a0077890a82e1c8046d4e0ff8b140bd3c30e3e82f35fee2224/prek-0.2.24-py3-none-win32.whl", hash = "sha256:bab279d54b6adf85d95923590dacaa9956eb354cc64204c45983fa2d5c2f7a8a", size = 4603284, upload-time = "2025-12-23T03:59:15.544Z" },
- { url = "https://files.pythonhosted.org/packages/41/31/cf0773b3cd7b965a7d15264ec96f85ee5f451db5e9df5d0d9d87d3b8e4ce/prek-0.2.24-py3-none-win_amd64.whl", hash = "sha256:c89ad7f73e8b38bd5e79e83fec3bf234dec87295957c94cc7d94a125bc609ff0", size = 5295275, upload-time = "2025-12-23T03:59:25.354Z" },
- { url = "https://files.pythonhosted.org/packages/97/34/b44663946ea7be1d0b1c7877e748603638a8d0eff9f3969f97b9439aa17b/prek-0.2.24-py3-none-win_arm64.whl", hash = "sha256:9257b3293746a69d600736e0113534b3b80a0ce8ee23a1b0db36253e9c7e24ab", size = 4962129, upload-time = "2025-12-23T03:59:08.609Z" },
-]
-
-[[package]]
-name = "prompt-toolkit"
-version = "3.0.52"
+version = "0.4.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/1c/01/1d2c238c6f226d75881cd7a5532e980f4d524babc3c034d16ad89e88b6e1/prek-0.4.1.tar.gz", hash = "sha256:622a8812bda87cf4ddcae2dab5ccecc55b88d70c677129dbe25e975d923179f0", size = 452606, upload-time = "2026-05-20T04:27:19.259Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d1/ca/0274343faf2672d649b1e648053d3cb48fdfef7a390b43713d95880ebb67/prek-0.4.1-py3-none-linux_armv6l.whl", hash = "sha256:10e7e78ffe65dfba7d687a8c71b2f473554d1ba60f43c742105da4c0030feed9", size = 5515584, upload-time = "2026-05-20T04:27:29.386Z" },
+ { url = "https://files.pythonhosted.org/packages/37/4e/6a067f530194a6e4141c36463eece92356dfd7f924ffe0cbf456bdca723b/prek-0.4.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b25807e0aa57d2118747e127b58e7a1bf41d5d7b3323f5f3f1f3cb10031245cc", size = 5878925, upload-time = "2026-05-20T04:27:31.71Z" },
+ { url = "https://files.pythonhosted.org/packages/e3/3d/a334c0f5b88fadca888eadfc1fb3d7f1dc8358b1a534d0987339ecb8eb92/prek-0.4.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:efa95331c4c171a867c0064c19d8a4abc94a1c1c920c8b8092f2d7d87f4b90a8", size = 5440994, upload-time = "2026-05-20T04:27:40.578Z" },
+ { url = "https://files.pythonhosted.org/packages/0c/3b/fa6eb635495c3576e65d7f42a48b9fdf4926dd052010df506ed98e9f9680/prek-0.4.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:2d1805123ab5d730629de588bf319ea39e7078b589b3288c95740f1b4780a1d4", size = 5692369, upload-time = "2026-05-20T04:27:23.184Z" },
+ { url = "https://files.pythonhosted.org/packages/70/cb/9d9078723b3facb40289444332ca82bf38c0e1db3b5a907af461aba12324/prek-0.4.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:051c442b570b53756225410240577bee1aeace6be52955dfacf45a9783223b36", size = 5430031, upload-time = "2026-05-20T04:27:27.475Z" },
+ { url = "https://files.pythonhosted.org/packages/ff/96/2d8cc6b5425215cd0b610f1dcef3f6f0f23db2a2b85f1a6fca43b7e7fe24/prek-0.4.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:76663998827a2cbc94f5e209319809655489b5bd1f8e70568a623372e80253f0", size = 5834244, upload-time = "2026-05-20T04:27:44.229Z" },
+ { url = "https://files.pythonhosted.org/packages/59/e0/cce02f3ade48a6d4bffb25e5f0ac28d10928263b0a4f53ecc72954957f4e/prek-0.4.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2ab3460641762edf128b1ec8e833ce7e9ae015d1268a894560cb90d3393a7527", size = 6711903, upload-time = "2026-05-20T04:27:34.128Z" },
+ { url = "https://files.pythonhosted.org/packages/c9/2a/ccd581b6222277a2aa095530844d5bb76db4547042f05a9cb649476bf904/prek-0.4.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6e69a9c02ead38706a5d2a4ae209dccba08ccb5d0026e1d08e723c66ab964750", size = 6084138, upload-time = "2026-05-20T04:27:46.549Z" },
+ { url = "https://files.pythonhosted.org/packages/0a/b7/6164a7dc6bb4796cfc19445be798302cc7625b62e2bec89ffb4272d7f983/prek-0.4.1-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:dc744fedf98df8a00a9e3bcd629b163fee5e9f9e22bce66029d9945241586165", size = 5698950, upload-time = "2026-05-20T04:27:36.165Z" },
+ { url = "https://files.pythonhosted.org/packages/96/40/8151d6445a0f41ad60e979db39d8b0c6b074aad919cf5c73233281f0dff1/prek-0.4.1-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:c0877e82c52359d655fe1072b3a5228639184d1d5f03c6803b6530cd6da1ef20", size = 5538662, upload-time = "2026-05-20T04:27:15.045Z" },
+ { url = "https://files.pythonhosted.org/packages/96/d7/1f9892a45bb2dc8a3b4b89eb08f5de1cf745fcd7df9e535463ba4d41cebe/prek-0.4.1-py3-none-musllinux_1_1_armv7l.whl", hash = "sha256:60928d1dad45ff3e491d3083a50643cc213aa2d54f1dbd8d702d7193773c020e", size = 5406581, upload-time = "2026-05-20T04:27:21.101Z" },
+ { url = "https://files.pythonhosted.org/packages/9f/b8/94ddac155b502859e4dc7943db99fa7fffecfa3878a2ef11726a8e72fad0/prek-0.4.1-py3-none-musllinux_1_1_i686.whl", hash = "sha256:17ffa9d8dd40791b9b99cafe558c5cc28e78e5be57607b280b15f0dab90264e9", size = 5688880, upload-time = "2026-05-20T04:27:25.27Z" },
+ { url = "https://files.pythonhosted.org/packages/d0/fd/e93d3853d1bdc06b281fff2aaf4106e19610fe5187c67c9ff13195f2df59/prek-0.4.1-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:cdf4503a240369f66321213d9c4bc6f925014b64ff7121de9e9920c9b9838ce2", size = 6203536, upload-time = "2026-05-20T04:27:42.366Z" },
+ { url = "https://files.pythonhosted.org/packages/14/c7/760969d6bfc77e3eba04f6c3801c81076e96a908a6c277c142a4b0f31f4e/prek-0.4.1-py3-none-win32.whl", hash = "sha256:7c515492ef3585e6bcd7b83f1bb1cb131038abc88ed2c843de1e4c3ceb865b19", size = 5208995, upload-time = "2026-05-20T04:27:38.331Z" },
+ { url = "https://files.pythonhosted.org/packages/89/12/d43daf290a73dbc3e1a3eabb9077e45df661923949bee045de67cbe82524/prek-0.4.1-py3-none-win_amd64.whl", hash = "sha256:8fa707971465d8ad021c907e43691aad7bb98942943e61e294ece5f95d9fbc78", size = 5591734, upload-time = "2026-05-20T04:27:12.744Z" },
+ { url = "https://files.pythonhosted.org/packages/ab/36/2ab7647fe1e84bba2baae7f04de241197eed62683fb3085e164de266d111/prek-0.4.1-py3-none-win_arm64.whl", hash = "sha256:5b4a348537924b20e208cbd87ef58e96ec37d691c5bec2969209c40de0ecf72e", size = 5423147, upload-time = "2026-05-20T04:27:17.023Z" },
+]
+
+[[package]]
+name = "psycopg"
+version = "3.3.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "wcwidth" },
+ { name = "tzdata", marker = "sys_platform == 'win32'" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/a1/96/06e01a7b38dce6fe1db213e061a4602dd6032a8a97ef6c1a862537732421/prompt_toolkit-3.0.52.tar.gz", hash = "sha256:28cde192929c8e7321de85de1ddbe736f1375148b02f2e17edd840042b1be855", size = 434198, upload-time = "2025-08-27T15:24:02.057Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/db/2f/cb91e5502ec9de1de6f1b76cfbf69531932725361168bb06963620c77e2e/psycopg-3.3.4.tar.gz", hash = "sha256:e21207764952cff81b6b8bdacad9a3939f2793367fdac2987b3aac36a651b5bc", size = 165799, upload-time = "2026-05-01T23:31:55.179Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/84/03/0d3ce49e2505ae70cf43bc5bb3033955d2fc9f932163e84dc0779cc47f48/prompt_toolkit-3.0.52-py3-none-any.whl", hash = "sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955", size = 391431, upload-time = "2025-08-27T15:23:59.498Z" },
+ { url = "https://files.pythonhosted.org/packages/5c/e0/7b3dee031daae7743609ce3c746565d4a3ed7c2c186479eb48e34e838c64/psycopg-3.3.4-py3-none-any.whl", hash = "sha256:b6bbc25ccf05c8fad3b061d9db2ef0909a555171b84b07f29458a447253d679a", size = 213001, upload-time = "2026-05-01T23:20:50.816Z" },
+]
+
+[package.optional-dependencies]
+binary = [
+ { name = "psycopg-binary", marker = "implementation_name != 'pypy'" },
]
[[package]]
-name = "psycopg2-binary"
-version = "2.9.11"
+name = "psycopg-binary"
+version = "3.3.4"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/ac/6c/8767aaa597ba424643dc87348c6f1754dd9f48e80fdc1b9f7ca5c3a7c213/psycopg2-binary-2.9.11.tar.gz", hash = "sha256:b6aed9e096bf63f9e75edf2581aa9a7e7186d97ab5c177aa6c87797cd591236c", size = 379620, upload-time = "2025-10-10T11:14:48.041Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/64/12/93ef0098590cf51d9732b4f139533732565704f45bdc1ffa741b7c95fb54/psycopg2_binary-2.9.11-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:92e3b669236327083a2e33ccfa0d320dd01b9803b3e14dd986a4fc54aa00f4e1", size = 3756567, upload-time = "2025-10-10T11:13:11.885Z" },
- { url = "https://files.pythonhosted.org/packages/7c/a9/9d55c614a891288f15ca4b5209b09f0f01e3124056924e17b81b9fa054cc/psycopg2_binary-2.9.11-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e0deeb03da539fa3577fcb0b3f2554a97f7e5477c246098dbb18091a4a01c16f", size = 3864755, upload-time = "2025-10-10T11:13:17.727Z" },
- { url = "https://files.pythonhosted.org/packages/13/1e/98874ce72fd29cbde93209977b196a2edae03f8490d1bd8158e7f1daf3a0/psycopg2_binary-2.9.11-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:9b52a3f9bb540a3e4ec0f6ba6d31339727b2950c9772850d6545b7eae0b9d7c5", size = 4411646, upload-time = "2025-10-10T11:13:24.432Z" },
- { url = "https://files.pythonhosted.org/packages/5a/bd/a335ce6645334fb8d758cc358810defca14a1d19ffbc8a10bd38a2328565/psycopg2_binary-2.9.11-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:db4fd476874ccfdbb630a54426964959e58da4c61c9feba73e6094d51303d7d8", size = 4468701, upload-time = "2025-10-10T11:13:29.266Z" },
- { url = "https://files.pythonhosted.org/packages/44/d6/c8b4f53f34e295e45709b7568bf9b9407a612ea30387d35eb9fa84f269b4/psycopg2_binary-2.9.11-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:47f212c1d3be608a12937cc131bd85502954398aaa1320cb4c14421a0ffccf4c", size = 4166293, upload-time = "2025-10-10T11:13:33.336Z" },
- { url = "https://files.pythonhosted.org/packages/4b/e0/f8cc36eadd1b716ab36bb290618a3292e009867e5c97ce4aba908cb99644/psycopg2_binary-2.9.11-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e35b7abae2b0adab776add56111df1735ccc71406e56203515e228a8dc07089f", size = 3983184, upload-time = "2025-10-30T02:55:32.483Z" },
- { url = "https://files.pythonhosted.org/packages/53/3e/2a8fe18a4e61cfb3417da67b6318e12691772c0696d79434184a511906dc/psycopg2_binary-2.9.11-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fcf21be3ce5f5659daefd2b3b3b6e4727b028221ddc94e6c1523425579664747", size = 3652650, upload-time = "2025-10-10T11:13:38.181Z" },
- { url = "https://files.pythonhosted.org/packages/76/36/03801461b31b29fe58d228c24388f999fe814dfc302856e0d17f97d7c54d/psycopg2_binary-2.9.11-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:9bd81e64e8de111237737b29d68039b9c813bdf520156af36d26819c9a979e5f", size = 3298663, upload-time = "2025-10-10T11:13:44.878Z" },
- { url = "https://files.pythonhosted.org/packages/97/77/21b0ea2e1a73aa5fa9222b2a6b8ba325c43c3a8d54272839c991f2345656/psycopg2_binary-2.9.11-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:32770a4d666fbdafab017086655bcddab791d7cb260a16679cc5a7338b64343b", size = 3044737, upload-time = "2025-10-30T02:55:35.69Z" },
- { url = "https://files.pythonhosted.org/packages/67/69/f36abe5f118c1dca6d3726ceae164b9356985805480731ac6712a63f24f0/psycopg2_binary-2.9.11-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c3cb3a676873d7506825221045bd70e0427c905b9c8ee8d6acd70cfcbd6e576d", size = 3347643, upload-time = "2025-10-10T11:13:53.499Z" },
- { url = "https://files.pythonhosted.org/packages/e1/36/9c0c326fe3a4227953dfb29f5d0c8ae3b8eb8c1cd2967aa569f50cb3c61f/psycopg2_binary-2.9.11-cp314-cp314-win_amd64.whl", hash = "sha256:4012c9c954dfaccd28f94e84ab9f94e12df76b4afb22331b1f0d3154893a6316", size = 2803913, upload-time = "2025-10-10T11:13:57.058Z" },
+ { url = "https://files.pythonhosted.org/packages/48/a6/828c9185701dab71b234c2a76c38a08b098ebfec5020716b4e93807492b5/psycopg_binary-3.3.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:28b7398fdd19db3232c884fb24550bdfe951221f510e195e233299e4c9b78f97", size = 4607292, upload-time = "2026-05-01T23:30:38.962Z" },
+ { url = "https://files.pythonhosted.org/packages/92/58/5b40dbc9d839045c9dae956960e4fb6d20bcabe6c59a2aa34fc3a371913f/psycopg_binary-3.3.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1fbaa292a3c8bb61b45df1ad3da1908ccee7cb889db9425e3557d9e34e2a4829", size = 4687023, upload-time = "2026-05-01T23:30:47.227Z" },
+ { url = "https://files.pythonhosted.org/packages/85/a9/793f0ac107a9003b48441d0d1f9f616d96e0f37458dd8dc12528ceff55fb/psycopg_binary-3.3.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:94596f9e7633ee3f6440711d43bb70aa31cc0a46a900ab8b4201a366ace5c9e7", size = 5486985, upload-time = "2026-05-01T23:30:55.517Z" },
+ { url = "https://files.pythonhosted.org/packages/8f/26/42e8533497e2592334f68ec529cf5f840f7fa4e99575a4bb61aa184dbfbf/psycopg_binary-3.3.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8c0056529e68dbe9184cd4019a1f3d8f3a4ead2f6fc7a5afcf27d3314edd1277", size = 5168745, upload-time = "2026-05-01T23:31:01.904Z" },
+ { url = "https://files.pythonhosted.org/packages/15/af/b7151776cc08d5935d45c833ec818a9beb417cf7c08239af1aafbdae78ee/psycopg_binary-3.3.4-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c09aad7051326e7603c14e50636db9c01f78272dc54b3accff03d46370461e6", size = 6761486, upload-time = "2026-05-01T23:31:14.511Z" },
+ { url = "https://files.pythonhosted.org/packages/d0/ed/c92533b9124712d592cbf1cd6c76da933a2e0acea81dfe1fbe7e735f0cff/psycopg_binary-3.3.4-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:514404ed543efd620c85602b747df2a23cf1241b4067199e1a66f2d2757aaa41", size = 4997427, upload-time = "2026-05-01T23:31:20.901Z" },
+ { url = "https://files.pythonhosted.org/packages/a2/23/ccadfd0de416aa188356daa199453af24087b042e296088706d190ae0295/psycopg_binary-3.3.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:46893c26858be12cc49ca4226ed6a60b4bfccadd946b3bebb783a60b38788228", size = 4533549, upload-time = "2026-05-01T23:31:26.204Z" },
+ { url = "https://files.pythonhosted.org/packages/fd/a0/c8f43cee36386f7bc891ab41a9d31ea07cf9826038e732da79f26b1e5f34/psycopg_binary-3.3.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:df1d567fc430f6df15c9fcf67d87685fc49bdb325adc0db5af1adfb2f44eb5c9", size = 4210256, upload-time = "2026-05-01T23:31:33.884Z" },
+ { url = "https://files.pythonhosted.org/packages/4e/2c/c1547871be3790676e8868b38655496422f94f0978dfb66b74bdba2f1676/psycopg_binary-3.3.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:6b9016b1714da4dd5ecaaa75b82098aa5a0b87854ce9b092e21c27c4ae23e014", size = 3946204, upload-time = "2026-05-01T23:31:39.626Z" },
+ { url = "https://files.pythonhosted.org/packages/c4/b1/f6670f00fa7ea601584623f6c11602ab92117d83eaff885e0210f6de7418/psycopg_binary-3.3.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:47c656a8a7ba6eb0cff1801a4caaa9c8bdc12d03080e273aff1c8ac39971a77e", size = 4255811, upload-time = "2026-05-01T23:31:44.986Z" },
+ { url = "https://files.pythonhosted.org/packages/eb/e6/5fff07a70d1f945ed90ae131c3bd76cab32beff7c58c6db15ad5820b6d1f/psycopg_binary-3.3.4-cp314-cp314-win_amd64.whl", hash = "sha256:c37e024c07308cd06cf3ec51bfd0e7f6157585a4d84d1bce4a7f5f7913719bf8", size = 3666849, upload-time = "2026-05-01T23:31:51.165Z" },
]
[[package]]
-name = "ptpython"
-version = "3.0.31"
+name = "pygments"
+version = "2.20.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
+]
+
+[[package]]
+name = "pytest"
+version = "9.0.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "appdirs" },
- { name = "jedi" },
- { name = "prompt-toolkit" },
+ { name = "colorama", marker = "sys_platform == 'win32'" },
+ { name = "iniconfig" },
+ { name = "packaging" },
+ { name = "pluggy" },
{ name = "pygments" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/b2/55/6275ed7bcfc146719ecbe22291054c18847c464285854265ee516a5b4c8b/ptpython-3.0.31.tar.gz", hash = "sha256:4fed0be42bad01b7c299922cf262f51d8a77c9c8ab8e261c902e981a57439c13", size = 73045, upload-time = "2025-08-27T15:30:11.577Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/f6/18/3d9874ef021a9df79e1f0fc971f4e990cee55750c8bc9fe547a24c130009/ptpython-3.0.31-py3-none-any.whl", hash = "sha256:ddd25fadb6f2ecd4469a699c068d2dcd40d77c7105922569bba6dc79c0523458", size = 67295, upload-time = "2025-08-27T15:30:09.984Z" },
+ { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" },
]
[[package]]
-name = "pygments"
-version = "2.19.2"
+name = "pytest-django"
+version = "4.12.0"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" }
+dependencies = [
+ { name = "pytest" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/13/2b/db9a193df89e5660137f5428063bcc2ced7ad790003b26974adf5c5ceb3b/pytest_django-4.12.0.tar.gz", hash = "sha256:df94ec819a83c8979c8f6de13d9cdfbe76e8c21d39473cfe2b40c9fc9be3c758", size = 91156, upload-time = "2026-02-14T18:40:49.235Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
+ { url = "https://files.pythonhosted.org/packages/83/a5/41d091f697c09609e7ef1d5d61925494e0454ebf51de7de05f0f0a728f1d/pytest_django-4.12.0-py3-none-any.whl", hash = "sha256:3ff300c49f8350ba2953b90297d23bf5f589db69545f56f1ec5f8cff5da83e85", size = 26123, upload-time = "2026-02-14T18:40:47.381Z" },
]
[[package]]
name = "python-dateutil"
-version = "2.9.0"
+version = "2.9.0.post0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "six" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/d9/77/bd458a2e387e98f71de86dcc2ca2cab64489736004c80bc12b70da8b5488/python-dateutil-2.9.0.tar.gz", hash = "sha256:78e73e19c63f5b20ffa567001531680d939dc042bf7850431877645523c66709", size = 342990, upload-time = "2024-03-01T03:52:54.963Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/13/7f/98d6f9ca8b731506c85785bbb8806c01f5966a4df6d68c0d1cf3b16967e1/python_dateutil-2.9.0-py2.py3-none-any.whl", hash = "sha256:cbf2f1da5e6083ac2fbfd4da39a25f34312230110440f424a14c7558bb85d82e", size = 230495, upload-time = "2024-03-01T03:52:51.479Z" },
+ { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" },
]
[[package]]
name = "python-dotenv"
-version = "1.2.1"
+version = "1.2.2"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/f0/26/19cadc79a718c5edbec86fd4919a6b6d3f681039a2f6d66d14be94e75fb9/python_dotenv-1.2.1.tar.gz", hash = "sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6", size = 44221, upload-time = "2025-10-26T15:12:10.434Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230, upload-time = "2025-10-26T15:12:09.109Z" },
+ { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" },
]
[[package]]
name = "pytz"
-version = "2025.2"
+version = "2026.2"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/f8/bf/abbd3cdfb8fbc7fb3d4d38d320f2441b1e7cbe29be4f23797b4a2b5d8aac/pytz-2025.2.tar.gz", hash = "sha256:360b9e3dbb49a209c21ad61809c7fb453643e048b38924c765813546746e81c3", size = 320884, upload-time = "2025-03-25T02:25:00.538Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/ff/46/dd499ec9038423421951e4fad73051febaa13d2df82b4064f87af8b8c0c3/pytz-2026.2.tar.gz", hash = "sha256:0e60b47b29f21574376f218fe21abc009894a2321ea16c6754f3cad6eb7cdd6a", size = 320861, upload-time = "2026-05-04T01:35:29.667Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/81/c4/34e93fe5f5429d7570ec1fa436f1986fb1f00c3e0f43a589fe2bbcd22c3f/pytz-2025.2-py2.py3-none-any.whl", hash = "sha256:5ddf76296dd8c44c26eb8f4b6f35488f3ccbf6fbbd7adee0b7262d43f0ec2f00", size = 509225, upload-time = "2025-03-25T02:24:58.468Z" },
+ { url = "https://files.pythonhosted.org/packages/ec/dd/96da98f892250475bdf2328112d7468abdd4acc7b902b6af23f4ed958ea0/pytz-2026.2-py2.py3-none-any.whl", hash = "sha256:04156e608bee23d3792fd45c94ae47fae1036688e75032eea2e3bf0323d1f126", size = 510141, upload-time = "2026-05-04T01:35:27.408Z" },
]
[[package]]
@@ -541,16 +629,16 @@ wheels = [
[[package]]
name = "redis"
-version = "7.1.0"
+version = "7.4.0"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/43/c8/983d5c6579a411d8a99bc5823cc5712768859b5ce2c8afe1a65b37832c81/redis-7.1.0.tar.gz", hash = "sha256:b1cc3cfa5a2cb9c2ab3ba700864fb0ad75617b41f01352ce5779dabf6d5f9c3c", size = 4796669, upload-time = "2025-11-19T15:54:39.961Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/7b/7f/3759b1d0d72b7c92f0d70ffd9dc962b7b7b5ee74e135f9d7d8ab06b8a318/redis-7.4.0.tar.gz", hash = "sha256:64a6ea7bf567ad43c964d2c30d82853f8df927c5c9017766c55a1d1ed95d18ad", size = 4943913, upload-time = "2026-03-24T09:14:37.53Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/89/f0/8956f8a86b20d7bb9d6ac0187cf4cd54d8065bc9a1a09eb8011d4d326596/redis-7.1.0-py3-none-any.whl", hash = "sha256:23c52b208f92b56103e17c5d06bdc1a6c2c0b3106583985a76a18f83b265de2b", size = 354159, upload-time = "2025-11-19T15:54:38.064Z" },
+ { url = "https://files.pythonhosted.org/packages/74/3a/95deec7db1eb53979973ebd156f3369a72732208d1391cd2e5d127062a32/redis-7.4.0-py3-none-any.whl", hash = "sha256:a9c74a5c893a5ef8455a5adb793a31bb70feb821c86eccb62eebef5a19c429ec", size = 409772, upload-time = "2026-03-24T09:14:35.968Z" },
]
[[package]]
name = "requests"
-version = "2.32.5"
+version = "2.34.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
@@ -558,35 +646,34 @@ dependencies = [
{ name = "idna" },
{ name = "urllib3" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" },
+ { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" },
]
[[package]]
name = "ruff"
-version = "0.14.5"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/82/fa/fbb67a5780ae0f704876cb8ac92d6d76da41da4dc72b7ed3565ab18f2f52/ruff-0.14.5.tar.gz", hash = "sha256:8d3b48d7d8aad423d3137af7ab6c8b1e38e4de104800f0d596990f6ada1a9fc1", size = 5615944, upload-time = "2025-11-13T19:58:51.155Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/68/31/c07e9c535248d10836a94e4f4e8c5a31a1beed6f169b31405b227872d4f4/ruff-0.14.5-py3-none-linux_armv6l.whl", hash = "sha256:f3b8248123b586de44a8018bcc9fefe31d23dda57a34e6f0e1e53bd51fd63594", size = 13171630, upload-time = "2025-11-13T19:57:54.894Z" },
- { url = "https://files.pythonhosted.org/packages/8e/5c/283c62516dca697cd604c2796d1487396b7a436b2f0ecc3fd412aca470e0/ruff-0.14.5-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:f7a75236570318c7a30edd7f5491945f0169de738d945ca8784500b517163a72", size = 13413925, upload-time = "2025-11-13T19:57:59.181Z" },
- { url = "https://files.pythonhosted.org/packages/b6/f3/aa319f4afc22cb6fcba2b9cdfc0f03bbf747e59ab7a8c5e90173857a1361/ruff-0.14.5-py3-none-macosx_11_0_arm64.whl", hash = "sha256:6d146132d1ee115f8802356a2dc9a634dbf58184c51bff21f313e8cd1c74899a", size = 12574040, upload-time = "2025-11-13T19:58:02.056Z" },
- { url = "https://files.pythonhosted.org/packages/f9/7f/cb5845fcc7c7e88ed57f58670189fc2ff517fe2134c3821e77e29fd3b0c8/ruff-0.14.5-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e2380596653dcd20b057794d55681571a257a42327da8894b93bbd6111aa801f", size = 13009755, upload-time = "2025-11-13T19:58:05.172Z" },
- { url = "https://files.pythonhosted.org/packages/21/d2/bcbedbb6bcb9253085981730687ddc0cc7b2e18e8dc13cf4453de905d7a0/ruff-0.14.5-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2d1fa985a42b1f075a098fa1ab9d472b712bdb17ad87a8ec86e45e7fa6273e68", size = 12937641, upload-time = "2025-11-13T19:58:08.345Z" },
- { url = "https://files.pythonhosted.org/packages/a4/58/e25de28a572bdd60ffc6bb71fc7fd25a94ec6a076942e372437649cbb02a/ruff-0.14.5-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:88f0770d42b7fa02bbefddde15d235ca3aa24e2f0137388cc15b2dcbb1f7c7a7", size = 13610854, upload-time = "2025-11-13T19:58:11.419Z" },
- { url = "https://files.pythonhosted.org/packages/7d/24/43bb3fd23ecee9861970978ea1a7a63e12a204d319248a7e8af539984280/ruff-0.14.5-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:3676cb02b9061fee7294661071c4709fa21419ea9176087cb77e64410926eb78", size = 15061088, upload-time = "2025-11-13T19:58:14.551Z" },
- { url = "https://files.pythonhosted.org/packages/23/44/a022f288d61c2f8c8645b24c364b719aee293ffc7d633a2ca4d116b9c716/ruff-0.14.5-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b595bedf6bc9cab647c4a173a61acf4f1ac5f2b545203ba82f30fcb10b0318fb", size = 14734717, upload-time = "2025-11-13T19:58:17.518Z" },
- { url = "https://files.pythonhosted.org/packages/58/81/5c6ba44de7e44c91f68073e0658109d8373b0590940efe5bd7753a2585a3/ruff-0.14.5-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f55382725ad0bdb2e8ee2babcbbfb16f124f5a59496a2f6a46f1d9d99d93e6e2", size = 14028812, upload-time = "2025-11-13T19:58:20.533Z" },
- { url = "https://files.pythonhosted.org/packages/ad/ef/41a8b60f8462cb320f68615b00299ebb12660097c952c600c762078420f8/ruff-0.14.5-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7497d19dce23976bdaca24345ae131a1d38dcfe1b0850ad8e9e6e4fa321a6e19", size = 13825656, upload-time = "2025-11-13T19:58:23.345Z" },
- { url = "https://files.pythonhosted.org/packages/7c/00/207e5de737fdb59b39eb1fac806904fe05681981b46d6a6db9468501062e/ruff-0.14.5-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:410e781f1122d6be4f446981dd479470af86537fb0b8857f27a6e872f65a38e4", size = 13959922, upload-time = "2025-11-13T19:58:26.537Z" },
- { url = "https://files.pythonhosted.org/packages/bc/7e/fa1f5c2776db4be405040293618846a2dece5c70b050874c2d1f10f24776/ruff-0.14.5-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:c01be527ef4c91a6d55e53b337bfe2c0f82af024cc1a33c44792d6844e2331e1", size = 12932501, upload-time = "2025-11-13T19:58:29.822Z" },
- { url = "https://files.pythonhosted.org/packages/67/d8/d86bf784d693a764b59479a6bbdc9515ae42c340a5dc5ab1dabef847bfaa/ruff-0.14.5-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:f66e9bb762e68d66e48550b59c74314168ebb46199886c5c5aa0b0fbcc81b151", size = 12927319, upload-time = "2025-11-13T19:58:32.923Z" },
- { url = "https://files.pythonhosted.org/packages/ac/de/ee0b304d450ae007ce0cb3e455fe24fbcaaedae4ebaad6c23831c6663651/ruff-0.14.5-py3-none-musllinux_1_2_i686.whl", hash = "sha256:d93be8f1fa01022337f1f8f3bcaa7ffee2d0b03f00922c45c2207954f351f465", size = 13206209, upload-time = "2025-11-13T19:58:35.952Z" },
- { url = "https://files.pythonhosted.org/packages/33/aa/193ca7e3a92d74f17d9d5771a765965d2cf42c86e6f0fd95b13969115723/ruff-0.14.5-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:c135d4b681f7401fe0e7312017e41aba9b3160861105726b76cfa14bc25aa367", size = 13953709, upload-time = "2025-11-13T19:58:39.002Z" },
- { url = "https://files.pythonhosted.org/packages/cc/f1/7119e42aa1d3bf036ffc9478885c2e248812b7de9abea4eae89163d2929d/ruff-0.14.5-py3-none-win32.whl", hash = "sha256:c83642e6fccfb6dea8b785eb9f456800dcd6a63f362238af5fc0c83d027dd08b", size = 12925808, upload-time = "2025-11-13T19:58:42.779Z" },
- { url = "https://files.pythonhosted.org/packages/3b/9d/7c0a255d21e0912114784e4a96bf62af0618e2190cae468cd82b13625ad2/ruff-0.14.5-py3-none-win_amd64.whl", hash = "sha256:9d55d7af7166f143c94eae1db3312f9ea8f95a4defef1979ed516dbb38c27621", size = 14331546, upload-time = "2025-11-13T19:58:45.691Z" },
- { url = "https://files.pythonhosted.org/packages/e5/80/69756670caedcf3b9be597a6e12276a6cf6197076eb62aad0c608f8efce0/ruff-0.14.5-py3-none-win_arm64.whl", hash = "sha256:4b700459d4649e2594b31f20a9de33bc7c19976d4746d8d0798ad959621d64a4", size = 13433331, upload-time = "2025-11-13T19:58:48.434Z" },
+version = "0.15.14"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/dc/8a/8bce2894573e9dae6ff4d77fe34ad727d79b9e6238ad288c5638990d90f6/ruff-0.15.14.tar.gz", hash = "sha256:48e866b165be4a9bdbf310f7d3c9a07edef2fe8cd63ffeb4e00bb590506ebf9f", size = 4700910, upload-time = "2026-05-21T14:34:55.177Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/b9/c8/74a92c6ff9fcfb4f1f947126d3ebee8389276e161ecc85de5bda7cda51bd/ruff-0.15.14-py3-none-linux_armv6l.whl", hash = "sha256:8dd2db9416e487c8d4b01fa7056bb02c4d05969d4f8d17a08c229c2f4ff3c108", size = 10739177, upload-time = "2026-05-21T14:34:37.332Z" },
+ { url = "https://files.pythonhosted.org/packages/45/91/254a35c20acc38a7223c9d2d594af12e794432464f2cdeb52af1dc4a892d/ruff-0.15.14-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:be4ff55af755bd71a00ab3dc6bd7ffc467bd76e0df6881e286c2e3d23e8fb43b", size = 11144969, upload-time = "2026-05-21T14:34:43.978Z" },
+ { url = "https://files.pythonhosted.org/packages/56/9e/d13e40f83b8d0a94430e6778ce1d94a43b38cf2efe63278bdd2b4c65abbf/ruff-0.15.14-py3-none-macosx_11_0_arm64.whl", hash = "sha256:48d5909d7d06276ce7dde6d32bfa4b0d4cb2651145cd8ee4b440722cbc77832f", size = 10478207, upload-time = "2026-05-21T14:34:48.378Z" },
+ { url = "https://files.pythonhosted.org/packages/8d/f1/b15a7839fa4f332f8acec78e20564f26bb2d866e3d21710b877fd0263000/ruff-0.15.14-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca8cbfa94c4f90984a67561978602746d4cd27103568f745fa90eee3f0d4107d", size = 10818459, upload-time = "2026-05-21T14:34:22.318Z" },
+ { url = "https://files.pythonhosted.org/packages/45/33/53d651177f84f94b400a0e27f8824eeada3dddc9d5ee8aeb048f4352a520/ruff-0.15.14-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9a6bbc0333f1ab053423bcbf6226477d266ca7cec7738c4c8e3f55647803f3c4", size = 10541800, upload-time = "2026-05-21T14:34:20.209Z" },
+ { url = "https://files.pythonhosted.org/packages/b8/a6/868f87e0bf9786ed24b5d0d0ad8676b8a94fd1912f42cddf9cfc7857818a/ruff-0.15.14-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8a24a4f7605d7003a6674d4387651effd939dead3fddd0f36561eb77a9a2e542", size = 11342149, upload-time = "2026-05-21T14:34:46.365Z" },
+ { url = "https://files.pythonhosted.org/packages/a7/8b/38cd5c19faffdcc05a408d2b78edccc69492ab9720eadb49ea15ef80d768/ruff-0.15.14-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:049b5326e53ed80978f2fc041a280603f69dd6b0c95464342a2bb4572d9d9e2f", size = 12212563, upload-time = "2026-05-21T14:34:28.579Z" },
+ { url = "https://files.pythonhosted.org/packages/3e/4d/a3c5b874a556d5731e3e657aaf04311bb76f0a5c3ec220ed43051be6b64b/ruff-0.15.14-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d4ed42e6696c8dfa5f06728e6441993901f548eb92d73bc472cb5a38d1395fbf", size = 11493299, upload-time = "2026-05-21T14:34:41.836Z" },
+ { url = "https://files.pythonhosted.org/packages/1e/c0/56472c251d09858a53e51efbd485b09e1995d8731668b76d52e5dd6ee0f1/ruff-0.15.14-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:715c543cf450c4888251f91c52f1942a800541d9bddd7ac060aa4e6b77ae7cba", size = 11455931, upload-time = "2026-05-21T14:34:57.276Z" },
+ { url = "https://files.pythonhosted.org/packages/2c/4a/e2e7b4d8dbf233d4eace59c75bc3435fa6d8bd3bae82d351d4e4300c0fd1/ruff-0.15.14-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:72ebab6013ec887d439d8b7593737a0a4ffb06d45d209d4e4bf2e92813082d3f", size = 11400794, upload-time = "2026-05-21T14:34:39.773Z" },
+ { url = "https://files.pythonhosted.org/packages/97/c7/83c0539fe34c3e09136204d1e75d6052492364e0b3cb05e9465423f567d7/ruff-0.15.14-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:49072d36abdbe97a8dd7f480afe9c675699c0c495d4c84076e2c1203c4550581", size = 10804759, upload-time = "2026-05-21T14:34:31.045Z" },
+ { url = "https://files.pythonhosted.org/packages/86/a6/18f2bfc095a2ab4a78745644e428205532ce6653a5d0fa8501572891534d/ruff-0.15.14-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:958522aee105068640c2c2ceae08f413ae44d922f52a1374ac13d6a96032fc93", size = 10539517, upload-time = "2026-05-21T14:34:53.064Z" },
+ { url = "https://files.pythonhosted.org/packages/54/3a/5a8b3b69c654d4e4bf1d246ac5b49cbcdac6eaab6905925f8915f31e3b80/ruff-0.15.14-py3-none-musllinux_1_2_i686.whl", hash = "sha256:f3707da619a143a2e8830e2abab8224478d69ace2d28cb6c20543ae97c36bf61", size = 11065169, upload-time = "2026-05-21T14:34:24.484Z" },
+ { url = "https://files.pythonhosted.org/packages/ed/c5/8864e4e7925b836ea354b31d57641ec03830564e281a8b6f061f8c3e0ec1/ruff-0.15.14-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:bb01d645694e3ec0102105d07ef2d53703970407d59c04e59d3ba0b7a1d53553", size = 11560214, upload-time = "2026-05-21T14:34:50.975Z" },
+ { url = "https://files.pythonhosted.org/packages/36/38/012bf76752e1f89ed50b77b99532d90f3a3e287bc7918e1fc0948ac866ac/ruff-0.15.14-py3-none-win32.whl", hash = "sha256:6d0c1ad2a0ab718d39b6d8fd2217981ce4d625cd96a720095f798fb47d8b13e6", size = 10805548, upload-time = "2026-05-21T14:34:33.453Z" },
+ { url = "https://files.pythonhosted.org/packages/d1/b7/4ea2c170f10ad760fff2a5250beb18897719dc8b52b53a24cddbb9dd3f19/ruff-0.15.14-py3-none-win_amd64.whl", hash = "sha256:802342981e056db3851a7836e5b070f8f15f67d4a685ae2a6160939d364b2902", size = 11939523, upload-time = "2026-05-21T14:34:18.077Z" },
+ { url = "https://files.pythonhosted.org/packages/62/d5/bc97ff895ec35cf3925d4bd60f3b39d822f377a446906ec9bcc87405e59b/ruff-0.15.14-py3-none-win_arm64.whl", hash = "sha256:ff47b90a9ef6a40c9e2f3b479c1fb78531adf055b94c1eba0a7ba04b31951826", size = 11208607, upload-time = "2026-05-21T14:34:26.525Z" },
]
[[package]]
@@ -598,31 +685,22 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
]
-[[package]]
-name = "sniffio"
-version = "1.3.1"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" },
-]
-
[[package]]
name = "soupsieve"
-version = "2.8"
+version = "2.8.3"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/6d/e6/21ccce3262dd4889aa3332e5a119a3491a95e8f60939870a3a035aabac0d/soupsieve-2.8.tar.gz", hash = "sha256:e2dd4a40a628cb5f28f6d4b0db8800b8f581b65bb380b97de22ba5ca8d72572f", size = 103472, upload-time = "2025-08-27T15:39:51.78Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/7b/ae/2d9c981590ed9999a0d91755b47fc74f74de286b0f5cee14c9269041e6c4/soupsieve-2.8.3.tar.gz", hash = "sha256:3267f1eeea4251fb42728b6dfb746edc9acaffc4a45b27e19450b676586e8349", size = 118627, upload-time = "2026-01-20T04:27:02.457Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/14/a0/bb38d3b76b8cae341dad93a2dd83ab7462e6dbcdd84d43f54ee60a8dc167/soupsieve-2.8-py3-none-any.whl", hash = "sha256:0cc76456a30e20f5d7f2e14a98a4ae2ee4e5abdc7c5ea0aafe795f344bc7984c", size = 36679, upload-time = "2025-08-27T15:39:50.179Z" },
+ { url = "https://files.pythonhosted.org/packages/46/2c/1462b1d0a634697ae9e55b3cecdcb64788e8b7d63f54d923fcd0bb140aed/soupsieve-2.8.3-py3-none-any.whl", hash = "sha256:ed64f2ba4eebeab06cc4962affce381647455978ffc1e36bb79a545b91f45a95", size = 37016, upload-time = "2026-01-20T04:27:01.012Z" },
]
[[package]]
name = "sqlparse"
-version = "0.5.3"
+version = "0.5.5"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/e5/40/edede8dd6977b0d3da179a342c198ed100dd2aba4be081861ee5911e4da4/sqlparse-0.5.3.tar.gz", hash = "sha256:09f67787f56a0b16ecdbde1bfc7f5d9c3371ca683cfeaa8e6ff60b4807ec9272", size = 84999, upload-time = "2024-12-10T12:05:30.728Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/90/76/437d71068094df0726366574cf3432a4ed754217b436eb7429415cf2d480/sqlparse-0.5.5.tar.gz", hash = "sha256:e20d4a9b0b8585fdf63b10d30066c7c94c5d7a7ec47c889a2d83a3caa93ff28e", size = 120815, upload-time = "2025-12-19T07:17:45.073Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/a9/5c/bfd6bd0bf979426d405cc6e71eceb8701b148b16c21d2dc3c261efc61c7b/sqlparse-0.5.3-py3-none-any.whl", hash = "sha256:cf2196ed3418f3ba5de6af7e82c694a9fbdbfecccdfc72e281548517081f16ca", size = 44415, upload-time = "2024-12-10T12:05:27.824Z" },
+ { url = "https://files.pythonhosted.org/packages/49/4b/359f28a903c13438ef59ebeee215fb25da53066db67b305c125f1c6d2a25/sqlparse-0.5.5-py3-none-any.whl", hash = "sha256:12a08b3bf3eec877c519589833aed092e2444e68240a3577e8e26148acc7b1ba", size = 46138, upload-time = "2025-12-19T07:17:46.573Z" },
]
[[package]]
@@ -636,27 +714,27 @@ wheels = [
[[package]]
name = "tzdata"
-version = "2025.2"
+version = "2026.2"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/95/32/1a225d6164441be760d75c2c42e2780dc0873fe382da3e98a2e1e48361e5/tzdata-2025.2.tar.gz", hash = "sha256:b60a638fcc0daffadf82fe0f57e53d06bdec2f36c4df66280ae79bce6bd6f2b9", size = 196380, upload-time = "2025-03-23T13:54:43.652Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/ba/19/1b9b0e29f30c6d35cb345486df41110984ea67ae69dddbc0e8a100999493/tzdata-2026.2.tar.gz", hash = "sha256:9173fde7d80d9018e02a662e168e5a2d04f87c41ea174b139fbef642eda62d10", size = 198254, upload-time = "2026-04-24T15:22:08.651Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/5c/23/c7abc0ca0a1526a0774eca151daeb8de62ec457e77262b66b359c3c7679e/tzdata-2025.2-py2.py3-none-any.whl", hash = "sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8", size = 347839, upload-time = "2025-03-23T13:54:41.845Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/e4/dccd7f47c4b64213ac01ef921a1337ee6e30e8c6466046018326977efd95/tzdata-2026.2-py2.py3-none-any.whl", hash = "sha256:bbe9af844f658da81a5f95019480da3a89415801f6cc966806612cc7169bffe7", size = 349321, upload-time = "2026-04-24T15:22:05.876Z" },
]
[[package]]
name = "urllib3"
-version = "2.5.0"
+version = "2.7.0"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/15/22/9ee70a2574a4f4599c47dd506532914ce044817c7752a79b6a51286319bc/urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760", size = 393185, upload-time = "2025-06-18T14:07:41.644Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795, upload-time = "2025-06-18T14:07:40.39Z" },
+ { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" },
]
[[package]]
name = "wcwidth"
-version = "0.2.14"
+version = "0.7.0"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/24/30/6b0809f4510673dc723187aeaf24c7f5459922d01e2f794277a3dfb90345/wcwidth-0.2.14.tar.gz", hash = "sha256:4d478375d31bc5395a3c55c40ccdf3354688364cd61c4f6adacaa9215d0b3605", size = 102293, upload-time = "2025-09-22T16:29:53.023Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/2c/ee/afaf0f85a9a18fe47a67f1e4422ed6cf1fe642f0ae0a2f81166231303c52/wcwidth-0.7.0.tar.gz", hash = "sha256:90e3a7ea092341c44b99562e75d09e4d5160fe7a3974c6fb842a101a95e7eed0", size = 182132, upload-time = "2026-05-02T16:04:12.653Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/af/b5/123f13c975e9f27ab9c0770f514345bd406d0e8d3b7a0723af9d43f710af/wcwidth-0.2.14-py2.py3-none-any.whl", hash = "sha256:a7bb560c8aee30f9957e5f9895805edd20602f2d7f720186dfd906e82b4982e1", size = 37286, upload-time = "2025-09-22T16:29:51.641Z" },
+ { url = "https://files.pythonhosted.org/packages/41/52/e465037f5375f43533d1a80b6923955201596a99142ed524d77b571a1418/wcwidth-0.7.0-py3-none-any.whl", hash = "sha256:5d69154c429a82910e241c738cd0e2976fac8a2dd47a1a805f4afed1c0f136f2", size = 110825, upload-time = "2026-05-02T16:04:11.033Z" },
]
diff --git a/website/config.py b/website/config.py
index fed93f5..3171b86 100644
--- a/website/config.py
+++ b/website/config.py
@@ -116,7 +116,7 @@ def get(
try:
value = reduce(operator.getitem, path, self._final_config)
- except (KeyError, TypeError):
+ except KeyError, TypeError:
if required:
raise ImproperlyConfigured(
f"Required setting '{key}' is not defined in any source."