diff --git a/.claude/skills/update-readme/SKILL.md b/.claude/skills/update-readme/SKILL.md index 557fb81..254b094 100644 --- a/.claude/skills/update-readme/SKILL.md +++ b/.claude/skills/update-readme/SKILL.md @@ -482,6 +482,7 @@ After saving all docs, report in under 25 lines: - **Stale references removed** — e.g. "Removed 4 remaining `render.yaml` mentions in Security and ToC" - **Anything you noticed but did NOT change** — sections that look stale but weren't in scope (surface these for the user to decide) - **Files NOT touched and why** — if only `docs/CELERY.md` was in scope for the staged diff, say so explicitly +- **A commit messagge with a super brief description of all changes** - e.g. "Created inspirational quote generator, cleaned legacy render config files and prepared everything for googl e cloud migration and updated versioning and precommit make commands" --- diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6c185de..1bfbec4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,6 +39,14 @@ jobs: - name: Bandit — security linter run: PYTHONUTF8=1 uv run bandit -r project/ -c pyproject.toml + - name: pip-audit — dependency CVE scan + run: uv run pip-audit + + - name: Hadolint — Dockerfile lint + uses: hadolint/hadolint-action@v3.1.0 + with: + dockerfile: Dockerfile + # ============================================================ # JOB 2: Type check — mypy (no DB needed) # ============================================================ @@ -150,7 +158,7 @@ jobs: - name: Upload coverage to Codecov if: always() - uses: codecov/codecov-action@v4 + uses: codecov/codecov-action@v5 with: files: project/coverage.xml fail_ci_if_error: false @@ -163,3 +171,105 @@ jobs: name: coverage-report path: project/coverage.xml retention-days: 7 + + # ============================================================ + # JOB 4: Docker build — validates Dockerfile builds cleanly + # ============================================================ + docker-build: + name: Docker build + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build image (no push) + uses: docker/build-push-action@v6 + with: + context: . + push: false + tags: five-a-day:ci + cache-from: type=gha + cache-to: type=gha,mode=max + + # ============================================================ + # JOB 5: Trivy — filesystem CVE scan → GitHub Security tab + # ============================================================ + trivy: + name: Trivy CVE scan + runs-on: ubuntu-latest + permissions: + security-events: write + + steps: + - uses: actions/checkout@v4 + + - name: Trivy — scan Python dependencies and filesystem + uses: aquasecurity/trivy-action@v0.35.0 + with: + scan-type: fs + scan-ref: . + format: sarif + output: trivy-results.sarif + severity: HIGH,CRITICAL + + - name: Upload results to GitHub Security tab + if: always() + uses: github/codeql-action/upload-sarif@v3 + with: + sarif_file: trivy-results.sarif + + # ============================================================ + # JOB 6: Publish to GHCR + Trivy image scan + # Runs only on push to main or testing (not on PRs). + # Depends on test + docker-build passing first. + # ============================================================ + docker-publish: + name: Publish image & scan + runs-on: ubuntu-latest + needs: [test, docker-build] + if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/testing') + permissions: + contents: read + packages: write + security-events: write + + steps: + - uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and push to GHCR + uses: docker/build-push-action@v6 + with: + context: . + push: true + tags: | + ghcr.io/${{ github.repository }}:${{ github.ref_name }} + ghcr.io/${{ github.repository }}:sha-${{ github.sha }} + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Trivy — scan Docker image for OS-level CVEs + uses: aquasecurity/trivy-action@v0.35.0 + with: + image-ref: ghcr.io/${{ github.repository }}:${{ github.ref_name }} + format: sarif + output: trivy-image-results.sarif + severity: HIGH,CRITICAL + + - name: Upload image scan to GitHub Security tab + if: always() + uses: github/codeql-action/upload-sarif@v3 + with: + sarif_file: trivy-image-results.sarif diff --git a/.github/workflows/dependabot-auto-merge.yml b/.github/workflows/dependabot-auto-merge.yml new file mode 100644 index 0000000..462d30a --- /dev/null +++ b/.github/workflows/dependabot-auto-merge.yml @@ -0,0 +1,33 @@ +name: Dependabot auto-merge + +# Automatically merges Dependabot PRs once CI passes. +# Only acts on minor/patch updates — major version bumps are left for manual review. +# Requires "Allow auto-merge" enabled in repo Settings → General → Pull Requests. + +on: pull_request + +permissions: + contents: write + pull-requests: write + +jobs: + auto-merge: + name: Enable auto-merge + runs-on: ubuntu-latest + if: github.actor == 'dependabot[bot]' + + steps: + - name: Fetch Dependabot metadata + id: metadata + uses: dependabot/fetch-metadata@v2 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + + - name: Enable auto-merge for minor/patch updates + if: | + steps.metadata.outputs.update-type == 'version-update:semver-minor' || + steps.metadata.outputs.update-type == 'version-update:semver-patch' + run: gh pr merge --auto --merge "$PR_URL" + env: + PR_URL: ${{ github.event.pull_request.html_url }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml new file mode 100644 index 0000000..9295d94 --- /dev/null +++ b/.github/workflows/dependency-review.yml @@ -0,0 +1,26 @@ +name: Dependency review + +# Runs on every PR and blocks merges that introduce a dependency with a known +# HIGH or CRITICAL CVE. Complements pip-audit (which checks all installed deps) +# by specifically flagging what the PR diff adds or changes. +# GitHub-native — no token or third-party service required. + +on: pull_request + +permissions: + contents: read + pull-requests: write + +jobs: + dependency-review: + name: Dependency review + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Dependency review + uses: actions/dependency-review-action@v4 + with: + fail-on-severity: high + comment-summary-in-pr: always diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml new file mode 100644 index 0000000..5747b79 --- /dev/null +++ b/.github/workflows/scorecard.yml @@ -0,0 +1,49 @@ +name: OSSF Scorecard + +# Grades the repo's supply-chain security posture (branch protection, dependency +# pinning, CI, secret scanning, etc.) and publishes results to the GitHub +# Security tab. Free for public repositories. +# https://securityscorecards.dev + +on: + branch_protection_rule: + schedule: + - cron: '0 6 * * 1' # Every Monday at 06:00 UTC + push: + branches: [main] + +permissions: read-all + +jobs: + analysis: + name: Scorecard analysis + runs-on: ubuntu-latest + permissions: + security-events: write + id-token: write + contents: read + actions: read + + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Run Scorecard analysis + uses: ossf/scorecard-action@v2.4.0 + with: + results_file: scorecard-results.sarif + results_format: sarif + publish_results: true + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: scorecard-results + path: scorecard-results.sarif + retention-days: 5 + + - name: Upload results to GitHub Security tab + uses: github/codeql-action/upload-sarif@v3 + with: + sarif_file: scorecard-results.sarif diff --git a/CLAUDE.md b/CLAUDE.md index 347b24e..c08e041 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -128,6 +128,7 @@ All pricing flows through `billing/services/`. The single source of truth is `Si - **Template names are English** — all email templates were renamed from Spanish (e.g., `matricula_niño.html` → `enrollment_child.html`). Never create templates with Spanish names. - **Version in four places** — `pyproject.toml`, `project/project/settings.py` (`APP_VERSION` default), the `README.md` header badge URL, and `uv.lock` (the project's own `[[package]]` entry). `make version x.y.z` (positional, with y/N confirmation) updates the first three with `sed` and regenerates `uv.lock` via `uv lock --quiet`. Running `make version` with no argument prints the pyproject and README badge values side-by-side and warns if they've drifted. `make pc-run`'s auto patch-bump does the same four updates, then the existing `git add uv.lock` block at the end of the target stages the regenerated lockfile so the next commit isn't blocked. - **APP_VERSION in `.env`** — the local `.env` may contain a legacy `APP_VERSION=0.x.y` line. Either remove the line or update it — it silently overrides the default in `settings.py` at runtime. +- **`load_dotenv(override=False)` — Docker env vars take precedence** — `settings.py` calls `load_dotenv(..., override=False)`. This means environment variables already set in the process (i.e. injected by Docker Compose's `environment:` block) are NOT overridden by the `.env` file. This is intentional: it allows compose overlays (`docker-compose.testing.yml`) to inject different credentials without the volume-mounted `.env` file silently overwriting them. If you need the `.env` file to win over Docker's env vars, you'd need `override=True` — but don't do that, it breaks multi-environment setups. - **`pc-run` renamed** — the old `make pre-commit-run` target is now `make pc-run`. It also auto-bumps the patch version on a clean pass (y/N prompt) and auto-stages `uv.lock` if regenerated. - **mypy CI job needs `DJANGO_DEBUG=True`** — `django-stubs` imports `project.settings` at load time for the Django plugin. Without `DJANGO_DEBUG=True` + a dummy `DJANGO_SECRET_KEY`, the production guard at the top of `settings.py` raises `ValueError: DJANGO_SECRET_KEY debe ser cambiado en producción`. The CI `mypy` step sets both, plus `PYTHONPATH=project`, as env vars. Any new static-analysis job that imports settings will need the same. - **Test settings disable production security redirects** — `settings_test.py` explicitly sets `SECURE_SSL_REDIRECT = False`, `SECURE_HSTS_SECONDS = 0`, `SESSION_COOKIE_SECURE = False`, `CSRF_COOKIE_SECURE = False`. Don't remove them — the Django test client speaks HTTP against `testserver`, and inheriting `SECURE_SSL_REDIRECT=True` from `settings.py` (which kicks in when `DEBUG=False`) turns every test request into a 301 to `https://testserver/...`. The overrides keep the test settings self-contained regardless of how CI configures `DJANGO_DEBUG`. diff --git a/Dockerfile b/Dockerfile index f30eb92..332d5b0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -12,6 +12,7 @@ ENV PYTHONUNBUFFERED=1 \ PYTHONDONTWRITEBYTECODE=1 # Install system deps needed to compile Python packages +# hadolint ignore=DL3008 RUN apt-get update && apt-get install -y --no-install-recommends \ gcc \ postgresql-client \ @@ -42,6 +43,7 @@ ENV PYTHONUNBUFFERED=1 \ PATH="/app/.venv/bin:$PATH" # Install only runtime system deps +# hadolint ignore=DL3008 RUN apt-get update && apt-get install -y --no-install-recommends \ postgresql-client \ libpq-dev \ diff --git a/Makefile b/Makefile index 93f074e..57ffbf7 100644 --- a/Makefile +++ b/Makefile @@ -3,15 +3,13 @@ # ============================================================================ # Docker and Django shortcuts. Run `make` or `make help` for usage. -.PHONY: help setup build up down restart stop start rebuild dev logs logs-web \ - logs-db ps stats shell bash migrate makemigrations createsuperuser \ - collectstatic check dbshell backup restore reset-db test test-local \ - test-verbose test-coverage test-unit test-integration test-cov-gate test-models test-services test-views \ - clean clean-all health url send-test-email generate-payments \ - testing-up testing-down testing-logs testing-seed testing-reset \ - testing-rebuild testing-shell testing-health \ - sync lint lint-fix format format-check pre-commit-install pc-run \ - mypy bandit audit coverage-badge \ +.PHONY: help setup build up down restart stop start rebuild dev logs \ + ps stats shell bash migrate makemigrations createsuperuser \ + collectstatic check dbshell backup restore reset-db \ + test test-cov-gate \ + clean clean-all health url generate-payments generate-payments-dry \ + sync lint format pre-commit-install pc-run \ + mypy bandit audit coverage-badge check-deploy \ celery-logs celery-restart celery-status celery-test-task # ============================================================================ @@ -23,98 +21,80 @@ help: @echo " ==========================" @echo "" @echo " Setup & Build:" - @echo " make setup Create empty .env (fill in from README.md '.env template')" - @echo " make build Build Docker images" - @echo " make rebuild Full rebuild (no cache) + start" - @echo " make rebuild-web Rebuild only the web image" + @echo " make setup Create empty .env" + @echo " make build Build Docker images" + @echo " make rebuild Full rebuild (no cache) + start" + @echo " make rebuild SERVICE=x Rebuild only a specific service" @echo "" @echo " Docker Lifecycle:" - @echo " make up Start all services (detached)" - @echo " make down Stop and remove containers" - @echo " make restart Restart all services" - @echo " make restart-web Restart only web" - @echo " make restart-db Restart only database" - @echo " make stop Stop without removing" - @echo " make start Start stopped containers" - @echo " make dev Start in foreground (logs visible)" - @echo " make dev-build Build + start in foreground" + @echo " make up Start all services (detached)" + @echo " make down Stop and remove containers" + @echo " make restart Restart all services" + @echo " make restart SERVICE=x Restart a specific service" + @echo " make stop Stop without removing" + @echo " make stop SERVICE=x Stop a specific service" + @echo " make start Start stopped containers" + @echo " make start SERVICE=x Start a specific service" + @echo " make dev Start in foreground (logs visible)" + @echo " make dev BUILD=1 Build + start in foreground" @echo "" @echo " Monitoring:" - @echo " make logs Tail logs (all services)" - @echo " make logs-web Tail web logs only" - @echo " make logs-db Tail database logs only" - @echo " make ps Show running services" - @echo " make stats Show resource usage" - @echo " make health Full health check (Django + DB)" - @echo " make url Show access URLs" + @echo " make logs Tail all logs" + @echo " make logs SERVICE=x Tail logs for a specific service" + @echo " make ps Show running services" + @echo " make stats Show resource usage" + @echo " make health Full health check (Django + DB)" + @echo " make url Show access URLs" @echo "" @echo " Django:" - @echo " make shell Django shell inside container" - @echo " make bash Bash shell inside container" - @echo " make migrate Apply all migrations" - @echo " make makemigrations Create migrations (all apps)" - @echo " make createsuperuser Create Django superuser" - @echo " make collectstatic Collect static files" - @echo " make check Run Django system checks" + @echo " make shell Django shell inside container" + @echo " make bash Bash shell inside container" + @echo " make migrate Apply all migrations" + @echo " make makemigrations Create migrations (all apps)" + @echo " make createsuperuser Create Django superuser" + @echo " make collectstatic Collect static files" + @echo " make check Run Django system checks" @echo "" @echo " Database:" - @echo " make dbshell PostgreSQL interactive shell" - @echo " make backup Dump DB to backups/" - @echo " make restore FILE=x Restore from SQL file" - @echo " make reset-db Drop and recreate DB (destructive!)" + @echo " make dbshell PostgreSQL interactive shell" + @echo " make backup Dump DB to backups/" + @echo " make restore FILE=x Restore from SQL file" + @echo " make reset-db Drop and recreate DB (destructive!)" @echo "" @echo " Testing:" - @echo " make test Run all tests (Docker)" - @echo " make test-local Run all tests (local, no Docker)" - @echo " make test-verbose Run all tests with verbose output" - @echo " make test-coverage Run tests with coverage report" - @echo " make test-unit Run only unit tests" - @echo " make test-integration Run only integration tests" - @echo " make test-models Run only model tests" - @echo " make test-services Run only service tests" - @echo " make test-views Run only view tests" - @echo " make test-fast Run tests, stop on first failure" - @echo "" - @echo " Email:" - @echo " make send-test-email Send a test birthday email" - @echo " make test-all-emails Send one test of each email template" + @echo " make test Run all tests (Docker + coverage)" + @echo " make test unit Run only unit tests" + @echo " make test integration Run only integration tests" + @echo " make test coverage All tests + HTML coverage report" + @echo " make test K= Filter by keyword (e.g. K=payment)" + @echo " make test ARGS='...' Pass raw pytest flags through" @echo "" @echo " Payments:" @echo " make generate-payments Generate current month" @echo " make generate-payments-dry Preview without creating" @echo "" - @echo " Testing / QA Environment:" - @echo " make testing-up Start QA environment (production-like)" - @echo " make testing-down Stop QA environment" - @echo " make testing-rebuild Full rebuild of QA environment" - @echo " make testing-logs Tail QA logs" - @echo " make testing-seed Populate QA database with test data" - @echo " make testing-reset Wipe QA database and re-seed" - @echo " make testing-shell Django shell in QA container" - @echo " make testing-health Health check for QA environment" - @echo "" @echo " Celery (async tasks):" - @echo " make celery-logs Tail Celery worker + beat logs" - @echo " make celery-restart Restart worker + beat containers" - @echo " make celery-status Show Celery worker status" - @echo " make celery-test-task Send a debug task to verify Celery works" + @echo " make celery-logs Tail Celery worker + beat logs" + @echo " make celery-restart Restart worker + beat containers" + @echo " make celery-status Show Celery worker status" + @echo " make celery-test-task Send a debug task to verify Celery works" @echo "" @echo " Developer Tooling:" - @echo " make sync Install all deps (including dev) via uv" - @echo " make lint Run Ruff linter" - @echo " make lint-fix Run Ruff linter with auto-fix" - @echo " make format Format code with Ruff" - @echo " make format-check Check formatting (no changes)" - @echo " make mypy Run mypy type checker" - @echo " make bandit Run bandit security linter" - @echo " make audit Audit dependencies for vulnerabilities" - @echo " make coverage-badge Generate coverage.svg badge from last test run" - @echo " make pre-commit-install Install pre-commit hooks" - @echo " make pc-run Run pre-commit on all files" + @echo " make sync Install all deps (including dev) via uv" + @echo " make lint Check code with Ruff (read-only)" + @echo " make lint FIX=1 Lint and auto-fix issues" + @echo " make format Format code with Ruff" + @echo " make format DRY=1 Check formatting without applying changes" + @echo " make mypy Run mypy type checker" + @echo " make bandit Run bandit security linter" + @echo " make audit Audit dependencies for vulnerabilities" + @echo " make coverage-badge Generate coverage.svg badge" + @echo " make pre-commit-install Install pre-commit hooks" + @echo " make pc-run Run pre-commit on all files" @echo "" @echo " Cleanup:" - @echo " make clean Remove stopped containers + prune" - @echo " make clean-all Remove everything including volumes" + @echo " make clean Remove stopped containers + prune" + @echo " make clean-all Remove everything including volumes" @echo "" # ============================================================================ @@ -141,49 +121,38 @@ up: down: docker compose down -restart: - docker compose restart - -restart-web: - docker compose restart web +# Rebuild with no cache. Without SERVICE rebuilds everything; with SERVICE=web +# only that service is stopped/rebuilt/started. +rebuild: + @if [ -z "$(SERVICE)" ]; then \ + docker compose down; \ + docker compose build --no-cache; \ + docker compose up -d; \ + echo "Rebuilt and started: http://localhost:8000"; \ + else \ + docker compose stop $(SERVICE); \ + docker compose build --no-cache $(SERVICE); \ + docker compose up -d $(SERVICE); \ + echo "Rebuilt service: $(SERVICE)"; \ + fi -restart-db: - docker compose restart db +restart: + docker compose restart $(SERVICE) stop: - docker compose stop + docker compose stop $(SERVICE) start: - docker compose start - -rebuild: - docker compose down - docker compose build --no-cache - docker compose up -d - @echo "Rebuilt and started: http://localhost:8000" - -rebuild-web: - docker compose stop web - docker compose build --no-cache web - docker compose up -d web + docker compose start $(SERVICE) dev: - docker compose up --remove-orphans - -dev-build: - docker compose up --build --remove-orphans + docker compose up $(if $(BUILD),--build,) --remove-orphans # ============================================================================ # MONITORING # ============================================================================ logs: - docker compose logs -f - -logs-web: - docker compose logs -f web - -logs-db: - docker compose logs -f db + docker compose logs -f $(SERVICE) ps: docker compose ps @@ -268,74 +237,50 @@ reset-db: # ============================================================================ # TESTING # ============================================================================ -# Tests use PostgreSQL by default (requires `make up` for the DB container). -# Set TEST_DB_ENGINE=sqlite to fall back to SQLite for quick local runs. +# All tests run inside Docker against PostgreSQL (same engine as production). +# +# Usage: +# make test all tests with coverage +# make test unit tests/unit/ only +# make test integration tests/integration/ only +# make test coverage all tests + HTML report (htmlcov/) +# make test K=payment filter by keyword +# make test ARGS='--lf' pass any raw pytest flag through + +ifeq ($(firstword $(MAKECMDGOALS)),test) + _SUITE := $(wordlist 2,$(words $(MAKECMDGOALS)),$(MAKECMDGOALS)) + ifneq ($(_SUITE),) + $(eval $(_SUITE):;@:) + endif +endif -# Run all tests inside Docker (uses the container's PostgreSQL) test: docker compose exec web uv sync --frozen --no-install-project --quiet - docker compose exec -e DJANGO_SETTINGS_MODULE=project.settings_test -e TEST_DB_HOST=db web python -m pytest project/tests/ -v --tb=short -n auto --cov=core --cov=students --cov=billing --cov=comms --cov-report=term-missing - -# Pre-commit coverage gate: same command as `make test` but fails fast if -# coverage drops below 75%. Invoked by the pytest-coverage pre-commit hook -# and safe to run manually. Output is terse (-q) so commit feedback stays -# readable; full coverage report still prints at the end. + @SUITE="$(_SUITE)"; \ + TEST_PATH="project/tests/"; \ + EXTRA=""; \ + case "$$SUITE" in \ + unit) TEST_PATH="project/tests/unit/" ;; \ + integration) TEST_PATH="project/tests/integration/" ;; \ + coverage) EXTRA="--cov-report=html" ;; \ + esac; \ + [ -n "$(K)" ] && EXTRA="$$EXTRA -k $(K)"; \ + docker compose exec \ + -e DJANGO_SETTINGS_MODULE=project.settings_test \ + -e TEST_DB_HOST=db \ + web python -m pytest $$TEST_PATH -v --tb=short -n auto \ + --cov=core --cov=students --cov=billing --cov=comms \ + --cov-report=term-missing $$EXTRA $(ARGS) + +# Pre-commit coverage gate: fails if coverage drops below 75%. +# Invoked by the pytest-coverage pre-commit hook; safe to run manually. test-cov-gate: @docker compose exec web uv sync --frozen --no-install-project --quiet @docker compose exec -e DJANGO_SETTINGS_MODULE=project.settings_test -e TEST_DB_HOST=db web python -m pytest project/tests/ -q --tb=line -n auto --cov=core --cov=students --cov=billing --cov=comms --cov-fail-under=75 -# Run tests locally against the Docker PostgreSQL (default) -test-local: - cd project && TEST_DB_HOST=localhost python -m pytest tests/ -v --tb=short - -# Run tests locally with SQLite (no Docker needed) -test-sqlite: - cd project && TEST_DB_ENGINE=sqlite python -m pytest tests/ -v --tb=short - -# Verbose output with full tracebacks -test-verbose: - cd project && TEST_DB_HOST=localhost python -m pytest tests/ -v --tb=long -s - -# Coverage report -test-coverage: - cd project && TEST_DB_HOST=localhost python -m pytest tests/ --cov=core --cov=students --cov=billing --cov=comms --cov-report=term-missing --cov-report=html - @echo "HTML report: project/htmlcov/index.html" - -# Run only unit tests (direct calls, no HTTP stack) -test-unit: - cd project && TEST_DB_HOST=localhost python -m pytest tests/unit/ -v --tb=short - -# Run only integration tests (Django test client through the middleware chain) -test-integration: - cd project && TEST_DB_HOST=localhost python -m pytest tests/integration/ -v --tb=short - -# Run specific test modules -test-models: - cd project && TEST_DB_HOST=localhost python -m pytest tests/unit/test_models.py -v --tb=short - -test-services: - cd project && TEST_DB_HOST=localhost python -m pytest tests/unit/test_services.py -v --tb=short - -test-views: - cd project && TEST_DB_HOST=localhost python -m pytest tests/integration/test_views.py -v --tb=short - -# Stop on first failure -test-fast: - cd project && TEST_DB_HOST=localhost python -m pytest tests/ -x -v --tb=short - -# Run tests matching a keyword (usage: make test-k K=payment) -test-k: - cd project && TEST_DB_HOST=localhost python -m pytest tests/ -v -k "$(K)" --tb=short - # ============================================================================ -# EMAIL & PAYMENTS +# PAYMENTS # ============================================================================ -send-test-email: - docker compose exec web python project/manage.py send_email --template happy_birthday --test - -test-all-emails: - docker compose exec web python project/manage.py test_all_emails --list - generate-payments: docker compose exec web python project/manage.py generate_payments @@ -363,15 +308,12 @@ clean-all: # ============================================================================ # VERSIONING # ============================================================================ -# App version is defined in two places: -# 1. pyproject.toml -> version = "x.y.z" -# 2. project/settings.py -> APP_VERSION fallback = "x.y.z" -# This command updates both at once. -# +# App version is defined in three places: +# 1. pyproject.toml -> version = "x.y.z" +# 2. settings.py -> APP_VERSION fallback = "x.y.z" +# 3. README.md -> badge URL # Usage: make version x.y.z -# Capture `make version x.y.z` - treat the version number as a goal with an empty recipe -# so Make doesn't complain about a missing target named "x.y.z". ifeq ($(firstword $(MAKECMDGOALS)),version) _VERSION_ARG := $(wordlist 2,$(words $(MAKECMDGOALS)),$(MAKECMDGOALS)) ifneq ($(_VERSION_ARG),) @@ -414,47 +356,6 @@ version: echo "Cancelled."; \ fi -# ============================================================================ -# TESTING / QA ENVIRONMENT -# ============================================================================ -# These targets use docker-compose.testing.yml on top of the base file. -# The QA environment mimics production: DEBUG=False, Gunicorn, HTTPS cookies. - -TESTING_COMPOSE = docker compose -f docker-compose.yml -f docker-compose.testing.yml - -testing-up: - $(TESTING_COMPOSE) up -d --remove-orphans - @echo "QA environment started: http://localhost:8000" - @echo "Login with credentials from .env.testing (LOGIN_USERNAME / LOGIN_PASSWORD)" - -testing-down: - $(TESTING_COMPOSE) down - -testing-rebuild: - $(TESTING_COMPOSE) down - $(TESTING_COMPOSE) build --no-cache - $(TESTING_COMPOSE) up -d - @echo "QA environment rebuilt: http://localhost:8000" - -testing-logs: - $(TESTING_COMPOSE) logs -f web - -testing-seed: - $(TESTING_COMPOSE) exec web python project/manage.py seed_testdata - -testing-reset: - $(TESTING_COMPOSE) exec web python project/manage.py seed_testdata --reset - -testing-shell: - $(TESTING_COMPOSE) exec web python project/manage.py shell - -testing-health: - @echo "=== QA Services ===" - @$(TESTING_COMPOSE) ps - @echo "" - @echo "=== Health endpoint ===" - @curl -sf http://localhost:8000/health/ 2>/dev/null || echo "(not reachable)" - # ============================================================================ # CELERY (async tasks + scheduled jobs) # ============================================================================ @@ -477,16 +378,10 @@ sync: uv sync --no-install-project lint: - uv run --no-project ruff check project/ - -lint-fix: - uv run --no-project ruff check --fix project/ + uv run --no-project ruff check $(if $(FIX),--fix,) project/ format: - uv run --no-project ruff format project/ - -format-check: - uv run --no-project ruff format --check project/ + uv run --no-project ruff format $(if $(DRY),--check,) project/ mypy: uv run mypy project/ diff --git a/README.md b/README.md index a3234c6..2c48b78 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # Five a Day eVolution

- Five a Day Logo + Five a Day Logo
Student Management System for Five a Day English Academy
@@ -9,10 +9,6 @@

- Version - Python - Django - PostgreSQL CI Coverage

@@ -23,127 +19,202 @@ Built to centralize student records, automate billing cycles, and streamline par ### Project Status +

+ Version +  |  + CI main +  |  + Coverage +  |  + OSSF Scorecard +  |  + Dependabot +

+ + | Environment | Branch | Hosting | CI Status | |-------------|--------|---------|-----------| | **Production** | `main` | [https://example.com/](...) | [![Production CI](https://github.com/starseeker-code-public/five-a-day/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/starseeker-code-public/five-a-day/actions/workflows/ci.yml?query=branch%3Amain) | | **Testing (QA)** | `testing` | [https://example.com/](...) | [![Testing CI](https://github.com/starseeker-code-public/five-a-day/actions/workflows/ci.yml/badge.svg?branch=testing)](https://github.com/starseeker-code-public/five-a-day/actions/workflows/ci.yml?query=branch%3Atesting) | -| **Development** | `development` | [Local Docker development: http://localhost:8000/](http://localhost:8000/) | [![Development CI](https://github.com/starseeker-code-public/five-a-day/actions/workflows/ci.yml/badge.svg?branch=development)](https://github.com/starseeker-code-public/five-a-day/actions/workflows/ci.yml?query=branch%3Adevelopment) | +| **Development** | `development` | [Docker in local](http://localhost:8000/) | [![Development CI](https://github.com/starseeker-code-public/five-a-day/actions/workflows/ci.yml/badge.svg?branch=development)](https://github.com/starseeker-code-public/five-a-day/actions/workflows/ci.yml?query=branch%3Adevelopment) | + | Version | Date | Description | |---------|------|-------------| -| **v1.0.9** | 2026-04-16 | Test suite restructure to unit/integration, 96% coverage, CI gates | -| v1.0.8 | 2026-04-15 | README trim, `docs/` purge, flaky CI test removed | -| v1.0.7 | 2026-04-15 | Favicon + social metadata, CI test-suite fixes | +| **v1.0.11** | 2026-04-22 | Testing env fixes, CI hardening, static files cleanup | +| v1.0.10 | 2026-04-21 | Branded admin theme, white-bg favicon, social meta | +| v1.0.9 | 2026-04-16 | Test suite restructure, 96% coverage, CI gates | --- ## Table of Contents -- [Project Status](#project-status) -- [Version History \& Roadmap](#version-history--roadmap) - - [Roadmap](#roadmap) - - [v1.1 — Waiting List \& Group Capacity](#v11--waiting-list--group-capacity) - - [v1.2 — Google Sheets Integration](#v12--google-sheets-integration) - - [v1.3 — PDF Invoice Generation](#v13--pdf-invoice-generation) - - [v1.4 — Celery + Redis Deployment](#v14--celery--redis-deployment) - - [v1.5 — Expense Tracking](#v15--expense-tracking) - - [v1.6 — Multi-User Permissions](#v16--multi-user-permissions) - - [v1.7 — Advanced Reporting \& Analytics](#v17--advanced-reporting--analytics) - - [v1.8 — SMS Notifications (Twilio)](#v18--sms-notifications-twilio) - - [v1.9 — Parent Portal](#v19--parent-portal) - - [v1.10 — Audit Log \& Security Hardening](#v110--audit-log--security-hardening) - - [v1.11 — Stripe Payment Integration](#v111--stripe-payment-integration) - - [v1.12 — Mobile Optimization \& PWA](#v112--mobile-optimization--pwa) -- [Tech Stack](#tech-stack) - - [Backend](#backend) - - [Frontend](#frontend) - - [Infrastructure \& Deployment](#infrastructure--deployment) - - [Python Dependencies](#python-dependencies) - - [Developer Tooling](#developer-tooling) -- [Database Schema](#database-schema) - - [ER Diagram](#er-diagram) - - [Key Constraints](#key-constraints) -- [Development \& Docker](#development--docker) - - [Quick Start](#quick-start) - - [.env template](#env-template) - - [Make Commands](#make-commands) - - [Environment Configuration](#environment-configuration) - - [Environment Variables Reference](#environment-variables-reference) - - [App Versioning](#app-versioning) -- [Project Structure \& Architecture](#project-structure--architecture) - - [Architecture Overview](#architecture-overview) - - [App Dependency Flow](#app-dependency-flow) - - [Directory Layout](#directory-layout) - - [App: core](#app-core) - - [App: students](#app-students) - - [App: billing](#app-billing) - - [App: comms](#app-comms) - - [Design Decisions](#design-decisions) -- [Features by View](#features-by-view) - - [Home (Dashboard)](#home-dashboard) - - [Students](#students) - - [Student Create](#student-create) - - [Student Detail \& Update](#student-detail--update) - - [Payments](#payments) - - [Schedule](#schedule) - - [Fun Friday](#fun-friday) - - [Apps (Email Tools)](#apps-email-tools) - - [Management](#management) - - [Database (All Info)](#database-all-info) - - [Login](#login) -- [Testing](#testing) - - [Testing Overview](#testing-overview) - - [Unit Tests](#unit-tests) - - [Integration Tests](#integration-tests) - - [Coverage Report](#coverage-report) -- [Migrations](#migrations) -- [Security](#security) - - [Authentication](#authentication) - - [Session \& Cookie Configuration](#session--cookie-configuration) - - [CSRF Protection](#csrf-protection) - - [Transport Security (HTTPS)](#transport-security-https) - - [Security Headers](#security-headers) - - [Infrastructure \& Deployment](#infrastructure--deployment-1) - - [Docker](#docker) - - [Google Cloud Run](#google-cloud-run) - - [Secrets Management](#secrets-management) - - [Email Security](#email-security) - - [Data Protection \& Input Validation](#data-protection--input-validation) - - [Logging \& Monitoring](#logging--monitoring) - - [Future Security Improvements](#future-security-improvements) -- [Testing Environment (QA)](#testing-environment-qa) - - [What is the testing environment?](#what-is-the-testing-environment) - - [How to access it](#how-to-access-it) - - [What you can test](#what-you-can-test) - - [How to report a problem](#how-to-report-a-problem) - - [Error pages you might see](#error-pages-you-might-see) - - [For developers: how the QA environment works](#for-developers-how-the-qa-environment-works) - - [Access control for `/testing/`](#access-control-for-testing) -- [CI/CD \& GitHub Actions](#cicd--github-actions) - - [Pipeline Overview](#pipeline-overview) - - [Branch Strategy](#branch-strategy) - - [Workflows](#workflows) - - [Automated Flows](#automated-flows) - - [Branch Protection — `main`](#branch-protection--main) - - [Branch Protection — `testing`](#branch-protection--testing) - - [Public Repository Hardening](#public-repository-hardening) - - [Required GitHub Secrets](#required-github-secrets) - - [Email Notifications](#email-notifications) - - [Dependabot](#dependabot) - - [CodeQL Security Scanning](#codeql-security-scanning) -- [Contributing](#contributing) - - [Development Workflow](#development-workflow) - - [Make Commands (Developer Tooling)](#make-commands-developer-tooling) - - [Code Conventions](#code-conventions) - - [Adding a Feature](#adding-a-feature) -- [License](#license) +- [Five a Day eVolution](#five-a-day-evolution) + - [Project Status](#project-status) + - [Table of Contents](#table-of-contents) + - [Version History \& Roadmap](#version-history--roadmap) + - [Roadmap](#roadmap) + - [v1.1 — Waiting List \& Group Capacity](#v11--waiting-list--group-capacity) + - [v1.2 — Google Sheets Integration](#v12--google-sheets-integration) + - [v1.3 — PDF Invoice Generation](#v13--pdf-invoice-generation) + - [v1.4 — Celery + Redis Deployment](#v14--celery--redis-deployment) + - [v1.5 — Expense Tracking](#v15--expense-tracking) + - [v1.6 — Multi-User Permissions](#v16--multi-user-permissions) + - [v1.7 — Advanced Reporting \& Analytics](#v17--advanced-reporting--analytics) + - [v1.8 — SMS Notifications (Twilio)](#v18--sms-notifications-twilio) + - [v1.9 — Parent Portal](#v19--parent-portal) + - [v1.10 — Audit Log \& Security Hardening](#v110--audit-log--security-hardening) + - [v1.11 — Stripe Payment Integration](#v111--stripe-payment-integration) + - [v1.12 — Mobile Optimization \& PWA](#v112--mobile-optimization--pwa) + - [Tech Stack](#tech-stack) + - [Backend](#backend) + - [Frontend](#frontend) + - [Infrastructure \& Deployment](#infrastructure--deployment) + - [Python Dependencies](#python-dependencies) + - [Developer Tooling](#developer-tooling) + - [Database Schema](#database-schema) + - [ER Diagram](#er-diagram) + - [Key Constraints](#key-constraints) + - [Development \& Docker](#development--docker) + - [Quick Start](#quick-start) + - [.env template](#env-template) + - [Make Commands](#make-commands) + - [Environment Configuration](#environment-configuration) + - [Environment Variables Reference](#environment-variables-reference) + - [App Versioning](#app-versioning) + - [Project Structure \& Architecture](#project-structure--architecture) + - [Architecture Overview](#architecture-overview) + - [App Dependency Flow](#app-dependency-flow) + - [Directory Layout](#directory-layout) + - [App: core](#app-core) + - [App: students](#app-students) + - [App: billing](#app-billing) + - [App: comms](#app-comms) + - [Design Decisions](#design-decisions) + - [Features by View](#features-by-view) + - [Home (Dashboard)](#home-dashboard) + - [Students](#students) + - [Student Create](#student-create) + - [Student Detail \& Update](#student-detail--update) + - [Payments](#payments) + - [Schedule](#schedule) + - [Fun Friday](#fun-friday) + - [Apps (Email Tools)](#apps-email-tools) + - [Management](#management) + - [Database (All Info)](#database-all-info) + - [Login](#login) + - [Testing](#testing) + - [Testing Overview](#testing-overview) + - [Unit Tests](#unit-tests) + - [Integration Tests](#integration-tests) + - [Coverage Report](#coverage-report) + - [Migrations](#migrations) + - [Security](#security) + - [Authentication](#authentication) + - [Session \& Cookie Configuration](#session--cookie-configuration) + - [CSRF Protection](#csrf-protection) + - [Transport Security (HTTPS)](#transport-security-https) + - [Security Headers](#security-headers) + - [Infrastructure \& Deployment](#infrastructure--deployment-1) + - [Docker](#docker) + - [Google Cloud Run](#google-cloud-run) + - [Secrets Management](#secrets-management) + - [Email Security](#email-security) + - [Data Protection \& Input Validation](#data-protection--input-validation) + - [Logging \& Monitoring](#logging--monitoring) + - [Future Security Improvements](#future-security-improvements) + - [Testing Environment (QA)](#testing-environment-qa) + - [What is the testing environment?](#what-is-the-testing-environment) + - [How to access it](#how-to-access-it) + - [What you can test](#what-you-can-test) + - [How to report a problem](#how-to-report-a-problem) + - [Error pages you might see](#error-pages-you-might-see) + - [For developers: how the QA environment works](#for-developers-how-the-qa-environment-works) + - [Access control for `/testing/`](#access-control-for-testing) + - [CI/CD \& GitHub Actions](#cicd--github-actions) + - [Pipeline Overview](#pipeline-overview) + - [Branch Strategy](#branch-strategy) + - [Workflows](#workflows) + - [Automated Flows](#automated-flows) + - [Branch Protection — `main`](#branch-protection--main) + - [Branch Protection — `testing`](#branch-protection--testing) + - [Public Repository Hardening](#public-repository-hardening) + - [Required GitHub Secrets](#required-github-secrets) + - [Email Notifications](#email-notifications) + - [Dependabot](#dependabot) + - [CodeQL Security Scanning](#codeql-security-scanning) + - [Contributing](#contributing) + - [Development Workflow](#development-workflow) + - [Make Commands (Developer Tooling)](#make-commands-developer-tooling) + - [Code Conventions](#code-conventions) + - [Adding a Feature](#adding-a-feature) + - [License](#license) --- ## Version History & Roadmap -
-v1.0.9 — Test Suite Restructure, 96% Coverage & CI Coverage Gates (current) +
+v1.0.11 — Testing Environment Fixes, CI Hardening & Static File Cleanup (current) + +**Testing environment** + +- `docker-compose.testing.yml`: added explicit `POSTGRES_DB`, `POSTGRES_USER`, `POSTGRES_PASSWORD` overrides to both `db` and `web` services — the base `docker-compose.yml` uses `.env` credentials while the overlay uses `.env.testing` credentials; without these overrides the `db` container initialised with dev credentials while the `web` container tried to connect with testing credentials +- `settings.py`: `load_dotenv(override=True)` → `override=False` — Docker `environment:` values now take precedence over the volume-mounted `.env` file; `override=True` was silently overwriting credentials injected by the compose overlay +- `core/context_processors.py`: added `hasattr(request, "session")` guard before `request.session.get("username")` — prevents `AttributeError` 500 errors in admin views and error-handler requests that bypass `SessionMiddleware` + +**Static files** + +- `STATICFILES_DIRS = [BASE_DIR / "static"]` removed from `settings.py`; all static assets now live under `project/core/static/` (served via `APP_DIRS=True`) — no separate `STATICFILES_DIRS` needed +- Moved to `project/core/static/`: `css/admin_custom.css`, `css/email.css`, `images/logo_white_bg.png` +- Deleted legacy `project/static/` assets: `apple-touch-icon.png`, `favicon-32x32.png`, `favicon.ico`, `images/logo.png` + +**CI/CD — new jobs and workflows** + +- `ci.yml` lint job: added `pip-audit` CVE scan and Hadolint Dockerfile lint +- New CI job — **Docker build**: validates `Dockerfile` builds cleanly on every push/PR (with GHA cache) +- New CI job — **Trivy filesystem scan**: scans Python deps + filesystem for HIGH/CRITICAL CVEs; uploads SARIF to GitHub Security tab +- New CI job — **Docker publish**: on push to `main`/`testing`, builds and pushes image to GHCR (`ghcr.io/starseeker-code-public/five-a-day:` + `sha-`), then runs Trivy image scan +- `codecov-action` upgraded v4 → v5 +- New `dependabot-auto-merge.yml`: automatically merges Dependabot minor/patch PRs once CI passes +- New `dependency-review.yml`: blocks PRs that introduce a HIGH/CRITICAL CVE dependency +- New `scorecard.yml`: OSSF Scorecard supply-chain security grading (weekly + on push to `main`); results published to GitHub Security tab + +**Admin** + +- `#nav-sidebar` right padding set to `1rem` in `admin_custom.css` + +
+ +
+v1.0.10 — Branded Admin Theme, White-Bg Favicon & Social Meta + +**Social sharing & branding** + +- Logo changed to `logo_white_bg.png` across README, `base.html` favicon, apple-touch-icon, Open Graph, and Twitter Card — white background improves rendering in light-themed link preview cards +- Favicon regenerated from `logo_white_bg.png`: multi-size ICO (16/32/48/64px), `favicon-32x32.png`, and `apple-touch-icon.png` (180×180) — dropped in both `project/static/` and `project/core/static/` +- `og:image:secure_url` added alongside `og:image` for Facebook's HTTPS-explicit crawler +- `twitter:image:alt` added to Twitter Card block +- Schema.org JSON-LD block added to `base.html` (`@type: WebApplication`, provider as `EducationalOrganization`) — covers Google Search previews, Gmail, and Google Chat link unfurling + +**Django admin — Five a Day theme** + +- `project/templates/admin/` created; `TEMPLATES.DIRS` now points to `project/templates/` so project-level overrides take priority over Django's built-ins +- `admin/base_site.html` — violet gradient header (`#4c1d95` → `#7c3aed`) with `logo_white_bg.png`, loads `admin_custom.css` on every admin page +- `admin/login.html` — card-style login page: logo, "Gestión Académica · Albacete" subtitle, Spanish field labels (Usuario / Contraseña / Entrar) +- `admin/index.html` — welcome banner with logo + Spanish action labels (Añadir / Editar / Ver / Acciones recientes) +- `project/static/css/admin_custom.css` — full CSS-variable override of Django admin (violet/purple palette, Trebuchet MS font, styled login card, fieldset headers, welcome banner) +- `core/admin.py` — `site_title` → "Five a Day · Admin", `index_title` → "Panel de administración" + +**Dependencies (Dependabot)** + +- `gunicorn` upgraded 22.0.0 → 23.0.0 (constraint widened `<23` → `<24`) +- `pandas` upgraded 2.3.3 → 3.0.2 (constraint widened `<3` → `<4`) + +
+ +
+v1.0.9 — Test Suite Restructure, 96% Coverage & CI Coverage Gates **Testing** @@ -505,13 +576,13 @@ Progressive Web App support: installable on mobile, offline-capable dashboard, p | Technology | Version | Purpose | |-----------|---------|---------| -| Python | 3.12+ | Runtime | -| Django | 5.2.5 | Web framework | -| PostgreSQL | 16 (Alpine) | Database (production, development, and testing) | +| [![Python](https://img.shields.io/badge/Python-3.12+-3776ab?style=flat-square&logo=python&logoColor=white)](https://python.org) | 3.12+ | Runtime | +| [![Django](https://img.shields.io/badge/Django-5.2-092e20?style=flat-square&logo=django&logoColor=white)](https://djangoproject.com) | 5.2.5 | Web framework | +| [![PostgreSQL](https://img.shields.io/badge/PostgreSQL-16-336791?style=flat-square&logo=postgresql&logoColor=white)](https://postgresql.org) | 16 (Alpine) | Database (production, development, and testing) | | Celery | 5.5.3 | Async task queue (eager mode without Redis, full async with Redis in v1.4) | | Celery Beat | (bundled with Celery) | Scheduled task execution (birthday emails, payment generation — v1.4) | | Redis | 7 (Alpine) | Message broker for Celery (planned, v1.4) | -| Gunicorn | 21.2.0 | Production WSGI server | +| Gunicorn | 23.0.0 | Production WSGI server | | WhiteNoise | 6.11.0 | Static file serving in production | ### Frontend @@ -1075,10 +1146,13 @@ five-a-day/ │ ├── .github/ CI/CD — see docs/GITHUB.md │ ├── workflows/ -│ │ ├── ci.yml Lint + typecheck + tests on every push/PR -│ │ ├── auto-merge.yml Hourly development → testing merge + PR to main -│ │ ├── codeql.yml Weekly Python security scan -│ │ └── notify-production.yml Email on push to main +│ │ ├── ci.yml Lint + typecheck + tests + Docker build + CVE scan on every push/PR +│ │ ├── auto-merge.yml Hourly development → testing merge + PR to main +│ │ ├── codeql.yml Weekly Python security scan +│ │ ├── notify-production.yml Email on push to main +│ │ ├── dependabot-auto-merge.yml Auto-merge Dependabot minor/patch PRs +│ │ ├── dependency-review.yml Block PRs introducing HIGH/CRITICAL CVEs +│ │ └── scorecard.yml OSSF Scorecard supply-chain security (weekly) │ ├── dependabot.yml Weekly dependency updates │ └── CODEOWNERS Auto-request reviews from owner accounts │ @@ -1095,7 +1169,7 @@ five-a-day/ ├── Dockerfile Multi-stage build (builder + runtime) ├── docker-compose.yml PostgreSQL + Redis + Django + Celery worker + beat ├── docker-compose.testing.yml QA override (Gunicorn, DEBUG=False) -├── Makefile 75+ commands (`make help`) +├── Makefile 45+ commands (`make help`) ├── pyproject.toml Dependencies (uv-managed) + tool config ├── uv.lock Reproducible dependency lock ├── entrypoint.sh Docker entrypoint (migrate, collectstatic, start) @@ -1790,10 +1864,13 @@ Feature branches off `development` are welcome for non-trivial work, but the exp | Workflow | File | Triggers | Purpose | |----------|------|----------|---------| -| **CI** | [`ci.yml`](.github/workflows/ci.yml) | Push to `development`/`testing`/`main`; PRs to `testing`/`main` | Three parallel jobs — **Lint** (Ruff + Bandit), **Type check** (mypy), **Tests** (pytest + PostgreSQL 16 service container + Codecov upload) | +| **CI** | [`ci.yml`](.github/workflows/ci.yml) | Push to `development`/`testing`/`main`; PRs to `testing`/`main` | Six jobs — **Lint** (Ruff + Bandit + pip-audit + Hadolint), **Type check** (mypy), **Tests** (pytest + PostgreSQL 16 + Codecov), **Docker build** (validates Dockerfile), **Trivy** (filesystem CVE scan → Security tab), **Docker publish** (GHCR push + image scan, on `main`/`testing` only) | | **Auto-merge** | [`auto-merge.yml`](.github/workflows/auto-merge.yml) | Hourly cron + manual dispatch | Merges `development` → `testing` when conditions pass, creates PR to `main`, emails owners | | **CodeQL** | [`codeql.yml`](.github/workflows/codeql.yml) | Push to `main`/`testing`/`development`; PRs to `main`; Monday 04:30 UTC | Python static security analysis (OWASP Top 10, Django-specific queries) | | **Notify production** | [`notify-production.yml`](.github/workflows/notify-production.yml) | Push to `main` | Emails `hellofiveaday@gmail.com` with commit info and `gcloud` deploy instructions | +| **Dependabot auto-merge** | [`dependabot-auto-merge.yml`](.github/workflows/dependabot-auto-merge.yml) | Pull request (Dependabot only) | Enables auto-merge for minor/patch Dependabot PRs once CI passes | +| **Dependency review** | [`dependency-review.yml`](.github/workflows/dependency-review.yml) | Pull request | Blocks PRs that introduce a HIGH/CRITICAL CVE dependency | +| **OSSF Scorecard** | [`scorecard.yml`](.github/workflows/scorecard.yml) | Push to `main`; weekly Monday 06:00 UTC; branch protection rule changes | Grades supply-chain security posture; uploads SARIF to GitHub Security tab | | **Dependabot** | [`dependabot.yml`](.github/dependabot.yml) | Weekly (Mondays 08:00 Madrid) | Grouped Python and GitHub Actions updates targeting `development` | Concurrent CI runs on the same branch cancel each other automatically — new pushes always produce a fresh run. @@ -1884,6 +1961,8 @@ Because this repository is **public**, extra care is taken to prevent accidental | **GitHub Secret Scanning** | Settings → Code security | Free for public repos — detects committed secrets across history | | **Push Protection** | Settings → Code security | Free for public repos — blocks pushes that contain secrets before they land | | **CodeQL** | `codeql.yml` + Settings → Code security | Free for public repos — weekly security analysis | +| **OSSF Scorecard** | `scorecard.yml` + Settings → Code security | Free for public repos — weekly supply-chain security grading (branch protection, dependency pinning, CI, secret scanning) | +| **Dependency review** | `dependency-review.yml` | Blocks PRs that introduce a new HIGH/CRITICAL CVE dependency — catches supply-chain attacks before they merge | | **Dependabot alerts + security updates** | Settings → Code security | Free for public repos — fixes known CVEs in dependencies | | **Require 2FA for all contributors** | Organization settings (if in an org) | Prevents compromised account pushes | | **Restrict fork PRs from running CI with secrets** | Settings → Actions → Fork PR workflows: require approval for first-time contributors | Prevents secret exfiltration via malicious PRs from forks | diff --git a/docker-compose.testing.yml b/docker-compose.testing.yml index 9295041..c31105c 100644 --- a/docker-compose.testing.yml +++ b/docker-compose.testing.yml @@ -12,10 +12,17 @@ services: db: - # Override: use a separate volume so QA data never touches dev data + # Override: use a separate volume so QA data never touches dev data, + # and use dedicated testing credentials that match .env.testing. volumes: - testing_postgres_data:/var/lib/postgresql/data/pgdata - ./backups:/backups + environment: + POSTGRES_DB: fiveaday_testing + POSTGRES_USER: fiveaday_tester + POSTGRES_PASSWORD: 'TestingQA2026!SecurePassword' + healthcheck: + test: ["CMD-SHELL", "PGPASSWORD=TestingQA2026!SecurePassword psql -h 127.0.0.1 -U fiveaday_tester -d fiveaday_testing -c '\\q'"] web: # Use the QA-specific env file instead of the default .env @@ -34,8 +41,11 @@ services: project.wsgi:application environment: - # Ensure the entrypoint collects static files DJANGO_ENV: testing + POSTGRES_DB: fiveaday_testing + POSTGRES_USER: fiveaday_tester + POSTGRES_PASSWORD: 'TestingQA2026!SecurePassword' + POSTGRES_HOST: db volumes: testing_postgres_data: diff --git a/docker-compose.yml b/docker-compose.yml index 10f515a..e4e64b7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -74,6 +74,13 @@ services: CELERY_RESULT_BACKEND: redis://redis:6379/0 POSTGRES_HOST: db + # Live-mount source code so template/CSS/Python changes are visible + # without a full rebuild. The anonymous .venv volume keeps the + # container's installed packages isolated from the host. + volumes: + - .:/app + - /app/.venv + # Wait for PostgreSQL + Redis before starting depends_on: db: @@ -122,6 +129,9 @@ services: CELERY_BROKER_URL: redis://redis:6379/0 CELERY_RESULT_BACKEND: redis://redis:6379/0 POSTGRES_HOST: db + volumes: + - .:/app + - /app/.venv working_dir: /app/project command: celery -A project.celery worker -l info -Q celery,emails --concurrency=2 depends_on: @@ -149,6 +159,9 @@ services: CELERY_BROKER_URL: redis://redis:6379/0 CELERY_RESULT_BACKEND: redis://redis:6379/0 POSTGRES_HOST: db + volumes: + - .:/app + - /app/.venv working_dir: /app/project command: celery -A project.celery beat -l info --pidfile=/tmp/celerybeat.pid --schedule=/tmp/celerybeat-schedule depends_on: diff --git a/project/core/README.md b/project/core/README.md index 09dfe04..868b2d3 100644 --- a/project/core/README.md +++ b/project/core/README.md @@ -51,18 +51,34 @@ Student, payment, management, and email app routes live in `students/urls.py`, ` All templates live in `core/templates/`: -- `base.html` — main layout (sidebar, header, support modal, Tailwind CDN config). Also carries the site-wide `` metadata: favicon + apple-touch-icon, `theme-color` (violet `#6d28d9`), meta description/author, full Open Graph set, and Twitter Card tags. Every content field is wrapped in a Django block (`meta_description`, `og_title`, `og_description`, `og_image`, `twitter_title`, `twitter_description`, `twitter_image`) so per-page templates can tailor link previews. +- `base.html` — main layout (sidebar, header, support modal, Tailwind CDN config). Site-wide `` metadata: `favicon.ico` + `favicon-32x32.png` + `apple-touch-icon.png` (all sourced from `logo_white_bg.png`), `theme-color` (#6d28d9), meta description/author, full Open Graph set (including `og:image:secure_url` for Facebook HTTPS), Twitter Card (including `twitter:image:alt`), and a Schema.org JSON-LD block (`WebApplication` / `EducationalOrganization`) for Google previews, Gmail, and Google Chat. Every OG/Twitter content field is wrapped in an overridable Django block so per-page templates can tailor link previews. - `home.html`, `login.html`, `schedule.html`, `fun_friday.html`, etc. - `payments/` — payment list, create, detail - `apps/` — email form views + `_email_preview.html` partial - `emails/` — 12 HTML email templates extending `emails/base_email.html` (all named in English: `enrollment_child.html`, `payment_reminder.html`, etc.) - `400.html` through `500.html` — error pages +## Admin Template Overrides + +Project-level admin templates live in `project/templates/admin/` (added to `TEMPLATES.DIRS` so they take priority over Django's defaults): + +| Template | Purpose | +| -------- | ------- | +| `base_site.html` | Violet gradient header with `logo_white_bg.png`; loads `admin_custom.css` on every admin page | +| `login.html` | Card-style login page with logo, "Gestión Académica · Albacete" subtitle, Spanish field labels | +| `index.html` | Dashboard welcome banner + Spanish Add/Edit/View/Recent-actions labels | + +The matching CSS (`project/static/css/admin_custom.css`) overrides all Django admin CSS variables to the app's violet/purple palette, switches the font to Trebuchet MS, and styles the login card and welcome banner. + ## Static Files -- `favicon.ico` — multi-resolution (16/32/48/64/128/256) icon generated from `images/logo.png`; referenced from `base.html` as both `rel="icon"` and `rel="shortcut icon"` -- `images/logo.png` — 500×500 PNG; reused as `apple-touch-icon` and Open Graph image +- `favicon.ico` — multi-size ICO (16/32/48/64px) generated from `images/logo_white_bg.png` +- `favicon-32x32.png` — 32×32 PNG favicon for modern browsers +- `apple-touch-icon.png` — 180×180 PNG for iOS/Safari home-screen icon +- `images/logo_white_bg.png` — 500×500 PNG with white background; used for favicon, apple-touch-icon, Open Graph, and Twitter Card +- `images/logo.png` — 500×500 PNG original (transparent background) - `css/app.css` — sidebar transitions, Material Symbols icon font settings +- `css/admin_custom.css` — Django admin theme override (CSS variables, login card, welcome banner) - `js/base.js` — notification/history dropdowns (loaded on every page) - `js/support.js` — support ticket modal - `js/home.js`, `js/students.js`, `js/payments.js`, etc. — per-page modules diff --git a/project/core/admin.py b/project/core/admin.py index ba0cc5a..85057bb 100644 --- a/project/core/admin.py +++ b/project/core/admin.py @@ -3,8 +3,8 @@ from .models import FunFridayAttendance, HistoryLog, ScheduleSlot, TodoItem admin.site.site_header = "Five a Day eVolution" -admin.site.site_title = "Five a Day eVolution" -admin.site.index_title = "Five a Day eVolution - Construyendo un mejor futuro!" +admin.site.site_title = "Five a Day · Admin" +admin.site.index_title = "Panel de administración" @admin.register(HistoryLog) diff --git a/project/core/context_processors.py b/project/core/context_processors.py index aa59f18..800545c 100644 --- a/project/core/context_processors.py +++ b/project/core/context_processors.py @@ -37,6 +37,7 @@ def today_notifications(request): show_testing_tools = ( settings.IS_TESTING_ENV and settings.QA_TESTING_USERNAME + and hasattr(request, "session") and request.session.get("username") == settings.QA_TESTING_USERNAME ) diff --git a/project/core/static/apple-touch-icon.png b/project/core/static/apple-touch-icon.png new file mode 100644 index 0000000..18031cb Binary files /dev/null and b/project/core/static/apple-touch-icon.png differ diff --git a/project/core/static/css/admin_custom.css b/project/core/static/css/admin_custom.css new file mode 100644 index 0000000..b2fb0ef --- /dev/null +++ b/project/core/static/css/admin_custom.css @@ -0,0 +1,526 @@ +/* Five a Day eVolution — Admin custom styles + Lavender palette, forced light mode, rounded UI, card-based index. + Loaded via admin/base_site.html extrastyle block. */ + +/* ==================================================================== + FORCE LIGHT MODE — no dark/light toggle + ==================================================================== */ +html { + color-scheme: only light; +} + +/* ==================================================================== + COLOR THEME — lavender / violet palette (matches main app primary-*) + ==================================================================== */ +:root { + --primary: #8b5cf6; /* violet-500 */ + --secondary: #7c3aed; /* violet-600 */ + --accent: #c4b5fd; /* violet-300 */ + --primary-fg: #ffffff; + + --body-fg: #1e1b4b; + --body-bg: #faf8ff; + --body-quiet-color: #7c3aed; + --body-loud-color: #5b21b6; + + --header-color: #ffffff; + --header-branding-color: #ddd6fe; + --header-bg: #6d28d9; + --header-link-color: #ede9fe; + + --breadcrumbs-fg: #ddd6fe; + --breadcrumbs-link-fg: #ffffff; + --breadcrumbs-bg: #7c3aed; + + --link-fg: #7c3aed; + --link-hover-color: #4c1d95; + --link-selected-fg: #5b21b6; + + --hairline-color: #ede9fe; + --border-color: #ddd6fe; + + --error-fg: #dc2626; + --message-success-bg: #d1fae5; + --message-warning-bg: #fef3c7; + --message-error-bg: #fee2e2; + + --darkened-bg: #f5f3ff; + --selected-bg: #ede9fe; + --selected-row: #f5f3ff; + + --button-fg: #ffffff; + --button-bg: #8b5cf6; + --button-hover-bg: #7c3aed; + --default-button-fg: #ffffff; + --default-button-bg: #7c3aed; + --default-button-hover-bg: #6d28d9; + --close-button-bg: #a78bfa; + --close-button-hover-bg: #8b5cf6; + --delete-button-bg: #dc2626; + --delete-button-hover-bg: #b91c1c; + + --object-tools-fg: #ffffff; + --object-tools-bg: #6d28d9; + --object-tools-hover-bg: #5b21b6; +} + +/* Hide the built-in dark/light theme toggle — we force light mode only */ +.theme-toggle { + display: none !important; +} + +/* ==================================================================== + FONT — match the main application + ==================================================================== */ +body, #header, #nav-sidebar, input, select, textarea, button { + font-family: 'Trebuchet MS', ui-sans-serif, system-ui, sans-serif; +} + +/* ==================================================================== + HEADER + ==================================================================== */ +#header { + background: linear-gradient(135deg, #5b21b6 0%, #7c3aed 60%, #8b5cf6 100%); + border-bottom: 3px solid #c4b5fd; + padding: 8px 16px; +} + +#branding h1, #branding h1 a, #branding h1 a:visited { + color: #ffffff; + font-size: 1.1rem; + font-weight: 700; + letter-spacing: 0.02em; + display: flex; + align-items: center; + gap: 10px; + text-decoration: none; +} + +#branding h1 .admin-logo { + border-radius: 6px; + background: #fff; + padding: 2px; + height: 36px; + width: 36px; + object-fit: contain; +} + +/* ==================================================================== + NAVIGATION SIDEBAR + ==================================================================== */ +#nav-sidebar { + padding-right: 1rem; +} + +#nav-sidebar .module caption { + background: #6d28d9; + color: #ffffff; +} + +/* ==================================================================== + MODULE / TABLE HEADERS — rounded containers + ==================================================================== */ +.module { + border-radius: 10px; + overflow: hidden; + border: 1px solid #ede9fe; +} + +.module h2, +.module caption, +.inline-group h2 { + background: linear-gradient(90deg, #7c3aed, #8b5cf6); + color: #ffffff; +} + +/* ==================================================================== + FIELDSETS + ==================================================================== */ +fieldset.module h2 { + background: #ede9fe; + color: #4c1d95; + border-left: 4px solid #8b5cf6; +} + +/* ==================================================================== + TABLES — rounded, lavender header + ==================================================================== */ +#result_list { + border-radius: 10px; + overflow: hidden; + border: 1px solid #ddd6fe; +} + +#result_list thead th { + background: #ede9fe; + color: #4c1d95; + border-bottom: 2px solid #c4b5fd; + font-weight: 700; +} + +#result_list tbody tr:nth-child(even) td, +#result_list tbody tr:nth-child(even) th { + background: #faf8ff; +} + +#result_list tbody tr:hover td, +#result_list tbody tr:hover th { + background: #f5f3ff; +} + +/* ==================================================================== + SEARCH BAR — rounded pill + ==================================================================== */ +#changelist-search input[type="text"], +#changelist-search input[type="search"], +.search-row input[type="text"], +.search-row input[type="search"] { + border-radius: 24px; + border: 1.5px solid #ddd6fe; + padding: 7px 16px; + background: #faf8ff; + transition: border-color 0.2s, box-shadow 0.2s; +} + +#changelist-search input[type="text"]:focus, +#changelist-search input[type="search"]:focus, +.search-row input:focus { + border-color: #8b5cf6; + box-shadow: 0 0 0 3px rgba(139, 92, 246, 0.12); + outline: none; +} + +#changelist-search input[type="submit"], +.search-row input[type="submit"] { + border-radius: 20px; + background: #8b5cf6; + color: #fff; + border: none; + padding: 7px 16px; + cursor: pointer; + font-weight: 600; + transition: background 0.15s; +} + +#changelist-search input[type="submit"]:hover { + background: #7c3aed; +} + +/* ==================================================================== + BUTTONS + ==================================================================== */ +.button, +input[type="submit"], +input[type="button"], +.submit-row input, +a.button { + border-radius: 8px; +} + +/* ==================================================================== + DASHBOARD WELCOME BANNER + ==================================================================== */ +.five-a-day-welcome { + background: linear-gradient(135deg, #f5f3ff 0%, #ede9fe 100%); + border: 1px solid #ddd6fe; + border-left: 5px solid #8b5cf6; + border-radius: 12px; + padding: 1.25rem 1.5rem; + margin-bottom: 2rem; + display: flex; + align-items: flex-start; + gap: 1rem; +} + +.five-a-day-welcome .welcome-logo { + width: 56px; + height: 56px; + object-fit: contain; + border-radius: 8px; + flex-shrink: 0; +} + +.five-a-day-welcome .welcome-text h2 { + color: #4c1d95; + font-size: 1.1rem; + font-weight: 700; + margin: 0 0 0.35rem; + background: none; + padding: 0; +} + +.five-a-day-welcome .welcome-text p { + color: #5b21b6; + margin: 0; + font-size: 0.88rem; + line-height: 1.55; +} + +/* ==================================================================== + ADMIN INDEX — SECTIONS + ==================================================================== */ +.five-a-day-section { + margin-bottom: 2.5rem; +} + +.five-a-day-section-header { + display: flex; + align-items: center; + gap: 0.65rem; + margin-bottom: 1rem; + padding-bottom: 0.55rem; + border-bottom: 2px solid #ddd6fe; +} + +.five-a-day-section-header .section-icon { + font-size: 1.25rem; + line-height: 1; +} + +.five-a-day-section-header h2 { + font-size: 0.98rem; + font-weight: 700; + color: #4c1d95; + margin: 0; + text-transform: none; + background: none; + padding: 0; +} + +.five-a-day-section-header .section-desc { + margin-left: auto; + color: #7c3aed; + font-size: 0.76rem; + font-style: italic; +} + +/* ==================================================================== + ADMIN INDEX — MODEL CARDS + ==================================================================== */ +.five-a-day-model-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); + gap: 13px; +} + +.model-card { + background: #ffffff; + border: 1px solid #e4d4fe; + border-left: 4px solid #8b5cf6; + border-radius: 12px; + padding: 1rem 1.1rem 0.85rem; + display: flex; + flex-direction: column; + gap: 0.3rem; + box-shadow: 0 1px 4px rgba(139, 92, 246, 0.07); + transition: box-shadow 0.2s, transform 0.15s; +} + +.model-card:hover { + box-shadow: 0 4px 18px rgba(139, 92, 246, 0.15); + transform: translateY(-2px); +} + +.model-card .card-icon { + font-size: 1.75rem; + line-height: 1; + margin-bottom: 0.15rem; +} + +.model-card .card-name { + font-weight: 700; + color: #4c1d95; + font-size: 0.93rem; + line-height: 1.2; +} + +.model-card .card-desc { + color: #6d28d9; + font-size: 0.77rem; + flex: 1; + line-height: 1.45; +} + +.model-card .card-actions { + display: flex; + gap: 0.45rem; + margin-top: 0.65rem; + flex-wrap: wrap; +} + +.model-card .card-btn { + display: inline-flex; + align-items: center; + gap: 3px; + padding: 4px 11px; + border-radius: 20px; + font-size: 0.73rem; + font-weight: 600; + text-decoration: none; + transition: background 0.15s; + line-height: 1.4; +} + +.model-card .card-btn.view-btn { + background: #ede9fe; + color: #6d28d9; +} + +.model-card .card-btn.view-btn:hover { + background: #ddd6fe; + color: #4c1d95; +} + +.model-card .card-btn.add-btn { + background: #8b5cf6; + color: #ffffff; +} + +.model-card .card-btn.add-btn:hover { + background: #7c3aed; +} + +/* ==================================================================== + LOGIN PAGE — standalone card, no Django chrome + ==================================================================== */ +body.login { + background: linear-gradient(160deg, #f5f3ff 0%, #ede9fe 45%, #ddd6fe 100%); + min-height: 100vh; + margin: 0; +} + +body.login #header, +body.login #nav-sidebar, +body.login div[role="navigation"], +body.login #nav-breadcrumbs { + display: none !important; +} + +body.login #container { + display: flex; + align-items: center; + justify-content: center; + min-height: 100vh; + padding: 2rem 1rem; + box-sizing: border-box; +} + +body.login #main { + width: 100%; + max-width: 420px; +} + +body.login #content-main { + background: #ffffff; + border-radius: 14px; + box-shadow: 0 8px 40px rgba(76, 29, 149, 0.15), + 0 2px 8px rgba(109, 40, 217, 0.1); + padding: 2.5rem 2.25rem; +} + +body.login .login-brand { + text-align: center; + margin-bottom: 2rem; + padding-bottom: 1.5rem; + border-bottom: 1px solid #ede9fe; +} + +body.login .login-brand img { + width: 80px; + height: 80px; + object-fit: contain; + border-radius: 10px; + margin: 0 auto 0.75rem; + display: block; +} + +body.login .login-brand h1 { + color: #4c1d95; + font-size: 1.35rem; + font-weight: 700; + margin: 0 0 0.2rem; +} + +body.login .login-brand .login-subtitle { + color: #7c3aed; + font-size: 0.85rem; + margin: 0; +} + +body.login .form-row { + margin-bottom: 1rem; +} + +body.login label { + color: #4c1d95; + font-weight: 600; + font-size: 0.85rem; + display: block; + margin-bottom: 4px; +} + +body.login input[type="text"], +body.login input[type="password"] { + width: 100%; + border: 1.5px solid #ddd6fe; + border-radius: 8px; + padding: 9px 12px; + font-size: 0.95rem; + font-family: 'Trebuchet MS', sans-serif; + transition: border-color 0.2s, box-shadow 0.2s; + box-sizing: border-box; + background: #faf8ff; +} + +body.login input[type="text"]:focus, +body.login input[type="password"]:focus { + border-color: #8b5cf6; + box-shadow: 0 0 0 3px rgba(139, 92, 246, 0.12); + outline: none; + background: #ffffff; +} + +body.login .submit-row { + margin-top: 1.5rem; + padding: 0; + background: transparent; + border: none; + text-align: center; + overflow: visible; + border-radius: 0; +} + +body.login input[type="submit"] { + width: 100%; + background: linear-gradient(135deg, #8b5cf6, #7c3aed); + color: #ffffff; + border: none; + border-radius: 8px; + padding: 11px; + font-size: 1rem; + font-weight: 600; + cursor: pointer; + font-family: 'Trebuchet MS', sans-serif; + transition: background 0.2s, transform 0.1s, box-shadow 0.2s; + box-shadow: 0 2px 8px rgba(139, 92, 246, 0.25); +} + +body.login input[type="submit"]:hover { + background: linear-gradient(135deg, #7c3aed, #6d28d9); + box-shadow: 0 4px 14px rgba(139, 92, 246, 0.35); + transform: translateY(-1px); +} + +body.login input[type="submit"]:active { + transform: translateY(0); +} + +body.login .errornote { + background: #fee2e2; + border: 1px solid #fca5a5; + border-radius: 7px; + color: #b91c1c; + padding: 0.6rem 1rem; + font-size: 0.9rem; + margin-bottom: 1rem; +} diff --git a/project/static/css/email.css b/project/core/static/css/email.css similarity index 100% rename from project/static/css/email.css rename to project/core/static/css/email.css diff --git a/project/core/static/favicon-32x32.png b/project/core/static/favicon-32x32.png new file mode 100644 index 0000000..db90b5d Binary files /dev/null and b/project/core/static/favicon-32x32.png differ diff --git a/project/core/static/favicon.ico b/project/core/static/favicon.ico index 7979c40..3a9e23f 100644 Binary files a/project/core/static/favicon.ico and b/project/core/static/favicon.ico differ diff --git a/project/core/static/images/divider.svg b/project/core/static/images/divider.svg new file mode 100644 index 0000000..a4aeeb2 --- /dev/null +++ b/project/core/static/images/divider.svg @@ -0,0 +1,239 @@ + + +Created by potrace 1.15, written by Peter Selinger 2001-2017 + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/project/core/static/images/logo_white_bg.png b/project/core/static/images/logo_white_bg.png new file mode 100644 index 0000000..5afe763 Binary files /dev/null and b/project/core/static/images/logo_white_bg.png differ diff --git a/project/core/static/js/login_effects.js b/project/core/static/js/login_effects.js new file mode 100644 index 0000000..a5c0a67 --- /dev/null +++ b/project/core/static/js/login_effects.js @@ -0,0 +1,69 @@ +/* login_effects.js — text & button animations for the login card */ +(function () { + 'use strict'; + + /* ── Inject all animation CSS ───────────────────────────── */ + var styleEl = document.createElement('style'); + styleEl.textContent = + + /* Bienvenida: quill reveal left-to-right */ + '@keyframes bridgertonBloom {' + + ' 0% { opacity:0; clip-path:inset(0 100% 0 0); filter:blur(5px); }' + + ' 18% { opacity:1; }' + + ' 100% { opacity:1; clip-path:inset(0 0% 0 0); filter:blur(0); }' + + '}' + + '.fx-heading { animation:bridgertonBloom 1.4s cubic-bezier(.23,1,.32,1) both; }' + + + /* Quote: fast spring reveal */ + '@keyframes quoteReveal {' + + ' from { opacity:0; transform:translateY(8px); }' + + ' to { opacity:1; transform:translateY(0); }' + + '}' + + '.fx-quote { animation:quoteReveal .3s cubic-bezier(.34,1.56,.64,1) both; }' + + + /* Quote: violet glow pulse */ + '@keyframes quoteGlow {' + + ' 0% { text-shadow:0 0 0 rgba(139,92,246,0); }' + + ' 35% { text-shadow:0 0 14px rgba(139,92,246,.55),0 0 30px rgba(124,58,237,.18); }' + + ' 100% { text-shadow:0 0 0 rgba(139,92,246,0); }' + + '}' + + '.fx-quote-glow { animation:quoteGlow 1.4s ease-in-out; }' + + + ''; + + document.head.appendChild(styleEl); + + /* ── Timing (ms) ────────────────────────────────────────── */ + var HEADING_MS = 1400; // bridgertonBloom duration + var QUOTE_MS = 300; // quoteReveal duration + var QUOTE_DELAY = 1000; // quote appears 1s after page load + + /* ── Sequence ───────────────────────────────────────────── */ + function run() { + var heading = document.querySelector('.card-heading'); + var quote = document.getElementById('login-quote'); + + /* Step 1 — Bienvenida, immediately on load */ + if (heading) heading.classList.add('fx-heading'); + + /* Step 2 — Quote, right after Bienvenida finishes */ + setTimeout(function () { + if (!quote) return; + quote.classList.add('fx-quote'); + + /* Quote glow fires once the reveal completes */ + setTimeout(function () { + if (!quote) return; + quote.style.opacity = '1'; + quote.classList.add('fx-quote-glow'); + }, QUOTE_MS); + + }, QUOTE_DELAY); + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', run); + } else { + run(); + } +})(); diff --git a/project/core/static/js/login_seasonal.js b/project/core/static/js/login_seasonal.js new file mode 100644 index 0000000..e69de29 diff --git a/project/core/templates/base.html b/project/core/templates/base.html index c6419ea..4c6ecef 100644 --- a/project/core/templates/base.html +++ b/project/core/templates/base.html @@ -13,24 +13,53 @@ - - + + - + - + + + + + - + - + + + + + diff --git a/project/core/templates/login.html b/project/core/templates/login.html index d8e0210..6b4384b 100644 --- a/project/core/templates/login.html +++ b/project/core/templates/login.html @@ -5,6 +5,59 @@ Acceso · Five a Day + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
-

Five a Day

-

Sistema de Gestión Escolar

+ + Five a Day + +

Donde el inglés es tuyo

Bienvenida

-

Accede para gestionar tu academia

+

Accede para gestionar tu academia

{% if messages %} {% for message in messages %} @@ -236,14 +316,16 @@

Five a Day

{% if google_oauth_available %} - - - - + + + + Iniciar sesión con Google -
o con usuario y contraseña
+ {% endif %} @@ -272,5 +354,22 @@

Five a Day

+ + diff --git a/project/project/settings.py b/project/project/settings.py index 967c73a..a1d392d 100644 --- a/project/project/settings.py +++ b/project/project/settings.py @@ -5,7 +5,7 @@ import dj_database_url from dotenv import load_dotenv -load_dotenv(Path(__file__).resolve().parent.parent.parent / ".env", override=True) +load_dotenv(Path(__file__).resolve().parent.parent.parent / ".env", override=False) BASE_DIR = Path(__file__).resolve().parent.parent @@ -15,7 +15,7 @@ # NOTA: Usa `make version x.y.z` para actualizar ambos sitios a la vez: # - pyproject.toml (campo version) # - README.md (badge y tabla de versiones — gestionado por la skill update-readme) -APP_VERSION = os.getenv("APP_VERSION", "1.0.9") +APP_VERSION = os.getenv("APP_VERSION", "1.0.11") # ============================================================================ # SECURITY SETTINGS @@ -133,7 +133,7 @@ TEMPLATES = [ { "BACKEND": "django.template.backends.django.DjangoTemplates", - "DIRS": [], + "DIRS": [BASE_DIR / "templates"], "APP_DIRS": True, "OPTIONS": { "context_processors": [ @@ -275,7 +275,6 @@ # ============================================================================ STATIC_URL = "/static/" STATIC_ROOT = BASE_DIR.parent / "staticfiles" -STATICFILES_DIRS = [BASE_DIR / "static"] # Configuración de WhiteNoise para producción STORAGES = { diff --git a/project/static/favicon.ico b/project/static/favicon.ico deleted file mode 100644 index 7979c40..0000000 Binary files a/project/static/favicon.ico and /dev/null differ diff --git a/project/static/images/logo.png b/project/static/images/logo.png deleted file mode 100644 index 813605f..0000000 Binary files a/project/static/images/logo.png and /dev/null differ diff --git a/project/templates/admin/base_site.html b/project/templates/admin/base_site.html new file mode 100644 index 0000000..2dbbbf5 --- /dev/null +++ b/project/templates/admin/base_site.html @@ -0,0 +1,28 @@ +{% extends "admin/base.html" %} +{% load i18n static %} + +{% block title %}{{ title }} | Five a Day Admin{% endblock %} + +{% block extrastyle %} +{{ block.super }} + + +{% endblock %} + +{% comment %}Empty this block to disable Django admin's dark-mode CSS variable overrides.{% endcomment %} +{% block dark-mode-vars %}{% endblock %} + +{% block branding %} + +{% endblock %} + +{% block nav-global %}{% endblock %} diff --git a/project/templates/admin/index.html b/project/templates/admin/index.html new file mode 100644 index 0000000..f5a650c --- /dev/null +++ b/project/templates/admin/index.html @@ -0,0 +1,330 @@ +{% extends "admin/base_site.html" %} +{% load i18n log static %} + +{% block title %}{% trans 'Panel de administración' %} | {{ block.super }}{% endblock %} + +{% block content %} +
+ + +
+ +
+

¡Bienvenidas, Silvia y Penelope! 👋

+

+ Desde aquí podéis gestionar todo lo de la academia: alumnos, familias, + matrículas, pagos, horarios y más. Usad las tarjetas de abajo para + ir directamente a lo que necesitéis. +

+
+
+ + +
+
+ 📊 +

Datos

+ Alumnos, familias, pagos y configuración de la academia +
+
+ {% for app in app_list %} + {% if app.app_label == "students" or app.app_label == "billing" or app.app_label == "core" %} + {% for model in app.models %} + {% if model.object_name != "SiteConfiguration" and model.object_name != "EnrollmentType" and model.object_name != "StudentParent" %} +
+ + {% if model.object_name == "Student" %}🧑‍🎓 + {% elif model.object_name == "Parent" %}👨‍👩‍👧 + {% elif model.object_name == "Teacher" %}👩‍🏫 + {% elif model.object_name == "Group" and app.app_label == "students" %}👥 + {% elif model.object_name == "StudentParent" %}🔗 + {% elif model.object_name == "Payment" %}💳 + {% elif model.object_name == "Enrollment" %}📋 + {% elif model.object_name == "EnrollmentType" %}📝 + {% elif model.object_name == "SiteConfiguration" %}⚙️ + {% elif model.object_name == "HistoryLog" %}📜 + {% elif model.object_name == "TodoItem" %}✅ + {% elif model.object_name == "ScheduleSlot" %}🗓️ + {% elif model.object_name == "FunFridayAttendance" %}🎉 + {% else %}📦 + {% endif %} + +
+ {% if model.object_name == "Student" %}Alumnos + {% elif model.object_name == "Parent" %}Padres y Tutores + {% elif model.object_name == "Teacher" %}Profesores + {% elif model.object_name == "Group" and app.app_label == "students" %}Grupos + {% elif model.object_name == "StudentParent" %}Vínculos alumno–familiar + {% elif model.object_name == "Payment" %}Pagos + {% elif model.object_name == "Enrollment" %}Matrículas + {% elif model.object_name == "EnrollmentType" %}Tipos de matrícula + {% elif model.object_name == "SiteConfiguration" %}Configuración global + {% elif model.object_name == "HistoryLog" %}Historial de actividad + {% elif model.object_name == "TodoItem" %}Tareas pendientes + {% elif model.object_name == "ScheduleSlot" %}Horarios + {% elif model.object_name == "FunFridayAttendance" %}Fun Friday + {% else %}{{ model.verbose_name_plural|capfirst }} + {% endif %} +
+
+ {% if model.object_name == "Student" %} + Ficha completa de cada alumno: datos personales, grupo y estado de matrícula. + {% elif model.object_name == "Parent" %} + Datos de contacto de los familiares y responsables de los alumnos. + {% elif model.object_name == "Teacher" %} + Ficha de cada profesor y monitor de la academia. + {% elif model.object_name == "Group" and app.app_label == "students" %} + Grupos y clases de la academia: nombre, nivel y horario. + {% elif model.object_name == "StudentParent" %} + Relaciones entre alumnos y sus padres o tutores. + {% elif model.object_name == "Payment" %} + Registro de todos los pagos: cuotas mensuales, matrículas y recibos. + {% elif model.object_name == "Enrollment" %} + Matrículas activas e históricas de cada alumno por año académico. + {% elif model.object_name == "EnrollmentType" %} + Modalidades de inscripción disponibles en la academia. + {% elif model.object_name == "SiteConfiguration" %} + Precios, cuotas, IVA y ajustes generales del sistema. Solo hay un registro. + {% elif model.object_name == "HistoryLog" %} + Registro automático de las acciones realizadas en el sistema. + {% elif model.object_name == "TodoItem" %} + Lista de tareas y recordatorios del equipo de la academia. + {% elif model.object_name == "ScheduleSlot" %} + Configuración de las franjas horarias del cuadrante semanal. + {% elif model.object_name == "FunFridayAttendance" %} + Registro de asistencia a las actividades especiales de los viernes. + {% else %} + Gestión de {{ model.verbose_name_plural }}. + {% endif %} +
+
+ {% if model.admin_url %} + 📋 Ver + {% endif %} + {% if model.add_url %} + + Añadir + {% endif %} +
+
+ {% endif %} + {% endfor %} + {% endif %} + {% endfor %} +
+
+ + +
+
+ 🗂️ +

Datos extra

+ Configuración avanzada y tablas de referencia +
+
+ {% for app in app_list %} + {% if app.app_label == "students" or app.app_label == "billing" %} + {% for model in app.models %} + {% if model.object_name == "SiteConfiguration" or model.object_name == "EnrollmentType" or model.object_name == "StudentParent" %} +
+ + {% if model.object_name == "SiteConfiguration" %}⚙️ + {% elif model.object_name == "EnrollmentType" %}📝 + {% elif model.object_name == "StudentParent" %}🔗 + {% endif %} + +
+ {% if model.object_name == "SiteConfiguration" %}Configuración global + {% elif model.object_name == "EnrollmentType" %}Tipos de matrícula + {% elif model.object_name == "StudentParent" %}Vínculos alumno–familiar + {% endif %} +
+
+ {% if model.object_name == "SiteConfiguration" %} + Precios, cuotas, IVA y ajustes generales del sistema. Solo hay un registro. + {% elif model.object_name == "EnrollmentType" %} + Modalidades de inscripción disponibles en la academia. + {% elif model.object_name == "StudentParent" %} + Relaciones entre alumnos y sus padres o tutores. + {% endif %} +
+
+ {% if model.admin_url %} + 📋 Ver + {% endif %} + {% if model.add_url %} + + Añadir + {% endif %} +
+
+ {% endif %} + {% endfor %} + {% endif %} + {% endfor %} +
+
+ + + {% for app in app_list %}{% if app.app_label == "gsheets" %} +
+
+ 📗 +

Google Sheets

+ Integración con hojas de cálculo de Google +
+
+ {% for model in app.models %} +
+ + {% if model.object_name == "AccessCredentials" %}🔑 + {% else %}📄{% endif %} + +
+ {% if model.object_name == "AccessCredentials" %}Credenciales Google + {% else %}{{ model.verbose_name_plural|capfirst }}{% endif %} +
+
+ {% if model.object_name == "AccessCredentials" %} + Token de acceso para la integración con Google Sheets. + {% else %} + Gestión de {{ model.verbose_name_plural }}. + {% endif %} +
+
+ {% if model.admin_url %} + 📋 Ver + {% endif %} + {% if model.add_url %} + + Añadir + {% endif %} +
+
+ {% endfor %} +
+
+ {% endif %}{% endfor %} + + + {% for app in app_list %}{% if app.app_label == "auth" %} +
+
+ 🔐 +

Seguridad

+ Usuarios y permisos de acceso al panel +
+
+ {% for model in app.models %} +
+ + {% if model.object_name == "User" %}👤 + {% elif model.object_name == "Group" %}🔐 + {% else %}🛡️{% endif %} + +
+ {% if model.object_name == "User" %}Usuarios + {% elif model.object_name == "Group" %}Grupos de permisos + {% else %}{{ model.verbose_name_plural|capfirst }}{% endif %} +
+
+ {% if model.object_name == "User" %} + Cuentas de acceso al panel de administración. + {% elif model.object_name == "Group" %} + Roles y conjuntos de permisos para los usuarios del panel. + {% else %} + Gestión de {{ model.verbose_name_plural }}. + {% endif %} +
+
+ {% if model.admin_url %} + 📋 Ver + {% endif %} + {% if model.add_url %} + + Añadir + {% endif %} +
+
+ {% endfor %} +
+
+ {% endif %}{% endfor %} + + + {% for app in app_list %} + {% if app.app_label != "students" and app.app_label != "billing" and app.app_label != "core" and app.app_label != "gsheets" and app.app_label != "auth" %} +
+
+ 🔧 +

Otros — {{ app.verbose_name }}

+
+
+ {% for model in app.models %} +
+ 📦 +
{{ model.verbose_name_plural|capfirst }}
+
Gestión de {{ model.verbose_name_plural }}.
+
+ {% if model.admin_url %} + 📋 Ver + {% endif %} + {% if model.add_url %} + + Añadir + {% endif %} +
+
+ {% endfor %} +
+
+ {% endif %} + {% endfor %} + +
+{% endblock %} + +{% block sidebar %} + +{% endblock %} diff --git a/project/templates/admin/login.html b/project/templates/admin/login.html new file mode 100644 index 0000000..112b31a --- /dev/null +++ b/project/templates/admin/login.html @@ -0,0 +1,69 @@ +{% extends "admin/base_site.html" %} +{% load i18n static %} + +{% block extrastyle %} +{{ block.super }} +{% comment %}Do NOT load admin/css/login.css — it conflicts with admin_custom.css.{% endcomment %} +{% endblock %} + +{% block bodyclass %}login{% endblock %} + +{% block usertools %}{% endblock %} +{% block nav-sidebar %}{% endblock %} +{% block breadcrumbs %}{% endblock %} +{% block content_title %}{% endblock %} + +{% block content %} +
+ + + + {% if user.is_authenticated %} +

+ {% blocktranslate trimmed with username=user.get_username %} + Estás conectado como {{ username }}, pero no tienes permiso para acceder a esta página. + ¿Deseas entrar con una cuenta diferente? + {% endblocktranslate %} +

+ {% endif %} + + {% if form.errors and not form.non_field_errors %} +

+ {% if form.errors.items|length == 1 %} + {% trans "Por favor, corrige el error." %} + {% else %} + {% trans "Por favor, corrige los errores." %} + {% endif %} +

+ {% endif %} + + {% if form.non_field_errors %} + {% for error in form.non_field_errors %} +

{{ error }}

+ {% endfor %} + {% endif %} + +
+ {% csrf_token %} + +
+ {{ form.username.errors }} + + {{ form.username }} +
+
+ {{ form.password.errors }} + + {{ form.password }} +
+
+ +
+
+ +
+{% endblock %} diff --git a/pyproject.toml b/pyproject.toml index 7198ac0..b2eae09 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "five-a-day" -version = "1.0.9" +version = "1.0.11" description = "Five a Day management software" readme = "README.md" requires-python = ">=3.12" @@ -9,7 +9,7 @@ authors = [ ] dependencies = [ "django>=5.2.5,<6", - "pandas>=2.3.1,<3", + "pandas>=2.3.1,<4", "django-filter>=25.1,<26", "djangorestframework>=3.16.1,<4", "markdown>=3.8.2,<4", @@ -23,7 +23,7 @@ dependencies = [ "httpx>=0.28.1,<1", "python-dotenv>=1.2.1,<2", "psycopg2-binary>=2.9.9,<3", - "gunicorn>=21.2.0,<23", + "gunicorn>=21.2.0,<24", "whitenoise>=6.11.0,<7", "dj-database-url>=3.0.1,<4", "openpyxl>=3.1.5,<4", diff --git a/uv.lock b/uv.lock index bd16390..8a0c1e2 100644 --- a/uv.lock +++ b/uv.lock @@ -2,8 +2,15 @@ version = 1 revision = 3 requires-python = ">=3.12" resolution-markers = [ - "python_full_version >= '3.13'", - "python_full_version < '3.13'", + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'emscripten'", + "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version < '3.13' and sys_platform == 'win32'", + "python_full_version < '3.13' and sys_platform == 'emscripten'", + "python_full_version < '3.13' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] [[package]] @@ -695,7 +702,7 @@ wheels = [ [[package]] name = "five-a-day" -version = "1.0.9" +version = "1.0.11" source = { editable = "." } dependencies = [ { name = "celery" }, @@ -752,11 +759,11 @@ requires-dist = [ { name = "django-storages", specifier = ">=1.14.6,<2" }, { name = "djangorestframework", specifier = ">=3.16.1,<4" }, { name = "gspread", specifier = ">=6.2.1,<7" }, - { name = "gunicorn", specifier = ">=21.2.0,<23" }, + { name = "gunicorn", specifier = ">=21.2.0,<24" }, { name = "httpx", specifier = ">=0.28.1,<1" }, { name = "markdown", specifier = ">=3.8.2,<4" }, { name = "openpyxl", specifier = ">=3.1.5,<4" }, - { name = "pandas", specifier = ">=2.3.1,<3" }, + { name = "pandas", specifier = ">=2.3.1,<4" }, { name = "psycopg2-binary", specifier = ">=2.9.9,<3" }, { name = "python-dotenv", specifier = ">=1.2.1,<2" }, { name = "whitenoise", specifier = ">=6.11.0,<7" }, @@ -876,14 +883,14 @@ wheels = [ [[package]] name = "gunicorn" -version = "22.0.0" +version = "23.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "packaging" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/1e/88/e2f93c5738a4c1f56a458fc7a5b1676fc31dcdbb182bef6b40a141c17d66/gunicorn-22.0.0.tar.gz", hash = "sha256:4a0b436239ff76fb33f11c07a16482c521a7e09c1ce3cc293c2330afe01bec63", size = 3639760, upload-time = "2024-04-16T22:58:19.218Z" } +sdist = { url = "https://files.pythonhosted.org/packages/34/72/9614c465dc206155d93eff0ca20d42e1e35afc533971379482de953521a4/gunicorn-23.0.0.tar.gz", hash = "sha256:f014447a0101dc57e294f6c18ca6b40227a4c90e9bdb586042628030cba004ec", size = 375031, upload-time = "2024-08-10T20:25:27.378Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/29/97/6d610ae77b5633d24b69c2ff1ac3044e0e565ecbd1ec188f02c45073054c/gunicorn-22.0.0-py3-none-any.whl", hash = "sha256:350679f91b24062c86e386e198a15438d53a7a8207235a78ba1b53df4c4378d9", size = 84443, upload-time = "2024-04-16T22:58:15.233Z" }, + { url = "https://files.pythonhosted.org/packages/cb/7d/6dac2a6e1eba33ee43f318edbed4ff29151a49b5d37f080aad1e6469bca4/gunicorn-23.0.0-py3-none-any.whl", hash = "sha256:ec400d38950de4dfd418cff8328b2c8faed0edb0d517d3394e457c317908ca4d", size = 85029, upload-time = "2024-08-10T20:25:24.996Z" }, ] [[package]] @@ -1286,49 +1293,54 @@ wheels = [ [[package]] name = "pandas" -version = "2.3.3" +version = "3.0.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "numpy" }, { name = "python-dateutil" }, - { name = "pytz" }, - { name = "tzdata" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/33/01/d40b85317f86cf08d853a4f495195c73815fdf205eef3993821720274518/pandas-2.3.3.tar.gz", hash = "sha256:e05e1af93b977f7eafa636d043f9f94c7ee3ac81af99c13508215942e64c993b", size = 4495223, upload-time = "2025-09-29T23:34:51.853Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9c/fb/231d89e8637c808b997d172b18e9d4a4bc7bf31296196c260526055d1ea0/pandas-2.3.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d21f6d74eb1725c2efaa71a2bfc661a0689579b58e9c0ca58a739ff0b002b53", size = 11597846, upload-time = "2025-09-29T23:19:48.856Z" }, - { url = "https://files.pythonhosted.org/packages/5c/bd/bf8064d9cfa214294356c2d6702b716d3cf3bb24be59287a6a21e24cae6b/pandas-2.3.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3fd2f887589c7aa868e02632612ba39acb0b8948faf5cc58f0850e165bd46f35", size = 10729618, upload-time = "2025-09-29T23:39:08.659Z" }, - { url = "https://files.pythonhosted.org/packages/57/56/cf2dbe1a3f5271370669475ead12ce77c61726ffd19a35546e31aa8edf4e/pandas-2.3.3-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ecaf1e12bdc03c86ad4a7ea848d66c685cb6851d807a26aa245ca3d2017a1908", size = 11737212, upload-time = "2025-09-29T23:19:59.765Z" }, - { url = "https://files.pythonhosted.org/packages/e5/63/cd7d615331b328e287d8233ba9fdf191a9c2d11b6af0c7a59cfcec23de68/pandas-2.3.3-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b3d11d2fda7eb164ef27ffc14b4fcab16a80e1ce67e9f57e19ec0afaf715ba89", size = 12362693, upload-time = "2025-09-29T23:20:14.098Z" }, - { url = "https://files.pythonhosted.org/packages/a6/de/8b1895b107277d52f2b42d3a6806e69cfef0d5cf1d0ba343470b9d8e0a04/pandas-2.3.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a68e15f780eddf2b07d242e17a04aa187a7ee12b40b930bfdd78070556550e98", size = 12771002, upload-time = "2025-09-29T23:20:26.76Z" }, - { url = "https://files.pythonhosted.org/packages/87/21/84072af3187a677c5893b170ba2c8fbe450a6ff911234916da889b698220/pandas-2.3.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:371a4ab48e950033bcf52b6527eccb564f52dc826c02afd9a1bc0ab731bba084", size = 13450971, upload-time = "2025-09-29T23:20:41.344Z" }, - { url = "https://files.pythonhosted.org/packages/86/41/585a168330ff063014880a80d744219dbf1dd7a1c706e75ab3425a987384/pandas-2.3.3-cp312-cp312-win_amd64.whl", hash = "sha256:a16dcec078a01eeef8ee61bf64074b4e524a2a3f4b3be9326420cabe59c4778b", size = 10992722, upload-time = "2025-09-29T23:20:54.139Z" }, - { url = "https://files.pythonhosted.org/packages/cd/4b/18b035ee18f97c1040d94debd8f2e737000ad70ccc8f5513f4eefad75f4b/pandas-2.3.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:56851a737e3470de7fa88e6131f41281ed440d29a9268dcbf0002da5ac366713", size = 11544671, upload-time = "2025-09-29T23:21:05.024Z" }, - { url = "https://files.pythonhosted.org/packages/31/94/72fac03573102779920099bcac1c3b05975c2cb5f01eac609faf34bed1ca/pandas-2.3.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bdcd9d1167f4885211e401b3036c0c8d9e274eee67ea8d0758a256d60704cfe8", size = 10680807, upload-time = "2025-09-29T23:21:15.979Z" }, - { url = "https://files.pythonhosted.org/packages/16/87/9472cf4a487d848476865321de18cc8c920b8cab98453ab79dbbc98db63a/pandas-2.3.3-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e32e7cc9af0f1cc15548288a51a3b681cc2a219faa838e995f7dc53dbab1062d", size = 11709872, upload-time = "2025-09-29T23:21:27.165Z" }, - { url = "https://files.pythonhosted.org/packages/15/07/284f757f63f8a8d69ed4472bfd85122bd086e637bf4ed09de572d575a693/pandas-2.3.3-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:318d77e0e42a628c04dc56bcef4b40de67918f7041c2b061af1da41dcff670ac", size = 12306371, upload-time = "2025-09-29T23:21:40.532Z" }, - { url = "https://files.pythonhosted.org/packages/33/81/a3afc88fca4aa925804a27d2676d22dcd2031c2ebe08aabd0ae55b9ff282/pandas-2.3.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4e0a175408804d566144e170d0476b15d78458795bb18f1304fb94160cabf40c", size = 12765333, upload-time = "2025-09-29T23:21:55.77Z" }, - { url = "https://files.pythonhosted.org/packages/8d/0f/b4d4ae743a83742f1153464cf1a8ecfafc3ac59722a0b5c8602310cb7158/pandas-2.3.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:93c2d9ab0fc11822b5eece72ec9587e172f63cff87c00b062f6e37448ced4493", size = 13418120, upload-time = "2025-09-29T23:22:10.109Z" }, - { url = "https://files.pythonhosted.org/packages/4f/c7/e54682c96a895d0c808453269e0b5928a07a127a15704fedb643e9b0a4c8/pandas-2.3.3-cp313-cp313-win_amd64.whl", hash = "sha256:f8bfc0e12dc78f777f323f55c58649591b2cd0c43534e8355c51d3fede5f4dee", size = 10993991, upload-time = "2025-09-29T23:25:04.889Z" }, - { url = "https://files.pythonhosted.org/packages/f9/ca/3f8d4f49740799189e1395812f3bf23b5e8fc7c190827d55a610da72ce55/pandas-2.3.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:75ea25f9529fdec2d2e93a42c523962261e567d250b0013b16210e1d40d7c2e5", size = 12048227, upload-time = "2025-09-29T23:22:24.343Z" }, - { url = "https://files.pythonhosted.org/packages/0e/5a/f43efec3e8c0cc92c4663ccad372dbdff72b60bdb56b2749f04aa1d07d7e/pandas-2.3.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:74ecdf1d301e812db96a465a525952f4dde225fdb6d8e5a521d47e1f42041e21", size = 11411056, upload-time = "2025-09-29T23:22:37.762Z" }, - { url = "https://files.pythonhosted.org/packages/46/b1/85331edfc591208c9d1a63a06baa67b21d332e63b7a591a5ba42a10bb507/pandas-2.3.3-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6435cb949cb34ec11cc9860246ccb2fdc9ecd742c12d3304989017d53f039a78", size = 11645189, upload-time = "2025-09-29T23:22:51.688Z" }, - { url = "https://files.pythonhosted.org/packages/44/23/78d645adc35d94d1ac4f2a3c4112ab6f5b8999f4898b8cdf01252f8df4a9/pandas-2.3.3-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:900f47d8f20860de523a1ac881c4c36d65efcb2eb850e6948140fa781736e110", size = 12121912, upload-time = "2025-09-29T23:23:05.042Z" }, - { url = "https://files.pythonhosted.org/packages/53/da/d10013df5e6aaef6b425aa0c32e1fc1f3e431e4bcabd420517dceadce354/pandas-2.3.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:a45c765238e2ed7d7c608fc5bc4a6f88b642f2f01e70c0c23d2224dd21829d86", size = 12712160, upload-time = "2025-09-29T23:23:28.57Z" }, - { url = "https://files.pythonhosted.org/packages/bd/17/e756653095a083d8a37cbd816cb87148debcfcd920129b25f99dd8d04271/pandas-2.3.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c4fc4c21971a1a9f4bdb4c73978c7f7256caa3e62b323f70d6cb80db583350bc", size = 13199233, upload-time = "2025-09-29T23:24:24.876Z" }, - { url = "https://files.pythonhosted.org/packages/04/fd/74903979833db8390b73b3a8a7d30d146d710bd32703724dd9083950386f/pandas-2.3.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:ee15f284898e7b246df8087fc82b87b01686f98ee67d85a17b7ab44143a3a9a0", size = 11540635, upload-time = "2025-09-29T23:25:52.486Z" }, - { url = "https://files.pythonhosted.org/packages/21/00/266d6b357ad5e6d3ad55093a7e8efc7dd245f5a842b584db9f30b0f0a287/pandas-2.3.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1611aedd912e1ff81ff41c745822980c49ce4a7907537be8692c8dbc31924593", size = 10759079, upload-time = "2025-09-29T23:26:33.204Z" }, - { url = "https://files.pythonhosted.org/packages/ca/05/d01ef80a7a3a12b2f8bbf16daba1e17c98a2f039cbc8e2f77a2c5a63d382/pandas-2.3.3-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6d2cefc361461662ac48810cb14365a365ce864afe85ef1f447ff5a1e99ea81c", size = 11814049, upload-time = "2025-09-29T23:27:15.384Z" }, - { url = "https://files.pythonhosted.org/packages/15/b2/0e62f78c0c5ba7e3d2c5945a82456f4fac76c480940f805e0b97fcbc2f65/pandas-2.3.3-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ee67acbbf05014ea6c763beb097e03cd629961c8a632075eeb34247120abcb4b", size = 12332638, upload-time = "2025-09-29T23:27:51.625Z" }, - { url = "https://files.pythonhosted.org/packages/c5/33/dd70400631b62b9b29c3c93d2feee1d0964dc2bae2e5ad7a6c73a7f25325/pandas-2.3.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c46467899aaa4da076d5abc11084634e2d197e9460643dd455ac3db5856b24d6", size = 12886834, upload-time = "2025-09-29T23:28:21.289Z" }, - { url = "https://files.pythonhosted.org/packages/d3/18/b5d48f55821228d0d2692b34fd5034bb185e854bdb592e9c640f6290e012/pandas-2.3.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6253c72c6a1d990a410bc7de641d34053364ef8bcd3126f7e7450125887dffe3", size = 13409925, upload-time = "2025-09-29T23:28:58.261Z" }, - { url = "https://files.pythonhosted.org/packages/a6/3d/124ac75fcd0ecc09b8fdccb0246ef65e35b012030defb0e0eba2cbbbe948/pandas-2.3.3-cp314-cp314-win_amd64.whl", hash = "sha256:1b07204a219b3b7350abaae088f451860223a52cfb8a6c53358e7948735158e5", size = 11109071, upload-time = "2025-09-29T23:32:27.484Z" }, - { url = "https://files.pythonhosted.org/packages/89/9c/0e21c895c38a157e0faa1fb64587a9226d6dd46452cac4532d80c3c4a244/pandas-2.3.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2462b1a365b6109d275250baaae7b760fd25c726aaca0054649286bcfbb3e8ec", size = 12048504, upload-time = "2025-09-29T23:29:31.47Z" }, - { url = "https://files.pythonhosted.org/packages/d7/82/b69a1c95df796858777b68fbe6a81d37443a33319761d7c652ce77797475/pandas-2.3.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0242fe9a49aa8b4d78a4fa03acb397a58833ef6199e9aa40a95f027bb3a1b6e7", size = 11410702, upload-time = "2025-09-29T23:29:54.591Z" }, - { url = "https://files.pythonhosted.org/packages/f9/88/702bde3ba0a94b8c73a0181e05144b10f13f29ebfc2150c3a79062a8195d/pandas-2.3.3-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a21d830e78df0a515db2b3d2f5570610f5e6bd2e27749770e8bb7b524b89b450", size = 11634535, upload-time = "2025-09-29T23:30:21.003Z" }, - { url = "https://files.pythonhosted.org/packages/a4/1e/1bac1a839d12e6a82ec6cb40cda2edde64a2013a66963293696bbf31fbbb/pandas-2.3.3-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2e3ebdb170b5ef78f19bfb71b0dc5dc58775032361fa188e814959b74d726dd5", size = 12121582, upload-time = "2025-09-29T23:30:43.391Z" }, - { url = "https://files.pythonhosted.org/packages/44/91/483de934193e12a3b1d6ae7c8645d083ff88dec75f46e827562f1e4b4da6/pandas-2.3.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d051c0e065b94b7a3cea50eb1ec32e912cd96dba41647eb24104b6c6c14c5788", size = 12699963, upload-time = "2025-09-29T23:31:10.009Z" }, - { url = "https://files.pythonhosted.org/packages/70/44/5191d2e4026f86a2a109053e194d3ba7a31a2d10a9c2348368c63ed4e85a/pandas-2.3.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3869faf4bd07b3b66a9f462417d0ca3a9df29a9f6abd5d0d0dbab15dac7abe87", size = 13202175, upload-time = "2025-09-29T23:31:59.173Z" }, + { name = "tzdata", marker = "sys_platform == 'emscripten' or sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/da/99/b342345300f13440fe9fe385c3c481e2d9a595ee3bab4d3219247ac94e9a/pandas-3.0.2.tar.gz", hash = "sha256:f4753e73e34c8d83221ba58f232433fca2748be8b18dbca02d242ed153945043", size = 4645855, upload-time = "2026-03-31T06:48:30.816Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f3/b0/c20bd4d6d3f736e6bd6b55794e9cd0a617b858eaad27c8f410ea05d953b7/pandas-3.0.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:232a70ebb568c0c4d2db4584f338c1577d81e3af63292208d615907b698a0f18", size = 10347921, upload-time = "2026-03-31T06:46:33.36Z" }, + { url = "https://files.pythonhosted.org/packages/35/d0/4831af68ce30cc2d03c697bea8450e3225a835ef497d0d70f31b8cdde965/pandas-3.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:970762605cff1ca0d3f71ed4f3a769ea8f85fc8e6348f6e110b8fea7e6eb5a14", size = 9888127, upload-time = "2026-03-31T06:46:36.253Z" }, + { url = "https://files.pythonhosted.org/packages/61/a9/16ea9346e1fc4a96e2896242d9bc674764fb9049b0044c0132502f7a771e/pandas-3.0.2-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aff4e6f4d722e0652707d7bcb190c445fe58428500c6d16005b02401764b1b3d", size = 10399577, upload-time = "2026-03-31T06:46:39.224Z" }, + { url = "https://files.pythonhosted.org/packages/c4/a8/3a61a721472959ab0ce865ef05d10b0d6bfe27ce8801c99f33d4fa996e65/pandas-3.0.2-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ef8b27695c3d3dc78403c9a7d5e59a62d5464a7e1123b4e0042763f7104dc74f", size = 10880030, upload-time = "2026-03-31T06:46:42.412Z" }, + { url = "https://files.pythonhosted.org/packages/da/65/7225c0ea4d6ce9cb2160a7fb7f39804871049f016e74782e5dade4d14109/pandas-3.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f8d68083e49e16b84734eb1a4dcae4259a75c90fb6e2251ab9a00b61120c06ab", size = 11409468, upload-time = "2026-03-31T06:46:45.2Z" }, + { url = "https://files.pythonhosted.org/packages/fa/5b/46e7c76032639f2132359b5cf4c785dd8cf9aea5ea64699eac752f02b9db/pandas-3.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:32cc41f310ebd4a296d93515fcac312216adfedb1894e879303987b8f1e2b97d", size = 11936381, upload-time = "2026-03-31T06:46:48.293Z" }, + { url = "https://files.pythonhosted.org/packages/7b/8b/721a9cff6fa6a91b162eb51019c6243b82b3226c71bb6c8ef4a9bd65cbc6/pandas-3.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:a4785e1d6547d8427c5208b748ae2efb64659a21bd82bf440d4262d02bfa02a4", size = 9744993, upload-time = "2026-03-31T06:46:51.488Z" }, + { url = "https://files.pythonhosted.org/packages/d5/18/7f0bd34ae27b28159aa80f2a6799f47fda34f7fb938a76e20c7b7fe3b200/pandas-3.0.2-cp312-cp312-win_arm64.whl", hash = "sha256:08504503f7101300107ecdc8df73658e4347586db5cfdadabc1592e9d7e7a0fd", size = 9056118, upload-time = "2026-03-31T06:46:54.548Z" }, + { url = "https://files.pythonhosted.org/packages/bf/ca/3e639a1ea6fcd0617ca4e8ca45f62a74de33a56ae6cd552735470b22c8d3/pandas-3.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b5918ba197c951dec132b0c5929a00c0bf05d5942f590d3c10a807f6e15a57d3", size = 10321105, upload-time = "2026-03-31T06:46:57.327Z" }, + { url = "https://files.pythonhosted.org/packages/0b/77/dbc82ff2fb0e63c6564356682bf201edff0ba16c98630d21a1fb312a8182/pandas-3.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d606a041c89c0a474a4702d532ab7e73a14fe35c8d427b972a625c8e46373668", size = 9864088, upload-time = "2026-03-31T06:46:59.935Z" }, + { url = "https://files.pythonhosted.org/packages/5c/2b/341f1b04bbca2e17e13cd3f08c215b70ef2c60c5356ef1e8c6857449edc7/pandas-3.0.2-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:710246ba0616e86891b58ab95f2495143bb2bc83ab6b06747c74216f583a6ac9", size = 10369066, upload-time = "2026-03-31T06:47:02.792Z" }, + { url = "https://files.pythonhosted.org/packages/12/c5/cbb1ffefb20a93d3f0e1fdcda699fb84976210d411b008f97f48bf6ce27e/pandas-3.0.2-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5d3cfe227c725b1f3dff4278b43d8c784656a42a9325b63af6b1492a8232209e", size = 10876780, upload-time = "2026-03-31T06:47:06.205Z" }, + { url = "https://files.pythonhosted.org/packages/98/fe/2249ae5e0a69bd0ddf17353d0a5d26611d70970111f5b3600cdc8be883e7/pandas-3.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c3b723df9087a9a9a840e263ebd9f88b64a12075d1bf2ea401a5a42f254f084d", size = 11375181, upload-time = "2026-03-31T06:47:09.383Z" }, + { url = "https://files.pythonhosted.org/packages/de/64/77a38b09e70b6464883b8d7584ab543e748e42c1b5d337a2ee088e0df741/pandas-3.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a3096110bf9eac0070b7208465f2740e2d8a670d5cb6530b5bb884eca495fd39", size = 11928899, upload-time = "2026-03-31T06:47:12.686Z" }, + { url = "https://files.pythonhosted.org/packages/5e/52/42855bf626868413f761addd574acc6195880ae247a5346477a4361c3acb/pandas-3.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:07a10f5c36512eead51bc578eb3354ad17578b22c013d89a796ab5eee90cd991", size = 9746574, upload-time = "2026-03-31T06:47:15.64Z" }, + { url = "https://files.pythonhosted.org/packages/88/39/21304ae06a25e8bf9fc820d69b29b2c495b2ae580d1e143146c309941760/pandas-3.0.2-cp313-cp313-win_arm64.whl", hash = "sha256:5fdbfa05931071aba28b408e59226186b01eb5e92bea2ab78b65863ca3228d84", size = 9047156, upload-time = "2026-03-31T06:47:18.595Z" }, + { url = "https://files.pythonhosted.org/packages/72/20/7defa8b27d4f330a903bb68eea33be07d839c5ea6bdda54174efcec0e1d2/pandas-3.0.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:dbc20dea3b9e27d0e66d74c42b2d0c1bed9c2ffe92adea33633e3bedeb5ac235", size = 10756238, upload-time = "2026-03-31T06:47:22.012Z" }, + { url = "https://files.pythonhosted.org/packages/e9/95/49433c14862c636afc0e9b2db83ff16b3ad92959364e52b2955e44c8e94c/pandas-3.0.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:b75c347eff42497452116ce05ef461822d97ce5b9ff8df6edacb8076092c855d", size = 10408520, upload-time = "2026-03-31T06:47:25.197Z" }, + { url = "https://files.pythonhosted.org/packages/3b/f8/462ad2b5881d6b8ec8e5f7ed2ea1893faa02290d13870a1600fe72ad8efc/pandas-3.0.2-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d1478075142e83a5571782ad007fb201ed074bdeac7ebcc8890c71442e96adf7", size = 10324154, upload-time = "2026-03-31T06:47:28.097Z" }, + { url = "https://files.pythonhosted.org/packages/0a/65/d1e69b649cbcddda23ad6e4c40ef935340f6f652a006e5cbc3555ac8adb3/pandas-3.0.2-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5880314e69e763d4c8b27937090de570f1fb8d027059a7ada3f7f8e98bdcb677", size = 10714449, upload-time = "2026-03-31T06:47:30.85Z" }, + { url = "https://files.pythonhosted.org/packages/47/a4/85b59bc65b8190ea3689882db6cdf32a5003c0ccd5a586c30fdcc3ffc4fc/pandas-3.0.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:b5329e26898896f06035241a626d7c335daa479b9bbc82be7c2742d048e41172", size = 11338475, upload-time = "2026-03-31T06:47:34.026Z" }, + { url = "https://files.pythonhosted.org/packages/1e/c4/bc6966c6e38e5d9478b935272d124d80a589511ed1612a5d21d36f664c68/pandas-3.0.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:81526c4afd31971f8b62671442a4b2b51e0aa9acc3819c9f0f12a28b6fcf85f1", size = 11786568, upload-time = "2026-03-31T06:47:36.941Z" }, + { url = "https://files.pythonhosted.org/packages/e8/74/09298ca9740beed1d3504e073d67e128aa07e5ca5ca2824b0c674c0b8676/pandas-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:7cadd7e9a44ec13b621aec60f9150e744cfc7a3dd32924a7e2f45edff31823b0", size = 10488652, upload-time = "2026-03-31T06:47:40.612Z" }, + { url = "https://files.pythonhosted.org/packages/bb/40/c6ea527147c73b24fc15c891c3fcffe9c019793119c5742b8784a062c7db/pandas-3.0.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:db0dbfd2a6cdf3770aa60464d50333d8f3d9165b2f2671bcc299b72de5a6677b", size = 10326084, upload-time = "2026-03-31T06:47:43.834Z" }, + { url = "https://files.pythonhosted.org/packages/95/25/bdb9326c3b5455f8d4d3549fce7abcf967259de146fe2cf7a82368141948/pandas-3.0.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0555c5882688a39317179ab4a0ed41d3ebc8812ab14c69364bbee8fb7a3f6288", size = 9914146, upload-time = "2026-03-31T06:47:46.67Z" }, + { url = "https://files.pythonhosted.org/packages/8d/77/3a227ff3337aa376c60d288e1d61c5d097131d0ac71f954d90a8f369e422/pandas-3.0.2-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:01f31a546acd5574ef77fe199bc90b55527c225c20ccda6601cf6b0fd5ed597c", size = 10444081, upload-time = "2026-03-31T06:47:49.681Z" }, + { url = "https://files.pythonhosted.org/packages/15/88/3cdd54fa279341afa10acf8d2b503556b1375245dccc9315659f795dd2e9/pandas-3.0.2-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:deeca1b5a931fdf0c2212c8a659ade6d3b1edc21f0914ce71ef24456ca7a6535", size = 10897535, upload-time = "2026-03-31T06:47:53.033Z" }, + { url = "https://files.pythonhosted.org/packages/06/9d/98cc7a7624f7932e40f434299260e2917b090a579d75937cb8a57b9d2de3/pandas-3.0.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0f48afd9bb13300ffb5a3316973324c787054ba6665cda0da3fbd67f451995db", size = 11446992, upload-time = "2026-03-31T06:47:56.193Z" }, + { url = "https://files.pythonhosted.org/packages/9a/cd/19ff605cc3760e80602e6826ddef2824d8e7050ed80f2e11c4b079741dc3/pandas-3.0.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6c4d8458b97a35717b62469a4ea0e85abd5ed8687277f5ccfc67f8a5126f8c53", size = 11968257, upload-time = "2026-03-31T06:47:59.137Z" }, + { url = "https://files.pythonhosted.org/packages/db/60/aba6a38de456e7341285102bede27514795c1eaa353bc0e7638b6b785356/pandas-3.0.2-cp314-cp314-win_amd64.whl", hash = "sha256:b35d14bb5d8285d9494fe93815a9e9307c0876e10f1e8e89ac5b88f728ec8dcf", size = 9865893, upload-time = "2026-03-31T06:48:02.038Z" }, + { url = "https://files.pythonhosted.org/packages/08/71/e5ec979dd2e8a093dacb8864598c0ff59a0cee0bbcdc0bfec16a51684d4f/pandas-3.0.2-cp314-cp314-win_arm64.whl", hash = "sha256:63d141b56ef686f7f0d714cfb8de4e320475b86bf4b620aa0b7da89af8cbdbbb", size = 9188644, upload-time = "2026-03-31T06:48:05.045Z" }, + { url = "https://files.pythonhosted.org/packages/f1/6c/7b45d85db19cae1eb524f2418ceaa9d85965dcf7b764ed151386b7c540f0/pandas-3.0.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:140f0cffb1fa2524e874dde5b477d9defe10780d8e9e220d259b2c0874c89d9d", size = 10776246, upload-time = "2026-03-31T06:48:07.789Z" }, + { url = "https://files.pythonhosted.org/packages/a8/3e/7b00648b086c106e81766f25322b48aa8dfa95b55e621dbdf2fdd413a117/pandas-3.0.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ae37e833ff4fed0ba352f6bdd8b73ba3ab3256a85e54edfd1ab51ae40cca0af8", size = 10424801, upload-time = "2026-03-31T06:48:10.897Z" }, + { url = "https://files.pythonhosted.org/packages/da/6e/558dd09a71b53b4008e7fc8a98ec6d447e9bfb63cdaeea10e5eb9b2dabe8/pandas-3.0.2-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4d888a5c678a419a5bb41a2a93818e8ed9fd3172246555c0b37b7cc27027effd", size = 10345643, upload-time = "2026-03-31T06:48:13.7Z" }, + { url = "https://files.pythonhosted.org/packages/be/e3/921c93b4d9a280409451dc8d07b062b503bbec0531d2627e73a756e99a82/pandas-3.0.2-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b444dc64c079e84df91baa8bf613d58405645461cabca929d9178f2cd392398d", size = 10743641, upload-time = "2026-03-31T06:48:16.659Z" }, + { url = "https://files.pythonhosted.org/packages/56/ca/fd17286f24fa3b4d067965d8d5d7e14fe557dd4f979a0b068ac0deaf8228/pandas-3.0.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:4544c7a54920de8eeacaa1466a6b7268ecfbc9bc64ab4dbb89c6bbe94d5e0660", size = 11361993, upload-time = "2026-03-31T06:48:19.475Z" }, + { url = "https://files.pythonhosted.org/packages/e4/a5/2f6ed612056819de445a433ca1f2821ac3dab7f150d569a59e9cc105de1d/pandas-3.0.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:734be7551687c00fbd760dc0522ed974f82ad230d4a10f54bf51b80d44a08702", size = 11815274, upload-time = "2026-03-31T06:48:22.695Z" }, + { url = "https://files.pythonhosted.org/packages/00/2f/b622683e99ec3ce00b0854bac9e80868592c5b051733f2cf3a868e5fea26/pandas-3.0.2-cp314-cp314t-win_amd64.whl", hash = "sha256:57a07209bebcbcf768d2d13c9b78b852f9a15978dac41b9e6421a81ad4cdd276", size = 10888530, upload-time = "2026-03-31T06:48:25.806Z" }, + { url = "https://files.pythonhosted.org/packages/cb/2b/f8434233fab2bd66a02ec014febe4e5adced20e2693e0e90a07d118ed30e/pandas-3.0.2-cp314-cp314t-win_arm64.whl", hash = "sha256:5371b72c2d4d415d08765f32d689217a43227484e81b2305b52076e328f6f482", size = 9455341, upload-time = "2026-03-31T06:48:28.418Z" }, ] [[package]] @@ -1670,15 +1682,6 @@ wheels = [ { 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 = "2026.1.post1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/56/db/b8721d71d945e6a8ac63c0fc900b2067181dbb50805958d4d4661cf7d277/pytz-2026.1.post1.tar.gz", hash = "sha256:3378dde6a0c3d26719182142c56e60c7f9af7e968076f31aae569d72a0358ee1", size = 321088, upload-time = "2026-03-03T07:47:50.683Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/10/99/781fe0c827be2742bcc775efefccb3b048a3a9c6ce9aec0cbf4a101677e5/pytz-2026.1.post1-py2.py3-none-any.whl", hash = "sha256:f2fd16142fda348286a75e1a524be810bb05d444e5a081f37f7affc635035f7a", size = 510489, upload-time = "2026-03-03T07:47:49.167Z" }, -] - [[package]] name = "pyyaml" version = "6.0.3"