diff --git a/.cspell.yaml b/.cspell.yaml new file mode 100644 index 00000000..be72eb74 --- /dev/null +++ b/.cspell.yaml @@ -0,0 +1,81 @@ +version: "0.2" +dictionaries: + - coding-terms + - cpp-compound-words + - docker + - filetypes + - fullstack + - npm + - python + - python-common + - software-tools +enabled: true +enabledFileTypes: + "*": true +ignorePaths: + - .devcontainer + - .dockerignore + - .git + - .gitignore + - .vscode + - package-lock.json + - uv.lock + - "frontend-app/src/assets/data/**" +language: en_us,nl +words: + - categorymateriallink + - categoryproducttypelink + - circularityproperties + - crowdsource + - dismissable + - Donati + - ERD + - fileparenttype + - imageparenttype + - Lierde + - materialproductlink + - newslettersubscriber + - orcid + - organizationrole + - oauthaccount + - physicalproperties + - pressable + - primaryjoin + - producttype + - relab + - remanufacturability + - remanufacturable + - repairability + - selectinload + - shellcheck + - subcomponent + - subcomponents + - subrepo + - subrepos + - supercategory + - taxonomydomain + - tsquery + - tsvector + - trixie + - UNEP + - viewability + - zenodo + + # Temp + - ASGI + - Caddyfile + - cloudflared + - Fernet + - fontawesome + - justfile + - mdformat + - mjml + - rclone + - Zensical + +ignoreWords: + - ellipsize + - htmlcov + - piexif + - worklets + - xdist diff --git a/.devcontainer/backend/devcontainer.json b/.devcontainer/backend/devcontainer.json index b6cd62af..5246b969 100644 --- a/.devcontainer/backend/devcontainer.json +++ b/.devcontainer/backend/devcontainer.json @@ -7,14 +7,13 @@ // The local workspace is mounted in /opt/relab for git integration "mounts": ["source=${localWorkspaceFolder},target=/opt/relab,type=bind,consistency=cached"], "overrideCommand": true, - "postCreateCommand": "", - "postAttachCommand": "echo 'πŸš€ Backend dev container ready!\\nπŸ’‘ To start the FastAPI dev server, run: fastapi dev\\nπŸ”„ If that fails, try: uv run fastapi dev\\n🌐 The server will be available at http://localhost:8011 (forwarded port)'", + "postAttachCommand": "echo 'πŸš€ Backend dev container ready!\\nπŸ“¦ If .venv is missing, run: uv sync\\nπŸ’‘ To start the FastAPI dev server, run: fastapi dev\\nπŸ”„ If that fails, try: uv run fastapi dev\\n🌐 The server will be available at http://localhost:8011 (forwarded port)'", "features": { "ghcr.io/devcontainers/features/git:1": {} }, "customizations": { "vscode": { - "extensions": ["charliermarsh.ruff", "ms-python.python", "wholroyd.jinja"], + "extensions": ["astral-sh.ty", "charliermarsh.ruff", "ms-python.python", "wholroyd.jinja"], "settings": { "[python][notebook]": { "editor.codeActionsOnSave": { @@ -27,8 +26,6 @@ "python-envs.terminal.showActivateButton": true, "python.analysis.autoFormatStrings": true, "python.analysis.typeCheckingMode": "standard", - "python.linting.enabled": true, - "python.linting.ruffEnabled": true, "python.terminal.activateEnvInCurrentTerminal": true, "python.terminal.activateEnvironment": true, "python.testing.pytestEnabled": true diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 162e29f9..bc8aafcb 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -1,7 +1,7 @@ { "name": "relab-fullstack", "dockerComposeFile": ["../compose.yml", "../compose.override.yml"], - "service": "frontend-web", + "service": "frontend-app", "workspaceFolder": "/opt/relab", "mounts": ["source=${localWorkspaceFolder},target=/opt/relab,type=bind,consistency=cached"], "features": { @@ -9,17 +9,19 @@ "ghcr.io/devcontainers-extra/features/expo-cli:1": {}, "ghcr.io/jsburckhardt/devcontainer-features/uv:1": {} }, - "postAttachCommand": "echo 'πŸš€ Fullstack dev container ready!\\nπŸ’‘ Frontend: http://localhost:8010\\nπŸ’‘ Backend: http://localhost:8011\\nπŸ’‘ Docs: http://localhost:8012 (all forwarded ports)'", + "postAttachCommand": "echo 'πŸš€ Fullstack dev container ready!\\nπŸ“¦ If node_modules/.venv are missing, run npm ci and uv sync in the relevant subrepo\\nπŸ’‘ Web frontend: http://localhost:8010\\nπŸ’‘ Backend: http://localhost:8011\\nπŸ’‘ Docs: http://localhost:8012\\nπŸ’‘ App frontend: http://localhost:8013'", "customizations": { "vscode": { "extensions": [ // Frontend + "astro-build.astro-vscode", "msjsdiag.vscode-react-native", "christian-kohler.npm-intellisense", "esbenp.prettier-vscode", "dbaeumer.vscode-eslint", "expo.vscode-expo-tools", // Backend + "astral-sh.ty", "charliermarsh.ruff", "ms-python.python", "wholroyd.jinja", @@ -51,8 +53,6 @@ "editor.defaultFormatter": "charliermarsh.ruff" }, "python.analysis.typeCheckingMode": "standard", - "python.linting.enabled": true, - "python.linting.ruffEnabled": true, "python.terminal.activateEnvInCurrentTerminal": true, "python.terminal.activateEnvironment": true, "python.testing.pytestEnabled": true, diff --git a/.devcontainer/docs/devcontainer.json b/.devcontainer/docs/devcontainer.json index 876dab6b..262ac85c 100644 --- a/.devcontainer/docs/devcontainer.json +++ b/.devcontainer/docs/devcontainer.json @@ -10,7 +10,7 @@ [Devcontainer: /opt/relab/docs] ⇄ [Local ./docs] ⇄ [Devcontainer: /docs] - Edit in git-integrated /opt/relab/docs (devcontainer) β†’ updates local ./docs - - MkDocs in the devcontainer live reloads /docs (synced from local ./docs) + - Zensical in the devcontainer live reloads /docs (synced from local ./docs) */ "mounts": ["source=${localWorkspaceFolder},target=/opt/relab,type=bind,consistency=cached"], "features": { @@ -25,20 +25,20 @@ "DavidAnson.vscode-markdownlint", "shd101wyy.markdown-preview-enhanced", "yzhang.markdown-all-in-one" - ] - }, - "settings": { - "[markdown]": { - "editor.defaultFormatter": "DavidAnson.vscode-markdownlint" - }, - "editor.formatOnSave": true, - "github.copilot.enable": { - "markdown": true - }, - "markdown.extension.completion.enabled": true, - "markdown.extension.orderedList.marker": "one", - "markdown.extension.tableFormatter.normalizeIndentation": true, - "markdown.extension.theming.decoration.renderTrailingSpace": true + ], + "settings": { + "[markdown]": { + "editor.defaultFormatter": "DavidAnson.vscode-markdownlint" + }, + "editor.formatOnSave": true, + "github.copilot.enable": { + "markdown": true + }, + "markdown.extension.completion.enabled": true, + "markdown.extension.orderedList.marker": "one", + "markdown.extension.tableFormatter.normalizeIndentation": true, + "markdown.extension.theming.decoration.renderTrailingSpace": true + } } } } diff --git a/.devcontainer/frontend/devcontainer.json b/.devcontainer/frontend-app/devcontainer.json similarity index 74% rename from .devcontainer/frontend/devcontainer.json rename to .devcontainer/frontend-app/devcontainer.json index 5006f9e9..165ff504 100644 --- a/.devcontainer/frontend/devcontainer.json +++ b/.devcontainer/frontend-app/devcontainer.json @@ -1,13 +1,13 @@ { - "name": "relab-frontend-web", + "name": "relab-frontend-app", "dockerComposeFile": ["../../compose.yml", "../../compose.override.yml"], - "service": "frontend-web", - "runServices": ["frontend-web"], - "workspaceFolder": "/opt/relab/frontend-web", + "service": "frontend-app", + "runServices": ["frontend-app"], + "workspaceFolder": "/opt/relab/frontend-app", // The local workspace is mounted in /opt/relab for git integration - "mounts": ["source=${localWorkspaceFolder},target=/opt/relab,type=bind,consistency=cached"], + "mounts": ["source=${localWorkspaceFolder},target=/opt/relab,type=bind"], "overrideCommand": true, - "postAttachCommand": "echo 'πŸš€ Frontend dev container ready!\\nπŸ’‘ To start the Expo dev server, run: npx expo start --web\\n🌐 The server will be available at http://localhost:8010 (forwarded port)'", + "postAttachCommand": "echo 'πŸš€ App frontend dev container ready!\\nπŸ“¦ If node_modules is missing, run: npm ci\\nπŸ’‘ To start the Expo dev server, run: npx expo start --web\\n🌐 The server will be available at http://localhost:8013 (forwarded port)'", "features": { "ghcr.io/devcontainers/features/git:1": {}, "ghcr.io/devcontainers-extra/features/expo-cli:1": {} diff --git a/.devcontainer/frontend-web/devcontainer.json b/.devcontainer/frontend-web/devcontainer.json new file mode 100644 index 00000000..42c5c8b8 --- /dev/null +++ b/.devcontainer/frontend-web/devcontainer.json @@ -0,0 +1,30 @@ +{ + "name": "relab-frontend-web", + "dockerComposeFile": ["../../compose.yml", "../../compose.override.yml"], + "service": "frontend-web", + "runServices": ["frontend-web"], + "workspaceFolder": "/opt/relab/frontend-web", + // The local workspace is mounted in /opt/relab for git integration + "mounts": ["source=${localWorkspaceFolder},target=/opt/relab,type=bind,consistency=cached"], + "overrideCommand": true, + "postAttachCommand": "echo 'πŸš€ Web frontend dev container ready!\\nπŸ“¦ If node_modules is missing, run: npm ci\\nπŸ’‘ To start the Astro dev server, run: npx astro dev\\n🌐 The server will be available at http://localhost:8010 (forwarded port)'", + "features": { + "ghcr.io/devcontainers/features/git:1": {} + }, + "customizations": { + "vscode": { + "extensions": ["astro-build.astro-vscode", "biomejs.biome", "christian-kohler.npm-intellisense"], + "settings": { + "editor.formatOnSave": true, + "biome.requireConfiguration": true, + "[astro][javascript][typescript][javascriptreact][typescriptreact][json][jsonc]": { + "editor.defaultFormatter": "biomejs.biome", + "editor.codeActionsOnSave": { + "source.fixAll.biome": "always", + "source.organizeImports.biome": "always" + } + } + } + } + } +} diff --git a/.env.example b/.env.example index b371bd67..00ae1785 100644 --- a/.env.example +++ b/.env.example @@ -1,14 +1,22 @@ # Example of root .env file +# Copy this file to .env and fill in the values marked with πŸ”€. # Enable docker compose bake COMPOSE_BAKE=true -# Cloudflare Tunnel Token -TUNNEL_TOKEN=your_token +# Cloudflare Tunnel Tokens (used by compose.prod.yml and compose.staging.yml) +TUNNEL_TOKEN_PROD=your_token # πŸ”€ +TUNNEL_TOKEN_STAGING=your_token # πŸ”€ # Host directory where database and user upload backups are stored BACKUP_DIR=./backups -# Remote backup config (for use of backend/scripts/backup/backup_rclone.sh script) -BACKUP_REMOTE_HOST=user@host -BACKUP_REMOTE_PATH=/path/to/remote/backup +# Remote rsync backup config (for use of backend/scripts/backup/rsync_backup.sh script) +BACKUP_RSYNC_REMOTE_HOST=user@host # πŸ”€ +BACKUP_RSYNC_REMOTE_PATH=/path/to/remote/backup # πŸ”€ + +# Remote rclone backup config (for use of backend/scripts/backup/rclone_backup.sh script) +BACKUP_RCLONE_REMOTE=myremote:/path/to/remote/backup # πŸ”€ +BACKUP_RCLONE_MULTI_THREAD_STREAMS=16 +BACKUP_RCLONE_TIMEOUT=5m +BACKUP_RCLONE_USE_COOKIES=false diff --git a/.github/.release-please-manifest.json b/.github/.release-please-manifest.json new file mode 100644 index 00000000..466df71c --- /dev/null +++ b/.github/.release-please-manifest.json @@ -0,0 +1,3 @@ +{ + ".": "0.1.0" +} diff --git a/.github/ISSUE_TEMPLATE/bug-report.md b/.github/ISSUE_TEMPLATE/bug-report.md new file mode 100644 index 00000000..d2c2fba5 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug-report.md @@ -0,0 +1,33 @@ +--- +about: Report a bug +assignees: '' +labels: bug +name: Bug report +title: 'bug: ' +--- + +## What happened? + +_Describe the bug in one or two sentences._ + +## Environment + +_Only include details that help reproduce the issue._ + +- _App area: backend / web / app_ +- _OS / browser / device_ +- _Version or build_ + +## How to reproduce + +1. _Go to ..._ +1. _Do ..._ +1. _See the error_ + +## What should happen? + +_Describe the expected behavior._ + +## Anything else? + +_Add logs, screenshots, or a link to a failing run if useful._ diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md deleted file mode 100644 index 0d8647c3..00000000 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ /dev/null @@ -1,36 +0,0 @@ ---- -name: Bug report -about: Create a report to help us improve -title: 'bug: ' -labels: bug -assignees: '' ---- - -## Bug description - -_A clear and concise description of the bug_ - -## Environment - -_For example: **Desktop**_ - -- _OS: [e.g. iOS]_ -- _Browser [e.g. chrome, safari]_ -- _Version [e.g. 22]_ - -## To Reproduce - -_Steps to reproduce the behavior, e.g.:_ - -1. _Go to '...'_ -1. _Click on '....'_ -1. _Scroll down to '....'_ -1. _See error_ - -## Expected behavior - -_A clear and concise description of what you expected to happen._ - -## Additional context - -_Optional: Add screenshots or any other context about the problem here._ diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 00000000..4870b249 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: false +contact_links: + - name: Contribution guide + url: https://github.com/CMLPlatform/relab/blob/main/CONTRIBUTING.md + about: Read this first if you need repo conventions, setup help, or contribution guidance. diff --git a/.github/ISSUE_TEMPLATE/feature-request.md b/.github/ISSUE_TEMPLATE/feature-request.md index eea6c5bf..8f06b9af 100644 --- a/.github/ISSUE_TEMPLATE/feature-request.md +++ b/.github/ISSUE_TEMPLATE/feature-request.md @@ -1,18 +1,18 @@ --- -name: Feature request -about: Suggest an idea for this project -title: 'feature request: ' -labels: feature request +about: Propose an improvement assignees: '' +labels: feature request +name: Feature request +title: 'feature: ' --- -## Problem statement +## Problem -_A clear and concise description of the problem._ +_What are users trying to do, and what's getting in the way?_ ## Proposed solution -_A clear and concise description of what you want to happen._ +_Describe the smallest change that would solve it._ ## Implementation ideas diff --git a/.github/ISSUE_TEMPLATE/internal-ticket.md b/.github/ISSUE_TEMPLATE/internal-ticket.md index e6873341..26030a1a 100644 --- a/.github/ISSUE_TEMPLATE/internal-ticket.md +++ b/.github/ISSUE_TEMPLATE/internal-ticket.md @@ -1,15 +1,19 @@ --- +about: Internal task for the team +assignees: '' +labels: '' name: Internal ticket -about: For internal development title: '' -labels: '' -assignees: '' --- -## Problem +## Goal + +_What are we trying to change or ship?_ + +## Context -## Proposed Solution +_Add links, constraints, or a short note if useful._ -## Acceptance Criteria +## Acceptance criteria -- \[ \] +- [ ] _Done when the team can verify the result._ diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 308b5568..bb9ad3ff 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,10 +1,10 @@ # Pull Request -## Description +## Summary -Please provide a brief description of the changes in this pull request. +_What does this PR change, in one or two sentences?_ -## Type of Change +## Type of change - [ ] πŸš€ feat: New feature - [ ] πŸ› fix: Bug fix @@ -15,28 +15,23 @@ Please provide a brief description of the changes in this pull request. - [ ] ♻️ refactor: Code refactoring (no functional changes) - [ ] 🎨 style: Code style/formatting changes - [ ] βœ… test: Adding or updating tests +- [ ] πŸ”§ chore: Other maintenance work + +## Why + +_What problem does this solve or why is it worth merging?_ ## Checklist - [ ] I've read the [contributing guidelines](../CONTRIBUTING.md) -- [ ] Code follows style guidelines and passes quality checks (ruff, pyright) -- [ ] Unit tests added/updated and passing locally +- [ ] Code follows style guidelines and passes quality checks (`just check`) +- [ ] Unit tests added/updated and passing locally (`just test`) - [ ] Documentation updated (if applicable) - [ ] Database migrations created (if applicable) -## Related Issues - -- Closes #[issue-number] -- Related to #[issue-number] - -## Additional Context - -Add any relevant context about the pull request here, such as: +## Notes for reviewers -- Implementation details or approach -- Challenges encountered and how they were addressed -- Alternative solutions that were considered -- Screenshots or GIFs demonstrating visual changes (if applicable) +_Add rollout notes, tradeoffs, follow-up work, or links to related issues._ - - +[![Coverage](https://img.shields.io/codecov/c/github/CMLPlatform/relab)](https://codecov.io/gh/CMLPlatform/relab) [![Contributor Covenant](https://img.shields.io/badge/Contributor%20Covenant-2.1-4baaaa.svg)](CODE_OF_CONDUCT.md) [![FAIR checklist badge](https://fairsoftwarechecklist.net/badge.svg)](https://fairsoftwarechecklist.net/v0.2?f=31&a=32113&i=22322&r=123) @@ -19,19 +17,53 @@ [![Deployed](https://img.shields.io/website?url=https%3A%2F%2Fcml-relab.org&label=website)](https://cml-relab.org) -A data collection platform for disassembled durable goods to support circular economy research and computer vision applications, developed by the Institute of Environmental Sciences (CML) at Leiden University. +**Reverse Engineering Lab (REL)** is an open-source, digital research infrastructure platform designed to systematically collect data on disassembled durable goods. Developed by the Institute of Environmental Sciences (CML) at Leiden University, the platform addresses a product data gap in industrial ecology and supports circular economy research, lifecycle assessment, and computer vision applications. + +## System Overview + +RELab features a modular architecture designed for high availability and reproducible research: + +- **Backend:** High-performance REST API built with FastAPI. +- **Database:** PostgreSQL for reliable, relational data storage. +- **Frontend:** Cross-platform mobile and web application built with Expo/React Native. + +## Project Status and Use + +The platform is actively used as research infrastructure: + +- **In-House Research:** Primarily used by the physical lab at CML, where a lab technician has systematically disassembled and recorded over 50 products, capturing more than 1,250 components and 3,000 photos. +- **User Base:** Currently supporting 30 registered users, including 5 regular researchers. +- **Future Pilots:** Scheduled for pilot deployments with Repair CafΓ©s across the Netherlands in 2026 to crowdsource consumer electronics disassembly data. + +## Quick Links + +- πŸš€ **[Live Platform](https://app.cml-relab.org)** - Access the production deployment +- πŸ“– **[Full Documentation](https://docs.cml-relab.org)** - Complete guides, architecture details, and technical references +- πŸ” **[API Documentation](https://api.cml-relab.org/docs)** - Interactive API reference for the backend +- βš™οΈ **[Installation & Setup](INSTALL.md)** - Guide for self-hosting and local evaluation + +## Development Workflow + +The monorepo uses a shared `just` command contract across subprojects: + +- `just check` for quality gates +- `just test` for local test runs +- `just test-ci` for CI-style coverage runs +- `just ci` for the full local CI pipeline +- `just commit` for an interactive Conventional Commit prompt + +Repository-wide policy checks are enforced with `pre-commit`, while GitHub Actions runs the same subrepo-level `check` and `test-ci` commands in CI. Dependency management is automated via Renovate, and CodeQL provides continuous security analysis. + +For a complete guide on the development setup, testing, and contribution processes, please see the [**Contributing Guidelines**](CONTRIBUTING.md). -## Platform Documentation +## Policies & Community -- πŸš€ **[Get Started](https://cml-relab.org)** - Access the live platform -- πŸ“– **[Full Documentation](https://docs.cml-relab.org)** - Complete guides and architecture -- πŸ” **[API Documentation](https://api.cml-relab.org/docs)** - Interactive API reference - 🀝 **[Contributing Guidelines](CONTRIBUTING.md)** - How to contribute - πŸ“‹ **[Code of Conduct](CODE_OF_CONDUCT.md)** - Community standards - πŸ“ **[Changelog](CHANGELOG.md)** - Version history - πŸ“‘ **[Citation Guidelines](CITATION.cff)** - How to attribute this work -- βš–οΈ **[License Information](LICENSE)** - The software code is licensed under [AGPL-v3+](https://spdx.org/licenses/AGPL-3.0-or-later.html), the data is licensed under [ODbL](https://opendatacommons.org/licenses/odbl/). +- βš–οΈ **[License Information](LICENSE)** - Software licensed under [AGPL-v3+](https://spdx.org/licenses/AGPL-3.0-or-later.html), data under [ODbL](https://opendatacommons.org/licenses/odbl/). ## Contact -For questions about the platform, code or dataset, please contact [relab@cml.leidenuniv.nl](mailto:relab@cml.leidenuniv.nl). +For questions about the platform, code, or dataset, please contact [relab@cml.leidenuniv.nl](mailto:relab@cml.leidenuniv.nl). diff --git a/backend/.dockerignore b/backend/.dockerignore index 4c510a33..c9f35de9 100644 --- a/backend/.dockerignore +++ b/backend/.dockerignore @@ -1,98 +1,42 @@ -# Python bytecode files and caches -**/__pycache__ -**/*.py[cod] -**/*$py.class - -# Distribution / packaging -.Python -build -develop-eggs -dist -downloads -eggs -.eggs -lib -lib64 -parts -sdist -var -wheels -share/python-wheels -*.egg-info -.installed.cfg -*.egg -MANIFEST - -# Unit test / coverage reports -**/htmlcov -**/.tox -**/.nox -**/.coverage -**/.coverage.* -**/.cache -**/nosetests.xml -**/coverage.xml -*.cover -*.py,cover -**/.hypothesis -**/.pytest_cache -**/cover - -# Jupyter Notebook checkpoints -**/.ipynb_checkpoints - -# IPython config -**/profile_default -**/ipython_config.py - -# Environment folders and files -**/env -**/.env -**/.env.* -**/venv -**/.venv -# Keep the .env files in top-level directories -!.env -!*/.env - -# Ruff cache -**/.ruff_cache - -# macOS system files -**/.DS_Store -**/.AppleDouble -**/.LSOverride -**/._* -**/.DocumentRevisions-V100 -**/.fseventsd -**/.Spotlight-V100 -**/.TemporaryItems -**/.Trashes -**/.VolumeIcon.icns -**/.com.apple.timemachine.donotpresent -**/.AppleDB -**/.AppleDesktop -**/Network Trash Folder -**/Temporary Items -**/.apdisk +# Virtual environment (created locally by uv) +.venv/ + +# Python bytecode +__pycache__/ +*.pyc +*.pyo + +# Local runtime artifacts +data/ +logs/ +reports/ + +# Test code +tests/ + +# Dev tooling +.vscode/ +.ruff_cache/ +justfile +README.md +local_setup.* + +# Secrets +.env +.env.* + +# Docker and git +Dockerfile +Dockerfile.* +.dockerignore +.git +.gitignore + +# macOS +.DS_Store # Linux system files **/.fuse_hidden* **/.directory **/.Trash-* **/.nfs* - -# VS Code settings -**/.vscode -**/*.code-workspace -**/.history - -# Debugging and local development files -./playground.ipynb -.local_setup.* - -# Locally uploaded user data -./data - -# Local logs -./logs diff --git a/backend/.env.dev.example b/backend/.env.dev.example new file mode 100644 index 00000000..2649aa1c --- /dev/null +++ b/backend/.env.dev.example @@ -0,0 +1,48 @@ +# Development environment variables. +# Copy this file to .env.dev and fill in the values marked with πŸ”€. +# This file is loaded automatically when ENVIRONMENT=dev (the default). + +## Main settings +# Database settings +DATABASE_HOST='localhost' # Overridden by compose.override.yml in Docker +POSTGRES_USER='postgres' # πŸ”€ Username that has access to the database +POSTGRES_PASSWORD='password' # πŸ”€ +POSTGRES_DB='relab_db' # πŸ”€ Name of the database + +## Authentication settings +FASTAPI_USERS_SECRET='secret-key' # πŸ”€ Secret key for authentication token generation. Generate a new one using `uv run python -c "import secrets; print(secrets.token_urlsafe(32))"` +NEWSLETTER_SECRET='secret-key' # Secret key for confirming and unsubscribing newsletter subscribers. Generate a new one using `openssl rand -hex 32` + +# OAuth settings +GOOGLE_OAUTH_CLIENT_ID='google-oauth-client-id' # πŸ”€ Client ID for Google OAuth +GOOGLE_OAUTH_CLIENT_SECRET='google-oauth-client-secret' # πŸ”€ Client secret for Google OAuth +GITHUB_OAUTH_CLIENT_ID='github-oauth-client-id' # πŸ”€ Client ID for GitHub OAuth +GITHUB_OAUTH_CLIENT_SECRET='github-oauth-client-secret' # πŸ”€ Client secret for GitHub OAuth + +# Settings used to configure the email server for sending emails from the app. +EMAIL_HOST='smtp.example.com' # πŸ”€ +EMAIL_USERNAME='your.email@example.com' # πŸ”€ Username for the SMTP server +EMAIL_PASSWORD='your-email-password' # πŸ”€ Password for the SMTP server +EMAIL_FROM='Your Name ' # Optional. Defaults to EMAIL_USERNAME when omitted. +EMAIL_REPLY_TO='your.replyto.alias.@example.com' # Optional. Defaults to EMAIL_USERNAME when omitted. + +# Redis settings for caching (disposable email domains, sessions, etc.) +REDIS_HOST='localhost' # Overridden by compose.override.yml in Docker +REDIS_PASSWORD='' # Redis password (leave empty for local dev) + +# Superuser details +SUPERUSER_EMAIL='your-email@example.com' # πŸ”€ +SUPERUSER_PASSWORD='example_password' # πŸ”€ + +# Network settings (overridden by compose.override.yml in Docker) +BACKEND_API_URL='http://127.0.0.1:8001' +FRONTEND_APP_URL='http://127.0.0.1:8003' +FRONTEND_WEB_URL='http://127.0.0.1:8000' + +# Allow CORS from any LAN IP (dev only β€” blocked in production). +# Default covers localhost, 127.0.0.1, and the 192.168.x.x subnet. +# If your local network uses a different range (e.g. 10.0.x.x), update this regex. +CORS_ORIGIN_REGEX='https?://(localhost|127\.0\.0\.1|192\.168\.\d+\.\d+)(:\d+)?' + +## Plugin settings +RPI_CAM_PLUGIN_SECRET='secret-key' # πŸ”€ Fernet key for encrypting the RPi camera plugin API keys. Generate a new one using `uv run python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"` diff --git a/backend/.env.example b/backend/.env.example deleted file mode 100644 index ab1001c9..00000000 --- a/backend/.env.example +++ /dev/null @@ -1,41 +0,0 @@ -# Note: Environment variables requiring input are marked with πŸ”€ - -## Main settings -DEBUG='True' # Set to 'True' to enable debug mode (which enables echoing of SQL queries) - -# Database settings -DATABASE_HOST='localhost' # In docker contexts, this is overridden to 'postgres' -DATABASE_PORT='5432' # Default port for PostgreSQL -POSTGRES_USER='postgres' # πŸ”€ Username that has access to the database -POSTGRES_PASSWORD='password' # πŸ”€ -POSTGRES_DB='relab_db' # πŸ”€ Name of the database -POSTGRES_TEST_DB='relab_test_db' # πŸ”€ Name of the test database - -## Authentication settings -FASTAPI_USERS_SECRET='secret-key' # πŸ”€ Secret key for authentication token generation. Generate a new one using `python -c "import secrets; print(secrets.token_urlsafe(32))"` -NEWSLETTER_SECRET='secret-key' # Secret key for confirming and unsubscribing newsletter subscribers. Generate a new one using `openssl rand -hex 32` - -# OAuth settings -GOOGLE_OAUTH_CLIENT_ID='google-oauth-client-id' # πŸ”€ Client ID for Google OAuth -GOOGLE_OAUTH_CLIENT_SECRET='google-oauth-client-secret' # πŸ”€ Client secret for Google OAuth -GITHUB_OAUTH_CLIENT_ID='github-oauth-client-id' # πŸ”€ Client ID for GitHub OAuth -GITHUB_OAUTH_CLIENT_SECRET='github-oauth-client-secret' # πŸ”€ Client secret for GitHub OAuth - -# Settings used to configure the email server for sending emails from the app. -EMAIL_HOST='smtp.example.com' # πŸ”€ -EMAIL_USERNAME='your.email@example.com' # πŸ”€ Username for the SMTP server -EMAIL_PASSWORD='your-email-password' # πŸ”€ Password for the SMTP server -EMAIL_FROM='Your Name ' # πŸ”€ Email address from which the emails are sent. Can be different from the SMTP server username. -EMAIL_REPLY_TO='your.replyto.alias.@example.com' # πŸ”€ Email address to which replies are sent. Can be different from the SMTP server username. - -# Superuser details -SUPERUSER_EMAIL='your-email@example.com' # πŸ”€ -SUPERUSER_PASSWORD='example_password' # πŸ”€ - -# Network settings -FRONTEND_WEB_URL='http://127.0.0.1:8000' # URL of the homepage frontend. Used for cookie management and reference to main website. -FRONTEND_APP_URL='http://127.0.0.1:8004' # URL of the application frontend. Used for generating links in emails. -ALLOWED_ORIGINS='["http://127.0.0.1:8000", "http://127.0.0.1:8010/"]' # List of allowed origins for CORS. - -## Plugin settings -RPI_CAM_PLUGIN_SECRET='secret-key' # πŸ”€ Fernet key for encrypting the RPi camera plugin API keys. Generate a new one using `python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"` diff --git a/backend/.env.prod.example b/backend/.env.prod.example new file mode 100644 index 00000000..0765caed --- /dev/null +++ b/backend/.env.prod.example @@ -0,0 +1,40 @@ +# Production environment variables. +# Copy this file to .env.prod and fill in the values marked with πŸ”€. +# This file is loaded automatically when ENVIRONMENT=prod. + +# Database settings +POSTGRES_USER='postgres' # πŸ”€ +POSTGRES_PASSWORD='' # πŸ”€ Use a strong generated password +POSTGRES_DB='relab_db' # πŸ”€ + +## Authentication settings +FASTAPI_USERS_SECRET='' # πŸ”€ Generate: uv run python -c "import secrets; print(secrets.token_urlsafe(32))" +NEWSLETTER_SECRET='' # πŸ”€ Generate: openssl rand -hex 32 + +# OAuth settings +GOOGLE_OAUTH_CLIENT_ID='' # πŸ”€ +GOOGLE_OAUTH_CLIENT_SECRET='' # πŸ”€ +GITHUB_OAUTH_CLIENT_ID='' # πŸ”€ +GITHUB_OAUTH_CLIENT_SECRET='' # πŸ”€ + +# Email settings +EMAIL_HOST='' # πŸ”€ +EMAIL_USERNAME='' # πŸ”€ +EMAIL_PASSWORD='' # πŸ”€ +EMAIL_FROM='' # πŸ”€ +EMAIL_REPLY_TO='' # πŸ”€ + +# Redis settings +REDIS_PASSWORD='' # πŸ”€ + +# Superuser details (only used on first deploy to create the account) +SUPERUSER_EMAIL='' # πŸ”€ +SUPERUSER_PASSWORD='' # πŸ”€ + +# Network settings +BACKEND_API_URL='https://api.cml-relab.org' +FRONTEND_APP_URL='https://app.cml-relab.org' +FRONTEND_WEB_URL='https://cml-relab.org' + +## Plugin settings +RPI_CAM_PLUGIN_SECRET='' # πŸ”€ Generate: uv run python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())" diff --git a/backend/.env.staging.example b/backend/.env.staging.example new file mode 100644 index 00000000..52d37b1e --- /dev/null +++ b/backend/.env.staging.example @@ -0,0 +1,40 @@ +# Staging environment variables. +# Copy this file to .env.staging and fill in the values marked with πŸ”€. +# This file is loaded automatically when ENVIRONMENT=staging. + +# Database settings +POSTGRES_USER='postgres' # πŸ”€ +POSTGRES_PASSWORD='' # πŸ”€ Use a strong generated password +POSTGRES_DB='relab_db' # πŸ”€ + +## Authentication settings +FASTAPI_USERS_SECRET='' # πŸ”€ Generate: uv run python -c "import secrets; print(secrets.token_urlsafe(32))" +NEWSLETTER_SECRET='' # πŸ”€ Generate: openssl rand -hex 32 + +# OAuth settings +GOOGLE_OAUTH_CLIENT_ID='' # πŸ”€ +GOOGLE_OAUTH_CLIENT_SECRET='' # πŸ”€ +GITHUB_OAUTH_CLIENT_ID='' # πŸ”€ +GITHUB_OAUTH_CLIENT_SECRET='' # πŸ”€ + +# Email settings +EMAIL_HOST='' # πŸ”€ +EMAIL_USERNAME='' # πŸ”€ +EMAIL_PASSWORD='' # πŸ”€ +EMAIL_FROM='' # πŸ”€ +EMAIL_REPLY_TO='' # πŸ”€ + +# Redis settings +REDIS_PASSWORD='' # πŸ”€ + +# Superuser details +SUPERUSER_EMAIL='' # πŸ”€ +SUPERUSER_PASSWORD='' # πŸ”€ + +# Network settings +BACKEND_API_URL='https://api-test.cml-relab.org' +FRONTEND_APP_URL='https://app-test.cml-relab.org' +FRONTEND_WEB_URL='https://web-test.cml-relab.org' + +## Plugin settings +RPI_CAM_PLUGIN_SECRET='' # πŸ”€ Generate: uv run python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())" diff --git a/backend/.env.test b/backend/.env.test new file mode 100644 index 00000000..0c3f6d0d --- /dev/null +++ b/backend/.env.test @@ -0,0 +1,41 @@ +# Test-only environment variables β€” safe to commit. +# Used exclusively by compose.e2e.yml and the full-stack E2E test suite. +# Do NOT use these values in any non-test environment. + +# Database +POSTGRES_USER=postgres +POSTGRES_PASSWORD=test_pg_password +POSTGRES_DB=relab_e2e_db + +# Auth +FASTAPI_USERS_SECRET=test-jwt-secret-do-not-use-in-production +NEWSLETTER_SECRET=test-newsletter-secret-do-not-use-in-production + +# OAuth (placeholder values β€” OAuth flows are not tested in E2E) +GOOGLE_OAUTH_CLIENT_ID=dummy-google-client-id +GOOGLE_OAUTH_CLIENT_SECRET=dummy-google-client-secret +GITHUB_OAUTH_CLIENT_ID=dummy-github-client-id +GITHUB_OAUTH_CLIENT_SECRET=dummy-github-client-secret + +# Email (no real SMTP needed β€” emails are not sent in E2E) +EMAIL_HOST=localhost +EMAIL_USERNAME=e2e@example.com +EMAIL_PASSWORD=test-email-password +EMAIL_FROM=E2E Tests +EMAIL_REPLY_TO=e2e@example.com + +# Redis (no password for test simplicity) +REDIS_PASSWORD= + +# Known test superuser β€” used by create_superuser.py and Playwright tests +SUPERUSER_EMAIL=e2e-admin@example.com +SUPERUSER_PASSWORD=E2eTestPass123! + +# Network (overridden by compose.e2e.yml via environment: block) +BACKEND_API_URL=http://localhost:8000 +FRONTEND_APP_URL=http://localhost:8081 +FRONTEND_WEB_URL=http://localhost:8010 + +# RPI cam plugin β€” must be a valid 32-byte URL-safe base64 Fernet key. +# This key is test-only and provides no security guarantee. +RPI_CAM_PLUGIN_SECRET=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= diff --git a/backend/.gitignore b/backend/.gitignore index 38f0b266..15ffdc0a 100644 --- a/backend/.gitignore +++ b/backend/.gitignore @@ -6,57 +6,18 @@ __pycache__/ *.py[cod] *$py.class -# Distribution / packaging -.Python -build/ -develop-eggs/ -dist/ -downloads/ -eggs/ -.eggs/ -lib/ -lib64/ -parts/ -sdist/ -var/ -wheels/ -share/python-wheels/ -*.egg-info/ -.installed.cfg -*.egg -MANIFEST +# Virtual environment (uv) +.venv -# Unit test / coverage reports +# Ruff linter +.ruff_cache/ + +# Test / coverage artifacts htmlcov/ -.tox/ -.nox/ .coverage .coverage.* -.cache -nosetests.xml -coverage.xml -*.cover -*.py,cover -.hypothesis/ .pytest_cache/ -cover/ - -# Jupyter Notebook -.ipynb_checkpoints -*/.ipynb_checkpoints - -# IPython -profile_default/ -ipython_config.py - -# Environments -.env -.venv -env/ -venv/ - -# Ruff linter -.ruff_cache/ +.hypothesis/ ### Manual additions # Debugging @@ -80,3 +41,17 @@ backups/* # VS Code settings !.vscode/settings.json !.vscode/extensions.json + +# Include built email templates +!app/templates/emails/build/ + +# Test coverage reports +reports/coverage/* +!reports/coverage/badge.svg + +# Ignore all .env files except for the example files and .env.test (which is used in CI) +.env +.env.* + +!.env.test +!.env.*.example diff --git a/backend/.vscode/extensions.json b/backend/.vscode/extensions.json index 5ab2c28d..6bba2b46 100644 --- a/backend/.vscode/extensions.json +++ b/backend/.vscode/extensions.json @@ -1,3 +1,3 @@ { - "recommendations": ["charliermarsh.ruff", "ms-python.python", "wholroyd.jinja"] + "recommendations": ["astral-sh.ty", "charliermarsh.ruff", "ms-python.python", "wholroyd.jinja"] } diff --git a/backend/.vscode/settings.json b/backend/.vscode/settings.json index 57b9152a..c24798fa 100644 --- a/backend/.vscode/settings.json +++ b/backend/.vscode/settings.json @@ -9,9 +9,8 @@ "python-envs.terminal.showActivateButton": true, "python.analysis.autoFormatStrings": true, "python.analysis.typeCheckingMode": "standard", - "python.linting.enabled": true, - "python.linting.ruffEnabled": true, "python.terminal.activateEnvInCurrentTerminal": true, "python.terminal.activateEnvironment": true, - "python.testing.pytestEnabled": true + "python.testing.pytestEnabled": true, + "ty.interpreter": ["${workspaceFolder}/.venv/bin/python"] } diff --git a/backend/Dockerfile b/backend/Dockerfile index 74b22a88..e4b7adc8 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -1,12 +1,11 @@ # --- Builder stage --- -FROM ghcr.io/astral-sh/uv:0.9-python3.13-trixie-slim@sha256:87db60325200a4fa5e9259fe43ff14c90c429adee952a8efe3f21b278409d09a AS builder +FROM ghcr.io/astral-sh/uv:0.10-python3.14-trixie-slim AS builder # Install git for custom dependencies (fastapi-users-db-sqlmodel) RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \ --mount=type=cache,target=/var/lib/apt,sharing=locked \ apt-get update && apt-get install -y --no-install-recommends \ - git \ - && apt-get dist-clean + git # Set the working directory inside the container WORKDIR /opt/relab/backend @@ -34,31 +33,28 @@ RUN --mount=type=cache,target=/root/.cache/uv \ uv sync --locked --no-editable --no-default-groups --group=api # --- Final runtime stage --- -FROM python:3.14-slim@sha256:1e7c3510ceb3d6ebb499c86e1c418b95cb4e5e2f682f8e195069f470135f8d51 +FROM python:3.14-slim@sha256:584e89d31009a79ae4d9e3ab2fba078524a6c0921cb2711d05e8bb5f628fc9b9 -# Build arguments ARG WORKDIR=/opt/relab/backend -ARG APP_PORT=8000 ARG APP_USER=appuser -# Set up a non-root user RUN useradd -m $APP_USER -# Copy built app and environment from builder -COPY --from=builder --chown=$APP_USER:$APP_USER $WORKDIR $WORKDIR - WORKDIR $WORKDIR -# Set Python variables +COPY --from=builder --chown=$APP_USER:$APP_USER $WORKDIR $WORKDIR + +# spell-checker: ignore PYTHONUNBUFFERED ENV PYTHONPATH=$WORKDIR \ PYTHONUNBUFFERED=1 \ PATH="$WORKDIR/.venv/bin:$PATH" -# Expose the application port EXPOSE 8000 -# Switch to non-root user USER $APP_USER -# Run the FastAPI application -CMD [".venv/bin/fastapi", "run", "app/main.py", "--host", "0.0.0.0", "--port", "8000"] +HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \ + CMD python -c "import sys,urllib.request; sys.exit(0 if urllib.request.urlopen('http://localhost:8000/live',timeout=5).status==200 else 1)" + +# Run the FastAPI application. Trust forwarded headers only from explicitly allowed proxy IPs. +CMD ["sh", "-c", ".venv/bin/fastapi run app/main.py --host 0.0.0.0 --port 8000 --proxy-headers --forwarded-allow-ips=${FORWARDED_ALLOW_IPS:-127.0.0.1}"] diff --git a/backend/Dockerfile.dev b/backend/Dockerfile.dev index dafcde7d..1de06d09 100644 --- a/backend/Dockerfile.dev +++ b/backend/Dockerfile.dev @@ -1,6 +1,6 @@ # Development Dockerfile for FastAPI Backend -# Note: This requires mounting the source code as a volume in docker-compose.override.yml -FROM ghcr.io/astral-sh/uv:0.9-python3.13-trixie-slim@sha256:87db60325200a4fa5e9259fe43ff14c90c429adee952a8efe3f21b278409d09a +# Note: Source is kept in image; use `docker compose watch` for hot reload +FROM ghcr.io/astral-sh/uv:0.10-python3.14-trixie-slim # Build arguments ARG WORKDIR=/opt/relab/backend @@ -9,8 +9,7 @@ ARG WORKDIR=/opt/relab/backend RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \ --mount=type=cache,target=/var/lib/apt,sharing=locked \ apt-get update && apt-get install -y --no-install-recommends \ - git \ - && apt-get dist-clean + git # Set the working directory inside the container WORKDIR $WORKDIR @@ -27,10 +26,14 @@ ENV UV_COMPILE_BYTECODE=1 \ COPY .python-version pyproject.toml uv.lock ./ # Install dependencies (see https://docs.astral.sh/uv/guides/integration/docker/#intermediate-layers) +# NOTE: This includes dev dependencies RUN --mount=type=cache,target=/root/.cache/uv \ uv sync --locked --no-install-project --no-editable -# Set Python variables +# Copy source files +COPY . . + +# spell-checker: ignore PYTHONUNBUFFERED ENV PYTHONPATH=$WORKDIR \ PYTHONUNBUFFERED=1 \ PATH="$WORKDIR/.venv/bin:$PATH" diff --git a/backend/Dockerfile.migrations b/backend/Dockerfile.migrations index 5f8b17a7..51ddb545 100644 --- a/backend/Dockerfile.migrations +++ b/backend/Dockerfile.migrations @@ -1,14 +1,12 @@ # --- Builder stage --- -FROM ghcr.io/astral-sh/uv:0.9-python3.13-trixie-slim@sha256:87db60325200a4fa5e9259fe43ff14c90c429adee952a8efe3f21b278409d09a AS builder - -WORKDIR /opt/relab/backend_migrations +FROM ghcr.io/astral-sh/uv:0.10-python3.14-trixie-slim AS builder +WORKDIR /opt/relab/backend-migrations # Install git for custom dependencies (fastapi-users-db-sqlmodel) RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \ --mount=type=cache,target=/var/lib/apt,sharing=locked \ apt-get update && apt-get install -y --no-install-recommends \ - git \ - && apt-get dist-clean + git # Create needed directories data uploads for seeding example images and files RUN mkdir -p data/uploads/files data/uploads/images @@ -24,36 +22,30 @@ COPY .python-version pyproject.toml uv.lock ./ # Install dependencies # Ref: https://docs.astral.sh/uv/guides/integration/docker/#intermediate-layers RUN --mount=type=cache,target=/root/.cache/uv \ - uv sync --frozen --no-default-groups --group=migrations --no-install-project + uv sync --locked --no-default-groups --group=migrations --no-install-project # Copy alembic migrations, scripts, and source code -COPY alembic.ini ./ COPY alembic/ alembic/ COPY scripts/ scripts/ COPY app/ app/ # --- Final runtime stage --- -FROM python:3.14-slim@sha256:1e7c3510ceb3d6ebb499c86e1c418b95cb4e5e2f682f8e195069f470135f8d51 +FROM python:3.14-slim@sha256:584e89d31009a79ae4d9e3ab2fba078524a6c0921cb2711d05e8bb5f628fc9b9 -# Build arguments -ARG WORKDIR=/opt/relab/backend_migrations +ARG WORKDIR=/opt/relab/backend-migrations ARG APP_USER=appuser -# Set up a non-root user RUN useradd $APP_USER -# Copy built app and environment from builder -COPY --from=builder --chown=$APP_USER:$APP_USER $WORKDIR $WORKDIR - WORKDIR $WORKDIR -# Set Python variables +COPY --from=builder --chown=$APP_USER:$APP_USER $WORKDIR $WORKDIR + +# spell-checker: ignore PYTHONUNBUFFERED ENV PYTHONPATH=$WORKDIR \ PYTHONUNBUFFERED=1 \ PATH="$WORKDIR/.venv/bin:$PATH" -# Switch to non-root user USER $APP_USER -# Run the entrypoint ENTRYPOINT ["./scripts/seed/migrations_entrypoint.sh"] diff --git a/backend/Dockerfile.user_upload_backups b/backend/Dockerfile.user-upload-backups similarity index 58% rename from backend/Dockerfile.user_upload_backups rename to backend/Dockerfile.user-upload-backups index ddb5c5bb..a27039ab 100644 --- a/backend/Dockerfile.user_upload_backups +++ b/backend/Dockerfile.user-upload-backups @@ -1,4 +1,4 @@ -FROM alpine:latest@sha256:4b7ce07002c69e8f3d704a9c5d6fd3053be500b7f1c69fc0d80990c2ad8dd412 +FROM alpine:3.22@sha256:55ae5d250caebc548793f321534bc6a8ef1d116f334f18f4ada1b2daad3251b2 # Build arguments ARG WORKDIR=/opt/relab/backend_backups @@ -15,12 +15,7 @@ WORKDIR $WORKDIR # Set BACKUP_SCRIPT variable for entrypoint script ENV BACKUP_SCRIPT=$WORKDIR/$BACKUP_SCRIPT_NAME -# Copy backup script -COPY scripts/backup/$BACKUP_SCRIPT_NAME . -RUN chmod +x ./$BACKUP_SCRIPT_NAME - -# Copy entrypoint script -COPY scripts/backup/user_upload_backups_entrypoint.sh . -RUN chmod +x ./user_upload_backups_entrypoint.sh +COPY --chmod=755 scripts/backup/$BACKUP_SCRIPT_NAME . +COPY --chmod=755 scripts/backup/user_upload_backups_entrypoint.sh . ENTRYPOINT ["./user_upload_backups_entrypoint.sh"] diff --git a/backend/README.md b/backend/README.md index a5a44601..b8f7fc46 100644 --- a/backend/README.md +++ b/backend/README.md @@ -1,6 +1,6 @@ -# ReLab Backend +# RELab Backend -The backend of the ReLab project is built using [FastAPI](https://fastapi.tiangolo.com/) and [PostgreSQL](https://www.postgresql.org/), providing a RESTful API and database management for the platform. +The backend of the RELab project is built using [FastAPI](https://fastapi.tiangolo.com/) and [PostgreSQL](https://www.postgresql.org/), providing a RESTful API and database management for the platform. For backend-specific contribution and workflow details, see: diff --git a/backend/alembic.ini b/backend/alembic.ini deleted file mode 100644 index 42395499..00000000 --- a/backend/alembic.ini +++ /dev/null @@ -1,122 +0,0 @@ -# A generic, single database configuration. - -[alembic] -# path to migration scripts -# Use forward slashes (/) also on windows to provide an os agnostic path -script_location = %(here)s/alembic - -# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s -# Uncomment the line below if you want the files to be prepended with date and time -# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file -# for all available tokens -# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s - -# sys.path path, will be prepended to sys.path if present. -# defaults to the current working directory. -prepend_sys_path = . - -# timezone to use when rendering the date within the migration file -# as well as the filename. -# If specified, requires the python>=3.9 or backports.zoneinfo library. -# Any required deps can installed by adding `alembic[tz]` to the pip requirements -# string value is passed to ZoneInfo() -# leave blank for localtime -# timezone = - -# max length of characters to apply to the "slug" field -# truncate_slug_length = 40 - -# set to 'true' to run the environment during -# the 'revision' command, regardless of autogenerate -# revision_environment = false - -# set to 'true' to allow .pyc and .pyo files without -# a source .py file to be detected as revisions in the -# versions/ directory -# sourceless = false - -# version location specification; This defaults -# to alembic/versions. When using multiple version -# directories, initial revisions must be specified with --version-path. -# The path separator used here should be the separator specified by "version_path_separator" below. -# version_locations = %(here)s/bar:%(here)s/bat:alembic/versions - -# version path separator; As mentioned above, this is the character used to split -# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep. -# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas. -# Valid values for version_path_separator are: -# -# version_path_separator = : -# version_path_separator = ; -# version_path_separator = space -version_path_separator = os # Use os.pathsep. Default configuration used for new projects. - -# set to 'true' to search source files recursively -# in each "version_locations" directory -# new in Alembic version 1.10 -# recursive_version_locations = false - -# the output encoding used when revision files -# are written from script.py.mako -# output_encoding = utf-8 - -sqlalchemy.url = %(sqlalchemy.url)s - - -[post_write_hooks] -# post_write_hooks defines scripts or Python functions that are run -# on newly generated revision scripts. See the documentation for further -# detail and examples - -# format using "black" - use the console_scripts runner, against the "black" entrypoint -# hooks = black -# black.type = console_scripts -# black.entrypoint = black -# black.options = -l 79 REVISION_SCRIPT_FILENAME - -hooks = ruff, ruff_format - -# Lint with attempts to fix using "ruff" -ruff.type = exec -ruff.executable = %(here)s/.venv/bin/ruff -ruff.options = check --fix REVISION_SCRIPT_FILENAME - -# Format using "ruff" - use the exec runner, execute a binary -ruff_format.type = exec -ruff_format.executable = %(here)s/.venv/bin/ruff -ruff_format.options = format REVISION_SCRIPT_FILENAME - -# Logging configuration -[loggers] -keys = root,sqlalchemy,alembic - -[handlers] -keys = console - -[formatters] -keys = generic - -[logger_root] -level = WARN -handlers = console -qualname = - -[logger_sqlalchemy] -level = WARN -handlers = -qualname = sqlalchemy.engine - -[logger_alembic] -level = INFO -handlers = -qualname = alembic - -[handler_console] -class = StreamHandler -args = (sys.stderr,) -level = NOTSET -formatter = generic - -[formatter_generic] -format = %(levelname)-5.5s [%(name)s] %(message)s -datefmt = %H:%M:%S diff --git a/backend/alembic/env.py b/backend/alembic/env.py index 397a112d..150df98c 100644 --- a/backend/alembic/env.py +++ b/backend/alembic/env.py @@ -1,55 +1,46 @@ -# noqa: D100, INP001 (the alembic folder should not be recognized as a module) +# noqa: D100 (the alembic folder should not be recognized as a module) +import logging import sys -from logging.config import fileConfig from pathlib import Path -import alembic_postgresql_enum # noqa: F401 (Make sure the PostgreSQL ENUM type is recognized) +import alembic_postgresql_enum from alembic import context from sqlalchemy import engine_from_config, pool +from sqlalchemy.engine.url import make_url from sqlmodel import SQLModel # Include the SQLModel metadata +from app.core.config import settings +from app.core.logging import setup_logging +from app.core.model_registry import load_sqlmodel_models + # Load settings from the FastAPI app config project_root = Path(__file__).resolve().parents[1] sys.path.append(str(project_root)) -from app.core.config import settings # noqa: E402, I001 # Allow the settings to be imported after the project root is added to the path - # this is the Alembic Config object, which provides # access to the values within the .ini file in use. config = context.config -# Interpret the config file for Python logging. -# This line sets up loggers basically. -if config.config_file_name is not None: - fileConfig(config.config_file_name) - -# Set the database URL dynamically from the loaded settings -config.set_main_option("sqlalchemy.url", settings.sync_database_url) - -# Import your models to include their metadata -from app.api.auth.models import OAuthAccount, Organization, User # noqa: E402, F401 -from app.api.background_data.models import ( # noqa: E402, F401 - Category, - CategoryMaterialLink, - CategoryProductTypeLink, - Material, - ProductType, - Taxonomy, -) -from app.api.data_collection.models import ( # noqa: E402, F401 - PhysicalProperties, - Product, -) -from app.api.file_storage.models.models import File, Image, Video # noqa: E402, F401 -from app.api.newsletter.models import NewsletterSubscriber # noqa: E402, F401 -from app.api.plugins.rpi_cam.models import Camera # noqa: E402, F401 +# Set the synchronous database URL if not already set in the test environment +if config.get_alembic_option("is_test") != "true": # noqa: PLR2004 # This variable is set in tests/conftest.py to indicate a test environment + setup_logging() + config.set_main_option("sqlalchemy.url", settings.sync_database_url) +else: + # In tests, logging is already configured in conftest.py. + # We just need to ensure the alembic.env logger exists. + pass + +logger = logging.getLogger("alembic.env") + +# Import all models so SQLModel.metadata is complete for autogenerate +load_sqlmodel_models() # Combine metadata from all imported models target_metadata = SQLModel.metadata # other values from the config, defined by the needs of env.py, # can be acquired: -# my_important_option = config.get_main_option("my_important_option") +# my_important_option = config.get_main_option("my_important_option") # noqa: ERA001 # ... etc. @@ -65,7 +56,10 @@ def run_migrations_offline() -> None: script output. """ - url = config.get_main_option("sqlalchemy.url") + url = config.get_main_option("sqlalchemy.url", "") + + logger.info("Running migrations offline on database: %s", make_url(url).render_as_string(hide_password=True)) + context.configure( url=url, target_metadata=target_metadata, @@ -84,11 +78,12 @@ def run_migrations_online() -> None: and associate a connection with the context. """ - connectable = engine_from_config( - config.get_section(config.config_ini_section, {}), - prefix="sqlalchemy.", - poolclass=pool.NullPool, - ) + url = config.get_main_option("sqlalchemy.url", "") + engine_config = config.get_section(config.config_ini_section, {"sqlalchemy.url": url}) + + connectable = engine_from_config(engine_config, prefix="sqlalchemy.", poolclass=pool.NullPool) + + logger.info("Running migrations online on database: %s", make_url(url).render_as_string(hide_password=True)) with connectable.connect() as connection: context.configure(connection=connection, target_metadata=target_metadata) diff --git a/backend/alembic/versions/07d992454431_add_fks.py b/backend/alembic/versions/07d992454431_add_fks.py index b5bdf76a..382b70f9 100644 --- a/backend/alembic/versions/07d992454431_add_fks.py +++ b/backend/alembic/versions/07d992454431_add_fks.py @@ -11,9 +11,9 @@ import sqlalchemy as sa import sqlmodel +from alembic import op import app.api.common.models.custom_types -from alembic import op # revision identifiers, used by Alembic. revision: str = "07d992454431" diff --git a/backend/alembic/versions/0faa2fa19f62_move_from_weight_kg_to_weight_g.py b/backend/alembic/versions/0faa2fa19f62_move_from_weight_kg_to_weight_g.py new file mode 100644 index 00000000..9fa2a2a2 --- /dev/null +++ b/backend/alembic/versions/0faa2fa19f62_move_from_weight_kg_to_weight_g.py @@ -0,0 +1,55 @@ +"""Move from weight_kg to weight_g + +Revision ID: 0faa2fa19f62 +Revises: b43d157d07f1 +Create Date: 2025-11-17 14:52:08.201228 + +""" + +from collections.abc import Sequence +from typing import Union + +import sqlalchemy as sa +import sqlmodel +from alembic import op + +import app.api.common.models.custom_types + +# revision identifiers, used by Alembic. +revision: str = "0faa2fa19f62" +down_revision: str | None = "b43d157d07f1" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.add_column("physicalproperties", sa.Column("weight_g", sa.Float(), nullable=True)) + + # Migrate data: convert kg to g (multiply by 1000) + op.execute(""" + UPDATE physicalproperties + SET weight_g = weight_kg * 1000 + WHERE weight_kg IS NOT NULL + """) + + op.drop_column("physicalproperties", "weight_kg") + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.add_column( + "physicalproperties", + sa.Column("weight_kg", sa.DOUBLE_PRECISION(precision=53), autoincrement=False, nullable=True), + ) + + # Migrate data back: convert g to kg (divide by 1000) + op.execute(""" + UPDATE physicalproperties + SET weight_kg = weight_g / 1000 + WHERE weight_g IS NOT NULL + """) + + op.drop_column("physicalproperties", "weight_g") + # ### end Alembic commands ### diff --git a/backend/alembic/versions/33b00b31e537_initial.py b/backend/alembic/versions/33b00b31e537_initial.py index e17d28ce..a5e2abc9 100644 --- a/backend/alembic/versions/33b00b31e537_initial.py +++ b/backend/alembic/versions/33b00b31e537_initial.py @@ -5,16 +5,18 @@ Create Date: 2025-06-29 18:10:44.514384 """ +# spell-checker: ignore astext from collections.abc import Sequence from typing import Union import sqlalchemy as sa import sqlmodel +from alembic import op from sqlalchemy.dialects import postgresql import app.api.common.models.custom_types -from alembic import op +import app.api.file_storage.models.storage as file_storage_storage # revision identifiers, used by Alembic. revision: str = "33b00b31e537" @@ -34,9 +36,9 @@ def upgrade() -> None: "material", sa.Column("created_at", sa.TIMESTAMP(timezone=True), server_default=sa.text("now()"), nullable=True), sa.Column("updated_at", sa.TIMESTAMP(timezone=True), server_default=sa.text("now()"), nullable=True), - sa.Column("name", sqlmodel.sql.sqltypes.AutoString(length=50), nullable=False), - sa.Column("description", sqlmodel.sql.sqltypes.AutoString(length=500), nullable=True), - sa.Column("source", sqlmodel.sql.sqltypes.AutoString(length=50), nullable=True), + sa.Column("name", sqlmodel.AutoString(length=50), nullable=False), + sa.Column("description", sqlmodel.AutoString(length=500), nullable=True), + sa.Column("source", sqlmodel.AutoString(length=50), nullable=True), sa.Column("density_kg_m3", sa.Float(), nullable=True), sa.Column("is_crm", sa.Boolean(), nullable=True), sa.Column("id", sa.Integer(), nullable=False), @@ -47,7 +49,7 @@ def upgrade() -> None: "newslettersubscriber", sa.Column("created_at", sa.TIMESTAMP(timezone=True), server_default=sa.text("now()"), nullable=True), sa.Column("updated_at", sa.TIMESTAMP(timezone=True), server_default=sa.text("now()"), nullable=True), - sa.Column("email", sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column("email", sqlmodel.AutoString(), nullable=False), sa.Column("id", sa.Uuid(), nullable=False), sa.Column("is_confirmed", sa.Boolean(), nullable=False), sa.PrimaryKeyConstraint("id"), @@ -57,9 +59,9 @@ def upgrade() -> None: "organization", sa.Column("created_at", sa.TIMESTAMP(timezone=True), server_default=sa.text("now()"), nullable=True), sa.Column("updated_at", sa.TIMESTAMP(timezone=True), server_default=sa.text("now()"), nullable=True), - sa.Column("name", sqlmodel.sql.sqltypes.AutoString(length=50), nullable=False), - sa.Column("location", sqlmodel.sql.sqltypes.AutoString(length=50), nullable=True), - sa.Column("description", sqlmodel.sql.sqltypes.AutoString(length=500), nullable=True), + sa.Column("name", sqlmodel.AutoString(length=50), nullable=False), + sa.Column("location", sqlmodel.AutoString(length=50), nullable=True), + sa.Column("description", sqlmodel.AutoString(length=500), nullable=True), sa.Column("id", sa.Uuid(), nullable=False), sa.Column("owner_id", sa.Uuid(), nullable=False), sa.ForeignKeyConstraint(["owner_id"], ["user.id"], name="fk_organization_owner", use_alter=True), @@ -70,8 +72,8 @@ def upgrade() -> None: "producttype", sa.Column("created_at", sa.TIMESTAMP(timezone=True), server_default=sa.text("now()"), nullable=True), sa.Column("updated_at", sa.TIMESTAMP(timezone=True), server_default=sa.text("now()"), nullable=True), - sa.Column("name", sqlmodel.sql.sqltypes.AutoString(length=50), nullable=False), - sa.Column("description", sqlmodel.sql.sqltypes.AutoString(length=500), nullable=True), + sa.Column("name", sqlmodel.AutoString(length=50), nullable=False), + sa.Column("description", sqlmodel.AutoString(length=500), nullable=True), sa.Column("id", sa.Integer(), nullable=False), sa.PrimaryKeyConstraint("id"), ) @@ -80,8 +82,8 @@ def upgrade() -> None: "taxonomy", sa.Column("created_at", sa.TIMESTAMP(timezone=True), server_default=sa.text("now()"), nullable=True), sa.Column("updated_at", sa.TIMESTAMP(timezone=True), server_default=sa.text("now()"), nullable=True), - sa.Column("name", sqlmodel.sql.sqltypes.AutoString(length=50), nullable=False), - sa.Column("description", sqlmodel.sql.sqltypes.AutoString(length=500), nullable=True), + sa.Column("name", sqlmodel.AutoString(length=50), nullable=False), + sa.Column("description", sqlmodel.AutoString(length=500), nullable=True), sa.Column( "domains", postgresql.ARRAY( @@ -89,7 +91,7 @@ def upgrade() -> None: ), nullable=True, ), - sa.Column("source", sqlmodel.sql.sqltypes.AutoString(length=50), nullable=True), + sa.Column("source", sqlmodel.AutoString(length=50), nullable=True), sa.Column("id", sa.Integer(), nullable=False), sa.PrimaryKeyConstraint("id"), ) @@ -97,14 +99,14 @@ def upgrade() -> None: op.create_table( "user", sa.Column("id", sa.Uuid(), nullable=False), - sa.Column("email", sqlmodel.sql.sqltypes.AutoString(), nullable=False), - sa.Column("hashed_password", sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column("email", sqlmodel.AutoString(), nullable=False), + sa.Column("hashed_password", sqlmodel.AutoString(), nullable=False), sa.Column("is_active", sa.Boolean(), nullable=False), sa.Column("is_superuser", sa.Boolean(), nullable=False), sa.Column("is_verified", sa.Boolean(), nullable=False), sa.Column("created_at", sa.TIMESTAMP(timezone=True), server_default=sa.text("now()"), nullable=True), sa.Column("updated_at", sa.TIMESTAMP(timezone=True), server_default=sa.text("now()"), nullable=True), - sa.Column("username", sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column("username", sqlmodel.AutoString(), nullable=True), sa.Column("organization_id", sa.Uuid(), nullable=True), sa.Column( "organization_role", @@ -120,12 +122,12 @@ def upgrade() -> None: "camera", sa.Column("created_at", sa.TIMESTAMP(timezone=True), server_default=sa.text("now()"), nullable=True), sa.Column("updated_at", sa.TIMESTAMP(timezone=True), server_default=sa.text("now()"), nullable=True), - sa.Column("name", sqlmodel.sql.sqltypes.AutoString(length=50), nullable=False), - sa.Column("description", sqlmodel.sql.sqltypes.AutoString(length=500), nullable=True), - sa.Column("url", sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column("name", sqlmodel.AutoString(length=50), nullable=False), + sa.Column("description", sqlmodel.AutoString(length=500), nullable=True), + sa.Column("url", sqlmodel.AutoString(), nullable=False), sa.Column("id", sa.Uuid(), nullable=False), - sa.Column("encrypted_api_key", sqlmodel.sql.sqltypes.AutoString(), nullable=False), - sa.Column("encrypted_auth_headers", sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column("encrypted_api_key", sqlmodel.AutoString(), nullable=False), + sa.Column("encrypted_auth_headers", sqlmodel.AutoString(), nullable=True), sa.Column("owner_id", sa.Uuid(), nullable=False), sa.ForeignKeyConstraint( ["owner_id"], @@ -138,9 +140,9 @@ def upgrade() -> None: "category", sa.Column("created_at", sa.TIMESTAMP(timezone=True), server_default=sa.text("now()"), nullable=True), sa.Column("updated_at", sa.TIMESTAMP(timezone=True), server_default=sa.text("now()"), nullable=True), - sa.Column("name", sqlmodel.sql.sqltypes.AutoString(length=250), nullable=False), - sa.Column("description", sqlmodel.sql.sqltypes.AutoString(length=500), nullable=True), - sa.Column("external_id", sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column("name", sqlmodel.AutoString(length=250), nullable=False), + sa.Column("description", sqlmodel.AutoString(length=500), nullable=True), + sa.Column("external_id", sqlmodel.AutoString(), nullable=True), sa.Column("id", sa.Integer(), nullable=False), sa.Column("supercategory_id", sa.Integer(), nullable=True), sa.Column("taxonomy_id", sa.Integer(), nullable=False), @@ -161,12 +163,12 @@ def upgrade() -> None: sa.Column("updated_at", sa.TIMESTAMP(timezone=True), server_default=sa.text("now()"), nullable=True), sa.Column("id", sa.Uuid(), nullable=False), sa.Column("user_id", sa.Uuid(), nullable=False), - sa.Column("oauth_name", sqlmodel.sql.sqltypes.AutoString(), nullable=False), - sa.Column("access_token", sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column("oauth_name", sqlmodel.AutoString(), nullable=False), + sa.Column("access_token", sqlmodel.AutoString(), nullable=False), sa.Column("expires_at", sa.Integer(), nullable=True), - sa.Column("refresh_token", sqlmodel.sql.sqltypes.AutoString(), nullable=True), - sa.Column("account_id", sqlmodel.sql.sqltypes.AutoString(), nullable=False), - sa.Column("account_email", sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column("refresh_token", sqlmodel.AutoString(), nullable=True), + sa.Column("account_id", sqlmodel.AutoString(), nullable=False), + sa.Column("account_email", sqlmodel.AutoString(), nullable=False), sa.ForeignKeyConstraint( ["user_id"], ["user.id"], @@ -179,11 +181,11 @@ def upgrade() -> None: "product", sa.Column("created_at", sa.TIMESTAMP(timezone=True), server_default=sa.text("now()"), nullable=True), sa.Column("updated_at", sa.TIMESTAMP(timezone=True), server_default=sa.text("now()"), nullable=True), - sa.Column("name", sqlmodel.sql.sqltypes.AutoString(length=50), nullable=False), - sa.Column("description", sqlmodel.sql.sqltypes.AutoString(length=500), nullable=True), - sa.Column("brand", sqlmodel.sql.sqltypes.AutoString(length=100), nullable=True), - sa.Column("model", sqlmodel.sql.sqltypes.AutoString(length=100), nullable=True), - sa.Column("dismantling_notes", sqlmodel.sql.sqltypes.AutoString(length=500), nullable=True), + sa.Column("name", sqlmodel.AutoString(length=50), nullable=False), + sa.Column("description", sqlmodel.AutoString(length=500), nullable=True), + sa.Column("brand", sqlmodel.AutoString(length=100), nullable=True), + sa.Column("model", sqlmodel.AutoString(length=100), nullable=True), + sa.Column("dismantling_notes", sqlmodel.AutoString(length=500), nullable=True), sa.Column("dismantling_time_start", sa.TIMESTAMP(timezone=True), nullable=False), sa.Column("dismantling_time_end", sa.TIMESTAMP(timezone=True), nullable=True), sa.Column("id", sa.Integer(), nullable=False), @@ -238,10 +240,10 @@ def upgrade() -> None: "file", sa.Column("created_at", sa.TIMESTAMP(timezone=True), server_default=sa.text("now()"), nullable=True), sa.Column("updated_at", sa.TIMESTAMP(timezone=True), server_default=sa.text("now()"), nullable=True), - sa.Column("description", sqlmodel.sql.sqltypes.AutoString(length=500), nullable=True), + sa.Column("description", sqlmodel.AutoString(length=500), nullable=True), sa.Column("id", sa.Uuid(), nullable=False), - sa.Column("filename", sqlmodel.sql.sqltypes.AutoString(), nullable=False), - sa.Column("file", app.api.file_storage.models.custom_types.FileType(), nullable=False), + sa.Column("filename", sqlmodel.AutoString(), nullable=False), + sa.Column("file", file_storage_storage.FileType(), nullable=False), sa.Column( "parent_type", postgresql.ENUM("PRODUCT", "PRODUCT_TYPE", "MATERIAL", name="fileparenttype", create_type=False), @@ -268,11 +270,11 @@ def upgrade() -> None: "image", sa.Column("created_at", sa.TIMESTAMP(timezone=True), server_default=sa.text("now()"), nullable=True), sa.Column("updated_at", sa.TIMESTAMP(timezone=True), server_default=sa.text("now()"), nullable=True), - sa.Column("description", sqlmodel.sql.sqltypes.AutoString(length=500), nullable=True), + sa.Column("description", sqlmodel.AutoString(length=500), nullable=True), sa.Column("image_metadata", postgresql.JSONB(astext_type=sa.Text()), nullable=True), sa.Column("id", sa.Uuid(), nullable=False), - sa.Column("filename", sqlmodel.sql.sqltypes.AutoString(), nullable=False), - sa.Column("file", app.api.file_storage.models.custom_types.ImageType(), nullable=False), + sa.Column("filename", sqlmodel.AutoString(), nullable=False), + sa.Column("file", file_storage_storage.ImageType(), nullable=False), sa.Column( "parent_type", postgresql.ENUM("PRODUCT", "PRODUCT_TYPE", "MATERIAL", name="imageparenttype", create_type=False), @@ -337,9 +339,9 @@ def upgrade() -> None: "video", sa.Column("created_at", sa.TIMESTAMP(timezone=True), server_default=sa.text("now()"), nullable=True), sa.Column("updated_at", sa.TIMESTAMP(timezone=True), server_default=sa.text("now()"), nullable=True), - sa.Column("url", sqlmodel.sql.sqltypes.AutoString(), nullable=False), - sa.Column("title", sqlmodel.sql.sqltypes.AutoString(length=100), nullable=True), - sa.Column("description", sqlmodel.sql.sqltypes.AutoString(length=500), nullable=True), + sa.Column("url", sqlmodel.AutoString(), nullable=False), + sa.Column("title", sqlmodel.AutoString(length=100), nullable=True), + sa.Column("description", sqlmodel.AutoString(length=500), nullable=True), sa.Column("video_metadata", postgresql.JSONB(astext_type=sa.Text()), nullable=True), sa.Column("id", sa.Integer(), nullable=False), sa.Column("product_id", sa.Integer(), nullable=False), diff --git a/backend/alembic/versions/4c248b3004c6_add_last_login_tracking_fields_to_user_.py b/backend/alembic/versions/4c248b3004c6_add_last_login_tracking_fields_to_user_.py new file mode 100644 index 00000000..c0f658da --- /dev/null +++ b/backend/alembic/versions/4c248b3004c6_add_last_login_tracking_fields_to_user_.py @@ -0,0 +1,33 @@ +"""Add last_login tracking fields to user model + +Revision ID: 4c248b3004c6 +Revises: 84d2f72dccc7 +Create Date: 2026-02-17 16:41:13.956150 + +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +import sqlmodel +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "4c248b3004c6" +down_revision: str | None = "84d2f72dccc7" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.add_column("user", sa.Column("last_login_at", sa.TIMESTAMP(timezone=True), nullable=True)) + op.add_column("user", sa.Column("last_login_ip", sqlmodel.AutoString(length=45), nullable=True)) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column("user", "last_login_ip") + op.drop_column("user", "last_login_at") + # ### end Alembic commands ### diff --git a/backend/alembic/versions/65da9d7e309c_increase_string_fields_max_length.py b/backend/alembic/versions/65da9d7e309c_increase_string_fields_max_length.py index a5cb94a2..76099ac6 100644 --- a/backend/alembic/versions/65da9d7e309c_increase_string_fields_max_length.py +++ b/backend/alembic/versions/65da9d7e309c_increase_string_fields_max_length.py @@ -11,9 +11,9 @@ import sqlalchemy as sa import sqlmodel +from alembic import op import app.api.common.models.custom_types -from alembic import op # revision identifiers, used by Alembic. revision: str = "65da9d7e309c" @@ -28,63 +28,63 @@ def upgrade() -> None: "camera", "name", existing_type=sa.VARCHAR(length=50), - type_=sqlmodel.sql.sqltypes.AutoString(length=100), + type_=sqlmodel.AutoString(length=100), existing_nullable=False, ) op.alter_column( "material", "name", existing_type=sa.VARCHAR(length=50), - type_=sqlmodel.sql.sqltypes.AutoString(length=100), + type_=sqlmodel.AutoString(length=100), existing_nullable=False, ) op.alter_column( "material", "source", existing_type=sa.VARCHAR(length=50), - type_=sqlmodel.sql.sqltypes.AutoString(length=100), + type_=sqlmodel.AutoString(length=100), existing_nullable=True, ) op.alter_column( "organization", "name", existing_type=sa.VARCHAR(length=50), - type_=sqlmodel.sql.sqltypes.AutoString(length=100), + type_=sqlmodel.AutoString(length=100), existing_nullable=False, ) op.alter_column( "organization", "location", existing_type=sa.VARCHAR(length=50), - type_=sqlmodel.sql.sqltypes.AutoString(length=100), + type_=sqlmodel.AutoString(length=100), existing_nullable=True, ) op.alter_column( "product", "name", existing_type=sa.VARCHAR(length=50), - type_=sqlmodel.sql.sqltypes.AutoString(length=100), + type_=sqlmodel.AutoString(length=100), existing_nullable=False, ) op.alter_column( "producttype", "name", existing_type=sa.VARCHAR(length=50), - type_=sqlmodel.sql.sqltypes.AutoString(length=100), + type_=sqlmodel.AutoString(length=100), existing_nullable=False, ) op.alter_column( "taxonomy", "name", existing_type=sa.VARCHAR(length=50), - type_=sqlmodel.sql.sqltypes.AutoString(length=100), + type_=sqlmodel.AutoString(length=100), existing_nullable=False, ) op.alter_column( "taxonomy", "source", existing_type=sa.VARCHAR(length=50), - type_=sqlmodel.sql.sqltypes.AutoString(length=500), + type_=sqlmodel.AutoString(length=500), existing_nullable=True, ) # ### end Alembic commands ### @@ -95,63 +95,63 @@ def downgrade() -> None: op.alter_column( "taxonomy", "source", - existing_type=sqlmodel.sql.sqltypes.AutoString(length=500), + existing_type=sqlmodel.AutoString(length=500), type_=sa.VARCHAR(length=50), existing_nullable=True, ) op.alter_column( "taxonomy", "name", - existing_type=sqlmodel.sql.sqltypes.AutoString(length=100), + existing_type=sqlmodel.AutoString(length=100), type_=sa.VARCHAR(length=50), existing_nullable=False, ) op.alter_column( "producttype", "name", - existing_type=sqlmodel.sql.sqltypes.AutoString(length=100), + existing_type=sqlmodel.AutoString(length=100), type_=sa.VARCHAR(length=50), existing_nullable=False, ) op.alter_column( "product", "name", - existing_type=sqlmodel.sql.sqltypes.AutoString(length=100), + existing_type=sqlmodel.AutoString(length=100), type_=sa.VARCHAR(length=50), existing_nullable=False, ) op.alter_column( "organization", "location", - existing_type=sqlmodel.sql.sqltypes.AutoString(length=100), + existing_type=sqlmodel.AutoString(length=100), type_=sa.VARCHAR(length=50), existing_nullable=True, ) op.alter_column( "organization", "name", - existing_type=sqlmodel.sql.sqltypes.AutoString(length=100), + existing_type=sqlmodel.AutoString(length=100), type_=sa.VARCHAR(length=50), existing_nullable=False, ) op.alter_column( "material", "source", - existing_type=sqlmodel.sql.sqltypes.AutoString(length=100), + existing_type=sqlmodel.AutoString(length=100), type_=sa.VARCHAR(length=50), existing_nullable=True, ) op.alter_column( "material", "name", - existing_type=sqlmodel.sql.sqltypes.AutoString(length=100), + existing_type=sqlmodel.AutoString(length=100), type_=sa.VARCHAR(length=50), existing_nullable=False, ) op.alter_column( "camera", "name", - existing_type=sqlmodel.sql.sqltypes.AutoString(length=100), + existing_type=sqlmodel.AutoString(length=100), type_=sa.VARCHAR(length=50), existing_nullable=False, ) diff --git a/backend/alembic/versions/84d2f72dccc7_simplify_circularity_properties_model.py b/backend/alembic/versions/84d2f72dccc7_simplify_circularity_properties_model.py new file mode 100644 index 00000000..eea46ce2 --- /dev/null +++ b/backend/alembic/versions/84d2f72dccc7_simplify_circularity_properties_model.py @@ -0,0 +1,50 @@ +"""Simplify Circularity_properties model + +Revision ID: 84d2f72dccc7 +Revises: 0faa2fa19f62 +Create Date: 2025-11-27 12:01:32.413795 + +""" + +from collections.abc import Sequence +from typing import Union + +import sqlalchemy as sa +import sqlmodel +from alembic import op + +import app.api.common.models.custom_types + +# revision identifiers, used by Alembic. +revision: str = "84d2f72dccc7" +down_revision: str | None = "0faa2fa19f62" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column( + "circularityproperties", "recyclability_observation", existing_type=sa.VARCHAR(length=500), nullable=True + ) + op.alter_column( + "circularityproperties", "repairability_observation", existing_type=sa.VARCHAR(length=500), nullable=True + ) + op.alter_column( + "circularityproperties", "remanufacturability_observation", existing_type=sa.VARCHAR(length=500), nullable=True + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column( + "circularityproperties", "remanufacturability_observation", existing_type=sa.VARCHAR(length=500), nullable=False + ) + op.alter_column( + "circularityproperties", "repairability_observation", existing_type=sa.VARCHAR(length=500), nullable=False + ) + op.alter_column( + "circularityproperties", "recyclability_observation", existing_type=sa.VARCHAR(length=500), nullable=False + ) + # ### end Alembic commands ### diff --git a/backend/alembic/versions/95cc94317b69_add_version_to_taxonomy_model.py b/backend/alembic/versions/95cc94317b69_add_version_to_taxonomy_model.py index 56d25512..2a4b697c 100644 --- a/backend/alembic/versions/95cc94317b69_add_version_to_taxonomy_model.py +++ b/backend/alembic/versions/95cc94317b69_add_version_to_taxonomy_model.py @@ -11,9 +11,9 @@ import sqlalchemy as sa import sqlmodel +from alembic import op import app.api.common.models.custom_types -from alembic import op # revision identifiers, used by Alembic. revision: str = "95cc94317b69" @@ -24,7 +24,7 @@ def upgrade() -> None: # ### commands auto generated by Alembic - please adjust! ### - op.add_column("taxonomy", sa.Column("version", sqlmodel.sql.sqltypes.AutoString(length=50), nullable=True)) + op.add_column("taxonomy", sa.Column("version", sqlmodel.AutoString(length=50), nullable=True)) # ### end Alembic commands ### diff --git a/backend/alembic/versions/b43d157d07f1_add_basic_circularity_properties_model.py b/backend/alembic/versions/b43d157d07f1_add_basic_circularity_properties_model.py new file mode 100644 index 00000000..ca9000ff --- /dev/null +++ b/backend/alembic/versions/b43d157d07f1_add_basic_circularity_properties_model.py @@ -0,0 +1,54 @@ +"""Add basic circularity_properties model + +Revision ID: b43d157d07f1 +Revises: 95cc94317b69 +Create Date: 2025-11-17 13:30:07.435637 + +""" + +from collections.abc import Sequence +from typing import Union + +import sqlalchemy as sa +import sqlmodel +from alembic import op + +import app.api.common.models.custom_types + +# revision identifiers, used by Alembic. +revision: str = "b43d157d07f1" +down_revision: str | None = "95cc94317b69" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "circularityproperties", + sa.Column("created_at", sa.TIMESTAMP(timezone=True), server_default=sa.text("now()"), nullable=True), + sa.Column("updated_at", sa.TIMESTAMP(timezone=True), server_default=sa.text("now()"), nullable=True), + sa.Column("recyclability_observation", sqlmodel.AutoString(length=500), nullable=False), + sa.Column("recyclability_comment", sqlmodel.AutoString(length=100), nullable=True), + sa.Column("recyclability_reference", sqlmodel.AutoString(length=100), nullable=True), + sa.Column("repairability_observation", sqlmodel.AutoString(length=500), nullable=False), + sa.Column("repairability_comment", sqlmodel.AutoString(length=100), nullable=True), + sa.Column("repairability_reference", sqlmodel.AutoString(length=100), nullable=True), + sa.Column("remanufacturability_observation", sqlmodel.AutoString(length=500), nullable=False), + sa.Column("remanufacturability_comment", sqlmodel.AutoString(length=100), nullable=True), + sa.Column("remanufacturability_reference", sqlmodel.AutoString(length=100), nullable=True), + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("product_id", sa.Integer(), nullable=False), + sa.ForeignKeyConstraint( + ["product_id"], + ["product.id"], + ), + sa.PrimaryKeyConstraint("id"), + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table("circularityproperties") + # ### end Alembic commands ### diff --git a/backend/alembic/versions/da288fbcf15e_add_oauth_account_uniqueness_constraint.py b/backend/alembic/versions/da288fbcf15e_add_oauth_account_uniqueness_constraint.py new file mode 100644 index 00000000..18ad46e8 --- /dev/null +++ b/backend/alembic/versions/da288fbcf15e_add_oauth_account_uniqueness_constraint.py @@ -0,0 +1,29 @@ +"""add_oauth_account_uniqueness_constraint + +Revision ID: da288fbcf15e +Revises: 4c248b3004c6 +Create Date: 2026-03-17 14:22:56.702224 + +""" + +from collections.abc import Sequence + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "da288fbcf15e" +down_revision: str | None = "4c248b3004c6" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_unique_constraint("uq_oauth_account_identity", "oauthaccount", ["oauth_name", "account_id"]) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_constraint("uq_oauth_account_identity", "oauthaccount", type_="unique") + # ### end Alembic commands ### diff --git a/backend/alembic/versions/f3a8c2d1e5b7_add_product_full_text_and_trigram_search.py b/backend/alembic/versions/f3a8c2d1e5b7_add_product_full_text_and_trigram_search.py new file mode 100644 index 00000000..6ae10d05 --- /dev/null +++ b/backend/alembic/versions/f3a8c2d1e5b7_add_product_full_text_and_trigram_search.py @@ -0,0 +1,54 @@ +"""Add full-text search (tsvector) and trigram fuzzy search indexes to product table + +Revision ID: f3a8c2d1e5b7 +Revises: da288fbcf15e +Create Date: 2026-03-22 00:00:00.000000 + +""" +# spell-checker: ignore trgm + +from collections.abc import Sequence + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "f3a8c2d1e5b7" +down_revision: str | None = "da288fbcf15e" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + # Enable pg_trgm extension for trigram fuzzy matching + op.execute("CREATE EXTENSION IF NOT EXISTS pg_trgm") + + # Add a stored generated tsvector column covering name, description, brand, model. + # GENERATED ALWAYS AS STORED means Postgres maintains this automatically on insert/update. + op.execute(""" + ALTER TABLE product + ADD COLUMN search_vector tsvector + GENERATED ALWAYS AS ( + to_tsvector('english', + coalesce(name, '') || ' ' || + coalesce(description, '') || ' ' || + coalesce(brand, '') || ' ' || + coalesce(model, '') + ) + ) STORED + """) + + # GIN index for full-text search (tsvector @@ tsquery) + op.execute("CREATE INDEX product_search_vector_idx ON product USING GIN (search_vector)") + + # GIN trigram indexes for fuzzy matching on name and brand + # (these are the fields users are most likely to mis-spell) + op.execute("CREATE INDEX product_name_trgm_idx ON product USING GIN (name gin_trgm_ops)") + op.execute("CREATE INDEX product_brand_trgm_idx ON product USING GIN (brand gin_trgm_ops)") + + +def downgrade() -> None: + op.execute("DROP INDEX IF EXISTS product_brand_trgm_idx") + op.execute("DROP INDEX IF EXISTS product_name_trgm_idx") + op.execute("DROP INDEX IF EXISTS product_search_vector_idx") + op.execute("ALTER TABLE product DROP COLUMN IF EXISTS search_vector") + # Note: we intentionally leave pg_trgm installed β€” other tables may rely on it diff --git a/backend/app/api/admin/__init__.py b/backend/app/api/admin/__init__.py deleted file mode 100644 index 180309fd..00000000 --- a/backend/app/api/admin/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Admin panel package.""" diff --git a/backend/app/api/admin/auth.py b/backend/app/api/admin/auth.py deleted file mode 100644 index 9371707a..00000000 --- a/backend/app/api/admin/auth.py +++ /dev/null @@ -1,76 +0,0 @@ -"""Authentication backend for the SQLAdmin interface, based on FastAPI-Users authentication backend.""" - -import json -from typing import Literal - -from fastapi import Response, status -from fastapi.responses import RedirectResponse -from sqladmin.authentication import AuthenticationBackend -from sqlalchemy.ext.asyncio import async_sessionmaker -from sqlmodel.ext.asyncio.session import AsyncSession -from starlette.requests import Request - -from app.api.admin.config import settings as admin_settings -from app.api.auth.config import settings as auth_settings -from app.api.auth.routers.frontend import router as frontend_auth_router -from app.api.auth.services.user_manager import cookie_transport, get_jwt_strategy -from app.api.auth.utils.context_managers import get_chained_async_user_manager_context -from app.core.database import async_engine - -async_session_generator = async_sessionmaker(bind=async_engine, class_=AsyncSession, expire_on_commit=False) - -# TODO: Redirect all backend login systems (admin panel, swagger docs, API landing page) to frontend login system -main_login_page_redirect_path = ( - f"{frontend_auth_router.url_path_for('login_page')}?next={admin_settings.admin_base_url}" -) - - -class AdminAuth(AuthenticationBackend): - """Authentication backend for the SQLAdmin interface, using FastAPI-Users.""" - - async def login(self, request: Request) -> bool: # noqa: ARG002 # Signature expected by the SQLAdmin implementation - """Placeholder logout function. - - Login is handled by the authenticate method, which redirects to the main API login page. - """ - return True - - async def logout(self, request: Request) -> bool: # noqa: ARG002 # Signature expected by the SQLAdmin implementation - """Placeholder logout function. - - Logout requires unsetting a cookie, which is not possible in the standard SQLAdmin logout function, - which is excepted to return a boolean. - Instead, the default logout route is overridden by the custom route below. - """ - return True - - async def authenticate(self, request: Request) -> RedirectResponse | Response | Literal[True]: - token = request.cookies.get(cookie_transport.cookie_name) - if not token: - return RedirectResponse(url=main_login_page_redirect_path) - async with get_chained_async_user_manager_context() as user_manager: - user = await get_jwt_strategy().read_token(token=token, user_manager=user_manager) - if user is None: - return RedirectResponse(url=main_login_page_redirect_path) - if not user.is_superuser: - return Response( - json.dumps({"detail": "You do not have permission to access this resource."}), - status_code=status.HTTP_403_FORBIDDEN, - media_type="application/json", - ) - - return True - - -def get_authentication_backend() -> AdminAuth: - """Get the authentication backend for the SQLAdmin interface.""" - return AdminAuth(secret_key=auth_settings.fastapi_users_secret) - - -async def logout_override(request: Request) -> RedirectResponse: # noqa: ARG001 # Signature expected by the SQLAdmin implementation - """Override of the default admin dashboard logout route to unset the authentication cookie.""" - response = RedirectResponse(url=frontend_auth_router.url_path_for("index"), status_code=302) - response.delete_cookie( - key=cookie_transport.cookie_name, domain=cookie_transport.cookie_domain, path=cookie_transport.cookie_path - ) - return response diff --git a/backend/app/api/admin/config.py b/backend/app/api/admin/config.py deleted file mode 100644 index 7ed84166..00000000 --- a/backend/app/api/admin/config.py +++ /dev/null @@ -1,13 +0,0 @@ -"""Configuration for the admin module.""" - -from pydantic_settings import BaseSettings - - -class AdminSettings(BaseSettings): - """Settings class to store settings related to admin components.""" - - admin_base_url: str = "/admin/dashboard" # The base url of the SQLadmin interface - - -# Create a settings instance that can be imported throughout the app -settings = AdminSettings() diff --git a/backend/app/api/admin/main.py b/backend/app/api/admin/main.py deleted file mode 100644 index df5aa675..00000000 --- a/backend/app/api/admin/main.py +++ /dev/null @@ -1,65 +0,0 @@ -"""SQLAdmin module for the FastAPI app.""" - -from fastapi import FastAPI -from sqladmin import Admin -from sqlalchemy import Engine -from sqlalchemy.ext.asyncio.engine import AsyncEngine -from starlette.applications import Starlette -from starlette.routing import Mount, Route - -from app.api.admin.auth import get_authentication_backend, logout_override -from app.api.admin.config import settings -from app.api.admin.models import ( - CategoryAdmin, - ImageAdmin, - MaterialAdmin, - MaterialProductLinkAdmin, - ProductAdmin, - ProductTypeAdmin, - TaxonomyAdmin, - UserAdmin, - VideoAdmin, -) - - -def init_admin(app: FastAPI, engine: Engine | AsyncEngine) -> Admin: - """Initialize the SQLAdmin interface for the FastAPI app. - - Args: - app (FastAPI): Main FastAPI application instance - engine (Engine | AsyncEngine): SQLAlchemy database engine, sync or async - """ - admin = Admin(app, engine, authentication_backend=get_authentication_backend(), base_url=settings.admin_base_url) - - # HACK: Override SQLAdmin logout route to allow cookie-based auth - for route in admin.app.routes: - # Find the mounted SQLAdmin app - if isinstance(route, Mount) and route.path == settings.admin_base_url and isinstance(route.app, Starlette): - for subroute in route.app.routes: - # Find the logout subroute and replace it with the custom override to allow cookie-based auth - if isinstance(subroute, Route) and subroute.name == "logout": - route.routes.remove(subroute) - route.app.add_route( - subroute.path, - logout_override, - methods=list(subroute.methods) if subroute.methods is not None else None, - name="logout", - ) - break - break - - # Add Background Data views to Admin interface - admin.add_view(CategoryAdmin) - admin.add_view(MaterialAdmin) - admin.add_view(ProductTypeAdmin) - admin.add_view(TaxonomyAdmin) - # Add Data Collection views to Admin interface - admin.add_view(MaterialProductLinkAdmin) - admin.add_view(ImageAdmin) - admin.add_view(ProductAdmin) - admin.add_view(VideoAdmin) - - # Add other admin views - admin.add_view(UserAdmin) - - return admin diff --git a/backend/app/api/admin/models.py b/backend/app/api/admin/models.py deleted file mode 100644 index f0efff66..00000000 --- a/backend/app/api/admin/models.py +++ /dev/null @@ -1,306 +0,0 @@ -"""Models for the admin module.""" - -import uuid -from collections.abc import Callable, Sequence -from pathlib import Path -from typing import Any, ClassVar - -from anyio import to_thread -from markupsafe import Markup -from sqladmin import ModelView -from sqladmin._types import MODEL_ATTR -from starlette.datastructures import UploadFile -from starlette.requests import Request -from wtforms import ValidationError -from wtforms.fields import FileField -from wtforms.form import Form -from wtforms.validators import InputRequired - -from app.api.auth.models import User -from app.api.background_data.models import Category, Material, ProductType, Taxonomy -from app.api.common.models.associations import MaterialProductLink -from app.api.data_collection.models import Product -from app.api.file_storage.models.models import Image, Video - -### Constants ### -ALLOWED_IMAGE_EXTENSIONS: set[str] = {".bmp", ".gif", ".jpeg", ".jpg", ".png", ".tiff", ".webp"} - - -### Form Validators ### -class FileSizeLimit: - """WTForms validator to limit the file size of a FileField.""" - - def __init__(self, max_size_mb: int, message: str | None = None) -> None: - self.max_size_mb = max_size_mb - self.message = message or f"File size must be under {self.max_size_mb} MB." - - def __call__(self, form: Form, field: FileField): # noqa: ARG002 # WTForms uses this signature - if isinstance(field.data, UploadFile) and field.data.size and field.data.size > self.max_size_mb * 1024 * 1024: - raise ValidationError(self.message) - - -class FileTypeValidator: - """WTForms validator to limit the file type of a FileField.""" - - def __init__(self, allowed_extensions: set[str], message: str | None = None): - self.allowed_extensions = allowed_extensions - self.message = message or f"Allowed file types: {', '.join(self.allowed_extensions)}." - - def __call__(self, form: Form, field: FileField): # noqa: ARG002 # WTForms uses this signature - if isinstance(field.data, UploadFile) and field.data.filename: - file_ext = Path(field.data.filename).suffix.lower() - if file_ext not in self.allowed_extensions: - raise ValidationError(self.message) - - -### Linking Models ### -class MaterialProductLinkAdmin(ModelView, model=MaterialProductLink): - """Admin view for Material-Product links.""" - - name = "Material-Product Link" - name_plural = "Material-Product Links" - icon = "fa-solid fa-link" - category = "Data Collection" - - column_list: ClassVar[Sequence[MODEL_ATTR]] = ["material", "product", "quantity", "unit"] - - column_formatters: ClassVar[dict[MODEL_ATTR, Callable]] = { - "material": lambda m, _: Markup('{}').format(m.material_id, m.material), - "product": lambda m, _: Markup('{}').format(m.product_id, m.product), - } - - column_searchable_list: ClassVar[Sequence[MODEL_ATTR]] = ["material.name", "product.name"] - - column_sortable_list: ClassVar[Sequence[MODEL_ATTR]] = ["quantity", "unit"] - - column_details_list: ClassVar[Sequence[MODEL_ATTR]] = [*column_list, "created_at", "updated_at"] - - -### Background Models ### -class CategoryAdmin(ModelView, model=Category): - """Admin view for Category model.""" - - name = "Category" - name_plural = "Categories" - icon = "fa-solid fa-list" - category = "Background Data" - column_list: ClassVar[Sequence[MODEL_ATTR]] = ["id", "name", "taxonomy_id"] - column_searchable_list: ClassVar[Sequence[MODEL_ATTR]] = ["name", "description"] - column_sortable_list: ClassVar[Sequence[MODEL_ATTR]] = ["id", "name", "taxonomy_id"] - - -class TaxonomyAdmin(ModelView, model=Taxonomy): - """Admin view for Taxonomy model.""" - - name = "Taxonomy" - name_plural = "Taxonomies" - icon = "fa-solid fa-sitemap" - category = "Background Data" - - column_list: ClassVar[Sequence[MODEL_ATTR]] = ["id", "name", "domain"] - column_searchable_list: ClassVar[Sequence[MODEL_ATTR]] = ["name", "domain"] - column_sortable_list: ClassVar[Sequence[MODEL_ATTR]] = ["id", "name"] - - -class MaterialAdmin(ModelView, model=Material): - """Admin view for Material model.""" - - name = "Material" - name_plural = "Materials" - icon = "fa-solid fa-cubes" - category = "Background Data" - - column_labels: ClassVar[dict[MODEL_ATTR, str]] = { - "density_kg_m3": "Density (kg/mΒ³)", - "is_crm": "Is CRM", - } - - column_list: ClassVar[Sequence[MODEL_ATTR]] = [ - "id", - "name", - "description", - "is_crm", - ] - column_searchable_list: ClassVar[Sequence[MODEL_ATTR]] = ["name", "description"] - column_sortable_list: ClassVar[Sequence[MODEL_ATTR]] = ["id", "name", "is_crm"] - - -class ProductTypeAdmin(ModelView, model=ProductType): - """Admin view for ProductType model.""" - - name = "Product Type" - name_plural = "Product Types" - icon = "fa-solid fa-tag" - category = "Background Data" - - column_labels: ClassVar[dict[MODEL_ATTR, str]] = { - "lifespan_yr": "Lifespan (years)", - } - - column_list: ClassVar[Sequence[MODEL_ATTR]] = ["id", "name", "description"] - column_searchable_list: ClassVar[Sequence[MODEL_ATTR]] = ["name", "description"] - column_sortable_list: ClassVar[Sequence[MODEL_ATTR]] = ["id", "name"] - - -### Product Models ### -class ProductAdmin(ModelView, model=Product): - """Admin view for Product model.""" - - name = "Product" - name_plural = "Products" - icon = "fa-solid fa-box" - category = "Data Collection" - - column_list: ClassVar[Sequence[MODEL_ATTR]] = [ - "id", - "name", - "type", - "description", - ] - column_searchable_list: ClassVar[Sequence[MODEL_ATTR]] = ["name", "description"] - column_sortable_list: ClassVar[Sequence[MODEL_ATTR]] = [ - "id", - "name", - "product_type_id", - ] - - -### Data Collection Models ### -class VideoAdmin(ModelView, model=Video): - """Admin view for Video model.""" - - name = "Video" - name_plural = "Videos" - icon = "fa-solid fa-video" - category = "Data Collection" - - column_list: ClassVar[Sequence[MODEL_ATTR]] = ["id", "url", "description", "product", "created_at"] - - column_formatters: ClassVar[dict[MODEL_ATTR, Callable]] = { - "url": lambda m, _: Markup('{}').format(m.url, m.url), - "product": lambda m, _: Markup('{}').format(m.product_id, m.product) - if m.product - else "", - "created_at": lambda m, _: m.created_at.strftime("%Y-%m-%d %H:%M") if m.created_at else "", - } - - column_searchable_list: ClassVar[Sequence[MODEL_ATTR]] = ["description", "url"] - - column_sortable_list: ClassVar[Sequence[MODEL_ATTR]] = ["id", "created_at"] - - column_details_list: ClassVar[Sequence[MODEL_ATTR]] = [*column_list, "updated_at"] - - -### User Models ### -class UserAdmin(ModelView, model=User): - """Admin view for User model.""" - - name = "User" - name_plural = "Users" - icon = "fa-solid fa-user" - category = "Users" - - # User CRUD should be handled by the auth module - can_create = False - can_edit = False - can_delete = False - - column_list: ClassVar[Sequence[MODEL_ATTR]] = [ - "id", - "email", - "username", - "organization", - "is_active", - "is_superuser", - "is_verified", - ] - column_searchable_list: ClassVar[Sequence[MODEL_ATTR]] = ["email", "organization"] - column_sortable_list: ClassVar[Sequence[MODEL_ATTR]] = ["email", "organization"] - - column_details_list: ClassVar[Sequence[MODEL_ATTR]] = column_list - - -### File Storage Models ### -class ImageAdmin(ModelView, model=Image): - """Admin view for Image model.""" - - # TODO: Use Image schema logic instead of duplicating it here - # TODO: Add a method to download the original file (should take it from the filename but rename it to original_name) - - name = "Image" - name_plural = "Images" - icon = "fa-solid fa-camera" - category = "Data Collection" - - # Display settings - column_list: ClassVar[Sequence[MODEL_ATTR]] = [ - "id", - "description", - "filename", - "created_at", - "updated_at", - "image_preview", - ] - column_details_list: ClassVar[Sequence[MODEL_ATTR]] = column_list - column_formatters: ClassVar[dict[MODEL_ATTR, Callable]] = { - "created_at": lambda model, _: model.created_at.strftime("%Y-%m-%d %H:%M:%S") if model.created_at else "", - "updated_at": lambda model, _: model.updated_at.strftime("%Y-%m-%d %H:%M:%S") if model.updated_at else "", - "image_preview": lambda model, _: model.image_preview(100), - } - column_formatters_detail: ClassVar[dict[MODEL_ATTR, Callable]] = column_formatters - - column_searchable_list: ClassVar[Sequence[MODEL_ATTR]] = [ - "id", - "description", - "filename", - "created_at", - "updated_at", - ] - column_sortable_list: ClassVar[Sequence[MODEL_ATTR]] = column_searchable_list - - # Create and edit settings - form_columns: ClassVar[Sequence[MODEL_ATTR]] = [ - "description", - "file", - ] - - form_args: ClassVar[dict[str, Any]] = { - "file": { - "validators": [ - InputRequired(), - FileSizeLimit(max_size_mb=10), - FileTypeValidator(allowed_extensions=ALLOWED_IMAGE_EXTENSIONS), - ], - } - } - - def _delete_image_file(self, image_path: Path) -> None: - """Delete the image file from the filesystem if it exists.""" - if image_path.exists(): - image_path.unlink() - - def handle_model_change(self, data: dict[str, Any], model: Image, is_created: bool) -> None: # noqa: FBT001 # Wtforms uses this signature - def new_image_uploaded(data: dict[str, Any]) -> bool: - """Check if a new image is present in form data.""" - return isinstance(data.get("file"), UploadFile) and data["file"].size - - if new_image_uploaded(data): - model.filename = data["file"].filename # Set the filename to the original filename - data["file"].filename = f"{uuid.uuid4()}{Path(model.filename).suffix}" # Store the file to a unique path - - if not is_created and model.file: # If the model is being edited and it has an existing image - if new_image_uploaded(data): - self._delete_image_file(Path(model.file.path)) - else: - data.pop("file", None) # Keep existing image if no new one uploaded - - def handle_model_delete(self, model: Image) -> None: - if model.file: - self._delete_image_file(model.file.path) - - async def on_model_change(self, data: dict[str, Any], model: Image, is_created: bool, request: Request) -> None: # noqa: ARG002, FBT001 # Wtforms uses this signature - """SQLAdmin expects on_model_change to be asynchronous. This method handles the synchronous model change.""" - await to_thread.run_sync(self.handle_model_change, data, model, is_created) - - async def after_model_delete(self, model: Image, request: Request) -> None: # noqa: ARG002 # Wtforms uses this signature - await to_thread.run_sync(lambda: self._delete_image_file(Path(model.file.path)) if model.file.path else None) diff --git a/backend/app/api/auth/config.py b/backend/app/api/auth/config.py index e412c4fd..b00242f2 100644 --- a/backend/app/api/auth/config.py +++ b/backend/app/api/auth/config.py @@ -1,56 +1,73 @@ """Configuration for the auth module.""" -from pathlib import Path +from pydantic import Field, SecretStr, model_validator -from pydantic_settings import BaseSettings, SettingsConfigDict +from app.core.constants import DAY, HOUR, MINUTE, MONTH +from app.core.env import RelabBaseSettings -# Set the project base directory and .env file -BASE_DIR: Path = (Path(__file__).parents[3]).resolve() - -class AuthSettings(BaseSettings): +class AuthSettings(RelabBaseSettings): """Settings class to store settings related to auth components.""" # Authentication settings - fastapi_users_secret: str = "" - newsletter_secret: str = "" + fastapi_users_secret: SecretStr = SecretStr("") + newsletter_secret: SecretStr = SecretStr("") # OAuth settings - google_oauth_client_id: str = "" - google_oauth_client_secret: str = "" - github_oauth_client_id: str = "" - github_oauth_client_secret: str = "" + google_oauth_client_id: SecretStr = SecretStr("") + google_oauth_client_secret: SecretStr = SecretStr("") + github_oauth_client_id: SecretStr = SecretStr("") + github_oauth_client_secret: SecretStr = SecretStr("") + + # OAuth frontend redirect hardening + # NOTE: Origin validation reuses the same normalized frontend URLs and dev-only regex as CORS. + + # Optional path allowlist. When empty, any path on an allowed origin is accepted. + oauth_allowed_redirect_paths: list[str] = Field(default_factory=list) + # Optional exact allowlist for native deep-link callbacks (scheme://host/path, no query/fragment). + oauth_allowed_native_redirect_uris: list[str] = Field(default_factory=list) # Settings used to configure the email server for sending emails from the app. email_host: str = "" email_port: int = 587 # Default SMTP port for TLS email_username: str = "" - email_password: str = "" + email_password: SecretStr = SecretStr("") email_from: str = "" email_reply_to: str = "" - # Initialize the settings configuration from the .env file - model_config = SettingsConfigDict(env_file=BASE_DIR / ".env", extra="ignore") + # Time to live for access (login) and verification tokens + access_token_ttl_seconds: int = 15 * MINUTE # 15 minutes (Redis token lifetime) + reset_password_token_ttl_seconds: int = HOUR # 1 hour + verification_token_ttl_seconds: int = DAY # 1 day + newsletter_unsubscription_token_ttl_seconds: int = MONTH # 30 days - # Set default values for email settings if not provided - if not email_from: - email_from = email_username - if not email_reply_to: - email_reply_to = email_username + # Auth settings - Refresh tokens and sessions + refresh_token_expire_days: int = 30 # 30 days for long-lived refresh tokens + session_id_length: int = 32 - # Time to live for access (login) and verification tokens - access_token_ttl_seconds: int = 60 * 60 * 3 # 3 hours - reset_password_token_ttl_seconds: int = 60 * 60 # 1 hour - verification_token_ttl_seconds: int = 60 * 60 * 24 # 1 day - newsletter_unsubscription_token_ttl_seconds: int = 60 * 60 * 24 * 30 # 7 days + # Auth settings - Rate limiting + rate_limit_login_attempts_per_minute: int = 3 + rate_limit_register_attempts_per_hour: int = 5 + rate_limit_password_reset_attempts_per_hour: int = 3 # Youtube API settings - youtube_api_scopes: list[str] = [ - "https://www.googleapis.com/auth/youtube", - "https://www.googleapis.com/auth/youtube.force-ssl", - "https://www.googleapis.com/auth/youtube.readonly", - "https://www.googleapis.com/auth/youtube.upload", - ] + youtube_api_scopes: list[str] = Field( + default_factory=lambda: [ + "https://www.googleapis.com/auth/youtube", + "https://www.googleapis.com/auth/youtube.force-ssl", + "https://www.googleapis.com/auth/youtube.readonly", + "https://www.googleapis.com/auth/youtube.upload", + ] + ) + + @model_validator(mode="after") + def apply_email_defaults(self) -> AuthSettings: + """Default sender fields to the SMTP username when omitted.""" + if not self.email_from: + self.email_from = self.email_username + if not self.email_reply_to: + self.email_reply_to = self.email_username + return self # Create a settings instance that can be imported throughout the app diff --git a/backend/app/api/auth/crud/__init__.py b/backend/app/api/auth/crud/__init__.py index f809fbe5..9d956d19 100644 --- a/backend/app/api/auth/crud/__init__.py +++ b/backend/app/api/auth/crud/__init__.py @@ -5,6 +5,7 @@ delete_organization_as_owner, force_delete_organization, get_organization_members, + get_organizations, get_user_organization, leave_organization, update_user_organization, @@ -12,22 +13,23 @@ ) from .users import ( add_user_role_in_organization_after_registration, - create_user_override, get_user_by_username, update_user_override, + validate_user_create, ) __all__ = [ "add_user_role_in_organization_after_registration", "create_organization", - "create_user_override", "delete_organization_as_owner", "force_delete_organization", "get_organization_members", + "get_organizations", "get_user_by_username", "get_user_organization", "leave_organization", "update_user_organization", "update_user_override", "user_join_organization", + "validate_user_create", ] diff --git a/backend/app/api/auth/crud/organizations.py b/backend/app/api/auth/crud/organizations.py index 8d0d6e51..c1fc3d12 100644 --- a/backend/app/api/auth/crud/organizations.py +++ b/backend/app/api/auth/crud/organizations.py @@ -1,25 +1,35 @@ """CRUD operations for organizations.""" -from pydantic import UUID4 +from typing import TYPE_CHECKING, cast + +from pydantic import UUID4, BaseModel +from sqlalchemy import delete from sqlalchemy.exc import IntegrityError +from sqlmodel import col, select from sqlmodel.ext.asyncio.session import AsyncSession from app.api.auth.exceptions import ( AlreadyMemberError, OrganizationHasMembersError, - OrganizationNameExistsError, UserDoesNotOwnOrgError, UserHasNoOrgError, UserIsNotMemberError, UserOwnsOrgError, + handle_organization_integrity_error, ) from app.api.auth.models import Organization, OrganizationRole, User from app.api.auth.schemas import OrganizationCreate, OrganizationUpdate -from app.api.common.crud.base import get_model_by_id -from app.api.common.crud.utils import db_get_model_with_id_if_it_exists +from app.api.common.crud.base import get_model_by_id, get_paginated_models +from app.api.common.crud.persistence import commit_and_refresh, delete_and_commit +from app.api.common.crud.utils import get_model_or_404 +from app.api.common.exceptions import InternalServerError + +if TYPE_CHECKING: + from fastapi_filter.contrib.sqlalchemy import Filter + from fastapi_pagination import Page ### Constants ### -UNIQUE_VIOLATION_PG_CODE = "23505" +OWNER_ID_FIELD = "owner_id" ## Create Organization ## @@ -27,6 +37,9 @@ async def create_organization(db: AsyncSession, organization: OrganizationCreate """Create a new organization in the database.""" if owner.organization_id: raise AlreadyMemberError(details="Leave your current organization before creating a new one.") + if owner.id is None: + err_msg = "Organization owner must have a persisted ID." + raise InternalServerError(details=err_msg, log_message=err_msg) # Create organization db_organization = Organization( @@ -41,20 +54,30 @@ async def create_organization(db: AsyncSession, organization: OrganizationCreate db.add(db_organization) await db.flush() except IntegrityError as e: - # TODO: Reuse this in general exception handling - if getattr(e.orig, "pgcode", None) == UNIQUE_VIOLATION_PG_CODE: - raise OrganizationNameExistsError from e - err_msg = f"Error creating organization: {e}" - raise RuntimeError(err_msg) from e + handle_organization_integrity_error(e, "creating") db.add(owner) - await db.commit() - await db.refresh(db_organization) - - return db_organization + return await commit_and_refresh(db, db_organization, add_before_commit=False) ## Read Organization ## +async def get_organizations( + db: AsyncSession, + *, + include_relationships: set[str] | None = None, + model_filter: Filter | None = None, + read_schema: type[BaseModel] | None = None, +) -> Page[Organization]: + """Get organizations with optional filtering, relationships, and pagination.""" + return await get_paginated_models( + db, + Organization, + include_relationships=include_relationships, + model_filter=model_filter, + read_schema=read_schema, + ) + + async def get_user_organization(user: User) -> Organization: """Get the organization of a user, optionally including related models.""" if not user.organization: @@ -67,24 +90,44 @@ async def update_user_organization( db: AsyncSession, db_organization: Organization, organization_in: OrganizationUpdate ) -> Organization: """Update an existing organization in the database.""" - # Update organization data - db_organization.sqlmodel_update(organization_in.model_dump(exclude_unset=True)) + transfer_owner_id = organization_in.owner_id if OWNER_ID_FIELD in organization_in.model_fields_set else None + if transfer_owner_id is not None: + db_organization = await get_model_by_id( + db, + Organization, + db_organization.db_id, + include_relationships={"members", "owner"}, + ) + new_owner = next((member for member in db_organization.members if member.id == transfer_owner_id), None) + if new_owner is None: + raise UserIsNotMemberError( + organization_id=db_organization.id, + details="Ownership can only be transferred to an existing member.", + ) + + # Update organization data without clobbering ownership transfer logic. + db_organization.sqlmodel_update(organization_in.model_dump(exclude_unset=True, exclude={"owner_id"})) + + if transfer_owner_id is not None and transfer_owner_id != db_organization.owner_id: + current_owner = db_organization.owner + new_owner = next((member for member in db_organization.members if member.id == transfer_owner_id), None) + if new_owner is None: + raise UserIsNotMemberError( + organization_id=db_organization.id, + details="Ownership can only be transferred to an existing member.", + ) + + current_owner.organization_role = OrganizationRole.MEMBER + new_owner.organization_role = OrganizationRole.OWNER + db_organization.owner_id = new_owner.db_id try: db.add(db_organization) await db.flush() except IntegrityError as e: - # TODO: Reuse this in general exception handling - if getattr(e.orig, "pgcode", None) == UNIQUE_VIOLATION_PG_CODE: - raise OrganizationNameExistsError from e - err_msg = f"Error updating organization: {e}" - raise RuntimeError(err_msg) from e - - # Save to database - await db.commit() - await db.refresh(db_organization) + handle_organization_integrity_error(e, "updating") - return db_organization + return await commit_and_refresh(db, db_organization, add_before_commit=False) ## Delete Organization ## @@ -98,16 +141,14 @@ async def delete_organization_as_owner(db: AsyncSession, owner: User) -> None: if len(db_organization.members) > 1: raise OrganizationHasMembersError - await db.delete(db_organization) - await db.commit() + await delete_and_commit(db, db_organization) async def force_delete_organization(db: AsyncSession, organization_id: UUID4) -> None: """Force delete a organization from the database.""" - db_organization = await db_get_model_with_id_if_it_exists(db, Organization, organization_id) + db_organization = await get_model_or_404(db, Organization, organization_id) - await db.delete(db_organization) - await db.commit() + await delete_and_commit(db, db_organization) ## Organization member CRUD operations ## @@ -118,34 +159,66 @@ async def user_join_organization( ) -> User: """Add user to organization as member.""" # Check if user already owns an organization - # TODO: Implement logic for owners to delegate ownership, or delete organization if it has no members if user.organization_id: if user.organization_role == OrganizationRole.OWNER: - raise UserOwnsOrgError( - details=" You cannot join another organization until you transfer ownership or remove all members." - ) - raise AlreadyMemberError(details="Leave your current organization before joining a new one.") + db_organization = user.organization + if db_organization is None or db_organization.id != user.organization_id: + db_organization = await get_model_by_id( + db, + Organization, + user.organization_id, + include_relationships={"members"}, + ) + + if len(db_organization.members) > 1: + raise UserOwnsOrgError( + details=" You cannot join another organization until you transfer ownership or remove all members." + ) + + # The owner is the only member, so we can delete the empty organization first. + user.organization_id = None + user.organization_role = None + user.organization = None + db.add(user) + await db.flush() + await db.exec(delete(Organization).where(col(Organization.id) == db_organization.id)) + else: + raise AlreadyMemberError(details="Leave your current organization before joining a new one.") # Update user user.organization_id = organization.id user.organization_role = OrganizationRole.MEMBER + user.organization = organization db.add(user) - await db.commit() - await db.refresh(organization) + await commit_and_refresh(db, organization, add_before_commit=False) return user -async def get_organization_members(db: AsyncSession, organization_id: UUID4, user: User) -> list[User]: +async def get_organization_members( + db: AsyncSession, + organization_id: UUID4, + user: User, + *, + paginate: bool = False, + read_schema: type[BaseModel] | None = None, +) -> list[User] | Page[User]: """Get organization members if user is a member or superuser.""" # Verify user is member or superuser if not user.is_superuser and user.organization_id != organization_id: raise UserIsNotMemberError + if paginate: + await get_model_by_id(db, Organization, organization_id) + statement = select(User).where(User.organization_id == organization_id) + return cast( + "Page[User]", + await get_paginated_models(db, User, statement=statement, read_schema=read_schema), + ) + organization = await get_model_by_id(db, Organization, organization_id, include_relationships={"members"}) - # TODO: Add pagination when there are many members return organization.members diff --git a/backend/app/api/auth/crud/users.py b/backend/app/api/auth/crud/users.py index 5961c18f..8be85a15 100644 --- a/backend/app/api/auth/crud/users.py +++ b/backend/app/api/auth/crud/users.py @@ -1,12 +1,15 @@ """Custom CRUD operations for the User model, on top of the standard FastAPI-Users implementation.""" +from __future__ import annotations + +from typing import TYPE_CHECKING + from fastapi import Request -from fastapi_users.db import BaseUserDatabase -from pydantic import UUID4, EmailStr, ValidationError -from sqlmodel import select -from sqlmodel.ext.asyncio.session import AsyncSession +from pydantic import EmailStr, ValidationError +from sqlalchemy import exists +from sqlmodel import col, select -from app.api.auth.exceptions import UserNameAlreadyExistsError +from app.api.auth.exceptions import DisposableEmailError, UserNameAlreadyExistsError from app.api.auth.models import Organization, OrganizationRole, User from app.api.auth.schemas import ( OrganizationCreate, @@ -14,23 +17,31 @@ UserCreateWithOrganization, UserUpdate, ) -from app.api.common.crud.utils import db_get_model_with_id_if_it_exists +from app.api.common.crud.utils import get_model_or_404 + +if TYPE_CHECKING: + from fastapi_users_db_sqlmodel import SQLModelUserDatabaseAsync + from sqlmodel.ext.asyncio.session import AsyncSession + + from app.api.auth.utils.email_validation import EmailChecker ## Create User ## -async def create_user_override( - user_db: BaseUserDatabase[User, UUID4], user_create: UserCreate | UserCreateWithOrganization +async def validate_user_create( + user_db: SQLModelUserDatabaseAsync, + user_create: UserCreate | UserCreateWithOrganization, + email_checker: EmailChecker | None = None, ) -> UserCreate: """Override of base user creation with additional username uniqueness check. Meant for use within the on_after_register event in FastAPI-Users UserManager. """ - # TODO: Fix type errors in this method and implement custom UserNameAlreadyExists error in FastAPI-Users + if email_checker and await email_checker.is_disposable(user_create.email): + raise DisposableEmailError(email=user_create.email) if user_create.username is not None: - query = select(User).where(User.username == user_create.username) - existing_username = await user_db.session.execute(query) - if existing_username.unique().scalar_one_or_none(): + query = select(exists().where(col(User.username) == user_create.username)) + if (await user_db.session.exec(query)).one(): raise UserNameAlreadyExistsError(user_create.username) if isinstance(user_create, UserCreateWithOrganization): @@ -46,34 +57,36 @@ async def create_user_override( elif user_create.organization_id: # Validate organization ID (will raise ValueError if not found) - await db_get_model_with_id_if_it_exists(user_db.session, Organization, user_create.organization_id) + await get_model_or_404(user_db.session, Organization, user_create.organization_id) return user_create async def add_user_role_in_organization_after_registration( - user_db: BaseUserDatabase[User, UUID4], - user: User, - registration_request: Request, + user_db: SQLModelUserDatabaseAsync, user: User, registration_request: Request ) -> User: """Add user to an organization after registration. Meant for use within the on_after_register event in FastAPI-Users UserManager. - Validation of organization data is performed in create_user_override. + Validation of organization data is performed in validate_user_create. """ user_create_data = await registration_request.json() + if organization_data := user_create_data.get("organization"): # Create organization - organization = Organization(**organization_data, owner_id=user.id) + organization = Organization(**organization_data, owner_id=user.db_id) user_db.session.add(organization) await user_db.session.flush() + # Set user as organization owner user.organization_id = organization.id user.organization_role = OrganizationRole.OWNER + elif organization_id := user_create_data.get("organization_id"): # User was added to an existing organization user.organization_id = organization_id user.organization_role = OrganizationRole.MEMBER + else: return user @@ -84,34 +97,28 @@ async def add_user_role_in_organization_after_registration( ## Read User ## -async def get_user_by_username( - session: AsyncSession, - username: str, -) -> User: +async def get_user_by_username(session: AsyncSession, username: str) -> User: """Get a user by their username.""" statement = select(User).where(User.username == username) - if not (user := (await session.exec(statement)).one_or_none()): + + if not (user := (await session.exec(statement)).unique().one_or_none()): err_msg: EmailStr = f"User not found with username: {username}" + raise ValueError(err_msg) return user ## Update User ## -async def update_user_override( - user_db: BaseUserDatabase[User, UUID4], - user: User, - user_update: UserUpdate, -) -> UserUpdate: +async def update_user_override(user_db: SQLModelUserDatabaseAsync, user: User, user_update: UserUpdate) -> UserUpdate: """Override base user update with organization validation.""" if user_update.username is not None: # Check username uniqueness - query = select(User).where(and_(User.username == user_update.username, User.id != user.id)) - existing_username = await user_db.session.execute(query) - if existing_username.scalar_one_or_none(): + query = select(exists().where((col(User.username) == user_update.username) & (col(User.id) != user.id))) + if (await user_db.session.exec(query)).one(): raise UserNameAlreadyExistsError(user_update.username) if user_update.organization_id is not None: # Validate organization exists - await db_get_model_with_id_if_it_exists(user_db.session, Organization, user_update.organization_id) + await get_model_or_404(user_db.session, Organization, user_update.organization_id) return user_update diff --git a/backend/app/api/auth/dependencies.py b/backend/app/api/auth/dependencies.py index 1420c307..fbd0a71e 100644 --- a/backend/app/api/auth/dependencies.py +++ b/backend/app/api/auth/dependencies.py @@ -3,12 +3,13 @@ from typing import Annotated from fastapi import Depends, Security +from fastapi_users_db_sqlmodel import SQLModelUserDatabaseAsync from pydantic import UUID4 from app.api.auth.exceptions import UserDoesNotOwnOrgError, UserIsNotMemberError from app.api.auth.models import Organization, OrganizationRole, User -from app.api.auth.services.user_manager import UserManager, fastapi_user_manager, get_user_manager -from app.api.common.crud.utils import db_get_model_with_id_if_it_exists +from app.api.auth.services.user_manager import UserManager, fastapi_user_manager, get_user_db, get_user_manager +from app.api.common.crud.utils import get_model_or_404 from app.api.common.routers.dependencies import AsyncSessionDep # Dependencies @@ -18,6 +19,7 @@ optional_current_active_user = fastapi_user_manager.current_user(optional=True) # Annotated dependency types. For example usage, see the `authenticated_route` function in the auth.routers module. +UserDBDep = Annotated[SQLModelUserDatabaseAsync[User, UUID4], Depends(get_user_db)] UserManagerDep = Annotated[UserManager, Depends(get_user_manager)] CurrentActiveUserDep = Annotated[User, Security(current_active_user)] CurrentActiveVerifiedUserDep = Annotated[User, Security(current_active_verified_user)] @@ -26,14 +28,12 @@ # Organizations - - async def get_org_by_id( organization_id: UUID4, session: AsyncSessionDep, ) -> Organization: """Get a valid organization by ID.""" - return await db_get_model_with_id_if_it_exists(session, Organization, organization_id) + return await get_model_or_404(session, Organization, organization_id) async def get_org_by_id_as_owner( diff --git a/backend/app/api/auth/exceptions.py b/backend/app/api/auth/exceptions.py index d03bdefd..53dca7d6 100644 --- a/backend/app/api/auth/exceptions.py +++ b/backend/app/api/auth/exceptions.py @@ -1,31 +1,36 @@ -"""Custom exceptions for user and organization operations.""" +"""Custom exceptions for authentication, user, and organization operations.""" -from fastapi import status +from fastapi import HTTPException, status +from fastapi_users.router.common import ErrorCode from pydantic import UUID4 - -from app.api.common.exceptions import APIError +from sqlalchemy.exc import IntegrityError + +from app.api.common.exceptions import ( + BadRequestError, + ConflictError, + ForbiddenError, + InternalServerError, + NotFoundError, + UnauthorizedError, +) from app.api.common.models.custom_types import IDT, MT -class AuthCRUDError(APIError): +class AuthCRUDError(Exception): """Base class for custom authentication CRUD exceptions.""" -class UserNameAlreadyExistsError(AuthCRUDError): +class UserNameAlreadyExistsError(ConflictError, AuthCRUDError): """Raised when a username is already taken.""" - http_status_code = status.HTTP_409_CONFLICT - def __init__(self, username: str): msg = f"Username '{username}' is already taken." super().__init__(msg) -class AlreadyMemberError(AuthCRUDError): +class AlreadyMemberError(ConflictError, AuthCRUDError): """Raised when a user already belongs to an organization.""" - http_status_code = status.HTTP_409_CONFLICT - def __init__(self, user_id: UUID4 | None = None, details: str | None = None) -> None: msg = ( f"User with ID {user_id} already belongs to an organization" @@ -35,11 +40,9 @@ def __init__(self, user_id: UUID4 | None = None, details: str | None = None) -> super().__init__(msg) -class UserOwnsOrgError(AuthCRUDError): +class UserOwnsOrgError(ConflictError, AuthCRUDError): """Raised when a user already owns an organization.""" - http_status_code = status.HTTP_409_CONFLICT - def __init__(self, user_id: UUID4 | None = None, details: str | None = None) -> None: msg = (f"User with ID {user_id} owns an organization" if user_id else "You own an organization") + ( f": {details}" if details else "" @@ -48,11 +51,9 @@ def __init__(self, user_id: UUID4 | None = None, details: str | None = None) -> super().__init__(msg) -class UserHasNoOrgError(AuthCRUDError): +class UserHasNoOrgError(NotFoundError, AuthCRUDError): """Raised when a user does not belong to any organization.""" - http_status_code = status.HTTP_404_NOT_FOUND - def __init__(self, user_id: UUID4 | None = None, details: str | None = None) -> None: msg = ( f"User with ID {user_id} does not belong to an organization" @@ -62,11 +63,9 @@ def __init__(self, user_id: UUID4 | None = None, details: str | None = None) -> super().__init__(msg) -class UserIsNotMemberError(AuthCRUDError): +class UserIsNotMemberError(ForbiddenError, AuthCRUDError): """Raised when a user does not belong to an organization.""" - http_status_code = status.HTTP_403_FORBIDDEN - def __init__( self, user_id: UUID4 | None = None, organization_id: UUID4 | None = None, details: str | None = None ) -> None: @@ -78,11 +77,9 @@ def __init__( super().__init__(msg) -class UserDoesNotOwnOrgError(AuthCRUDError): +class UserDoesNotOwnOrgError(ForbiddenError, AuthCRUDError): """Raised when a user does not own an organization.""" - http_status_code = status.HTTP_403_FORBIDDEN - def __init__(self, user_id: UUID4 | None = None, details: str | None = None) -> None: msg = ( f"User with ID {user_id} does not own an organization" if user_id else "You do not own an organization" @@ -90,11 +87,9 @@ def __init__(self, user_id: UUID4 | None = None, details: str | None = None) -> super().__init__(msg) -class OrganizationHasMembersError(AuthCRUDError): +class OrganizationHasMembersError(ConflictError, AuthCRUDError): """Raised when an organization has members and cannot be deleted.""" - http_status_code = status.HTTP_409_CONFLICT - def __init__(self, organization_id: UUID4 | None = None) -> None: msg = ( f"Organization {' with ID ' + str(organization_id) if organization_id else ''}" @@ -104,20 +99,16 @@ def __init__(self, organization_id: UUID4 | None = None) -> None: super().__init__(msg) -class OrganizationNameExistsError(AuthCRUDError): +class OrganizationNameExistsError(ConflictError, AuthCRUDError): """Raised when an organization with the same name already exists.""" - http_status_code = status.HTTP_409_CONFLICT - def __init__(self, msg: str = "Organization with this name already exists") -> None: super().__init__(msg) -class UserOwnershipError(APIError): +class UserOwnershipError(ForbiddenError): """Exception raised when a user does not own the specified model.""" - http_status_code = status.HTTP_403_FORBIDDEN - def __init__( self, model_type: type[MT], @@ -126,3 +117,165 @@ def __init__( ) -> None: model_name = model_type.get_api_model_name().name_capital super().__init__(message=(f"User {user_id} does not own {model_name} with ID {model_id}.")) + + +class DisposableEmailError(BadRequestError, AuthCRUDError): + """Raised when a disposable email address is used.""" + + def __init__(self, email: str) -> None: + msg = f"The email address '{email}' is from a disposable email provider, which is not allowed." + super().__init__(msg) + + +class InvalidOAuthProviderError(BadRequestError): + """Raised when an unsupported OAuth provider is requested.""" + + def __init__(self, provider: str) -> None: + super().__init__(f"Invalid OAuth provider: {provider}.") + + +class OAuthAccountNotLinkedError(NotFoundError): + """Raised when the current user has no linked OAuth account for the provider.""" + + def __init__(self, provider: str) -> None: + super().__init__(f"OAuth account not linked for provider: {provider}.") + + +class RefreshTokenError(UnauthorizedError): + """Base class for refresh token authentication failures.""" + + +class RefreshTokenNotFoundError(RefreshTokenError): + """Raised when no refresh token is present in the request.""" + + def __init__(self) -> None: + super().__init__("Refresh token not found") + + +class RefreshTokenInvalidError(RefreshTokenError): + """Raised when a refresh token is invalid or expired.""" + + def __init__(self) -> None: + super().__init__("Invalid or expired refresh token") + + +class RefreshTokenRevokedError(RefreshTokenError): + """Raised when a refresh token has already been revoked.""" + + def __init__(self) -> None: + super().__init__("Token has been revoked") + + +class RefreshTokenUserInactiveError(RefreshTokenError): + """Raised when the refresh token resolves to a missing or inactive user.""" + + def __init__(self) -> None: + super().__init__("User not found or inactive") + + +class OAuthHTTPError(HTTPException): + """Base class for OAuth flow errors that intentionally preserve FastAPI HTTPException payloads.""" + + def __init__(self, detail: str | ErrorCode, status_code: int = status.HTTP_400_BAD_REQUEST) -> None: + super().__init__(status_code=status_code, detail=detail) + + +class OAuthStateDecodeError(OAuthHTTPError): + """Raised when an OAuth state token cannot be decoded.""" + + def __init__(self) -> None: + super().__init__(ErrorCode.ACCESS_TOKEN_DECODE_ERROR) + + +class OAuthStateExpiredError(OAuthHTTPError): + """Raised when an OAuth state token has expired.""" + + def __init__(self) -> None: + super().__init__(ErrorCode.ACCESS_TOKEN_ALREADY_EXPIRED) + + +class OAuthInvalidStateError(OAuthHTTPError): + """Raised when OAuth CSRF state validation fails.""" + + def __init__(self) -> None: + super().__init__(ErrorCode.OAUTH_INVALID_STATE) + + +class OAuthInvalidRedirectURIError(OAuthHTTPError): + """Raised when a frontend OAuth redirect URI is not allowlisted.""" + + def __init__(self) -> None: + super().__init__("Invalid redirect_uri") + + +class OAuthEmailUnavailableError(OAuthHTTPError): + """Raised when the OAuth provider does not return an email address.""" + + def __init__(self) -> None: + super().__init__(ErrorCode.OAUTH_NOT_AVAILABLE_EMAIL) + + +class OAuthUserAlreadyExistsHTTPError(OAuthHTTPError): + """Raised when an OAuth login collides with an existing unlinked user.""" + + def __init__(self) -> None: + super().__init__(ErrorCode.OAUTH_USER_ALREADY_EXISTS) + + +class OAuthInactiveUserHTTPError(OAuthHTTPError): + """Raised when an OAuth-authenticated user is inactive.""" + + def __init__(self) -> None: + super().__init__(ErrorCode.LOGIN_BAD_CREDENTIALS) + + +class OAuthAccountAlreadyLinkedError(OAuthHTTPError): + """Raised when an OAuth provider account is already linked to another user.""" + + def __init__(self) -> None: + super().__init__("This account is already linked to another user.") + + +class RegistrationHTTPError(HTTPException): + """Base class for registration-route HTTP errors with stable string details.""" + + def __init__(self, detail: str, status_code: int) -> None: + super().__init__(status_code=status_code, detail=detail) + + +class RegistrationUserAlreadyExistsHTTPError(RegistrationHTTPError): + """Raised when a registration email is already in use.""" + + def __init__(self, email: str) -> None: + super().__init__(detail=f"User with email {email} already exists", status_code=status.HTTP_409_CONFLICT) + + +class RegistrationInvalidPasswordHTTPError(RegistrationHTTPError): + """Raised when password validation fails during registration.""" + + def __init__(self, reason: str) -> None: + super().__init__( + detail=f"Password validation failed: {reason}", + status_code=status.HTTP_400_BAD_REQUEST, + ) + + +class RegistrationUnexpectedHTTPError(RegistrationHTTPError): + """Raised when an unexpected registration failure occurs.""" + + def __init__(self) -> None: + super().__init__( + detail="An unexpected error occurred during registration", + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) + + +UNIQUE_VIOLATION_PG_CODE = "23505" + + +def handle_organization_integrity_error(e: IntegrityError, action: str) -> None: + """Handle integrity errors when creating or updating an organization, and raise appropriate exceptions.""" + if getattr(e.orig, "pgcode", None) == UNIQUE_VIOLATION_PG_CODE: + raise OrganizationNameExistsError from e + err_msg = f"Error {action} organization: {e}" + raise InternalServerError(details=err_msg, log_message=err_msg) from e diff --git a/backend/app/api/auth/filters.py b/backend/app/api/auth/filters.py index eb6f47d6..1c290aa4 100644 --- a/backend/app/api/auth/filters.py +++ b/backend/app/api/auth/filters.py @@ -1,12 +1,15 @@ """Fastapi-filter schemas for filtering User and Organization models.""" -from typing import ClassVar +from typing import TYPE_CHECKING from fastapi_filter import FilterDepends, with_prefix from fastapi_filter.contrib.sqlalchemy import Filter from app.api.auth.models import Organization, User +if TYPE_CHECKING: + from typing import ClassVar + class UserFilter(Filter): """FastAPI-filter class for User filtering.""" @@ -18,15 +21,18 @@ class UserFilter(Filter): is_superuser: bool | None = None is_verified: bool | None = None - search_model_fields: ClassVar[list[str]] = [ - "email", - "username", - "organization", - ] + search: str | None = None + + class Constants(Filter.Constants): + """Constants for UserFilter.""" - class Constants(Filter.Constants): # noqa: D106 # Standard FastAPI-filter class model = User + search_model_fields: ClassVar[list[str]] = [ + "email", + "username", + ] + class OrganizationFilter(Filter): """FastAPI-filter class for Organization filtering.""" @@ -35,15 +41,21 @@ class OrganizationFilter(Filter): location__ilike: str | None = None description__ilike: str | None = None - search_model_fields: ClassVar[list[str]] = [ - "name", - "location", - "description", - ] + search: str | None = None + + order_by: list[str] | None = None + + class Constants(Filter.Constants): + """Constants for OrganizationFilter.""" - class Constants(Filter.Constants): # noqa: D106 # Standard FastAPI-filter class model = Organization + search_model_fields: ClassVar[list[str]] = [ + "name", + "location", + "description", + ] + class UserFilterWithRelationships(UserFilter): """FastAPI-filter class for User filtering with relationships.""" diff --git a/backend/app/api/auth/models.py b/backend/app/api/auth/models.py index 46acc523..f3e032a0 100644 --- a/backend/app/api/auth/models.py +++ b/backend/app/api/auth/models.py @@ -1,26 +1,24 @@ """Database models related to platform users.""" import uuid -from enum import Enum +from datetime import datetime +from enum import StrEnum from functools import cached_property -from typing import TYPE_CHECKING, Annotated, Optional +from typing import Optional from fastapi_users_db_sqlmodel import SQLModelBaseOAuthAccount, SQLModelBaseUserDB -from pydantic import UUID4, BaseModel, ConfigDict, StringConstraints +from pydantic import UUID4, BaseModel, ConfigDict +from sqlalchemy import DateTime, ForeignKey, UniqueConstraint from sqlalchemy import Enum as SAEnum -from sqlalchemy import ForeignKey from sqlmodel import Column, Field, Relationship -from app.api.common.models.base import CustomBase, CustomBaseBare, TimeStampMixinBare +from app.api.common.models.base import CustomBase, CustomBaseBare, TimeStampMixinBare, UUIDPrimaryKeyMixin -if TYPE_CHECKING: - from app.api.data_collection.models import Product +# Note: Keeping auth models together avoids circular imports in SQLAlchemy/Pydantic schema building. -# TODO: Refactor into separate files for each model. -# This is tricky due to circular imports and the way SQLAlchemy and Pydantic handle schema building. ### Enums ### -class OrganizationRole(str, Enum): +class OrganizationRole(StrEnum): """Enum for organization roles.""" OWNER = "owner" @@ -31,36 +29,63 @@ class OrganizationRole(str, Enum): class UserBase(BaseModel): """Base schema for user data.""" - username: Annotated[ - str | None, - StringConstraints(strip_whitespace=True, pattern=r"^[\w]+$"), # Allows only letters, numbers, and underscores - ] = Field(index=True, unique=True, default=None) + username: str | None = Field(index=True, unique=True, default=None, min_length=2, max_length=50) - model_config = ConfigDict(use_enum_values=True) # pyright: ignore [reportIncompatibleVariableOverride] # This is not a type override, see https://github.com/fastapi/sqlmodel/discussions/855 + model_config = ConfigDict(use_enum_values=True) -class User(UserBase, CustomBaseBare, TimeStampMixinBare, SQLModelBaseUserDB, table=True): +class User(SQLModelBaseUserDB, CustomBaseBare, UUIDPrimaryKeyMixin, UserBase, TimeStampMixinBare, table=True): """Database model for platform users.""" + # Redefine id to allow None in the backend which is required by the > 2.12 pydantic/sqlmodel combo + id: UUID4 | None = Field(default_factory=uuid.uuid4, primary_key=True, nullable=False) + + # Login tracking + last_login_at: datetime | None = Field( + default=None, + sa_column=Column(DateTime(timezone=True), nullable=True), + ) + last_login_ip: str | None = Field(default=None, max_length=45, nullable=True) # Max 45 for IPv6 + # One-to-many relationship with OAuthAccount oauth_accounts: list["OAuthAccount"] = Relationship( back_populates="user", - sa_relationship_kwargs={"lazy": "joined"}, # Required because of FastAPI-Users OAuth implementation + sa_relationship_kwargs={ + "lazy": "joined", # Required because of FastAPI-Users OAuth implementation + "foreign_keys": "[OAuthAccount.user_id]", + }, ) - products: list["Product"] = Relationship(back_populates="owner") - # Many-to-one relationship with Organization organization_id: UUID4 | None = Field( default=None, - sa_column=Column(ForeignKey("organization.id", use_alter=True, name="fk_user_organization"), nullable=True), + sa_column=Column( + ForeignKey("organization.id", use_alter=True, name="fk_user_organization"), + nullable=True, + ), ) - organization: Optional["Organization"] = Relationship( - back_populates="members", sa_relationship_kwargs={"lazy": "selectin", "foreign_keys": "[User.organization_id]"} + organization: Optional["Organization"] = Relationship( # `Optional` and quotes needed for proper sqlalchemy mapping + back_populates="members", + sa_relationship_kwargs={ + "lazy": "selectin", + "foreign_keys": "[User.organization_id]", + }, ) organization_role: OrganizationRole | None = Field(default=None, sa_column=Column(SAEnum(OrganizationRole))) + # One-to-one relationship with owned Organization + owned_organization: Optional["Organization"] = ( + Relationship( # `Optional` and quotes needed for proper sqlalchemy mapping + back_populates="owner", + sa_relationship_kwargs={ + "uselist": False, + "foreign_keys": "[Organization.owner_id]", + }, + ) + ) + @cached_property def is_organization_owner(self) -> bool: + """Check if the user is an organization owner.""" return self.organization_role == OrganizationRole.OWNER def __str__(self) -> str: @@ -68,11 +93,21 @@ def __str__(self) -> str: ### OAuthAccount Model ### -class OAuthAccount(SQLModelBaseOAuthAccount, CustomBaseBare, TimeStampMixinBare, table=True): +class OAuthAccount(SQLModelBaseOAuthAccount, CustomBaseBare, UUIDPrimaryKeyMixin, TimeStampMixinBare, table=True): """Database model for OAuth accounts. Note that the main implementation is in the base class.""" + # Redefine id to allow None in the backend which is required by the > 2.12 pydantic/sqlmodel combo + id: UUID4 | None = Field(default_factory=uuid.uuid4, primary_key=True, nullable=False) + + # Redefine user_id to ensure the ForeignKey survives mixin inheritance. + user_id: UUID4 = Field(foreign_key="user.id", nullable=False) + # Many-to-one relationship with User - user: User = Relationship(back_populates="oauth_accounts") + user: User = Relationship( + back_populates="oauth_accounts", + sa_relationship_kwargs={"foreign_keys": "[OAuthAccount.user_id]"}, + ) + __table_args__ = (UniqueConstraint("oauth_name", "account_id", name="uq_oauth_account_identity"),) ### Organization Model ### @@ -84,27 +119,25 @@ class OrganizationBase(CustomBase): description: str | None = Field(default=None, max_length=500) -class Organization(OrganizationBase, TimeStampMixinBare, table=True): +class Organization(OrganizationBase, UUIDPrimaryKeyMixin, TimeStampMixinBare, table=True): """Database model for organizations.""" - id: UUID4 = Field(default_factory=uuid.uuid4, primary_key=True, nullable=False) + id: UUID4 | None = Field(default_factory=uuid.uuid4, primary_key=True, nullable=False) # One-to-one relationship with owner User + # Use sa_column with explicit ForeignKey to preserve constraint through mixin inheritance owner_id: UUID4 = Field( - sa_column=Column(ForeignKey("user.id", use_alter=True, name="fk_organization_owner"), nullable=False), + sa_column=Column(ForeignKey("user.id", use_alter=True, name="fk_organization_owner"), nullable=False) ) owner: User = Relationship( - back_populates="organization", - sa_relationship_kwargs={"primaryjoin": "Organization.owner_id == User.id", "foreign_keys": "[User.id]"}, + back_populates="owned_organization", + sa_relationship_kwargs={"uselist": False, "foreign_keys": "[Organization.owner_id]", "post_update": True}, ) # One-to-many relationship with member Users - members: list["User"] = Relationship( + members: list[User] = Relationship( back_populates="organization", - sa_relationship_kwargs={ - "primaryjoin": "Organization.id == User.organization_id", - "foreign_keys": "[User.organization_id]", - }, + sa_relationship_kwargs={"foreign_keys": "[User.organization_id]"}, ) def __str__(self) -> str: diff --git a/backend/app/api/auth/routers/admin/organizations.py b/backend/app/api/auth/routers/admin/organizations.py index 86165272..59d0ad0e 100644 --- a/backend/app/api/auth/routers/admin/organizations.py +++ b/backend/app/api/auth/routers/admin/organizations.py @@ -1,37 +1,45 @@ """Admin routes for managing organizations.""" -from collections.abc import Sequence from typing import Annotated -from fastapi import APIRouter, Query, Security +from fastapi import APIRouter, Security +from fastapi_filter import FilterDepends +from fastapi_pagination import Page from pydantic import UUID4 -from app.api.auth.crud import force_delete_organization +from app.api.auth import crud from app.api.auth.dependencies import current_active_superuser +from app.api.auth.filters import OrganizationFilter from app.api.auth.models import Organization from app.api.auth.schemas import OrganizationReadWithRelationships -from app.api.common.crud.base import get_model_by_id, get_models from app.api.common.routers.dependencies import AsyncSessionDep +from app.api.common.routers.query_params import relationship_include_query +from app.api.common.routers.read_helpers import get_model_response router = APIRouter(prefix="/admin/organizations", tags=["admin"], dependencies=[Security(current_active_superuser)]) +ORGANIZATION_INCLUDE_EXAMPLES = { + "none": {"value": []}, + "all": {"value": ["owner", "members"]}, +} -@router.get("", response_model=list[OrganizationReadWithRelationships], summary="Get all organizations") + +@router.get("", response_model=Page[OrganizationReadWithRelationships], summary="Get all organizations") async def get_all_organizations( session: AsyncSessionDep, + org_filter: Annotated[OrganizationFilter, FilterDepends(OrganizationFilter)], include: Annotated[ set[str] | None, - Query( - description="Relationships to include", - openapi_examples={ - "none": {"value": []}, - "all": {"value": ["owner", "members"]}, - }, - ), + relationship_include_query(openapi_examples=ORGANIZATION_INCLUDE_EXAMPLES), ] = None, -) -> Sequence[Organization]: +) -> Page[Organization]: """Get all organizations with optional relationships. Only superusers can access this route.""" - return await get_models(session, Organization, include_relationships=include) + return await crud.get_organizations( + session, + include_relationships=include, + model_filter=org_filter, + read_schema=OrganizationReadWithRelationships, + ) @router.get("/{organization_id}", response_model=OrganizationReadWithRelationships, summary="Get organization by ID") @@ -40,20 +48,19 @@ async def get_organization_with_relationships( session: AsyncSessionDep, include: Annotated[ set[str] | None, - Query( - description="Relationships to include", - openapi_examples={ - "none": {"value": []}, - "all": {"value": ["owner", "members"]}, - }, - ), + relationship_include_query(openapi_examples=ORGANIZATION_INCLUDE_EXAMPLES), ] = None, ) -> Organization: """Get organization by ID with optional relationships. Only superusers can access this route.""" - return await get_model_by_id(session, Organization, organization_id, include_relationships=include) + return await get_model_response( + session, + Organization, + organization_id, + include_relationships=include, + ) @router.delete("/{organization_id}", status_code=204, summary="Delete organization by ID") async def delete_organization(organization_id: UUID4, session: AsyncSessionDep) -> None: """Delete organization by ID. Only superusers can access this route.""" - await force_delete_organization(session, organization_id) + await crud.force_delete_organization(session, organization_id) diff --git a/backend/app/api/auth/routers/admin/users.py b/backend/app/api/auth/routers/admin/users.py index 77fc1cfc..2e26045a 100644 --- a/backend/app/api/auth/routers/admin/users.py +++ b/backend/app/api/auth/routers/admin/users.py @@ -1,11 +1,11 @@ """Admin routes for managing users.""" -from collections.abc import Sequence -from typing import Annotated +from typing import Annotated, cast -from fastapi import APIRouter, Path, Query, Security +from fastapi import APIRouter, Path, Security from fastapi.responses import RedirectResponse from fastapi_filter import FilterDepends +from fastapi_pagination import Page from pydantic import UUID4, EmailStr from app.api.auth.crud import get_user_by_username @@ -13,18 +13,25 @@ from app.api.auth.filters import UserFilter from app.api.auth.models import User from app.api.auth.routers.users import router as public_user_router -from app.api.auth.schemas import UserRead, UserReadWithRelationships -from app.api.common.crud.base import get_models +from app.api.auth.schemas import UserRead +from app.api.common.crud.base import get_paginated_models from app.api.common.routers.dependencies import AsyncSessionDep +from app.api.common.routers.query_params import relationship_include_query router = APIRouter(prefix="/admin/users", tags=["admin"], dependencies=[Security(current_active_superuser)]) +USER_INCLUDE_EXAMPLES = { + "none": {"value": []}, + "products": {"value": ["products"]}, + "all": {"value": ["products", "organization"]}, +} + ## GET ## @router.get( "", summary="View all users", - response_model=list[UserRead] | list[UserReadWithRelationships], + response_model=Page[UserRead], responses={ 200: { "description": "List of users", @@ -74,24 +81,22 @@ async def get_users( session: AsyncSessionDep, include: Annotated[ set[str] | None, - Query( - description="Relationships to include", - openapi_examples={ - "none": {"value": []}, - "products": {"value": ["products"]}, - "all": {"value": ["products", "organization"]}, - }, - ), + relationship_include_query(openapi_examples=USER_INCLUDE_EXAMPLES), ] = None, -) -> Sequence[User]: +) -> Page[UserRead]: """Get a list of all users with optional filtering and relationships.""" - return await get_models(session, User, include_relationships=include, model_filter=user_filter) + return cast( + "Page[UserRead]", + await get_paginated_models( + session, User, include_relationships=include, model_filter=user_filter, read_schema=UserRead + ), + ) @router.get( "/{user_id}", summary="View a single user by ID", - response_model=UserReadWithRelationships, + response_model=UserRead, ) async def get_user( user_id: Annotated[UUID4, Path(description="The user's ID")], diff --git a/backend/app/api/auth/routers/auth.py b/backend/app/api/auth/routers/auth.py index 22a105a5..2f561af4 100644 --- a/backend/app/api/auth/routers/auth.py +++ b/backend/app/api/auth/routers/auth.py @@ -1,45 +1,65 @@ """Authentication, registration, and login routes.""" -from fastapi import APIRouter -from pydantic import EmailStr +from typing import Annotated -from app.api.auth.schemas import UserCreate, UserCreateWithOrganization, UserRead -from app.api.auth.services.user_manager import bearer_auth_backend, cookie_auth_backend, fastapi_user_manager -from app.api.auth.utils.email_validation import is_disposable_email +from fastapi import APIRouter, Depends +from fastapi.routing import APIRoute +from pydantic import EmailStr # Needed for Fastapi dependency injection + +from app.api.auth.routers import refresh, register +from app.api.auth.schemas import UserRead +from app.api.auth.services.user_manager import ( + bearer_auth_backend, + cookie_auth_backend, + fastapi_user_manager, +) +from app.api.auth.utils.email_validation import EmailChecker, get_email_checker_dependency +from app.api.auth.utils.rate_limit import LOGIN_RATE_LIMIT, limiter from app.api.common.routers.openapi import mark_router_routes_public +LOGIN_PATH = "/login" + router = APIRouter(prefix="/auth", tags=["auth"]) -# Basic authentication routes -# TODO: Allow both username and email logins with custom login router -router.include_router(fastapi_user_manager.get_auth_router(bearer_auth_backend), prefix="/bearer") -router.include_router(fastapi_user_manager.get_auth_router(cookie_auth_backend), prefix="/cookie") -# Mark all routes in the auth router thus far as public -mark_router_routes_public(router) +# Use FastAPI-Users' built-in auth routers with rate limiting on login +bearer_router = fastapi_user_manager.get_auth_router(bearer_auth_backend) +cookie_router = fastapi_user_manager.get_auth_router(cookie_auth_backend) -# Registration, verification, and password reset routes -# TODO: Write custom register router for custom exception handling and use UserReadPublic schema for responses -# This will make the on_after_register and custom create methods in the user manager unnecessary. +# Apply rate limiting to login routes +for route in bearer_router.routes: + if isinstance(route, APIRoute) and route.path == LOGIN_PATH: + route.endpoint = limiter.limit(LOGIN_RATE_LIMIT)(route.endpoint) -router.include_router( - fastapi_user_manager.get_register_router( - UserRead, - UserCreate | UserCreateWithOrganization, # TODO: Investigate this type error - ), -) +for route in cookie_router.routes: + if isinstance(route, APIRoute) and route.path == LOGIN_PATH: + route.endpoint = limiter.limit(LOGIN_RATE_LIMIT)(route.endpoint) -router.include_router( - fastapi_user_manager.get_verify_router(user_schema=UserRead), -) -router.include_router( - fastapi_user_manager.get_reset_password_router(), -) +router.include_router(bearer_router, prefix="/bearer", tags=["auth"]) +router.include_router(cookie_router, prefix="/cookie", tags=["auth"]) + +# Custom registration route +router.include_router(register.router, tags=["auth"]) + +# Refresh token and multi-device session management +router.include_router(refresh.router, tags=["auth"]) + +# Mark all routes in the auth router thus far as public +mark_router_routes_public(router) + +# Verification and password reset routes (keep FastAPI-Users defaults) +router.include_router(fastapi_user_manager.get_verify_router(user_schema=UserRead)) +router.include_router(fastapi_user_manager.get_reset_password_router()) @router.get("/validate-email") -async def validate_email(email: EmailStr) -> dict: +async def validate_email( + email: EmailStr, + email_checker: Annotated[EmailChecker | None, Depends(get_email_checker_dependency)], +) -> dict: """Validate email address for registration.""" - is_disposable = await is_disposable_email(email) + is_disposable = False + if email_checker: + is_disposable = await email_checker.is_disposable(email) return {"isValid": not is_disposable, "reason": "Please use a permanent email address" if is_disposable else None} diff --git a/backend/app/api/auth/routers/frontend.py b/backend/app/api/auth/routers/frontend.py index 8a839091..d209b521 100644 --- a/backend/app/api/auth/routers/frontend.py +++ b/backend/app/api/auth/routers/frontend.py @@ -6,7 +6,6 @@ from fastapi.responses import HTMLResponse, RedirectResponse from fastapi.templating import Jinja2Templates -from app.api.admin.config import settings as admin_settings from app.api.auth.dependencies import OptionalCurrentActiveUserDep from app.core.config import settings as core_settings @@ -18,10 +17,7 @@ @router.get("/", response_class=HTMLResponse) -async def index( - request: Request, - user: OptionalCurrentActiveUserDep, -) -> HTMLResponse: +async def index(request: Request, user: OptionalCurrentActiveUserDep) -> HTMLResponse: """Render the landing page.""" return templates.TemplateResponse( "index.html", @@ -30,7 +26,6 @@ async def index( "user": user, "show_full_docs": user.is_superuser if user else False, "frontend_web_url": core_settings.frontend_web_url, - "admin_path": admin_settings.admin_base_url, }, ) @@ -49,6 +44,6 @@ async def login_page( ) -> Response: """Render the login page.""" if user: - return RedirectResponse(url=(next_page or router.url_path_for("index")), status_code=302) + return RedirectResponse(url=str(router.url_path_for("index")), status_code=302) return templates.TemplateResponse("login.html", {"request": request, "next": next_page}) diff --git a/backend/app/api/auth/routers/oauth.py b/backend/app/api/auth/routers/oauth.py index eba947c3..3a37225b 100644 --- a/backend/app/api/auth/routers/oauth.py +++ b/backend/app/api/auth/routers/oauth.py @@ -1,50 +1,81 @@ """OAuth-related routes.""" -from fastapi import APIRouter, Security +from fastapi import APIRouter, status +from sqlmodel import select from app.api.auth.config import settings -from app.api.auth.dependencies import current_active_superuser +from app.api.auth.dependencies import CurrentActiveUserDep +from app.api.auth.exceptions import InvalidOAuthProviderError, OAuthAccountNotLinkedError +from app.api.auth.models import OAuthAccount from app.api.auth.schemas import UserRead -from app.api.auth.services.oauth import github_oauth_client, google_oauth_client -from app.api.auth.services.user_manager import bearer_auth_backend, cookie_auth_backend, fastapi_user_manager - -# TODO: include simple UI for OAuth login and association on login page -# TODO: Create single callback endpoint for each provider at /auth/oauth/{provider}/callback -# This requires us to manually set up a single callback route that can handle multiple actions -# (token login, session login, association) +from app.api.auth.services.oauth import ( + CustomOAuthAssociateRouterBuilder, + CustomOAuthRouterBuilder, + github_oauth_client, + google_oauth_client, +) +from app.api.auth.services.user_manager import ( + bearer_auth_backend, + cookie_auth_backend, + fastapi_user_manager, +) +from app.api.common.routers.dependencies import AsyncSessionDep router = APIRouter( prefix="/auth/oauth", tags=["oauth"], - dependencies=[ # TODO: Remove superuser dependency when enabling public OAuth login - Security(current_active_superuser) - ], ) -for oauth_client in (github_oauth_client, google_oauth_client): - provider_name = oauth_client.name +for client in (github_oauth_client, google_oauth_client): + provider_name = client.name + # Google verifies email ownership, so auto-linking by email is safe. + # GitHub does not guarantee verified emails, so we keep it off to prevent account takeover. + associate_by_email = client is google_oauth_client - # Authentication router for token (bearer transport) and session (cookie transport) methods - - # TODO: Investigate: Session-based Oauth login is currently not redirecting from the auth provider to the callback. - for auth_backend, transport_method in ((bearer_auth_backend, "token"), (cookie_auth_backend, "session")): + # Authentication routers + for auth_backend, transport in ((bearer_auth_backend, "token"), (cookie_auth_backend, "session")): router.include_router( - fastapi_user_manager.get_oauth_router( - oauth_client, + CustomOAuthRouterBuilder( + client, auth_backend, - settings.fastapi_users_secret, - associate_by_email=True, + settings.fastapi_users_secret.get_secret_value(), is_verified_by_default=True, - ), - prefix=f"/{provider_name}/{transport_method}", + associate_by_email=associate_by_email, + ).build(), + prefix=f"/{provider_name}/{transport}", ) # Association router router.include_router( - fastapi_user_manager.get_oauth_associate_router( - oauth_client, + CustomOAuthAssociateRouterBuilder( + client, + fastapi_user_manager.authenticator, UserRead, - settings.fastapi_users_secret, - ), + settings.fastapi_users_secret.get_secret_value(), + ).build(), prefix=f"/{provider_name}/associate", ) + + +@router.delete("/{provider}/associate", status_code=status.HTTP_204_NO_CONTENT) +async def remove_oauth_association( + provider: str, + current_user: CurrentActiveUserDep, + session: AsyncSessionDep, +) -> None: + """Remove a linked OAuth account.""" + if provider not in ("google", "github"): + raise InvalidOAuthProviderError(provider) + + query = select(OAuthAccount).where( + OAuthAccount.user_id == current_user.db_id, + OAuthAccount.oauth_name == provider, + ) + result = await session.exec(query) + oauth_account = result.first() + + if not oauth_account: + raise OAuthAccountNotLinkedError(provider) + + await session.delete(oauth_account) + await session.commit() diff --git a/backend/app/api/auth/routers/organizations.py b/backend/app/api/auth/routers/organizations.py index 2a30521f..8e93a45d 100644 --- a/backend/app/api/auth/routers/organizations.py +++ b/backend/app/api/auth/routers/organizations.py @@ -1,14 +1,13 @@ """Public routes for managing organizations.""" -from collections.abc import Sequence -from typing import Annotated +from typing import Annotated, cast -from fastapi import APIRouter from fastapi_filter import FilterDepends +from fastapi_pagination import Page from pydantic import UUID4 from app.api.auth import crud -from app.api.auth.dependencies import CurrentActiveVerifiedUserDep, OrgByID +from app.api.auth.dependencies import CurrentActiveVerifiedUserDep from app.api.auth.filters import OrganizationFilter from app.api.auth.models import Organization, User from app.api.auth.schemas import ( @@ -18,30 +17,33 @@ UserReadPublic, UserReadWithOrganization, ) -from app.api.common.crud.base import get_models +from app.api.common.crud.utils import get_model_or_404 from app.api.common.routers.dependencies import AsyncSessionDep -from app.api.common.routers.openapi import mark_router_routes_public +from app.api.common.routers.openapi import PublicAPIRouter -router = APIRouter(prefix="/organizations", tags=["organizations"]) +router = PublicAPIRouter(prefix="/organizations", tags=["organizations"]) ### Main organization routes ### -@router.get("", summary="View all organizations", response_model=list[OrganizationReadPublic]) +@router.get("", summary="View all organizations", response_model=Page[OrganizationReadPublic]) async def get_organizations( org_filter: Annotated[OrganizationFilter, FilterDepends(OrganizationFilter)], session: AsyncSessionDep -) -> Sequence[Organization]: +) -> Page[OrganizationReadPublic]: """Get a list of all organizations with optional filtering.""" - return await get_models(session, Organization, model_filter=org_filter) + return cast( + "Page[OrganizationReadPublic]", + await crud.get_organizations(session, model_filter=org_filter, read_schema=OrganizationReadPublic), + ) @router.get( - "/{organization_id}", # noqa: FAST003 # organization_id is used by OrgByID dependency + "/{organization_id}", summary="View a single organization", response_model=OrganizationReadPublic, ) -async def get_organization(organization: OrgByID) -> Organization: +async def get_organization(organization_id: UUID4, session: AsyncSessionDep) -> Organization: """Get an organization by ID.""" - return organization + return await get_model_or_404(session, Organization, organization_id) @router.post("", response_model=OrganizationRead, status_code=201, summary="Create new organization") @@ -49,34 +51,38 @@ async def create_organization( organization: OrganizationCreate, current_user: CurrentActiveVerifiedUserDep, session: AsyncSessionDep ) -> Organization: """Create new organization with current user as owner.""" - db_org = await crud.create_organization(session, organization, current_user) - - return db_org + return await crud.create_organization(session, organization, current_user) ## Organization member routes ## @router.get( - "/{organization_id}/members", response_model=list[UserReadPublic], summary="Get the members of an organization" + "/{organization_id}/members", response_model=Page[UserReadPublic], summary="Get the members of an organization" ) async def get_organization_members( organization_id: UUID4, current_user: CurrentActiveVerifiedUserDep, session: AsyncSessionDep -) -> list[User]: +) -> Page[UserReadPublic]: """Get the members of an organization.""" - return await crud.get_organization_members(session, organization_id, current_user) + return cast( + "Page[UserReadPublic]", + await crud.get_organization_members( + session, + organization_id, + current_user, + paginate=True, + read_schema=UserReadPublic, + ), + ) @router.post( - "/{organization_id}/members/me", # noqa: FAST003 # organization_id is used by OrgByID dependency + "/{organization_id}/members/me", response_model=UserReadWithOrganization, status_code=201, summary="Join organization", ) async def join_organization( - organization: OrgByID, session: AsyncSessionDep, current_user: CurrentActiveVerifiedUserDep + organization_id: UUID4, session: AsyncSessionDep, current_user: CurrentActiveVerifiedUserDep ) -> User: """Join an organization as a member.""" + organization = await get_model_or_404(session, Organization, organization_id) return await crud.user_join_organization(session, organization, current_user) - - -# TODO: Initializing as PublicRouter doesn't seem to work, need to manually mark all routes as public. Investigate why. -mark_router_routes_public(router) diff --git a/backend/app/api/auth/routers/refresh.py b/backend/app/api/auth/routers/refresh.py new file mode 100644 index 00000000..ae77fd5d --- /dev/null +++ b/backend/app/api/auth/routers/refresh.py @@ -0,0 +1,153 @@ +"""Refresh token and multi-device session management endpoints.""" + +from typing import Annotated + +from fastapi import APIRouter, Cookie, Depends, Response, status +from fastapi.security import OAuth2PasswordBearer +from fastapi_users.authentication import Strategy + +from app.api.auth.config import settings as auth_settings +from app.api.auth.dependencies import CurrentActiveUserDep, UserManagerDep +from app.api.auth.exceptions import RefreshTokenNotFoundError, RefreshTokenUserInactiveError +from app.api.auth.schemas import ( + RefreshTokenRequest, + RefreshTokenResponse, +) +from app.api.auth.services import refresh_token_service +from app.api.auth.services.user_manager import bearer_auth_backend, cookie_auth_backend +from app.core.config import settings as core_settings +from app.core.redis import OptionalRedisDep + +oauth2_scheme = OAuth2PasswordBearer(tokenUrl="auth/bearer/login", auto_error=False) + +router = APIRouter() + + +@router.post( + "/refresh", + name="auth:bearer.refresh", + response_model=RefreshTokenResponse, + responses={ + status.HTTP_401_UNAUTHORIZED: {"description": "Invalid or expired refresh token"}, + }, +) +async def refresh_access_token( + user_manager: UserManagerDep, + strategy: Annotated[Strategy, Depends(bearer_auth_backend.get_strategy)], + redis: OptionalRedisDep, + request: RefreshTokenRequest | None = None, + cookie_refresh_token: Annotated[str | None, Cookie(alias="refresh_token")] = None, +) -> RefreshTokenResponse: + """Refresh access token using refresh token for bearer auth. + + Validates refresh token and issues new access token. + Updates session activity timestamp. + """ + actual_refresh_token = (request.refresh_token.get_secret_value() if request else None) or cookie_refresh_token + if not actual_refresh_token: + raise RefreshTokenNotFoundError + + # Verify refresh token + user_id = await refresh_token_service.verify_refresh_token(redis, actual_refresh_token) + + # Get user + user = await user_manager.get(user_id) + if not user or not user.is_active: + raise RefreshTokenUserInactiveError + + # Generate new access token + access_token = await strategy.write_token(user) + new_refresh_token = await refresh_token_service.rotate_refresh_token(redis, actual_refresh_token) + + return RefreshTokenResponse( + access_token=access_token, + refresh_token=new_refresh_token, + token_type="bearer", # noqa: S106 + expires_in=auth_settings.access_token_ttl_seconds, + ) + + +@router.post( + "/cookie/refresh", + name="auth:cookie.refresh", + responses={ + status.HTTP_204_NO_CONTENT: {"description": "Successfully refreshed"}, + status.HTTP_401_UNAUTHORIZED: {"description": "Invalid or expired refresh token"}, + }, + status_code=status.HTTP_204_NO_CONTENT, +) +async def refresh_access_token_cookie( + response: Response, + user_manager: UserManagerDep, + strategy: Annotated[Strategy, Depends(cookie_auth_backend.get_strategy)], + redis: OptionalRedisDep, + refresh_token: Annotated[str | None, Cookie()] = None, +) -> None: + """Refresh access token using refresh token from cookie. + + Validates refresh token cookie and issues new access token cookie. + Updates session activity timestamp. + """ + if not refresh_token: + raise RefreshTokenNotFoundError + + # Verify token first, then rotate after user validation succeeds. + user_id = await refresh_token_service.verify_refresh_token(redis, refresh_token) + + # Get user + user = await user_manager.get(user_id) + if not user or not user.is_active: + raise RefreshTokenUserInactiveError + + # Generate new access token and set cookie + access_token = await strategy.write_token(user) + new_refresh_token = await refresh_token_service.rotate_refresh_token(redis, refresh_token) + response.set_cookie( + key="auth", + value=access_token, + max_age=auth_settings.access_token_ttl_seconds, + httponly=True, + secure=core_settings.secure_cookies, + samesite="lax", + ) + response.set_cookie( + key="refresh_token", + value=new_refresh_token, + max_age=auth_settings.refresh_token_expire_days * 86_400, + httponly=True, + secure=core_settings.secure_cookies, + samesite="lax", + ) + + +@router.post( + "/logout", + name="auth:logout", + status_code=status.HTTP_204_NO_CONTENT, +) +async def logout( + response: Response, + current_user: CurrentActiveUserDep, + strategy: Annotated[Strategy, Depends(cookie_auth_backend.get_strategy)], + redis: OptionalRedisDep, + cookie_refresh_token: Annotated[str | None, Cookie(alias="refresh_token")] = None, + cookie_auth_token: Annotated[str | None, Cookie(alias="auth")] = None, + bearer_token: Annotated[str | None, Depends(oauth2_scheme)] = None, +) -> None: + """Logout the current user. + + Destroys the current access token in Redis and blacklists the refresh token. + Clears cookies on the client side. + """ + # 1. Destroy access token + token = bearer_token or cookie_auth_token + if token: + await strategy.destroy_token(token, current_user) + + # 2. Clear cookies + response.delete_cookie("auth", secure=core_settings.secure_cookies, httponly=True, samesite="lax") + response.delete_cookie("refresh_token", secure=core_settings.secure_cookies, httponly=True, samesite="lax") + + # 3. Blacklist refresh token + if cookie_refresh_token: + await refresh_token_service.blacklist_token(redis, cookie_refresh_token) diff --git a/backend/app/api/auth/routers/register.py b/backend/app/api/auth/routers/register.py new file mode 100644 index 00000000..fedd854b --- /dev/null +++ b/backend/app/api/auth/routers/register.py @@ -0,0 +1,85 @@ +"""Custom registration router for user creation with proper exception handling.""" + +from __future__ import annotations + +import logging + +from fastapi import APIRouter, HTTPException, Request, status +from fastapi_users.exceptions import InvalidPasswordException, UserAlreadyExists + +from app.api.auth.crud import add_user_role_in_organization_after_registration, validate_user_create +from app.api.auth.dependencies import UserManagerDep +from app.api.auth.exceptions import ( + RegistrationInvalidPasswordHTTPError, + RegistrationUnexpectedHTTPError, + RegistrationUserAlreadyExistsHTTPError, +) +from app.api.auth.models import User +from app.api.auth.schemas import UserCreate, UserCreateWithOrganization, UserReadPublic +from app.api.auth.utils.rate_limit import REGISTER_RATE_LIMIT, limiter +from app.api.common.exceptions import APIError +from app.core.logging import sanitize_log_value + +logger = logging.getLogger(__name__) + +router = APIRouter() + + +@router.post( + "/register", + response_model=UserReadPublic, + status_code=status.HTTP_201_CREATED, + summary="Register a new user", + responses={ + status.HTTP_400_BAD_REQUEST: {"description": "Bad request (disposable email, invalid password, etc.)"}, + status.HTTP_409_CONFLICT: {"description": "Conflict (user with email or username already exists)"}, + status.HTTP_429_TOO_MANY_REQUESTS: {"description": "Too many registration attempts"}, + }, +) +@limiter.limit(REGISTER_RATE_LIMIT) +async def register( + request: Request, + user_create: UserCreate | UserCreateWithOrganization, + user_manager: UserManagerDep, +) -> User: + """Register a new user with optional organization creation or joining. + + Supports two registration modes: + - With organization creation: User creates and owns a new organization + - With organization joining: User joins an existing organization as a member + - No organization: User registers without an organization + """ + try: + # Get email checker from app state if available + email_checker = ( + request.app.state.email_checker if (request.app and hasattr(request.app.state, "email_checker")) else None + ) + + # Validate user creation data (username uniqueness, disposable email, organization) + user_create = await validate_user_create(user_manager.user_db, user_create, email_checker) + + # Create the user through UserManager (handles password hashing, validation) + user = await user_manager.create(user_create, safe=True, request=request) + + # Add user to organization if specified + user = await add_user_role_in_organization_after_registration(user_manager.user_db, user, request) + + # Request email verification automatically (this triggers on_after_request_verify -> sends email) + await user_manager.request_verify(user, request) + + logger.info("User %s registered successfully", sanitize_log_value(user.email)) + + except UserAlreadyExists as e: + raise RegistrationUserAlreadyExistsHTTPError(user_create.email) from e + + except InvalidPasswordException as e: + raise RegistrationInvalidPasswordHTTPError(e.reason) from e + + except APIError as e: + raise HTTPException(status_code=e.http_status_code, detail=str(e)) from e + + except Exception as e: + logger.exception("Unexpected error during user registration") + raise RegistrationUnexpectedHTTPError from e + else: + return user diff --git a/backend/app/api/auth/routers/users.py b/backend/app/api/auth/routers/users.py index 624468af..9606d8e6 100644 --- a/backend/app/api/auth/routers/users.py +++ b/backend/app/api/auth/routers/users.py @@ -1,11 +1,16 @@ """Public user management routes.""" -from fastapi import APIRouter, HTTPException, Security +from __future__ import annotations + +from typing import cast + +from fastapi import Security +from fastapi_pagination import Page from app.api.auth import crud -from app.api.auth.dependencies import CurrentActiveVerifiedUserDep, OrgAsOwner, current_active_user -from app.api.auth.exceptions import UserHasNoOrgError -from app.api.auth.models import Organization, User +from app.api.auth.dependencies import CurrentActiveVerifiedUserDep, current_active_user +from app.api.auth.exceptions import UserDoesNotOwnOrgError, UserHasNoOrgError +from app.api.auth.models import Organization, OrganizationRole from app.api.auth.schemas import ( OrganizationRead, OrganizationReadPublic, @@ -15,12 +20,13 @@ UserUpdate, ) from app.api.auth.services.user_manager import fastapi_user_manager +from app.api.common.crud.base import get_model_by_id from app.api.common.routers.dependencies import AsyncSessionDep -from app.api.common.routers.openapi import mark_router_routes_public +from app.api.common.routers.openapi import PublicAPIRouter ### User self-management routes ### -router = APIRouter(prefix="/users", tags=["users"], dependencies=[Security(current_active_user)]) +router = PublicAPIRouter(prefix="/users", tags=["users"], dependencies=[Security(current_active_user)]) # Include autogenerated user management routes (for self-management) router.include_router( @@ -41,29 +47,50 @@ async def get_user_organization(current_user: CurrentActiveVerifiedUserDep) -> O @router.get( "/me/organization/members", - response_model=list[UserReadPublic], + response_model=Page[UserReadPublic], summary="Get the members of the organization of the current user", ) async def get_user_organization_members( current_user: CurrentActiveVerifiedUserDep, session: AsyncSessionDep, -) -> list[User]: +) -> Page[UserReadPublic]: """Get the members of the organization of the current user.""" if current_user.organization_id is None: - raise HTTPException(status_code=404, detail="User does not belong to an organization.") - return await crud.get_organization_members(session, current_user.organization_id, current_user) + raise UserHasNoOrgError(user_id=current_user.id) + return cast( + "Page[UserReadPublic]", + await crud.get_organization_members( + session, + current_user.organization_id, + current_user, + paginate=True, + read_schema=UserReadPublic, + ), + ) @router.patch("/me/organization", response_model=OrganizationRead, summary="Update your organization") async def update_organization( - db_organization: OrgAsOwner, + current_user: CurrentActiveVerifiedUserDep, organization_in: OrganizationUpdate, session: AsyncSessionDep, ) -> Organization: """Update organization as owner.""" - db_org = await crud.update_user_organization(session, db_organization, organization_in) + db_organization = current_user.organization + if db_organization is None: + if current_user.organization_id is None: + raise UserDoesNotOwnOrgError(user_id=current_user.id) + db_organization = await get_model_by_id( + session, + Organization, + current_user.organization_id, + include_relationships={"members", "owner"}, + ) - return db_org + if current_user.organization_role != OrganizationRole.OWNER: + raise UserDoesNotOwnOrgError(user_id=current_user.id) + + return await crud.update_user_organization(session, db_organization, organization_in) @router.delete("/me/organization", status_code=204, summary="Delete your organization as owner") @@ -82,7 +109,3 @@ async def leave_organization( ) -> None: """Leave current organization. Cannot be used by organization owner.""" await crud.leave_organization(session, current_user) - - -# TODO: Initializing as PublicRouter doesn't seem to work, need to manually mark all routes as public. Investigate why. -mark_router_routes_public(router) diff --git a/backend/app/api/auth/schemas.py b/backend/app/api/auth/schemas.py index 9f694819..74122cea 100644 --- a/backend/app/api/auth/schemas.py +++ b/backend/app/api/auth/schemas.py @@ -1,16 +1,17 @@ """DTO schemas for users.""" +from __future__ import annotations + import uuid -from typing import Annotated, Optional +from typing import Annotated -from fastapi_users import schemas -from pydantic import UUID4, ConfigDict, EmailStr, Field, StringConstraints +from fastapi_users import schemas as fastapi_users_schemas +from pydantic import UUID4, BaseModel, ConfigDict, EmailStr, Field, SecretStr, StringConstraints from app.api.auth.models import OrganizationBase, UserBase from app.api.common.schemas.base import BaseCreateSchema, BaseReadSchemaWithTimeStamp, BaseUpdateSchema, ProductRead -# TODO: Refactor into separate files for each model. -# This is tricky due to circular imports and the way SQLAlchemy and Pydantic handle schema building. +# Note: These auth schemas stay together to avoid circular imports during model/schema construction. ### Organizations ### @@ -31,34 +32,43 @@ class OrganizationRead(OrganizationBase): class OrganizationReadWithRelationshipsPublic(BaseReadSchemaWithTimeStamp, OrganizationBase): """Read schema for organizations, including relationships.""" - members: list["UserReadPublic"] = Field(default_factory=list, description="List of users in the organization.") + members: list[UserReadPublic] = Field(default_factory=list, description="List of users in the organization.") class OrganizationReadWithRelationships(BaseReadSchemaWithTimeStamp, OrganizationBase): """Read schema for organizations, including relationships.""" - members: list["UserRead"] = Field(default_factory=list, description="List of users in the organization.") + members: list[UserRead] = Field(default_factory=list, description="List of users in the organization.") class OrganizationUpdate(BaseUpdateSchema): """Update schema for organizations.""" - name: str = Field(min_length=2, max_length=100) + name: str | None = Field(default=None, min_length=2, max_length=100) location: str | None = Field(default=None, max_length=100) description: str | None = Field(default=None, max_length=500) - - # TODO: Handle transfer of ownership + owner_id: UUID4 | None = Field( + default=None, + description="ID of the member who should become the new owner.", + ) ### Users ### -class UserCreateBase(UserBase, schemas.BaseUserCreate): + +# Validation constraints for username field +ValidatedUsername = Annotated[ + str | None, StringConstraints(strip_whitespace=True, pattern=r"^\w+$", min_length=2, max_length=50) +] + + +class UserCreateBase(UserBase, fastapi_users_schemas.BaseUserCreate): """Base schema for user creation.""" - # Override for validation - username: Annotated[str | None, StringConstraints(strip_whitespace=True)] = None + # Override for username field validation + username: ValidatedUsername = None # Override for OpenAPI schema configuration - password: str = Field(json_schema_extra={"format": "password"}) + password: str = Field(json_schema_extra={"format": "password"}, min_length=8) class UserCreate(UserCreateBase): @@ -66,13 +76,13 @@ class UserCreate(UserCreateBase): organization_id: UUID4 | None = None - model_config: ConfigDict = ConfigDict( # pyright: ignore [reportIncompatibleVariableOverride] # This is not a type override, see https://github.com/fastapi/sqlmodel/discussions/855 + model_config: ConfigDict = ConfigDict( { "json_schema_extra": { "examples": [ { "email": "user@example.com", - "password": "fakepassword", + "password": "fake_password", "username": "username", "organization_id": "1fa85f64-5717-4562-b3fc-2c963f66afa6", } @@ -85,15 +95,15 @@ class UserCreate(UserCreateBase): class UserCreateWithOrganization(UserCreateBase): """Create schema for users with organization to create and own.""" - organization: "OrganizationCreate" + organization: OrganizationCreate - model_config: ConfigDict = ConfigDict( # pyright: ignore [reportIncompatibleVariableOverride] # This is not a type override, see https://github.com/fastapi/sqlmodel/discussions/855 + model_config: ConfigDict = ConfigDict( { "json_schema_extra": { "examples": [ { "email": "user@example.com", - "password": "fakepassword", + "password": "fake_password", "username": "username", "organization": {"name": "organization", "location": "location", "description": "description"}, } @@ -103,16 +113,28 @@ class UserCreateWithOrganization(UserCreateBase): ) +class OAuthAccountRead(BaseModel): + """Read schema for OAuth accounts.""" + + model_config: ConfigDict = ConfigDict(from_attributes=True) + + oauth_name: str + account_id: str + account_email: str + + class UserReadPublic(UserBase): """Public read schema for users.""" email: EmailStr -class UserRead(UserBase, schemas.BaseUser[uuid.UUID]): +class UserRead(UserBase, fastapi_users_schemas.BaseUser[uuid.UUID]): """Read schema for users.""" - model_config: ConfigDict = ConfigDict( # pyright: ignore [reportIncompatibleVariableOverride] # This is not a type override, see https://github.com/fastapi/sqlmodel/discussions/855 + oauth_accounts: list[OAuthAccountRead] = Field(default_factory=list, description="List of linked OAuth accounts.") + + model_config: ConfigDict = ConfigDict( { "json_schema_extra": { "examples": [ @@ -133,7 +155,7 @@ class UserRead(UserBase, schemas.BaseUser[uuid.UUID]): class UserReadWithOrganization(UserRead): """Read schema for users with organization.""" - organization: Optional["OrganizationRead"] = Field(default=None, description="Organization the user belongs to.") + organization: OrganizationRead | None = Field(default=None, description="Organization the user belongs to.") class UserReadWithRelationships(UserReadWithOrganization): @@ -142,16 +164,18 @@ class UserReadWithRelationships(UserReadWithOrganization): products: list[ProductRead] = Field(default_factory=list, description="List of products owned by the user.") -class UserUpdate(UserBase, schemas.BaseUserUpdate): +class UserUpdate(UserBase, fastapi_users_schemas.BaseUserUpdate): """Update schema for users.""" - username: Annotated[str | None, StringConstraints(strip_whitespace=True)] = None + # Override for username field validation + username: ValidatedUsername = None + organization_id: UUID4 | None = None # Override password field to include password format in JSON schema - password: str | None = Field(default=None, json_schema_extra={"format": "password"}) + password: str | None = Field(default=None, json_schema_extra={"format": "password"}, min_length=8) - model_config: ConfigDict = ConfigDict( # pyright: ignore [reportIncompatibleVariableOverride] # This is not a type override, see https://github.com/fastapi/sqlmodel/discussions/855 + model_config: ConfigDict = ConfigDict( { "json_schema_extra": { "examples": [ @@ -168,3 +192,19 @@ class UserUpdate(UserBase, schemas.BaseUserUpdate): } } ) + + +### Authentication & Sessions ### +class RefreshTokenRequest(BaseModel): + """Request schema for refreshing access token.""" + + refresh_token: SecretStr = Field(description="Refresh token obtained from login") + + +class RefreshTokenResponse(BaseModel): + """Response for token refresh.""" + + access_token: str = Field(description="New JWT access token") + refresh_token: str = Field(description="Rotated refresh token") + token_type: str = Field(default="bearer", description="Token type (always 'bearer')") + expires_in: int = Field(description="Access token expiration time in seconds") diff --git a/backend/app/api/auth/services/auth_backends.py b/backend/app/api/auth/services/auth_backends.py new file mode 100644 index 00000000..b0bbdcb3 --- /dev/null +++ b/backend/app/api/auth/services/auth_backends.py @@ -0,0 +1,71 @@ +"""Authentication backend and transport wiring.""" + +import ipaddress +from typing import cast +from urllib.parse import urlparse + +from fastapi_users.authentication import ( + AuthenticationBackend, + BearerTransport, + CookieTransport, + JWTStrategy, + RedisStrategy, + Strategy, +) +from pydantic import UUID4, SecretStr + +from app.api.auth.config import settings as auth_settings +from app.api.auth.models import User +from app.core.config import settings as core_settings +from app.core.redis import OptionalRedisDep + +ACCESS_TOKEN_TTL = auth_settings.access_token_ttl_seconds +SECRET: SecretStr = auth_settings.fastapi_users_secret + + +def build_cookie_domain(frontend_url: str) -> str | None: + """Build a cookie domain from the configured frontend URL.""" + hostname = urlparse(frontend_url).hostname or "" + try: + ipaddress.ip_address(hostname) + except ValueError: + parts = hostname.split(".") + return f".{'.'.join(parts[-2:])}" if len(parts) >= 2 else None + else: + return None + + +cookie_transport = CookieTransport( + cookie_name="auth", + cookie_max_age=ACCESS_TOKEN_TTL, + cookie_domain=build_cookie_domain(str(core_settings.frontend_web_url)), + cookie_secure=core_settings.secure_cookies, +) + +bearer_transport = BearerTransport(tokenUrl="auth/bearer/login") + + +def get_token_strategy(redis: OptionalRedisDep) -> Strategy[User, UUID4]: + """Return an authentication token strategy.""" + if redis: + return cast("Strategy[User, UUID4]", RedisStrategy(redis, lifetime_seconds=ACCESS_TOKEN_TTL)) + + return cast( + "Strategy[User, UUID4]", + JWTStrategy(secret=SECRET.get_secret_value(), lifetime_seconds=ACCESS_TOKEN_TTL), + ) + + +def build_authentication_backends() -> tuple[AuthenticationBackend[User, UUID4], AuthenticationBackend[User, UUID4]]: + """Create the bearer and cookie authentication backends.""" + bearer_auth_backend = AuthenticationBackend( + name="bearer", + transport=bearer_transport, + get_strategy=get_token_strategy, + ) + cookie_auth_backend = AuthenticationBackend( + name="cookie", + transport=cookie_transport, + get_strategy=get_token_strategy, + ) + return bearer_auth_backend, cookie_auth_backend diff --git a/backend/app/api/auth/services/login_hooks.py b/backend/app/api/auth/services/login_hooks.py new file mode 100644 index 00000000..e29389bb --- /dev/null +++ b/backend/app/api/auth/services/login_hooks.py @@ -0,0 +1,57 @@ +"""Post-login side effects for auth flows.""" + +import logging +from datetime import UTC, datetime +from typing import TYPE_CHECKING, cast + +from pydantic import UUID4 +from sqlmodel.ext.asyncio.session import AsyncSession + +from app.api.auth.config import settings as auth_settings +from app.api.auth.models import User +from app.api.auth.services import refresh_token_service +from app.core.config import settings as core_settings +from app.core.logging import sanitize_log_value + +if TYPE_CHECKING: + from starlette.requests import Request + from starlette.responses import Response + +logger = logging.getLogger(__name__) + + +async def update_last_login_metadata(user: User, request: Request | None, session: AsyncSession) -> None: + """Persist the latest login timestamp and IP address.""" + user.last_login_at = datetime.now(UTC).replace(tzinfo=None) + if request and request.client: + user.last_login_ip = request.client.host + await session.commit() + + +async def maybe_set_refresh_token_cookie(user: User, request: Request | None, response: Response | None) -> None: + """Create and attach a refresh token cookie when Redis is available.""" + if not request or not request.app.state.redis: + return + + redis = request.app.state.redis + user_id = cast("UUID4", user.id) + refresh_token = await refresh_token_service.create_refresh_token(redis, user_id) + + if response is not None: + response.set_cookie( + key="refresh_token", + value=refresh_token, + max_age=auth_settings.refresh_token_expire_days * 86_400, + httponly=True, + secure=core_settings.secure_cookies, + samesite="lax", + ) + + +def log_successful_login(user: User) -> None: + """Log a successful login event.""" + logger.info( + "User %s logged in from %s", + sanitize_log_value(user.email), + sanitize_log_value(user.last_login_ip), + ) diff --git a/backend/app/api/auth/services/oauth.py b/backend/app/api/auth/services/oauth.py index 1d12052f..c4837490 100644 --- a/backend/app/api/auth/services/oauth.py +++ b/backend/app/api/auth/services/oauth.py @@ -1,23 +1,488 @@ -"""OAuth services.""" +"""Consolidation of OAuth services and builders.""" -from httpx_oauth.clients.github import GitHubOAuth2 -from httpx_oauth.clients.google import BASE_SCOPES as GOOGLE_BASE_SCOPES -from httpx_oauth.clients.google import GoogleOAuth2 +import logging +import re +import secrets +from typing import TYPE_CHECKING, Annotated, Any, cast +from urllib.parse import ParseResult, parse_qsl, urlencode, urlparse, urlunparse -from app.api.auth.config import settings +import jwt +from fastapi import APIRouter, Depends, Query, Request, Response +from fastapi.responses import RedirectResponse +from fastapi.responses import Response as FastAPIResponse +from fastapi_users import schemas +from fastapi_users.authentication import AuthenticationBackend, Authenticator, Strategy +from fastapi_users.exceptions import UserAlreadyExists +from fastapi_users.jwt import SecretType, decode_jwt +from fastapi_users.router.common import ErrorCode +from httpx_oauth.integrations.fastapi import OAuth2AuthorizeCallback +from pydantic import UUID4 +from sqlmodel import select -### Google OAuth ### -# Standard Google OAuth (no YouTube) -google_oauth_client = GoogleOAuth2( - settings.google_oauth_client_id, settings.google_oauth_client_secret, scopes=GOOGLE_BASE_SCOPES +from app.api.auth.config import settings +from app.api.auth.exceptions import ( + OAuthAccountAlreadyLinkedError, + OAuthEmailUnavailableError, + OAuthInactiveUserHTTPError, + OAuthInvalidRedirectURIError, + OAuthInvalidStateError, + OAuthStateDecodeError, + OAuthStateExpiredError, + OAuthUserAlreadyExistsHTTPError, ) - -# YouTube-specific OAuth (only used for RPi-cam plugin) -GOOGLE_YOUTUBE_SCOPES = GOOGLE_BASE_SCOPES + settings.youtube_api_scopes -google_youtube_oauth_client = GoogleOAuth2( - settings.google_oauth_client_id, settings.google_oauth_client_secret, scopes=GOOGLE_YOUTUBE_SCOPES +from app.api.auth.models import OAuthAccount, User +from app.api.auth.services.oauth_clients import ( + GOOGLE_YOUTUBE_SCOPES, + github_oauth_client, + google_oauth_client, + google_youtube_oauth_client, +) +from app.api.auth.services.oauth_utils import ( + ACCESS_TOKEN_KEY, + CSRF_TOKEN_COOKIE_NAME, + CSRF_TOKEN_KEY, + SET_COOKIE_HEADER, + STATE_TOKEN_AUDIENCE, + OAuth2AuthorizeResponse, + OAuthCookieSettings, + generate_csrf_token, + generate_state_token, + set_csrf_cookie, +) +from app.api.auth.services.user_manager import ( + UserManager, + fastapi_user_manager, ) +from app.core.config import settings as core_settings + +if TYPE_CHECKING: + from collections.abc import Awaitable, Callable + + from httpx_oauth.oauth2 import BaseOAuth2, OAuth2Token + +logger = logging.getLogger(__name__) + +__all__ = [ + "ACCESS_TOKEN_KEY", + "CSRF_TOKEN_COOKIE_NAME", + "CSRF_TOKEN_KEY", + "GOOGLE_YOUTUBE_SCOPES", + "STATE_TOKEN_AUDIENCE", + "BaseOAuthRouterBuilder", + "CustomOAuthAssociateRouterBuilder", + "CustomOAuthRouterBuilder", + "OAuth2AuthorizeResponse", + "OAuthCookieSettings", + "generate_csrf_token", + "generate_state_token", + "github_oauth_client", + "google_oauth_client", + "google_youtube_oauth_client", +] + + +class BaseOAuthRouterBuilder: + """Base class for building OAuth routers with dynamic redirects.""" + + def __init__( + self, + oauth_client: BaseOAuth2, + state_secret: SecretType, + redirect_url: str | None = None, + cookie_settings: OAuthCookieSettings | None = None, + ) -> None: + """Initialize base builder properties.""" + self.oauth_client = oauth_client + self.state_secret = state_secret + self.redirect_url = redirect_url + self.cookie_settings = cookie_settings or OAuthCookieSettings() + + def set_csrf_cookie(self, response: Response, csrf_token: str) -> None: + """Set the CSRF cookie on the response.""" + set_csrf_cookie(response, self.cookie_settings, csrf_token) + + def verify_state(self, request: Request, state: str) -> dict[str, Any]: + """Decode the state JWT and verify CSRF protection.""" + try: + state_data = decode_jwt(state, self.state_secret, [STATE_TOKEN_AUDIENCE]) + except jwt.DecodeError as err: + raise OAuthStateDecodeError from err + except jwt.ExpiredSignatureError as err: + raise OAuthStateExpiredError from err + + cookie_csrf_token = request.cookies.get(self.cookie_settings.name) + state_csrf_token = state_data.get(CSRF_TOKEN_KEY) + + if ( + not cookie_csrf_token + or not state_csrf_token + or not secrets.compare_digest(cookie_csrf_token, state_csrf_token) + ): + raise OAuthInvalidStateError + + return state_data + + def _create_success_redirect( + self, + frontend_redirect: str, + response: Response, + ) -> Response: + """Create a redirect to the frontend with cookies and success status.""" + parts = list(urlparse(frontend_redirect)) + query = dict(parse_qsl(parts[4])) + + # Do not propagate access tokens through URL query params. + query.pop(ACCESS_TOKEN_KEY, None) + query["success"] = "true" + + parts[4] = urlencode(query) + redirect_response = RedirectResponse(urlunparse(parts)) + + for raw_header in response.raw_headers: + if raw_header[0].lower() == SET_COOKIE_HEADER: + redirect_response.headers.append("set-cookie", raw_header[1].decode("latin-1")) + return redirect_response + + @staticmethod + def _create_error_redirect(frontend_redirect: str, detail: str) -> Response: + """Create a redirect to the frontend with an error detail in the query string.""" + parts = list(urlparse(frontend_redirect)) + query = dict(parse_qsl(parts[4])) + query.pop(ACCESS_TOKEN_KEY, None) + query["success"] = "false" + query["detail"] = detail + parts[4] = urlencode(query) + return RedirectResponse(urlunparse(parts)) + + @staticmethod + def _normalize_origin(url: str) -> str: + """Normalize a URL into scheme://host[:port].""" + parsed = urlparse(url) + return f"{parsed.scheme.lower()}://{parsed.netloc.lower()}".rstrip("/") + + @staticmethod + def _normalize_redirect_target(url: str) -> str: + """Normalize a redirect target to scheme://netloc/path with no query/fragment.""" + parsed = urlparse(url) + return urlunparse((parsed.scheme.lower(), parsed.netloc.lower(), parsed.path, "", "", "")).rstrip("/") + + @staticmethod + def _is_allowed_redirect_path(path: str) -> bool: + """Validate the redirect path against the optional allowlist.""" + return not settings.oauth_allowed_redirect_paths or path in settings.oauth_allowed_redirect_paths + + def _is_allowed_http_redirect(self, redirect_uri: str, parsed_redirect: ParseResult) -> bool: + """Validate an HTTP(S) frontend redirect against trusted origins.""" + if not parsed_redirect.netloc: + return False + + redirect_origin = self._normalize_origin(redirect_uri) + if core_settings.cors_origin_regex and re.fullmatch(core_settings.cors_origin_regex, redirect_origin): + return self._is_allowed_redirect_path(parsed_redirect.path) + + return redirect_origin in core_settings.allowed_origins and self._is_allowed_redirect_path(parsed_redirect.path) + + def _is_allowed_native_redirect(self, redirect_uri: str) -> bool: + """Validate a native deep-link callback against the explicit allowlist.""" + normalized_redirect = self._normalize_redirect_target(redirect_uri) + allowed_native_redirects = { + self._normalize_redirect_target(uri) for uri in settings.oauth_allowed_native_redirect_uris + } + return normalized_redirect in allowed_native_redirects + + def _is_allowed_frontend_redirect(self, redirect_uri: str) -> bool: + """Validate whether a frontend redirect URI is explicitly allowed.""" + parsed = urlparse(redirect_uri) + # Prevent credentials in URL and prevent fragment smuggling. + if not parsed.scheme or parsed.username or parsed.password or parsed.fragment: + return False + + if parsed.scheme in {"http", "https"}: + return self._is_allowed_http_redirect(redirect_uri, parsed) + + return self._is_allowed_native_redirect(redirect_uri) + + +class CustomOAuthRouterBuilder(BaseOAuthRouterBuilder): + """Builder for the main OAuth authentication router.""" + + def __init__( + self, + oauth_client: BaseOAuth2, + backend: AuthenticationBackend[User, UUID4], + state_secret: SecretType, + redirect_url: str | None = None, + cookie_settings: OAuthCookieSettings | None = None, + *, + associate_by_email: bool = False, + is_verified_by_default: bool = False, + ) -> None: + """Initialize the router builder.""" + super().__init__(oauth_client, state_secret, redirect_url, cookie_settings) + self.backend = backend + self.associate_by_email = associate_by_email + self.is_verified_by_default = is_verified_by_default + self.callback_route_name = f"oauth:{oauth_client.name}.{backend.name}.callback" + + def build(self) -> APIRouter: + """Construct the APIRouter.""" + router = APIRouter() + + callback_route_name = self.callback_route_name + if self.redirect_url is not None: + oauth2_authorize_callback = OAuth2AuthorizeCallback(self.oauth_client, redirect_url=self.redirect_url) + else: + oauth2_authorize_callback = OAuth2AuthorizeCallback(self.oauth_client, route_name=callback_route_name) + + @router.get( + "/authorize", + name=f"oauth:{self.oauth_client.name}.{self.backend.name}.authorize", + response_model=OAuth2AuthorizeResponse, + ) + async def authorize( + request: Request, + response: Response, + scopes: Annotated[list[str] | None, Query()] = None, + ) -> OAuth2AuthorizeResponse: + return await self._get_authorize_handler(request, response, scopes) + + @router.get( + "/callback", + name=callback_route_name, + description="The response varies based on the authentication backend used.", + ) + async def callback( + request: Request, + access_token_state: Annotated[tuple[OAuth2Token, str], Depends(oauth2_authorize_callback)], + user_manager: Annotated[UserManager, Depends(fastapi_user_manager.get_user_manager)], + strategy: Annotated[Strategy[User, UUID4], Depends(self.backend.get_strategy)], + ) -> Response: + return await self._get_callback_handler(request, access_token_state, user_manager, strategy) + + return router + + async def _get_authorize_handler( + self, + request: Request, + response: Response, + scopes: list[str] | None, + ) -> OAuth2AuthorizeResponse: + authorize_redirect_url = self.redirect_url + if authorize_redirect_url is None: + authorize_redirect_url = str(request.url_for(self.callback_route_name)) + + csrf_token = generate_csrf_token() + state_data: dict[str, str] = {CSRF_TOKEN_KEY: csrf_token} + + redirect_uri = request.query_params.get("redirect_uri") + if redirect_uri: + if not self._is_allowed_frontend_redirect(redirect_uri): + raise OAuthInvalidRedirectURIError + state_data["frontend_redirect_uri"] = redirect_uri + + state = generate_state_token(state_data, self.state_secret) + authorization_url = await self.oauth_client.get_authorization_url( + authorize_redirect_url, + state, + scopes, + ) + + self.set_csrf_cookie(response, csrf_token) + return OAuth2AuthorizeResponse(authorization_url=authorization_url) + + async def _get_callback_handler( + self, + request: Request, + access_token_state: tuple[OAuth2Token, str], + user_manager: UserManager, + strategy: Strategy[User, UUID4], + ) -> Response: + token, state = access_token_state + state_data = self.verify_state(request, state) + frontend_redirect = state_data.get("frontend_redirect_uri") + + account_id, account_email = await self.oauth_client.get_id_email(token["access_token"]) + if account_email is None: + if frontend_redirect: + return self._create_error_redirect(frontend_redirect, ErrorCode.OAUTH_NOT_AVAILABLE_EMAIL.value) + raise OAuthEmailUnavailableError + + oauth_callback = cast( + "Callable[..., Awaitable[User]]", + user_manager.oauth_callback, + ) + + try: + user = await oauth_callback( + self.oauth_client.name, + token[ACCESS_TOKEN_KEY], + account_id, + account_email, + token.get("expires_at"), + token.get("refresh_token"), + request, + associate_by_email=self.associate_by_email, + is_verified_by_default=self.is_verified_by_default, + ) + except UserAlreadyExists as err: + if frontend_redirect: + return self._create_error_redirect(frontend_redirect, ErrorCode.OAUTH_USER_ALREADY_EXISTS.value) + raise OAuthUserAlreadyExistsHTTPError from err + + if not user.is_active: + if frontend_redirect: + return self._create_error_redirect(frontend_redirect, ErrorCode.LOGIN_BAD_CREDENTIALS.value) + raise OAuthInactiveUserHTTPError + + response = await self.backend.login(strategy, user) + await user_manager.on_after_login(user, request, response) + + if frontend_redirect: + return self._create_success_redirect(frontend_redirect, response) + + return response + + +class CustomOAuthAssociateRouterBuilder(BaseOAuthRouterBuilder): + """Builder for the OAuth association router.""" + + def __init__( + self, + oauth_client: BaseOAuth2, + authenticator: Authenticator[User, UUID4], + user_schema: type[schemas.U], + state_secret: SecretType, + redirect_url: str | None = None, + cookie_settings: OAuthCookieSettings | None = None, + *, + requires_verification: bool = False, + ) -> None: + """Initialize association router builder.""" + super().__init__(oauth_client, state_secret, redirect_url, cookie_settings) + self.authenticator = authenticator + self.user_schema = user_schema + self.requires_verification = requires_verification + self.callback_route_name = f"oauth-associate:{oauth_client.name}.callback" + + def build(self) -> APIRouter: + """Construct the APIRouter.""" + router = APIRouter() + get_current_active_user = self.authenticator.current_user(active=True, verified=self.requires_verification) + + callback_route_name = self.callback_route_name + if self.redirect_url is not None: + oauth2_authorize_callback = OAuth2AuthorizeCallback(self.oauth_client, redirect_url=self.redirect_url) + else: + oauth2_authorize_callback = OAuth2AuthorizeCallback(self.oauth_client, route_name=callback_route_name) + + @router.get( + "/authorize", + name=f"oauth-associate:{self.oauth_client.name}.authorize", + response_model=OAuth2AuthorizeResponse, + ) + async def authorize( + request: Request, + response: Response, + user: Annotated[User, Depends(get_current_active_user)], + scopes: Annotated[list[str] | None, Query()] = None, + ) -> OAuth2AuthorizeResponse: + return await self._get_authorize_handler(request, response, user, scopes) + + @router.get( + "/callback", + response_model=self.user_schema, + name=callback_route_name, + description="The response varies based on the authentication backend used.", + ) + async def callback( + request: Request, + user: Annotated[User, Depends(get_current_active_user)], + access_token_state: Annotated[tuple[OAuth2Token, str], Depends(oauth2_authorize_callback)], + user_manager: Annotated[UserManager, Depends(fastapi_user_manager.get_user_manager)], + ) -> Response | schemas.U: + return await self._get_callback_handler(request, user, access_token_state, user_manager) + + return router + + async def _get_authorize_handler( + self, + request: Request, + response: Response, + user: User, + scopes: list[str] | None, + ) -> OAuth2AuthorizeResponse: + authorize_redirect_url = self.redirect_url + if authorize_redirect_url is None: + authorize_redirect_url = str(request.url_for(self.callback_route_name)) + + csrf_token = generate_csrf_token() + state_data: dict[str, str] = {"sub": str(user.id), CSRF_TOKEN_KEY: csrf_token} + + redirect_uri = request.query_params.get("redirect_uri") + if redirect_uri: + if not self._is_allowed_frontend_redirect(redirect_uri): + raise OAuthInvalidRedirectURIError + state_data["frontend_redirect_uri"] = redirect_uri + + state = generate_state_token(state_data, self.state_secret) + authorization_url = await self.oauth_client.get_authorization_url( + authorize_redirect_url, + state, + scopes, + ) + + self.set_csrf_cookie(response, csrf_token) + return OAuth2AuthorizeResponse(authorization_url=authorization_url) + + async def _get_callback_handler( + self, + request: Request, + user: User, + access_token_state: tuple[OAuth2Token, str], + user_manager: UserManager, + ) -> Response | schemas.U: + token, state = access_token_state + state_data = self.verify_state(request, state) + + if state_data.get("sub") != str(user.id): + raise OAuthInvalidStateError + + account_id, account_email = await self.oauth_client.get_id_email(token["access_token"]) + if account_email is None: + raise OAuthEmailUnavailableError + + # Pre-check: Is this account already linked somewhere else? + session = user_manager.user_db.session + existing_account = ( + await session.exec( + select(OAuthAccount).where( + OAuthAccount.oauth_name == self.oauth_client.name, + OAuthAccount.account_id == account_id, + ) + ) + ).first() + + if existing_account and existing_account.user_id != user.id: + raise OAuthAccountAlreadyLinkedError + + oauth_associate_callback = cast( + "Callable[..., Awaitable[User]]", + user_manager.oauth_associate_callback, + ) + + user = await oauth_associate_callback( + user, + self.oauth_client.name, + token["access_token"], + account_id, + account_email, + token.get("expires_at"), + token.get("refresh_token"), + request, + ) + frontend_redirect = state_data.get("frontend_redirect_uri") + if frontend_redirect: + return self._create_success_redirect(frontend_redirect, FastAPIResponse()) -### GitHub OAuth ### -github_oauth_client = GitHubOAuth2(settings.github_oauth_client_id, settings.github_oauth_client_secret) + return cast("schemas.U", self.user_schema.model_validate(user)) diff --git a/backend/app/api/auth/services/oauth_clients.py b/backend/app/api/auth/services/oauth_clients.py new file mode 100644 index 00000000..199af014 --- /dev/null +++ b/backend/app/api/auth/services/oauth_clients.py @@ -0,0 +1,28 @@ +"""OAuth client instances and scope definitions.""" + +from httpx_oauth.clients.github import GitHubOAuth2 +from httpx_oauth.clients.google import BASE_SCOPES as GOOGLE_BASE_SCOPES +from httpx_oauth.clients.google import GoogleOAuth2 + +from app.api.auth.config import settings + +# Google +google_oauth_client = GoogleOAuth2( + settings.google_oauth_client_id.get_secret_value(), + settings.google_oauth_client_secret.get_secret_value(), + scopes=GOOGLE_BASE_SCOPES, +) + +# YouTube (only used for RPi-cam plugin) +GOOGLE_YOUTUBE_SCOPES = GOOGLE_BASE_SCOPES + settings.youtube_api_scopes +google_youtube_oauth_client = GoogleOAuth2( + settings.google_oauth_client_id.get_secret_value(), + settings.google_oauth_client_secret.get_secret_value(), + scopes=GOOGLE_YOUTUBE_SCOPES, +) + +# GitHub +github_oauth_client = GitHubOAuth2( + settings.github_oauth_client_id.get_secret_value(), + settings.github_oauth_client_secret.get_secret_value(), +) diff --git a/backend/app/api/auth/services/oauth_utils.py b/backend/app/api/auth/services/oauth_utils.py new file mode 100644 index 00000000..330c9e06 --- /dev/null +++ b/backend/app/api/auth/services/oauth_utils.py @@ -0,0 +1,64 @@ +"""OAuth helper DTOs and token utilities.""" + +import secrets +from dataclasses import dataclass +from typing import TYPE_CHECKING + +from fastapi import Response +from fastapi_users.jwt import SecretType, generate_jwt +from pydantic import BaseModel + +from app.core.config import settings as core_settings +from app.core.constants import HOUR + +if TYPE_CHECKING: + from typing import Literal + +STATE_TOKEN_AUDIENCE = "fastapi-users:oauth-state" # noqa: S105 +CSRF_TOKEN_KEY = "csrftoken" # noqa: S105 +CSRF_TOKEN_COOKIE_NAME = "fastapiusersoauthcsrf" # noqa: S105 # spell-checker: ignore fastapiusersoauthcsrf +SET_COOKIE_HEADER = b"set-cookie" +ACCESS_TOKEN_KEY = "access_token" # noqa: S105 + + +class OAuth2AuthorizeResponse(BaseModel): + """Response model for OAuth2 authorization endpoint.""" + + authorization_url: str + + +def generate_state_token(data: dict[str, str], secret: SecretType, lifetime_seconds: int = HOUR) -> str: + """Generate a JWT state token for OAuth flows.""" + data["aud"] = STATE_TOKEN_AUDIENCE + return generate_jwt(data, secret, lifetime_seconds) + + +def generate_csrf_token() -> str: + """Generate a CSRF token for OAuth flows.""" + return secrets.token_urlsafe(32) + + +@dataclass +class OAuthCookieSettings: + """Configuration for OAuth CSRF cookies.""" + + name: str = CSRF_TOKEN_COOKIE_NAME + path: str = "/" + domain: str | None = None + secure: bool = core_settings.secure_cookies + httponly: bool = True + samesite: Literal["lax", "strict", "none"] = "lax" + + +def set_csrf_cookie(response: Response, cookie_settings: OAuthCookieSettings, csrf_token: str) -> None: + """Set the CSRF cookie on the response.""" + response.set_cookie( + cookie_settings.name, + csrf_token, + max_age=HOUR, + path=cookie_settings.path, + domain=cookie_settings.domain, + secure=cookie_settings.secure, + httponly=cookie_settings.httponly, + samesite=cookie_settings.samesite, + ) diff --git a/backend/app/api/auth/services/refresh_token_service.py b/backend/app/api/auth/services/refresh_token_service.py new file mode 100644 index 00000000..7e5c478c --- /dev/null +++ b/backend/app/api/auth/services/refresh_token_service.py @@ -0,0 +1,165 @@ +"""Refresh token service for managing long-lived authentication tokens. + +This module supports both Redis-backed storage and an in-memory fallback +used when Redis is unavailable (convenient for local development). +""" +# spell-checker: ignore setex + +from __future__ import annotations + +import secrets +import time +from typing import TYPE_CHECKING +from uuid import UUID + +from pydantic import UUID4 + +from app.api.auth.config import settings +from app.api.auth.exceptions import RefreshTokenInvalidError, RefreshTokenRevokedError +from app.core.constants import HOUR + +if TYPE_CHECKING: + from redis.asyncio import Redis + + +# In-memory stores used when Redis is not available. Keys are the raw token strings. +# Values for _memory_tokens: token -> (user_id_str, expire_ts) +# Values for _memory_blacklist: token -> expire_ts +_memory_tokens: dict[str, tuple[str, float]] = {} +_memory_blacklist: dict[str, float] = {} + + +async def create_refresh_token( + redis: Redis | None, + user_id: UUID4, +) -> str: + """Create a new refresh token. + + Args: + redis: Redis client or None for in-memory fallback + user_id: User's UUID + + Returns: + Refresh token string + """ + token = secrets.token_urlsafe(48) + + ttl = settings.refresh_token_expire_days * 86400 + + if redis is None: + expire_ts = time.time() + ttl + _memory_tokens[token] = (str(user_id), expire_ts) + return token + + # Store token with user_id mapping in Redis + token_key = f"auth:rt:{token}" + await redis.setex(token_key, ttl, str(user_id)) + return token + + +async def verify_refresh_token( + redis: Redis | None, + token: str, +) -> UUID: + """Verify a refresh token and return the user ID. + + Args: + redis: Redis client + token: Refresh token to verify + + Returns: + UUID of the user + + Raises: + RefreshTokenError: If token is invalid, expired, or blacklisted + """ + # Check if token is blacklisted + if redis is None: + # In-memory blacklist check + bl_expire = _memory_blacklist.get(token) + if bl_expire and bl_expire > time.time(): + raise RefreshTokenRevokedError + else: + blacklist_key = f"auth:rt_blacklist:{token}" + if await redis.exists(blacklist_key): + raise RefreshTokenRevokedError + + if redis is None: + token_data = _memory_tokens.get(token) + if not token_data or token_data[1] <= time.time(): + raise RefreshTokenInvalidError + user_id_str = token_data[0] + else: + token_key = f"auth:rt:{token}" + user_id_str = await redis.get(token_key) + + if not user_id_str: + raise RefreshTokenInvalidError + + return UUID(user_id_str if isinstance(user_id_str, str) else user_id_str.decode("utf-8")) + + +async def blacklist_token( + redis: Redis | None, + token: str, + ttl_seconds: int | None = None, +) -> None: + """Blacklist a refresh token and delete it. + + Args: + redis: Redis client + token: Refresh token to blacklist + ttl_seconds: TTL for blacklist entry (if None, uses remaining token TTL) + """ + token_key = f"auth:rt:{token}" + + if redis is None: + # Determine ttl from token if not provided + if ttl_seconds is None: + token_data = _memory_tokens.get(token) + ttl_seconds = max(int(token_data[1] - time.time()), HOUR) if token_data else HOUR + + _memory_blacklist[token] = time.time() + ttl_seconds + _memory_tokens.pop(token, None) + return + + if ttl_seconds is None: + # Get remaining TTL from the token itself + ttl_seconds = await redis.ttl(token_key) + if ttl_seconds <= 0: + ttl_seconds = HOUR # Default 1 hour if token already expired + + # Add to blacklist + blacklist_key = f"auth:rt_blacklist:{token}" + await redis.setex(blacklist_key, ttl_seconds, "1") + + # Delete the token + await redis.delete(token_key) + + +async def rotate_refresh_token( + redis: Redis | None, + old_token: str, +) -> str: + """Rotate a refresh token (create new, blacklist old). + + Args: + redis: Redis client + old_token: Old refresh token + + Returns: + New refresh token + + Raises: + RefreshTokenError: If old token is invalid + """ + # Verify old token + user_id = await verify_refresh_token(redis, old_token) + + # Create new token + new_token = await create_refresh_token(redis, user_id) + + # Blacklist old token + await blacklist_token(redis, old_token) + + return new_token diff --git a/backend/app/api/auth/services/user_db.py b/backend/app/api/auth/services/user_db.py new file mode 100644 index 00000000..6631b8ce --- /dev/null +++ b/backend/app/api/auth/services/user_db.py @@ -0,0 +1,18 @@ +"""User database adapter boundary for FastAPI Users + SQLModel.""" + +from typing import TYPE_CHECKING + +from fastapi_users_db_sqlmodel import SQLModelUserDatabaseAsync + +from app.api.auth.models import OAuthAccount, User +from app.api.common.routers.dependencies import AsyncSessionDep + +if TYPE_CHECKING: + from collections.abc import AsyncGenerator + + from pydantic import UUID4 + + +async def get_user_db(session: AsyncSessionDep) -> AsyncGenerator[SQLModelUserDatabaseAsync[User, UUID4]]: + """Async generator for the user database.""" + yield SQLModelUserDatabaseAsync(session, User, OAuthAccount) diff --git a/backend/app/api/auth/services/user_manager.py b/backend/app/api/auth/services/user_manager.py index daad8b77..5ae352cd 100644 --- a/backend/app/api/auth/services/user_manager.py +++ b/backend/app/api/auth/services/user_manager.py @@ -1,101 +1,92 @@ """User management service.""" import logging -from collections.abc import AsyncGenerator +from typing import TYPE_CHECKING, cast -import tldextract from fastapi import Depends -from fastapi_users import FastAPIUsers, InvalidPasswordException, UUIDIDMixin -from fastapi_users.authentication import AuthenticationBackend, BearerTransport, CookieTransport, JWTStrategy -from fastapi_users.jwt import SecretType +from fastapi.security import OAuth2PasswordRequestForm +from fastapi_users import FastAPIUsers, InvalidPasswordException, UUIDIDMixin, schemas from fastapi_users.manager import BaseUserManager -from fastapi_users_db_sqlmodel import SQLModelUserDatabaseAsync -from pydantic import UUID4, SecretStr -from starlette.requests import Request +from pydantic import UUID4, EmailStr, SecretStr, TypeAdapter, ValidationError +from sqlmodel import select from app.api.auth.config import settings as auth_settings -from app.api.auth.crud import ( - add_user_role_in_organization_after_registration, - create_user_override, - update_user_override, +from app.api.auth.crud.users import update_user_override +from app.api.auth.models import User +from app.api.auth.schemas import UserCreate, UserUpdate +from app.api.auth.services.auth_backends import build_authentication_backends +from app.api.auth.services.login_hooks import ( + log_successful_login, + maybe_set_refresh_token_cookie, + update_last_login_metadata, ) -from app.api.auth.exceptions import AuthCRUDError -from app.api.auth.models import OAuthAccount, User -from app.api.auth.schemas import UserCreate, UserCreateWithOrganization, UserUpdate +from app.api.auth.services.user_db import get_user_db from app.api.auth.utils.programmatic_emails import ( send_post_verification_email, - send_registration_email, send_reset_password_email, send_verification_email, ) -from app.api.common.routers.dependencies import AsyncSessionDep -from app.core.config import settings as core_settings +from app.core.logging import sanitize_log_value + +if TYPE_CHECKING: + from collections.abc import AsyncGenerator + + from fastapi_users.authentication import AuthenticationBackend + from fastapi_users.jwt import SecretType + from fastapi_users_db_sqlmodel import SQLModelUserDatabaseAsync + from starlette.requests import Request + from starlette.responses import Response # Set up logging logger = logging.getLogger(__name__) # Declare constants -SECRET: str = auth_settings.fastapi_users_secret +SECRET: SecretStr = auth_settings.fastapi_users_secret ACCESS_TOKEN_TTL = auth_settings.access_token_ttl_seconds RESET_TOKEN_TTL = auth_settings.reset_password_token_ttl_seconds VERIFICATION_TOKEN_TTL = auth_settings.verification_token_ttl_seconds -class UserManager(UUIDIDMixin, BaseUserManager[User, UUID4]): +_AUTH_COOKIE_PREFIX = "auth=" +_SET_COOKIE_HEADER = "set-cookie" + + +class UserManager(UUIDIDMixin, BaseUserManager[User, UUID4]): # spell-checker: ignore UUIDID """User manager class for FastAPI-Users.""" + # We will initialize the user manager with a SQLModelUserDatabaseAsync instance in the dependency function below + user_db: SQLModelUserDatabaseAsync + # Set up token secrets and lifetimes - reset_password_token_secret: SecretType = SECRET + reset_password_token_secret: SecretType = SECRET.get_secret_value() reset_password_token_lifetime_seconds = RESET_TOKEN_TTL - verification_token_secret: SecretType = SECRET + verification_token_secret: SecretType = SECRET.get_secret_value() verification_token_lifetime_seconds = VERIFICATION_TOKEN_TTL - async def create( - self, - user_create: UserCreate | UserCreateWithOrganization, - safe: bool = False, # noqa: FBT001, FBT002 # This boolean-typed positional argument is expected by the `create` function signature - request: Request | None = None, - ) -> User: - """Override of base user creation with additional username uniqueness check and organization creation.""" - try: - user_create = await create_user_override(self.user_db, user_create) - # HACK: This is a temporary solution to allow error propagation for username and organization creation errors. - # The built-in UserManager register route can only catch UserAlreadyExists and InvalidPasswordException errors. - # TODO: Implement custom exceptions in custom register router, this will also simplify user creation crud. - except AuthCRUDError as e: - raise InvalidPasswordException( - reason="WARNING: This is an AuthCRUDError error, not an InvalidPasswordException. To be fixed. " - + str(e) - ) from e - return await super().create(user_create, safe, request) - - async def update( - self, - user_update: UserUpdate, - user: User, - safe: bool = False, # noqa: FBT001, FBT002 # This boolean-typed positional argument is expected by the `create` function signature - request: Request | None = None, - ) -> User: - """Override of base user update with additional username and organization validation.""" + async def authenticate(self, credentials: OAuth2PasswordRequestForm) -> User | None: + """Support login with either email or username.""" + is_email = False try: - user_update = await update_user_override(self.user_db, user, user_update) - # HACK: This is a temporary solution to allow error propagation for username and organization creation errors. - # The built-in UserManager register route can only catch UserAlreadyExists and InvalidPasswordException errors. - # TODO: Implement custom exceptions in custom update router, this will also simplify user creation crud. - except AuthCRUDError as e: - raise InvalidPasswordException( - reason="WARNING: This is an AuthCRUDError error, not an InvalidPasswordException. To be fixed. " - + str(e) - ) from e - - return await super().update(user_update, user, safe, request) - - async def validate_password( # pyright: ignore [reportIncompatibleMethodOverride] # Allow overriding user type in method + TypeAdapter(EmailStr).validate_python(credentials.username) + is_email = True + except ValidationError: + pass + + if not is_email: + statement = select(User).where(User.username == credentials.username) + result = await self.user_db.session.exec(statement) + db_user = result.unique().one_or_none() + if db_user: + credentials.username = db_user.email + return await super().authenticate(credentials) + + async def validate_password( self, password: str | SecretStr, user: UserCreate | User, ) -> None: + """Validate password meets security requirements.""" if isinstance(password, SecretStr): password = password.get_secret_value() if len(password) < 8: @@ -104,80 +95,59 @@ async def validate_password( # pyright: ignore [reportIncompatibleMethodOverrid raise InvalidPasswordException(reason="Password should not contain e-mail") if user.username and user.username in password: raise InvalidPasswordException(reason="Password should not contain username") + if user.username and user.username in password: + raise InvalidPasswordException(reason="Password should not contain username") - async def on_after_register(self, user: User, request: Request | None = None) -> None: - if not request: - err_msg = "Request object is required for user registration" - raise RuntimeError(err_msg) - - user = await add_user_role_in_organization_after_registration(self.user_db, user, request) - - # HACK: Skip sending registration email for programmatically created users by using synthetic request state - if request and hasattr(request.state, "send_registration_email") and not request.state.send_registration_email: - logger.info("Skipping registration email for user %s", user.email) - return - - # HACK: Create synthetic request to specify sending registration email with verification token - # instead of normal verification email - request = Request(scope={"type": "http"}) - request.state.send_registration_email = True - await self.request_verify(user, request) - - async def on_after_request_verify( - self, user: User, token: str, request: Request | None = None - ) -> None: # Request argument is expected in the method signature - if request and hasattr(request.state, "send_registration_email") and request.state.send_registration_email: - # Send registration email with verification token if synthetic request state is set - await send_registration_email(user.email, user.username, token) - logger.info("Registration email sent to user %s", user.email) - else: - await send_verification_email(user.email, user.username, token) - logger.info("Verification email sent to user %s", user.email) + async def update( + self, + user_update: schemas.UU, + user: User, + safe: bool = False, # noqa: FBT002, FBT001 # Expected by parent class signature + request: Request | None = None, + ) -> User: + """Update a user, injecting custom organization & username validation first.""" + # Will raise exceptions like UserNameAlreadyExistsError if validation fails + real_user_update = cast("UserUpdate", user_update) + real_user_update = await update_user_override(self.user_db, user, real_user_update) + user_update = cast("schemas.UU", real_user_update) + + # Proceed with base FastAPI User update logic + return await super().update(user_update, user, safe=safe, request=request) + + async def on_after_request_verify(self, user: User, token: str, request: Request | None = None) -> None: # noqa: ARG002 # Request argument is expected in the method signature + """Send verification email after verification is requested.""" + await send_verification_email(user.email, user.username, token) + logger.info("Verification email sent to user %s", sanitize_log_value(user.email)) async def on_after_verify(self, user: User, request: Request | None = None) -> None: # noqa: ARG002 # Request argument is expected in the method signature - logger.info("User %s has been verified.", user.email) + """Send welcome email after user verifies their email.""" + logger.info("User %s has been verified.", sanitize_log_value(user.email)) await send_post_verification_email(user.email, user.username) async def on_after_forgot_password(self, user: User, token: str, request: Request | None = None) -> None: # noqa: ARG002 # Request argument is expected in the method signature - logger.info("User %s has forgot their password. Reset token: %s", user.email, token) + """Send password reset email.""" + logger.info("User %s has forgot their password. Sending reset token", sanitize_log_value(user.email)) await send_reset_password_email(user.email, user.username, token) - -async def get_user_db(session: AsyncSessionDep) -> AsyncGenerator[SQLModelUserDatabaseAsync]: - """Async generator for the user database.""" - yield SQLModelUserDatabaseAsync(session, User, OAuthAccount) + async def on_after_login( + self, user: User, request: Request | None = None, response: Response | None = None + ) -> None: + """Update last login timestamp, create refresh token and session after successful authentication.""" + await update_last_login_metadata(user, request, self.user_db.session) + await maybe_set_refresh_token_cookie(user, request, response) + log_successful_login(user) -async def get_user_manager(user_db: SQLModelUserDatabaseAsync = Depends(get_user_db)) -> AsyncGenerator[UserManager]: +async def get_user_manager( + user_db: SQLModelUserDatabaseAsync[User, UUID4] = Depends(get_user_db), +) -> AsyncGenerator[UserManager]: """Async generator for the user manager.""" yield UserManager(user_db) -# Bearer Transport -bearer_transport = BearerTransport(tokenUrl="auth/bearer/login") - - -# Cookie Transport - -# Set the cookie domain to the main host, including subdomains (hence the dot prefix) -url_extract = tldextract.extract(str(core_settings.frontend_web_url)) -cookie_domain = f".{url_extract.domain}.{url_extract.suffix}" if url_extract.domain and url_extract.suffix else None - -cookie_transport = CookieTransport( - cookie_name="auth", - cookie_max_age=ACCESS_TOKEN_TTL, - cookie_domain=cookie_domain, -) - - -def get_jwt_strategy() -> JWTStrategy: - """Get a JWT strategy to be used in authentication backends.""" - return JWTStrategy(secret=SECRET, lifetime_seconds=ACCESS_TOKEN_TTL) - - -# Authentication backends -bearer_auth_backend = AuthenticationBackend(name="bearer", transport=bearer_transport, get_strategy=get_jwt_strategy) -cookie_auth_backend = AuthenticationBackend(name="cookie", transport=cookie_transport, get_strategy=get_jwt_strategy) +bearer_auth_backend: AuthenticationBackend[User, UUID4] +cookie_auth_backend: AuthenticationBackend[User, UUID4] +bearer_auth_backend, cookie_auth_backend = build_authentication_backends() # User manager singleton fastapi_user_manager = FastAPIUsers[User, UUID4](get_user_manager, [bearer_auth_backend, cookie_auth_backend]) diff --git a/backend/app/api/auth/utils/context_managers.py b/backend/app/api/auth/utils/context_managers.py index 9823ba65..107a15b9 100644 --- a/backend/app/api/auth/utils/context_managers.py +++ b/backend/app/api/auth/utils/context_managers.py @@ -1,6 +1,5 @@ """Async context managers for user database and user manager.""" -from collections.abc import AsyncGenerator from contextlib import asynccontextmanager from typing import TYPE_CHECKING @@ -10,6 +9,8 @@ from app.core.database import async_session_context if TYPE_CHECKING: + from collections.abc import AsyncGenerator + from app.api.auth.services.user_manager import UserManager get_async_user_db_context = asynccontextmanager(get_user_db) @@ -19,7 +20,7 @@ @asynccontextmanager async def get_chained_async_user_manager_context( session: AsyncSession | None = None, -) -> AsyncGenerator["UserManager"]: +) -> AsyncGenerator[UserManager]: """Provides a user manager context using the user database and an async database session. If a session is provided, it will be used; otherwise, a new session for the default database will be created. diff --git a/backend/app/api/auth/utils/email_config.py b/backend/app/api/auth/utils/email_config.py new file mode 100644 index 00000000..81014280 --- /dev/null +++ b/backend/app/api/auth/utils/email_config.py @@ -0,0 +1,31 @@ +"""Email configuration for fastapi-mail. + +This module provides the FastMail instance and configuration for sending emails +throughout the application. +""" + +from pathlib import Path + +from fastapi_mail import ConnectionConfig, FastMail + +from app.api.auth.config import settings as auth_settings +from app.core.config import settings as core_settings + +# Path to pre-compiled HTML email templates +TEMPLATE_FOLDER = Path(__file__).parent.parent.parent.parent / "templates" / "emails" / "build" + +# Configure email connection +email_conf = ConnectionConfig( + MAIL_USERNAME=auth_settings.email_username, + MAIL_PASSWORD=auth_settings.email_password, + MAIL_FROM=auth_settings.email_from, + MAIL_PORT=auth_settings.email_port, + MAIL_SERVER=auth_settings.email_host, + MAIL_STARTTLS=True, + MAIL_SSL_TLS=False, + TEMPLATE_FOLDER=TEMPLATE_FOLDER, + SUPPRESS_SEND=core_settings.mock_emails, +) + +# Create FastMail instance +fm = FastMail(email_conf) diff --git a/backend/app/api/auth/utils/email_validation.py b/backend/app/api/auth/utils/email_validation.py index b4e3ac1a..27131d12 100644 --- a/backend/app/api/auth/utils/email_validation.py +++ b/backend/app/api/auth/utils/email_validation.py @@ -1,54 +1,155 @@ -# backend/app/api/auth/utils/email_validation.py -from datetime import UTC, datetime, timedelta -from pathlib import Path +"""Utilities for validating email addresses.""" -import anyio -import httpx -from fastapi import HTTPException +import logging +from typing import TYPE_CHECKING +from fastapi import Request +from fastapi_mail.email_utils import DefaultChecker +from redis.exceptions import RedisError + +from app.core.background_tasks import PeriodicBackgroundTask +from app.core.config import Environment, settings + +if TYPE_CHECKING: + from redis.asyncio import Redis + +logger = logging.getLogger(__name__) + +# Custom source for disposable domains DISPOSABLE_DOMAINS_URL = "https://raw.githubusercontent.com/disposable/disposable-email-domains/master/domains.txt" -BASE_DIR: Path = (Path(__file__).parents[4]).resolve() -CACHE_FILE = BASE_DIR / "data" / "cache" / "disposable_domains_cache.txt" -CACHE_DURATION = timedelta(days=1) +_EMAIL_CHECKER_ERRORS = (RuntimeError, ValueError, ConnectionError, OSError, RedisError) + + +class EmailChecker(PeriodicBackgroundTask): + """Email checker that manages disposable domain validation.""" + + def __init__(self, redis_client: Redis | None) -> None: + """Initialize email checker with Redis client. + + Args: + redis_client: Redis client instance to use for caching + """ + super().__init__(interval_seconds=60 * 60 * 24) # 24 hours + self.redis_client = redis_client + self.checker: DefaultChecker | None = None + + async def initialize(self) -> None: + """Initialize the disposable email checker. + + Should be called during application startup. + """ + try: + if self.redis_client is None: + self.checker = DefaultChecker(source=DISPOSABLE_DOMAINS_URL) + logger.info("Disposable email checker initialized without Redis") + # Fetch initial domains when using in-memory storage + await self._refresh_domains() + else: + self.checker = DefaultChecker( + source=DISPOSABLE_DOMAINS_URL, + db_provider="redis", + redis_client=self.redis_client, + ) + + # Check if domains already exist in Redis cache + domains_exist = await self.redis_client.exists("temp_domains") + + if not domains_exist: + # Fetch and cache domains for the first time + await self.checker.init_redis() + logger.info("Disposable email checker initialized with Redis (fetched new domains)") + else: + # Domains already cached - checker will use them automatically + logger.info("Disposable email checker initialized with Redis (using cached domains)") + + # Start periodic refresh task + await super().initialize() + + except _EMAIL_CHECKER_ERRORS as e: + logger.warning("Failed to initialize disposable email checker: %s", e) + self.checker = None + + async def run_once(self) -> None: + """Refresh disposable domains (called periodically by the base class loop).""" + await self._refresh_domains() + + async def _refresh_domains(self) -> None: + """Refresh the list of disposable email domains from the source.""" + if self.checker is None: + logger.warning("Email checker not initialized, cannot refresh domains") + return + try: + await self.checker.fetch_temp_email_domains() + logger.info("Disposable email domains refreshed successfully") + except _EMAIL_CHECKER_ERRORS: + logger.exception("Failed to refresh disposable email domains:") + + async def close(self) -> None: + """Close the email checker and cleanup resources. + + Should be called during application shutdown. + """ + await super().close() + + # Close checker connections if initialized + if self.checker is not None and self.redis_client is not None: + logger.info("Closing email checker Redis connections") + try: + await self.checker.close_connections() + logger.info("Email checker closed successfully") + except _EMAIL_CHECKER_ERRORS as e: + logger.warning("Error closing email checker: %s", e) + finally: + self.checker = None + + async def is_disposable(self, email: str) -> bool: + """Check if email domain is disposable. + + Args: + email: Email address to check + + Returns: + bool: True if email is from a disposable domain, False otherwise + """ + if self.checker is None: + logger.warning("Email checker not initialized, allowing registration") + return False + try: + return await self.checker.is_disposable(email) + except _EMAIL_CHECKER_ERRORS: + logger.exception("Failed to check if email is disposable: %s. Allowing registration.", email) + # If check fails, allow registration (fail open) + return False + + +def get_email_checker_dependency(request: Request) -> EmailChecker | None: + """FastAPI dependency to get EmailChecker from app state. + + Args: + request: FastAPI request object + + Returns: + EmailChecker instance or None if not initialized + Usage: + @app.get("/example") + async def example(email_checker: EmailChecker | None = Depends(get_email_checker_dependency)): + if email_checker: + await email_checker.is_disposable("test@example.com") + """ + return request.app.state.email_checker -async def get_disposable_domains() -> set[str]: - """Get disposable email domains, using cache if fresh.""" - # Check if cache exists and is fresh - if CACHE_FILE.exists(): - cache_age = datetime.now(tz=UTC) - datetime.fromtimestamp(CACHE_FILE.stat().st_mtime, tz=UTC) - if cache_age < CACHE_DURATION: - async with await anyio.open_file(CACHE_FILE, "r") as f: - content = await f.read() # Read the entire file first - return {line.strip().lower() for line in content.splitlines() if line.strip()} - # Fetch fresh list +async def init_email_checker(redis: Redis | None) -> EmailChecker | None: + """Initialize the EmailChecker instance.""" + if settings.environment in (Environment.DEV, Environment.TESTING): + return None try: - async with httpx.AsyncClient() as client: - response = await client.get(DISPOSABLE_DOMAINS_URL, timeout=10.0) - response.raise_for_status() - domains = {line.strip().lower() for line in response.text.splitlines() if line.strip()} - - # Ensure cache directory exists - CACHE_FILE.parent.mkdir(parents=True, exist_ok=True) - - # Update cache - async with await anyio.open_file(CACHE_FILE, "w") as f: - await f.write("\n".join(sorted(domains))) - - return domains - except Exception as e: - # If fetch fails and cache exists, use stale cache - if CACHE_FILE.exists(): - async with await anyio.open_file(CACHE_FILE, "r") as f: - content = await f.read() # Read the entire file first - return {line.strip().lower() for line in content.splitlines() if line.strip()} - raise HTTPException(status_code=503, detail="Email validation service unavailable") from e - - -async def is_disposable_email(email: str) -> bool: - """Check if email domain is disposable.""" - domain = email.split("@")[-1].lower() - disposable_domains = await get_disposable_domains() - return domain in disposable_domains + email_checker = EmailChecker(redis) + await email_checker.initialize() + except (RuntimeError, ValueError, ConnectionError) as e: + logger.warning("Failed to initialize email checker: %s", e) + return None + else: + return email_checker diff --git a/backend/app/api/auth/utils/programmatic_emails.py b/backend/app/api/auth/utils/programmatic_emails.py index dc1cefa0..77cfe0d9 100644 --- a/backend/app/api/auth/utils/programmatic_emails.py +++ b/backend/app/api/auth/utils/programmatic_emails.py @@ -1,169 +1,146 @@ -"""Utilities for sending authentication-related emails.""" +"""Utilities for sending authentication-related emails using fastapi-mail.""" import logging -from email.mime.multipart import MIMEMultipart -from email.mime.text import MIMEText -from enum import Enum +from typing import TYPE_CHECKING, Any from urllib.parse import urljoin -import markdown -from aiosmtplib import SMTP, SMTPException +from fastapi_mail import MessageSchema, MessageType +from pydantic import AnyUrl, EmailStr, NameEmail -from app.api.auth.config import settings as auth_settings +from app.api.auth.utils.email_config import fm from app.core.config import settings as core_settings -logger: logging.Logger = logging.getLogger(__name__) +if TYPE_CHECKING: + from fastapi import BackgroundTasks +logger: logging.Logger = logging.getLogger(__name__) -### Common email functions ### -# TODO: Move to using MJML or similar templating system for email content. +### Helper functions ### +def generate_token_link(token: str, route: str, base_url: str | AnyUrl | None = None) -> str: + """Generate a link with the specified token and route.""" + if base_url is None: + # Default to frontend app URL from core settings + base_url = str(core_settings.frontend_app_url) + return urljoin(str(base_url), f"{route}?token={token}") -class TextContentType(str, Enum): - """Type for specifying the content type of the email body.""" - PLAIN = "plain" - HTML = "html" - MARKDOWN = "markdown" +def mask_email_for_log(email: EmailStr, *, mask: bool = True, max_len: int = 80) -> str: + """Mask emails for logging. - def body_to_mimetext(self, body: str) -> MIMEText: - """Convert an email body to MIMEText format.""" - match self: - case TextContentType.PLAIN: - return MIMEText(body, "plain") - case TextContentType.HTML: - return MIMEText(body, "html") - case TextContentType.MARKDOWN: - # Convert Markdown to HTML - html = markdown.markdown(body) - return MIMEText(html, "html") + Also remove non-printable characters and truncates long domains. Explicitly removes log-breaking control characters. + """ + # Remove non-printable and log-breaking control characters + string = "".join(ch for ch in str(email) if ch.isprintable()).replace("\n", "").replace("\r", "") + local, sep, domain = string.partition("@") + masked = (f"{local[0]}***@{domain}" if len(local) > 1 else f"*@{domain}") if sep and mask else string + return f"{masked[: max_len - 3]}..." if len(masked) > max_len else masked -async def send_email( - to_email: str, +### Generic email function ### +async def send_email_with_template( + to_email: EmailStr, subject: str, - body: str, - content_type: TextContentType = TextContentType.PLAIN, - headers: dict | None = None, + template_name: str, + template_body: dict[str, Any], + background_tasks: BackgroundTasks | None = None, ) -> None: - """Send an email with the specified subject and body.""" - msg = MIMEMultipart() - msg["From"] = auth_settings.email_from - msg["Reply-To"] = auth_settings.email_reply_to - msg["To"] = to_email - msg["Subject"] = subject - - # Add additional headers if provided - if headers: - for key, value in headers.items(): - msg[key] = value - - # Attach the body in the specified content type - msg.attach(content_type.body_to_mimetext(body)) - - try: - # TODO: Investigate use of managed outlook address for sending emails - smtp = SMTP( - hostname=auth_settings.email_host, - port=auth_settings.email_port, + """Send an HTML email using a template. + + Args: + to_email: Recipient email address + subject: Email subject line + template_name: Name of the template file (e.g., "registration.html") + template_body: Dictionary of variables to pass to the template + background_tasks: Optional BackgroundTasks instance for async sending + """ + message = MessageSchema( + subject=subject, + recipients=[NameEmail(name=str(to_email), email=str(to_email))], + template_body=template_body, + subtype=MessageType.html, + ) + + if background_tasks: + background_tasks.add_task(fm.send_message, message, template_name=template_name) + logger.info( + "Email queued for background sending to %s using template %s", mask_email_for_log(to_email), template_name ) - await smtp.connect() - # logger.info("Sending email to %s", auth_settings.__dict__) - await smtp.login(auth_settings.email_username, auth_settings.email_password) - await smtp.send_message(msg) - await smtp.quit() - logger.info("Email sent to %s", to_email) - except SMTPException as e: - error_message = f"Error sending email: {e}" - raise SMTPException(error_message) from e - - -def generate_token_link(token: str, route: str) -> str: - """Generate a link with the specified token and route.""" - # TODO: Check that the base url works in remote deployment - return urljoin(str(core_settings.frontend_app_url), f"{route}?token={token}") + else: + await fm.send_message(message, template_name=template_name) + logger.info("Email sent to %s using template %s", mask_email_for_log(to_email), template_name) -### Email content ### -async def send_registration_email(to_email: str, username: str | None, token: str) -> None: +### Authentication email functions ### +async def send_registration_email( + to_email: EmailStr, + username: str | None, + token: str, + background_tasks: BackgroundTasks | None = None, +) -> None: """Send a registration email with verification token.""" - # TODO: Store frontend paths required by the backend in a shared .env or other config file in the root directory - # Alternatively, we can send the right path as a parameter from the frontend to the backend verification_link = generate_token_link(token, "/verify") subject = "Welcome to Reverse Engineering Lab - Verify Your Email" - body = f""" -Hello {username if username else to_email}, - -Thank you for registering! Please verify your email by clicking the link below: -{verification_link} + await send_email_with_template( + to_email=to_email, + subject=subject, + template_name="registration.html", + template_body={"username": username or to_email, "verification_link": verification_link}, + background_tasks=background_tasks, + ) -This link will expire in 1 hour. -If you did not register for this service, please ignore this email. - -Best regards, - -The Reverse Engineering Lab Team - """ - - await send_email(subject=subject, body=body, to_email=to_email) - - -async def send_reset_password_email(to_email: str, username: str | None, token: str) -> None: +async def send_reset_password_email( + to_email: EmailStr, + username: str | None, + token: str, + background_tasks: BackgroundTasks | None = None, +) -> None: """Send a reset password email with the token.""" - request_password_link = generate_token_link(token, "/reset-password") + reset_link = generate_token_link(token, "/reset-password") subject = "Password Reset" - body = f""" -Hello {username if username else to_email}, - -Please reset your password by clicking the link below: -{request_password_link} + await send_email_with_template( + to_email=to_email, + subject=subject, + template_name="password_reset.html", + template_body={"username": username or to_email, "reset_link": reset_link}, + background_tasks=background_tasks, + ) -This link will expire in 1 hour. -If you did not request a password reset, please ignore this email. - -Best regards, - -The Reverse Engineering Lab Team - """ - await send_email(to_email, subject, body) - - -async def send_verification_email(to_email: str, username: str | None, token: str) -> None: +async def send_verification_email( + to_email: EmailStr, + username: str | None, + token: str, + background_tasks: BackgroundTasks | None = None, +) -> None: """Send a verification email with the token.""" verification_link = generate_token_link(token, "/verify") subject = "Email Verification" - body = f""" -Hello {username if username else to_email}, - -Please verify your email by clicking the link below: - -{verification_link} -This link will expire in 1 hour. + await send_email_with_template( + to_email=to_email, + subject=subject, + template_name="verification.html", + template_body={"username": username or to_email, "verification_link": verification_link}, + background_tasks=background_tasks, + ) -If you did not request verification, please ignore this email. -Best regards, - -The Reverse Engineering Lab Team - """ - await send_email(to_email, subject, body) - - -async def send_post_verification_email(to_email: str, username: str | None) -> None: +async def send_post_verification_email( + to_email: EmailStr, + username: str | None, + background_tasks: BackgroundTasks | None = None, +) -> None: """Send a post-verification email.""" subject = "Email Verified" - body = f""" -Hello {username if username else to_email}, - -Your email has been verified! -Best regards, - -The Reverse Engineering Lab Team - """ - await send_email(to_email, subject, body) + await send_email_with_template( + to_email=to_email, + subject=subject, + template_name="post_verification.html", + template_body={"username": username or to_email}, + background_tasks=background_tasks, + ) diff --git a/backend/app/api/auth/utils/programmatic_user_crud.py b/backend/app/api/auth/utils/programmatic_user_crud.py index 3ecc12d5..bf28c6a9 100644 --- a/backend/app/api/auth/utils/programmatic_user_crud.py +++ b/backend/app/api/auth/utils/programmatic_user_crud.py @@ -1,27 +1,48 @@ """Programmatic CRUD operations for FastAPI-users.""" +from __future__ import annotations + +from typing import TYPE_CHECKING + from fastapi_users.exceptions import InvalidPasswordException, UserAlreadyExists -from sqlmodel.ext.asyncio.session import AsyncSession -from starlette.requests import Request -from app.api.auth.models import User -from app.api.auth.schemas import UserCreate from app.api.auth.utils.context_managers import get_chained_async_user_manager_context +if TYPE_CHECKING: + from sqlmodel.ext.asyncio.session import AsyncSession + + from app.api.auth.models import User + from app.api.auth.schemas import UserCreate + async def create_user( async_session: AsyncSession, user_create: UserCreate, *, send_registration_email: bool = False ) -> User: - """Programmatically create a new user in the database.""" + """Programmatically create a new user in the database. + + Args: + async_session: Database session + user_create: User creation schema + send_registration_email: Whether to send verification email to the user + + Returns: + Created user instance + + Raises: + UserAlreadyExists: If user with email already exists + InvalidPasswordException: If password validation fails + """ try: async with get_chained_async_user_manager_context(async_session) as user_manager: - # HACK: Synthetic request to avoid sending emails for programmatically created users - request = Request(scope={"type": "http"}) - request._body = b"{}" - request.state.send_registration_email = send_registration_email + # Create user (password hashing and validation handled by UserManager) + user: User = await user_manager.create(user_create) + + # Send verification email if requested + if send_registration_email: + await user_manager.request_verify(user) - user: User = await user_manager.create(user_create, request=request) return user + except UserAlreadyExists: err_msg: str = f"User with email {user_create.email} already exists." raise UserAlreadyExists(err_msg) from None diff --git a/backend/app/api/auth/utils/rate_limit.py b/backend/app/api/auth/utils/rate_limit.py new file mode 100644 index 00000000..e49bee1b --- /dev/null +++ b/backend/app/api/auth/utils/rate_limit.py @@ -0,0 +1,24 @@ +"""Rate limiting configuration using SlowAPI for authentication endpoints.""" + +from slowapi import Limiter +from slowapi.util import get_remote_address + +from app.api.auth.config import settings as auth_settings +from app.core.config import settings as core_settings + +# Create limiter instance +# Rate limit is expressed as "max_attempts/window_seconds" +# Example: "5/900second" = 5 attempts per 15 minutes + +limiter = Limiter( + key_func=get_remote_address, + default_limits=[], # No default limits, set per route + storage_uri=core_settings.cache_url, + strategy="fixed-window", + enabled=core_settings.enable_rate_limit, +) + +# Rate limit strings for common use cases +LOGIN_RATE_LIMIT = f"{auth_settings.rate_limit_login_attempts_per_minute}/60second" +REGISTER_RATE_LIMIT = f"{auth_settings.rate_limit_register_attempts_per_hour}/3600second" +PASSWORD_RESET_RATE_LIMIT = f"{auth_settings.rate_limit_password_reset_attempts_per_hour}/3600second" diff --git a/backend/app/api/background_data/__init__.py b/backend/app/api/background_data/__init__.py index 0fb16357..aa4dc563 100644 --- a/backend/app/api/background_data/__init__.py +++ b/backend/app/api/background_data/__init__.py @@ -1 +1 @@ -"""Background data module.""" +"""Routes for interacting with background data.""" diff --git a/backend/app/api/background_data/crud.py b/backend/app/api/background_data/crud.py index 999b3c8f..fb232e30 100644 --- a/backend/app/api/background_data/crud.py +++ b/backend/app/api/background_data/crud.py @@ -1,18 +1,14 @@ """CRUD operations for the background data models.""" -from collections.abc import Sequence +from typing import TYPE_CHECKING, Any, cast -from sqlalchemy import Delete, delete from sqlalchemy.orm import selectinload -from sqlalchemy.orm.attributes import set_committed_value +from sqlalchemy.orm.attributes import QueryableAttribute from sqlmodel import col, select -from sqlmodel.ext.asyncio.session import AsyncSession -from sqlmodel.sql._expression_select_cls import SelectOfScalar from app.api.background_data.filters import ( CategoryFilter, CategoryFilterWithRelationships, - TaxonomyFilter, ) from app.api.background_data.models import ( Category, @@ -40,33 +36,151 @@ ) from app.api.common.crud.associations import create_model_links from app.api.common.crud.base import get_model_by_id +from app.api.common.crud.persistence import ( + SupportsModelDump, + commit_and_refresh, + delete_and_commit, + update_and_commit, +) from app.api.common.crud.utils import ( - db_get_model_with_id_if_it_exists, - db_get_models_with_ids_if_they_exist, - enum_set_to_str, - set_to_str, + enum_format_id_set, + format_id_set, + get_model_or_404, + get_models_by_ids_or_404, validate_linked_items_exist, - validate_model_with_id_exists, validate_no_duplicate_linked_items, ) -from app.api.file_storage.crud import ( - ParentStorageOperations, - create_file, - create_image, - delete_file, - delete_image, -) +from app.api.common.exceptions import BadRequestError, InternalServerError +from app.api.file_storage.crud import ParentStorageOperations, file_storage_service, image_storage_service from app.api.file_storage.filters import FileFilter, ImageFilter -from app.api.file_storage.models.models import File, FileParentType, Image, ImageParentType +from app.api.file_storage.models.models import File, Image, MediaParentType from app.api.file_storage.schemas import FileCreate, ImageCreateFromForm +if TYPE_CHECKING: + from collections.abc import Sequence + + from sqlmodel.ext.asyncio.session import AsyncSession + from sqlmodel.sql._expression_select_cls import SelectOfScalar + # NOTE: GET operations are implemented in the crud.common.base module -# TODO: Extract common CRUD operations to class-based factories in a separate module. This includes basic CRUD, +# TODO: Extract common CRUD operations to class-based factories in a separate +# module. This includes basic CRUD, # filter generation, relationship handling, category links, and file management for all models. # See the parent-file operations in the file_storage module for an example of how to refactor these operations +def _normalize_category_ids(category_ids: int | set[int]) -> set[int]: + """Normalize single category IDs into a set-based API.""" + return {category_ids} if isinstance(category_ids, int) else category_ids + + +async def _create_background_model[ModelT: Taxonomy | Material | ProductType]( + db: AsyncSession, + model: type[ModelT], + payload: SupportsModelDump, + *, + exclude_fields: set[str], +) -> ModelT: + """Create and flush a background-data model from a request payload.""" + model_data = cast("dict[str, Any]", payload.model_dump(exclude=exclude_fields)) + db_model = model(**model_data) + db.add(db_model) + await db.flush() + return db_model + + +async def _update_background_model[ModelT: Taxonomy | Material | ProductType | Category]( + db: AsyncSession, + model: type[ModelT], + model_id: int, + payload: SupportsModelDump, +) -> ModelT: + """Apply a partial update and persist the model.""" + db_model: ModelT = await get_model_or_404(db, model, model_id) + return await update_and_commit(db, db_model, payload) + + +async def _delete_background_model[ModelT: Taxonomy | Material | ProductType | Category]( + db: AsyncSession, + model: type[ModelT], + model_id: int, +) -> ModelT: + """Delete a model after resolving it from the database.""" + db_model: ModelT = await get_model_or_404(db, model, model_id) + await delete_and_commit(db, db_model) + return db_model + + +async def _add_categories_to_parent_model[ParentT: Material | ProductType]( + db: AsyncSession, + *, + parent_model: type[ParentT], + parent_id: int, + category_ids: int | set[int], + expected_domains: set[TaxonomyDomain], + link_model: type[CategoryMaterialLink | CategoryProductTypeLink], + link_parent_id_field: str, +) -> tuple[ParentT, Sequence[Category]]: + """Create validated category links for a material-like parent model.""" + normalized_category_ids = _normalize_category_ids(category_ids) + + db_parent = await get_model_by_id( + db, + parent_model, + model_id=parent_id, + include_relationships={"categories"}, + ) + + db_categories: Sequence[Category] = await get_models_by_ids_or_404(db, Category, normalized_category_ids) + await validate_category_taxonomy_domains(db, normalized_category_ids, expected_domains) + + if db_parent.categories: + validate_no_duplicate_linked_items(normalized_category_ids, db_parent.categories, "Categories") + + await create_model_links( + db, + id1=db_parent.id, + id1_field=link_parent_id_field, + id2_set=normalized_category_ids, + id2_field="category_id", + link_model=link_model, + ) + + return db_parent, db_categories + + +async def _remove_categories_from_parent_model[ParentT: Material | ProductType]( + db: AsyncSession, + *, + parent_model: type[ParentT], + parent_id: int, + category_ids: int | set[int], + link_model: type[CategoryMaterialLink | CategoryProductTypeLink], + link_parent_id_field: str, +) -> None: + """Remove validated category links from a material-like parent model.""" + normalized_category_ids = _normalize_category_ids(category_ids) + + db_parent = await get_model_by_id( + db, + parent_model, + model_id=parent_id, + include_relationships={"categories"}, + ) + + validate_linked_items_exist(normalized_category_ids, db_parent.categories, "Categories") + + statement = ( + select(link_model) + .where(col(getattr(link_model, link_parent_id_field)) == parent_id) + .where(col(link_model.category_id).in_(normalized_category_ids)) + ) + results = await db.exec(statement) + for category_link in results.all(): + await db.delete(category_link) + + ### Category CRUD operations ### ## Utilities ## async def validate_category_creation( @@ -79,22 +193,22 @@ async def validate_category_creation( ) -> tuple[int, Category | None]: """Validate category creation parameters and return taxonomy_id and supercategory.""" if supercategory_id: - supercategory: Category = await db_get_model_with_id_if_it_exists(db, Category, supercategory_id) + supercategory: Category = await get_model_or_404(db, Category, supercategory_id) taxonomy_id = taxonomy_id or supercategory.taxonomy_id if supercategory.taxonomy_id != taxonomy_id: err_msg: str = f"Supercategory with id {supercategory_id} does not belong to taxonomy with id {taxonomy_id}" - raise ValueError(err_msg) + raise BadRequestError(err_msg) return taxonomy_id, supercategory taxonomy_id = taxonomy_id or getattr(category, "taxonomy_id", None) if not taxonomy_id: err_msg = "Taxonomy ID is required for top-level categories" - raise ValueError(err_msg) + raise BadRequestError(err_msg) # Check if taxonomy exists - await db_get_model_with_id_if_it_exists(db, Taxonomy, taxonomy_id) + await get_model_or_404(db, Taxonomy, taxonomy_id) return taxonomy_id, None @@ -116,14 +230,14 @@ async def validate_category_taxonomy_domains( select(Category) .join(Taxonomy) .where(col(Category.id).in_(category_ids)) - .options(selectinload(Category.taxonomy)) + .options(selectinload(cast("QueryableAttribute[Any]", Category.taxonomy))) ) - categories: Sequence[Category] = (await db.exec(categories_statement)).all() + categories: Sequence[Category] = list((await db.exec(categories_statement)).all()) if len(categories) != len(category_ids): missing = set(category_ids) - {c.id for c in categories} - err_msg: str = f"Categories with id {set_to_str(missing)} not found" - raise ValueError(err_msg) + err_msg: str = f"Categories with id {format_id_set(missing)} not found" + raise BadRequestError(err_msg) # Cast single domain to set if needed if isinstance(expected_domains, TaxonomyDomain): @@ -136,10 +250,10 @@ async def validate_category_taxonomy_domains( } if invalid: err_msg: str = ( - f"Categories with id {set_to_str(invalid)} belong to taxonomies " - f"outside of domains: {enum_set_to_str(expected_domains)}" + f"Categories with id {format_id_set(invalid)} belong to taxonomies " + f"outside of domains: {enum_format_id_set(expected_domains)}" ) - raise ValueError(err_msg) + raise BadRequestError(err_msg) ## Basic CRUD operations ## @@ -150,7 +264,7 @@ async def get_category_trees( supercategory_id: int | None = None, taxonomy_id: int | None = None, category_filter: CategoryFilter | CategoryFilterWithRelationships | None = None, -) -> Sequence[Category]: +) -> list[Category]: """Get categories with their subcategories up to specified depth. If supercategory_id is None, get top-level categories. @@ -158,28 +272,35 @@ async def get_category_trees( # Provide either supercategory_id or taxonomy_id if supercategory_id and taxonomy_id: err_msg = "Provide either supercategory_id or taxonomy_id, not both" - raise ValueError(err_msg) + raise BadRequestError(err_msg) # Validate that supercategory or taxonomy exists if supercategory_id: - await db_get_model_with_id_if_it_exists(db, Category, supercategory_id) + await get_model_or_404(db, Category, supercategory_id) if taxonomy_id: - await db_get_model_with_id_if_it_exists(db, Taxonomy, taxonomy_id) + await get_model_or_404(db, Taxonomy, taxonomy_id) - statement: SelectOfScalar[Category] = select(Category).where(Category.supercategory_id == supercategory_id) + statement: SelectOfScalar[Category] = ( + select(Category) + .where(Category.supercategory_id == supercategory_id) + # Refresh already-present ORM instances so recursive reads don't reuse stale relationship collections. + .execution_options(populate_existing=True) + ) if taxonomy_id: - await db_get_model_with_id_if_it_exists(db, Taxonomy, taxonomy_id) + await get_model_or_404(db, Taxonomy, taxonomy_id) statement = statement.where(Category.taxonomy_id == taxonomy_id) if category_filter: statement = category_filter.filter(statement) # Load subcategories recursively - statement = statement.options(selectinload(Category.subcategories, recursion_depth=recursion_depth)) + statement = statement.options( + selectinload(cast("QueryableAttribute[Any]", Category.subcategories), recursion_depth=recursion_depth) + ) - return (await db.exec(statement)).all() + return list((await db.exec(statement)).all()) async def create_category( @@ -234,126 +355,48 @@ async def create_category( async def update_category(db: AsyncSession, category_id: int, category: CategoryUpdate) -> Category: """Update an existing category in the database.""" - db_category: Category = await db_get_model_with_id_if_it_exists(db, Category, category_id) - - category_data = category.model_dump(exclude_unset=True) - db_category.sqlmodel_update(category_data) - - db.add(db_category) - await db.commit() - await db.refresh(db_category) - return db_category + return await _update_background_model(db, Category, category_id, category) async def delete_category(db: AsyncSession, category_id: int) -> None: """Delete a category from the database.""" - db_category: Category = await db_get_model_with_id_if_it_exists(db, Category, category_id) - - await db.delete(db_category) - await db.commit() + await _delete_background_model(db, Category, category_id) ### Taxonomy CRUD operations ### ## Basic CRUD operations ## -async def get_taxonomies( - db: AsyncSession, - *, - include_base_categories: bool = False, - taxonomy_filter: TaxonomyFilter | None = None, - statement: SelectOfScalar[Taxonomy] | None = None, -) -> Sequence[Taxonomy]: - """Get taxonomies with optional filtering and base categories.""" - if statement is None: - statement = select(Taxonomy) - - if taxonomy_filter: - statement = taxonomy_filter.filter(statement) - - # Only load base categories if requested - if include_base_categories: - statement = statement.options( - selectinload(Taxonomy.categories.and_(Category.supercategory_id == None)) # noqa: E711 # SQLalchemy 'select' statement requires '== None' for 'IS NULL' - ) - - result: Sequence[Taxonomy] = (await db.exec(statement)).all() - - # Set empty categories list if not included - if not include_base_categories: - for taxonomy in result: - set_committed_value(taxonomy, "categories", []) - - return result - - -async def get_taxonomy_by_id(db: AsyncSession, taxonomy_id: int, *, include_base_categories: bool = False) -> Taxonomy: - """Get taxonomy by ID with specified relationships.""" - statement: SelectOfScalar[Taxonomy] = select(Taxonomy).where(Taxonomy.id == taxonomy_id) - - if include_base_categories: - statement = statement.options( - selectinload(Taxonomy.categories.and_(Category.supercategory_id == None)) # noqa: E711 # SQLalchemy 'select' statement requires '== None' for 'IS NULL' - ) - - taxonomy: Taxonomy = validate_model_with_id_exists((await db.exec(statement)).one_or_none(), Taxonomy, taxonomy_id) - if not include_base_categories: - set_committed_value(taxonomy, "categories", []) - return taxonomy - - async def create_taxonomy(db: AsyncSession, taxonomy: TaxonomyCreate | TaxonomyCreateWithCategories) -> Taxonomy: """Create a new taxonomy in the database.""" - taxonomy_data = taxonomy.model_dump(exclude={"categories"}) - db_taxonomy = Taxonomy(**taxonomy_data) - - db.add(db_taxonomy) - await db.flush() # Assigns an ID to taxonomy + db_taxonomy = await _create_background_model(db, Taxonomy, taxonomy, exclude_fields={"categories"}) # Handle categories if provided if isinstance(taxonomy, TaxonomyCreateWithCategories) and taxonomy.categories: for category_data in taxonomy.categories: await create_category(db, category_data, taxonomy_id=db_taxonomy.id) - await db.commit() - await db.refresh(db_taxonomy) - return db_taxonomy + return await commit_and_refresh(db, db_taxonomy, add_before_commit=False) async def update_taxonomy(db: AsyncSession, taxonomy_id: int, taxonomy: TaxonomyUpdate) -> Taxonomy: """Update an existing taxonomy in the database.""" - db_taxonomy: Taxonomy = await db_get_model_with_id_if_it_exists(db, Taxonomy, taxonomy_id) - - taxonomy_data = taxonomy.model_dump(exclude_unset=True) - - db_taxonomy.sqlmodel_update(taxonomy_data) - - db.add(db_taxonomy) - await db.commit() - await db.refresh(db_taxonomy) - return db_taxonomy + return await _update_background_model(db, Taxonomy, taxonomy_id, taxonomy) async def delete_taxonomy(db: AsyncSession, taxonomy_id: int) -> None: """Delete a taxonomy from the database, including its categories.""" - db_taxonomy: Taxonomy = await db_get_model_with_id_if_it_exists(db, Taxonomy, taxonomy_id) - - await db.delete(db_taxonomy) - await db.commit() + await _delete_background_model(db, Taxonomy, taxonomy_id) ### Material CRUD operations ### ## Basic CRUD operations ## async def create_material(db: AsyncSession, material: MaterialCreate | MaterialCreateWithCategories) -> Material: """Create a new material in the database, optionally with category links.""" - # Create material - material_data = material.model_dump(exclude={"category_ids"}) - db_material = Material(**material_data) - db.add(db_material) - await db.flush() # Get material ID + db_material = await _create_background_model(db, Material, material, exclude_fields={"category_ids"}) # Add category links if provided if isinstance(material, MaterialCreateWithCategories) and material.category_ids: # Validate categories exist - await db_get_models_with_ids_if_they_exist(db, Category, material.category_ids) + await get_models_by_ids_or_404(db, Category, material.category_ids) # Validate category domains await validate_category_taxonomy_domains(db, material.category_ids, {TaxonomyDomain.MATERIALS}) @@ -361,34 +404,24 @@ async def create_material(db: AsyncSession, material: MaterialCreate | MaterialC # Create links await create_model_links( db, - id1=db_material.id, # pyright: ignore[reportArgumentType] # material ID is guaranteed by database flush above, + id1=db_material.db_id, id1_field="material_id", id2_set=material.category_ids, id2_field="category_id", link_model=CategoryMaterialLink, ) - await db.commit() - await db.refresh(db_material) - return db_material + return await commit_and_refresh(db, db_material, add_before_commit=False) async def update_material(db: AsyncSession, material_id: int, material: MaterialUpdate) -> Material: """Update an existing material in the database.""" - db_material: Material = await db_get_model_with_id_if_it_exists(db, Material, material_id) - - material_data = material.model_dump(exclude_unset=True) - db_material.sqlmodel_update(material_data) - - db.add(db_material) - await db.commit() - await db.refresh(db_material) - return db_material + return await _update_background_model(db, Material, material_id, material) async def delete_material(db: AsyncSession, material_id: int) -> None: """Delete a material from the database.""" - db_material: Material = await db_get_model_with_id_if_it_exists(db, Material, material_id) + db_material = await get_model_or_404(db, Material, material_id) # Delete storage files await material_files_crud.delete_all(db, material_id) @@ -403,30 +436,16 @@ async def add_categories_to_material( db: AsyncSession, material_id: int, category_ids: int | set[int] ) -> Sequence[Category]: """Add categories to a material.""" - # Cast single ID to set - category_ids = {category_ids} if isinstance(category_ids, int) else category_ids - - # Validate material exists - db_material: Material = await get_model_by_id( - db, Material, model_id=material_id, include_relationships={"categories"} + db_material, db_categories = await _add_categories_to_parent_model( + db, + parent_model=Material, + parent_id=material_id, + category_ids=category_ids, + expected_domains={TaxonomyDomain.MATERIALS}, + link_model=CategoryMaterialLink, + link_parent_id_field="material_id", ) - # Validate categories exist and belong to the correct domain - db_categories: Sequence[Category] = await db_get_models_with_ids_if_they_exist(db, Category, category_ids) - await validate_category_taxonomy_domains(db, category_ids, {TaxonomyDomain.MATERIALS}) - - if db_material.categories: - validate_no_duplicate_linked_items(category_ids, db_material.categories, "Categories") - - await create_model_links( - db, - id1=db_material.id, # pyright: ignore[reportArgumentType] # material ID is guaranteed by database flush above, - id1_field="material_id", - id2_set=category_ids, - id2_field="category_id", - link_model=CategoryMaterialLink, - ) - await db.commit() await db.refresh(db_material) return db_categories @@ -440,30 +459,21 @@ async def add_category_to_material(db: AsyncSession, material_id: int, category_ err_msg: str = ( f"Database integrity error: Expected 1 category with id {category_id}, got {len(db_category_list)}" ) - raise RuntimeError(err_msg) + raise InternalServerError(log_message=err_msg) return db_category_list[0] async def remove_categories_from_material(db: AsyncSession, material_id: int, category_ids: int | set[int]) -> None: """Remove categories from a material.""" - # Cast single ID to set - category_ids = {category_ids} if isinstance(category_ids, int) else category_ids - - # Validate material exists - db_material: Material = await get_model_by_id( - db, Material, model_id=material_id, include_relationships={"categories"} - ) - - # Check that categories are actually assigned - validate_linked_items_exist(category_ids, db_material.categories, "Categories") - - statement: Delete = ( - delete(CategoryMaterialLink) - .where(col(CategoryMaterialLink.material_id) == material_id) - .where(col(CategoryMaterialLink.category_id).in_(category_ids)) + await _remove_categories_from_parent_model( + db, + parent_model=Material, + parent_id=material_id, + category_ids=category_ids, + link_model=CategoryMaterialLink, + link_parent_id_field="material_id", ) - await db.execute(statement) await db.commit() @@ -471,19 +481,17 @@ async def remove_categories_from_material(db: AsyncSession, material_id: int, ca material_files_crud = ParentStorageOperations[Material, File, FileCreate, FileFilter]( parent_model=Material, storage_model=File, - parent_type=FileParentType.MATERIAL, + parent_type=MediaParentType.MATERIAL, parent_field="material_id", - create_func=create_file, - delete_func=delete_file, + storage_service=file_storage_service, ) material_images_crud = ParentStorageOperations[Material, Image, ImageCreateFromForm, ImageFilter]( parent_model=Material, storage_model=Image, - parent_type=ImageParentType.MATERIAL, + parent_type=MediaParentType.MATERIAL, parent_field="material_id", - create_func=create_image, - delete_func=delete_image, + storage_service=image_storage_service, ) @@ -493,43 +501,29 @@ async def create_product_type( db: AsyncSession, product_type: ProductTypeCreate | ProductTypeCreateWithCategories ) -> ProductType: """Create a new product type in the database, optionally with category links.""" - # Create product type - product_type_data = product_type.model_dump(exclude={"category_ids"}) - db_product_type = ProductType(**product_type_data) - db.add(db_product_type) - await db.flush() # Get product type ID + db_product_type = await _create_background_model(db, ProductType, product_type, exclude_fields={"category_ids"}) # Add category links if provided if isinstance(product_type, ProductTypeCreateWithCategories) and product_type.category_ids: await create_model_links( db, - id1=db_product_type.id, # pyright: ignore[reportArgumentType] # material ID is guaranteed by database flush above, + id1=db_product_type.db_id, id1_field="product_type", id2_set=product_type.category_ids, id2_field="category_id", link_model=CategoryProductTypeLink, ) - await db.commit() - await db.refresh(db_product_type) - return db_product_type + return await commit_and_refresh(db, db_product_type, add_before_commit=False) async def update_product_type(db: AsyncSession, product_type_id: int, product_type: ProductTypeUpdate) -> ProductType: """Update an existing product type in the database.""" - db_product_type: ProductType = await db_get_model_with_id_if_it_exists(db, ProductType, product_type_id) - - product_type_data = product_type.model_dump(exclude_unset=True) - db_product_type.sqlmodel_update(product_type_data) - - db.add(db_product_type) - await db.commit() - await db.refresh(db_product_type) - return db_product_type + return await _update_background_model(db, ProductType, product_type_id, product_type) async def delete_product_type(db: AsyncSession, product_type_id: int) -> None: """Delete a product type from the database.""" - db_product_type: ProductType = await db_get_model_with_id_if_it_exists(db, ProductType, product_type_id) + db_product_type: ProductType = await get_model_or_404(db, ProductType, product_type_id) # Delete storage files await product_type_files.delete_all(db, product_type_id) @@ -547,25 +541,14 @@ async def add_categories_to_product_type( db: AsyncSession, product_type_id: int, category_ids: set[int] ) -> Sequence[Category]: """Add categories to a product type.""" - # Validate product type exists - db_product_type: ProductType = await get_model_by_id( - db, ProductType, product_type_id, include_relationships={"categories"} - ) - - # Validate categories exist and belong to the correct domain - db_categories: Sequence[Category] = await db_get_models_with_ids_if_they_exist(db, Category, category_ids) - await validate_category_taxonomy_domains(db, category_ids, {TaxonomyDomain.PRODUCTS}) - - if db_product_type.categories: - validate_no_duplicate_linked_items(category_ids, db_product_type.categories, "Categories") - - await create_model_links( + _, db_categories = await _add_categories_to_parent_model( db, - id1=db_product_type.id, # pyright: ignore[reportArgumentType] # material ID is guaranteed by database flush above, - id1_field="product_type", - id2_set=category_ids, - id2_field="category_id", + parent_model=ProductType, + parent_id=product_type_id, + category_ids=category_ids, + expected_domains={TaxonomyDomain.PRODUCTS}, link_model=CategoryProductTypeLink, + link_parent_id_field="product_type", ) await db.commit() @@ -580,7 +563,7 @@ async def add_category_to_product_type(db: AsyncSession, product_type_id: int, c err_msg: str = ( f"Database integrity error: Expected 1 category with id {category_id}, got {len(db_category_list)}" ) - raise RuntimeError(err_msg) + raise InternalServerError(log_message=err_msg) return db_category_list[0] @@ -589,23 +572,14 @@ async def remove_categories_from_product_type( db: AsyncSession, product_type_id: int, category_ids: int | set[int] ) -> None: """Remove categories from a product type.""" - # Cast single ID to set - category_ids = {category_ids} if isinstance(category_ids, int) else category_ids - - # Validate product type exists - db_product_type: ProductType = await get_model_by_id( - db, ProductType, product_type_id, include_relationships={"categories"} - ) - - # Check that categories are actually assigned - validate_linked_items_exist(category_ids, db_product_type.categories, "Categories") - - statement: Delete = ( - delete(CategoryProductTypeLink) - .where(col(CategoryProductTypeLink.product_type_id) == product_type_id) - .where(col(CategoryProductTypeLink.category_id).in_(category_ids)) + await _remove_categories_from_parent_model( + db, + parent_model=ProductType, + parent_id=product_type_id, + category_ids=category_ids, + link_model=CategoryProductTypeLink, + link_parent_id_field="product_type_id", ) - await db.execute(statement) await db.commit() @@ -613,17 +587,15 @@ async def remove_categories_from_product_type( product_type_files = ParentStorageOperations[ProductType, File, FileCreate, FileFilter]( parent_model=ProductType, storage_model=File, - parent_type=FileParentType.PRODUCT_TYPE, + parent_type=MediaParentType.PRODUCT_TYPE, parent_field="product_type_id", - create_func=create_file, - delete_func=delete_file, + storage_service=file_storage_service, ) product_type_images = ParentStorageOperations[ProductType, Image, ImageCreateFromForm, ImageFilter]( parent_model=ProductType, storage_model=Image, - parent_type=ImageParentType.PRODUCT_TYPE, + parent_type=MediaParentType.PRODUCT_TYPE, parent_field="product_type_id", - create_func=create_image, - delete_func=delete_image, + storage_service=image_storage_service, ) diff --git a/backend/app/api/background_data/filters.py b/backend/app/api/background_data/filters.py index ac5992a9..46fdc92b 100644 --- a/backend/app/api/background_data/filters.py +++ b/backend/app/api/background_data/filters.py @@ -16,6 +16,8 @@ class TaxonomyFilter(Filter): search: str | None = None + order_by: list[str] | None = None + # TODO: Add custom domain filtering (given a list of domains, return all taxonomies that have at least one of them). # See https://github.com/arthurio/fastapi-filter/issues/556 for inspiration. Or move to https://github.com/OleksandrZhydyk/FastAPI-SQLAlchemy-Filters. @@ -39,6 +41,8 @@ class CategoryFilter(Filter): search: str | None = None + order_by: list[str] | None = None + class Constants(Filter.Constants): """FilterAPI class configuration.""" @@ -68,6 +72,8 @@ class MaterialFilter(Filter): search: str | None = None + order_by: list[str] | None = None + class Constants(Filter.Constants): """FilterAPI class configuration.""" @@ -90,10 +96,13 @@ class ProductTypeFilter(Filter): """FastAPI-Filter class for ProductType filtering.""" name__ilike: str | None = None + name__in: list[str] | None = None description__ilike: str | None = None search: str | None = None + order_by: list[str] | None = None + class Constants(Filter.Constants): """FilterAPI class configuration.""" diff --git a/backend/app/api/background_data/models.py b/backend/app/api/background_data/models.py index d03a2956..5dfc7b42 100644 --- a/backend/app/api/background_data/models.py +++ b/backend/app/api/background_data/models.py @@ -1,20 +1,16 @@ """Database models for background data.""" -from enum import Enum -from typing import TYPE_CHECKING, Optional +from enum import StrEnum +from typing import Optional # noqa: TC003 # Needed for runtime ORM mapping from pydantic import ConfigDict from sqlalchemy import Enum as SAEnum from sqlalchemy.dialects.postgresql import ARRAY from sqlmodel import Column, Field, Relationship -from app.api.common.models.base import CustomBase, CustomLinkingModelBase, TimeStampMixinBare +from app.api.common.models.base import CustomBase, CustomLinkingModelBase, IntPrimaryKeyMixin, TimeStampMixinBare from app.api.file_storage.models.models import File, Image -if TYPE_CHECKING: - from app.api.common.models.associations import MaterialProductLink - from app.api.data_collection.models import Product - ### Linking Models ### class CategoryMaterialLink(CustomLinkingModelBase, table=True): @@ -32,7 +28,7 @@ class CategoryProductTypeLink(CustomLinkingModelBase, table=True): ### Taxonomy Model ### -class TaxonomyDomain(str, Enum): +class TaxonomyDomain(StrEnum): """Enumeration of taxonomy domains.""" MATERIALS = "materials" @@ -51,22 +47,21 @@ class TaxonomyBase(CustomBase): description=f"Domains of the taxonomy, e.g. {{{', '.join([d.value for d in TaxonomyDomain][:3])}}}", ) - # TODO: Implement Source model source: str | None = Field( default=None, max_length=500, description="Source of the taxonomy data, e.g. URL, IRI or citation key" ) - model_config: ConfigDict = ConfigDict(use_enum_values=True) # pyright: ignore [reportIncompatibleVariableOverride] # This is not a type override, see https://github.com/fastapi/sqlmodel/discussions/855 + model_config: ConfigDict = ConfigDict(use_enum_values=True) -class Taxonomy(TaxonomyBase, TimeStampMixinBare, table=True): +class Taxonomy(TaxonomyBase, IntPrimaryKeyMixin, TimeStampMixinBare, table=True): """Database model for Taxonomy.""" id: int | None = Field(default=None, primary_key=True) - categories: list["Category"] = Relationship(back_populates="taxonomy", cascade_delete=True) + categories: list[Category] = Relationship(back_populates="taxonomy", cascade_delete=True) - model_config: ConfigDict = ConfigDict(use_enum_values=True, arbitrary_types_allowed=True) # pyright: ignore [reportIncompatibleVariableOverride] # This is not a type override, see https://github.com/fastapi/sqlmodel/discussions/855 + model_config: ConfigDict = ConfigDict(use_enum_values=True, arbitrary_types_allowed=True) # Magic methods def __str__(self) -> str: @@ -82,18 +77,18 @@ class CategoryBase(CustomBase): external_id: str | None = Field(default=None, description="ID of the category in the external taxonomy") -class Category(CategoryBase, TimeStampMixinBare, table=True): +class Category(CategoryBase, IntPrimaryKeyMixin, TimeStampMixinBare, table=True): """Database model for Category.""" id: int | None = Field(default=None, primary_key=True) # Self-referential relationship supercategory_id: int | None = Field(foreign_key="category.id", default=None, nullable=True) - supercategory: Optional["Category"] = Relationship( + supercategory: Optional["Category"] = Relationship( # noqa: UP037, UP045 # `Optional` and quotes needed for proper sqlalchemy mapping back_populates="subcategories", sa_relationship_kwargs={"remote_side": "Category.id", "lazy": "selectin", "join_depth": 1}, ) - subcategories: list["Category"] | None = Relationship( + subcategories: list[Category] | None = Relationship( back_populates="supercategory", sa_relationship_kwargs={"lazy": "selectin", "join_depth": 1}, cascade_delete=True, @@ -104,8 +99,8 @@ class Category(CategoryBase, TimeStampMixinBare, table=True): taxonomy: Taxonomy = Relationship(back_populates="categories") # Many-to-many relationships. This is ugly but SQLModel doesn't allow for polymorphic association. - materials: list["Material"] | None = Relationship(back_populates="categories", link_model=CategoryMaterialLink) - product_types: list["ProductType"] | None = Relationship( + materials: list[Material] | None = Relationship(back_populates="categories", link_model=CategoryMaterialLink) + product_types: list[ProductType] | None = Relationship( back_populates="categories", link_model=CategoryProductTypeLink ) @@ -127,7 +122,7 @@ class MaterialBase(CustomBase): is_crm: bool | None = Field(default=None, description="Is this material a Critical Raw Material (CRM)?") -class Material(MaterialBase, TimeStampMixinBare, table=True): +class Material(MaterialBase, IntPrimaryKeyMixin, TimeStampMixinBare, table=True): """Database model for Material.""" id: int | None = Field(default=None, primary_key=True) @@ -138,7 +133,6 @@ class Material(MaterialBase, TimeStampMixinBare, table=True): # Many-to-many relationships categories: list[Category] | None = Relationship(back_populates="materials", link_model=CategoryMaterialLink) - product_links: list["MaterialProductLink"] | None = Relationship(back_populates="material") # Magic methods def __str__(self) -> str: @@ -153,15 +147,14 @@ class ProductTypeBase(CustomBase): description: str | None = Field(default=None, max_length=500, description="Description of the Product Type.") -class ProductType(ProductTypeBase, TimeStampMixinBare, table=True): +class ProductType(ProductTypeBase, IntPrimaryKeyMixin, TimeStampMixinBare, table=True): """Database model for ProductType.""" id: int | None = Field(default=None, primary_key=True) # One-to-many relationships - products: list["Product"] | None = Relationship(back_populates="product_type") - files: list[File] | None = Relationship(back_populates="product_type", cascade_delete=True) - images: list[Image] | None = Relationship(back_populates="product_type", cascade_delete=True) + files: list[File] | None = Relationship(cascade_delete=True) + images: list[Image] | None = Relationship(cascade_delete=True) # Many-to-many relationships categories: list[Category] | None = Relationship( diff --git a/backend/app/api/background_data/router_factories.py b/backend/app/api/background_data/router_factories.py new file mode 100644 index 00000000..d3014ff1 --- /dev/null +++ b/backend/app/api/background_data/router_factories.py @@ -0,0 +1,415 @@ +"""Shared route builders for background data routers.""" + +from __future__ import annotations + +from inspect import Parameter, Signature +from typing import TYPE_CHECKING, Annotated, Protocol, cast + +from fastapi import APIRouter, Body, Path +from pydantic import PositiveInt + +from app.api.background_data.dependencies import CategoryFilterDep +from app.api.background_data.models import Category +from app.api.background_data.schemas import CategoryRead +from app.api.common.routers.dependencies import AsyncSessionDep +from app.api.common.routers.query_params import relationship_include_query + +if TYPE_CHECKING: + from collections.abc import Awaitable, Callable, Sequence + from typing import Any + + from fastapi.openapi.models import Example + from pydantic import BaseModel + from sqlmodel import SQLModel + + +class SupportsSignature(Protocol): + """Callable object whose signature FastAPI should inspect.""" + + __signature__: Signature + + +CategoryIncludeExamples = cast( + "dict[str, Example]", + { + "none": {"value": []}, + "materials": {"value": ["materials"]}, + "all": {"value": ["materials", "product_types", "subcategories"]}, + }, +) + +TaxonomyCategoryIncludeExamples = cast( + "dict[str, Example]", + { + "none": {"value": []}, + "taxonomy": {"value": ["taxonomy"]}, + "all": {"value": ["taxonomy", "subcategories"]}, + }, +) + +MaterialIncludeExamples = cast( + "dict[str, Example]", + { + "none": {"value": []}, + "categories": {"value": ["categories"]}, + "all": {"value": ["categories", "files", "images", "product_links"]}, + }, +) + + +def _set_signature(route_handler: Callable[..., Awaitable[Any]], parameters: Sequence[Parameter]) -> None: + """Assign an explicit callable signature for FastAPI route generation.""" + cast("SupportsSignature", route_handler).__signature__ = Signature(parameters=list(parameters)) + + +def _set_route_signature( + route_handler: Callable[..., Awaitable[Any]], + *, + path_param_name: str, + path_description: str, + leading_parameters: Sequence[Parameter], +) -> None: + """Assign a concrete FastAPI-compatible signature to a dynamically generated route handler.""" + _set_signature( + route_handler, + [ + *leading_parameters, + Parameter( + path_param_name, + kind=Parameter.POSITIONAL_OR_KEYWORD, + annotation=PositiveInt, + default=Path(..., description=path_description), + ), + ], + ) + + +def add_linked_category_read_routes( + router: APIRouter, + *, + parent_path_param: str, + parent_label: str, + get_categories: Callable[[Any, int, set[str] | None, Any], Awaitable[Sequence[Category]]], + get_category: Callable[[Any, int, int, set[str] | None], Awaitable[Category]], +) -> None: + """Add shared read-only category link routes for a parent resource.""" + + async def list_categories(**kwargs: object) -> Sequence[Category]: + parent_id = cast("int", kwargs[parent_path_param]) + session = kwargs["session"] + include = cast("set[str] | None", kwargs["include"]) + category_filter = kwargs["category_filter"] + return await get_categories(session, parent_id, include, category_filter) + + async def get_single_category(**kwargs: object) -> Category: + parent_id = cast("int", kwargs[parent_path_param]) + category_id = cast("int", kwargs["category_id"]) + include = cast("set[str] | None", kwargs["include"]) + session = kwargs["session"] + return await get_category(session, parent_id, category_id, include) + + list_categories.__name__ = f"get_categories_for_{parent_path_param.removesuffix('_id')}" + get_single_category.__name__ = f"get_category_for_{parent_path_param.removesuffix('_id')}" + _set_route_signature( + list_categories, + path_param_name=parent_path_param, + path_description=f"{parent_label} ID", + leading_parameters=[ + Parameter("session", kind=Parameter.POSITIONAL_OR_KEYWORD, annotation=AsyncSessionDep), + Parameter( + "category_filter", + kind=Parameter.POSITIONAL_OR_KEYWORD, + annotation=CategoryFilterDep, + ), + Parameter( + "include", + kind=Parameter.POSITIONAL_OR_KEYWORD, + annotation=Annotated[ + set[str] | None, + relationship_include_query(openapi_examples=TaxonomyCategoryIncludeExamples), + ], + ), + ], + ) + _set_route_signature( + get_single_category, + path_param_name=parent_path_param, + path_description=f"{parent_label} ID", + leading_parameters=[ + Parameter("session", kind=Parameter.POSITIONAL_OR_KEYWORD, annotation=AsyncSessionDep), + Parameter( + "category_id", + kind=Parameter.POSITIONAL_OR_KEYWORD, + annotation=Annotated[PositiveInt, Path(description="Category ID")], + ), + Parameter( + "include", + kind=Parameter.POSITIONAL_OR_KEYWORD, + annotation=Annotated[ + set[str] | None, + relationship_include_query(openapi_examples=TaxonomyCategoryIncludeExamples), + ], + ), + ], + ) + + router.add_api_route( + f"/{{{parent_path_param}}}/categories", + list_categories, + methods=["GET"], + response_model=list[CategoryRead], + summary=f"View categories of {parent_label.lower()}", + ) + router.add_api_route( + f"/{{{parent_path_param}}}/categories/{{category_id}}", + get_single_category, + methods=["GET"], + response_model=CategoryRead, + summary="Get category by ID", + ) + + +def add_linked_category_write_routes( + router: APIRouter, + *, + parent_path_param: str, + parent_label: str, + add_categories: Callable[[Any, int, set[int]], Awaitable[Sequence[Category]]], + add_category: Callable[[Any, int, int], Awaitable[Category]], + remove_categories: Callable[[Any, int, int | set[int]], Awaitable[None]], +) -> None: + """Add shared write category link routes for a parent resource.""" + + async def add_categories_bulk(**kwargs: object) -> Sequence[Category]: + parent_id = cast("int", kwargs[parent_path_param]) + category_ids = cast("set[int]", kwargs["category_ids"]) + session = kwargs["session"] + return await add_categories(session, parent_id, set(category_ids)) + + async def add_single_category(**kwargs: object) -> Category: + parent_id = cast("int", kwargs[parent_path_param]) + category_id = cast("int", kwargs["category_id"]) + session = kwargs["session"] + return await add_category(session, parent_id, category_id) + + async def remove_categories_bulk(**kwargs: object) -> None: + parent_id = cast("int", kwargs[parent_path_param]) + category_ids = cast("set[int]", kwargs["category_ids"]) + session = kwargs["session"] + await remove_categories(session, parent_id, set(category_ids)) + + async def remove_single_category(**kwargs: object) -> None: + parent_id = cast("int", kwargs[parent_path_param]) + category_id = cast("int", kwargs["category_id"]) + session = kwargs["session"] + await remove_categories(session, parent_id, category_id) + + route_suffix = parent_path_param.removesuffix("_id") + add_categories_bulk.__name__ = f"add_categories_to_{route_suffix}" + add_single_category.__name__ = f"add_category_to_{route_suffix}" + remove_categories_bulk.__name__ = f"remove_categories_from_{route_suffix}_bulk" + remove_single_category.__name__ = f"remove_category_from_{route_suffix}" + _set_route_signature( + add_categories_bulk, + path_param_name=parent_path_param, + path_description=f"{parent_label} ID", + leading_parameters=[ + Parameter("session", kind=Parameter.POSITIONAL_OR_KEYWORD, annotation=AsyncSessionDep), + Parameter( + "category_ids", + kind=Parameter.POSITIONAL_OR_KEYWORD, + annotation=Annotated[ + set[PositiveInt], + Body( + description=f"Category IDs to assign to the {parent_label.lower()}", + default_factory=set, + examples=[[1, 2, 3]], + ), + ], + ), + ], + ) + _set_route_signature( + add_single_category, + path_param_name=parent_path_param, + path_description=f"{parent_label} ID", + leading_parameters=[ + Parameter("session", kind=Parameter.POSITIONAL_OR_KEYWORD, annotation=AsyncSessionDep), + Parameter( + "category_id", + kind=Parameter.POSITIONAL_OR_KEYWORD, + annotation=Annotated[ + PositiveInt, + Path(description=f"ID of category to add to the {parent_label.lower()}"), + ], + ), + ], + ) + _set_route_signature( + remove_categories_bulk, + path_param_name=parent_path_param, + path_description=f"{parent_label} ID", + leading_parameters=[ + Parameter("session", kind=Parameter.POSITIONAL_OR_KEYWORD, annotation=AsyncSessionDep), + Parameter( + "category_ids", + kind=Parameter.POSITIONAL_OR_KEYWORD, + annotation=Annotated[ + set[PositiveInt], + Body( + description=f"Category IDs to remove from the {parent_label.lower()}", + default_factory=set, + examples=[[1, 2, 3]], + ), + ], + ), + ], + ) + _set_route_signature( + remove_single_category, + path_param_name=parent_path_param, + path_description=f"{parent_label} ID", + leading_parameters=[ + Parameter("session", kind=Parameter.POSITIONAL_OR_KEYWORD, annotation=AsyncSessionDep), + Parameter( + "category_id", + kind=Parameter.POSITIONAL_OR_KEYWORD, + annotation=Annotated[ + PositiveInt, + Path(description=f"ID of category to remove from the {parent_label.lower()}"), + ], + ), + ], + ) + + router.add_api_route( + f"/{{{parent_path_param}}}/categories", + add_categories_bulk, + methods=["POST"], + response_model=list[CategoryRead], + summary=f"Add multiple categories to the {parent_label.lower()}", + status_code=201, + ) + router.add_api_route( + f"/{{{parent_path_param}}}/categories/{{category_id}}", + add_single_category, + methods=["POST"], + response_model=CategoryRead, + summary=f"Add a category to the {parent_label.lower()}", + status_code=201, + ) + router.add_api_route( + f"/{{{parent_path_param}}}/categories", + remove_categories_bulk, + methods=["DELETE"], + summary=f"Remove multiple categories from the {parent_label.lower()}", + status_code=204, + ) + router.add_api_route( + f"/{{{parent_path_param}}}/categories/{{category_id}}", + remove_single_category, + methods=["DELETE"], + summary=f"Remove a category from the {parent_label.lower()}", + status_code=204, + ) + + +def add_basic_admin_crud_routes( + router: APIRouter, + *, + model_label: str, + path_param: str, + response_model: type[SQLModel], + create_schema: type[BaseModel], + update_schema: type[BaseModel], + create_handler: Callable[..., Awaitable[object]], + update_handler: Callable[..., Awaitable[object]], + delete_handler: Callable[[Any, int], Awaitable[None]], +) -> None: + """Add the standard create/update/delete admin routes for a simple background-data model.""" + + async def create_model(**kwargs: object) -> object: + session = kwargs["session"] + payload = kwargs["payload"] + return await create_handler(session, payload) + + async def update_model(**kwargs: object) -> object: + session = kwargs["session"] + model_id = cast("int", kwargs[path_param]) + payload = kwargs["payload"] + return await update_handler(session, model_id, payload) + + async def delete_model(**kwargs: object) -> None: + session = kwargs["session"] + model_id = cast("int", kwargs[path_param]) + await delete_handler(session, model_id) + + route_suffix = path_param.removesuffix("_id") + create_model.__name__ = f"create_{route_suffix}" + update_model.__name__ = f"update_{route_suffix}" + delete_model.__name__ = f"delete_{route_suffix}" + _set_signature( + create_model, + [ + Parameter("session", kind=Parameter.POSITIONAL_OR_KEYWORD, annotation=AsyncSessionDep), + Parameter( + "payload", + kind=Parameter.POSITIONAL_OR_KEYWORD, + annotation=cast("Any", create_schema), + default=Body(...), + ), + ], + ) + _set_signature( + update_model, + [ + Parameter("session", kind=Parameter.POSITIONAL_OR_KEYWORD, annotation=AsyncSessionDep), + Parameter( + "payload", + kind=Parameter.POSITIONAL_OR_KEYWORD, + annotation=cast("Any", update_schema), + default=Body(...), + ), + Parameter( + path_param, + kind=Parameter.POSITIONAL_OR_KEYWORD, + annotation=PositiveInt, + default=Path(..., description=f"{model_label.title()} ID"), + ), + ], + ) + _set_signature( + delete_model, + [ + Parameter("session", kind=Parameter.POSITIONAL_OR_KEYWORD, annotation=AsyncSessionDep), + Parameter( + path_param, + kind=Parameter.POSITIONAL_OR_KEYWORD, + annotation=PositiveInt, + default=Path(..., description=f"{model_label.title()} ID"), + ), + ], + ) + + router.add_api_route( + "", + create_model, + methods=["POST"], + response_model=response_model, + summary=f"Create {model_label}", + status_code=201, + ) + router.add_api_route( + f"/{{{path_param}}}", + update_model, + methods=["PATCH"], + response_model=response_model, + summary=f"Update {model_label}", + ) + router.add_api_route( + f"/{{{path_param}}}", + delete_model, + methods=["DELETE"], + summary=f"Delete {model_label}", + status_code=204, + ) diff --git a/backend/app/api/background_data/routers/__init__.py b/backend/app/api/background_data/routers/__init__.py index e69de29b..aa4dc563 100644 --- a/backend/app/api/background_data/routers/__init__.py +++ b/backend/app/api/background_data/routers/__init__.py @@ -0,0 +1 @@ +"""Routes for interacting with background data.""" diff --git a/backend/app/api/background_data/routers/admin.py b/backend/app/api/background_data/routers/admin.py index cdc4fa1f..73e372b8 100644 --- a/backend/app/api/background_data/routers/admin.py +++ b/backend/app/api/background_data/routers/admin.py @@ -1,53 +1,17 @@ -"""Admin routers for background data models.""" +"""Admin background-data router composition.""" -from collections.abc import Sequence from typing import Annotated -from fastapi import APIRouter, Body, Path, Security -from pydantic import PositiveInt +from fastapi import APIRouter, Path, Security from app.api.auth.dependencies import current_active_superuser -from app.api.background_data import crud -from app.api.background_data.models import ( - Category, - Material, - ProductType, - Taxonomy, -) -from app.api.background_data.schemas import ( - CategoryCreateWithinCategoryWithSubCategories, - CategoryCreateWithinTaxonomyWithSubCategories, - CategoryCreateWithSubCategories, - CategoryRead, - CategoryUpdate, - MaterialCreate, - MaterialCreateWithCategories, - MaterialRead, - MaterialUpdate, - ProductTypeCreateWithCategories, - ProductTypeRead, - ProductTypeUpdate, - TaxonomyCreate, - TaxonomyCreateWithCategories, - TaxonomyRead, - TaxonomyUpdate, -) -from app.api.common.crud.base import get_nested_model_by_id -from app.api.common.routers.dependencies import AsyncSessionDep -from app.api.file_storage.router_factories import StorageRouteMethod, add_storage_routes - -# TODO: Extract common logic and turn into router-factory functions. -# See FileStorageRouterFactory in common/router_factories.py for an example. - -# TODO: Improve HTTP method choices for linked resources -# (e.g., POST vs PATCH for adding categories to material, or DELETE vs. PATCH for removing categories) - -# TODO: Improve HTTP status codes (e.g., 201 for creation) and error handling. -# TODO: Consider supporting comma-separated list of relationships to include, -# TODO: Add paging and sorting to filters - +from app.api.background_data.routers.admin_categories import router as category_router +from app.api.background_data.routers.admin_materials import router as material_router +from app.api.background_data.routers.admin_product_types import router as product_type_router +from app.api.background_data.routers.admin_taxonomies import router as taxonomy_router +from app.core.cache import clear_cache_namespace +from app.core.config import CacheNamespace -# Initialize API router router = APIRouter( prefix="/admin", tags=["admin"], @@ -55,629 +19,15 @@ ) -### Category routers ### -category_router = APIRouter(prefix="/categories", tags=["categories"]) - - -@category_router.post( - "", - response_model=CategoryRead, - summary="Create a new category", - status_code=201, -) -async def create_category( - category: Annotated[ - CategoryCreateWithSubCategories, - Body( - openapi_examples={ - "simple": { - "summary": "Basic category", - "description": "Create a category without subcategories", - "value": {"name": "Metals", "description": "All kinds of metals", "taxonomy_id": 1}, - }, - "nested": { - "summary": "Category with subcategories", - "description": "Create a category with nested subcategories", - "value": { - "name": "Metals", - "description": "All kinds of metals", - "taxonomy_id": 1, - "subcategories": [ - { - "name": "Ferrous metals", - "description": "Iron and its alloys", - "subcategories": [ - {"name": "Steel", "description": "Steel alloys"}, - ], - } - ], - }, - }, - } - ), - ], - session: AsyncSessionDep, -) -> Category: - """Create a new category, optionally with subcategories.""" - return await crud.create_category(session, category) - # TODO: Figure out how to deduplicate this type of exception handling logic - - -@category_router.patch("/{category_id}", response_model=CategoryRead, summary="Update category") -async def update_category( - category_id: PositiveInt, - category: Annotated[ - CategoryUpdate, - Body( - openapi_examples={ - "name": {"summary": "Update name", "value": {"name": "Updated Metal Category"}}, - "description": { - "summary": "Update description", - "value": {"description": "Updated description for metals category"}, - }, - } - ), - ], - session: AsyncSessionDep, -) -> Category: - """Update an existing category.""" - return await crud.update_category(session, category_id, category) - - -@category_router.delete( - "/{category_id}", - summary="Delete category", - status_code=204, -) -async def delete_category(category_id: PositiveInt, session: AsyncSessionDep) -> None: - """Delete a category by ID, including its subcategories.""" - await crud.delete_category(session, category_id) - - -## Subcategory routers ## -@category_router.post("/{category_id}/subcategories", response_model=CategoryRead, status_code=201) -async def create_subcategory( - category_id: PositiveInt, - category: Annotated[ - CategoryCreateWithinCategoryWithSubCategories, - Body( - openapi_examples={ - "simple": { - "summary": "Basic subcategory", - "description": "Create a subcategory without nested subcategories", - "value": { - "name": "Ferrous metals", - "description": "Iron and its alloys", - }, - }, - "nested": { - "summary": "Category with subcategories", - "description": "Create a subcategory with nested subcategories", - "value": { - "name": "Ferrous metals", - "description": "Iron and its alloys", - "subcategories": [ - {"name": "Steel", "description": "Steel alloys"}, - ], - }, - }, - } - ), - ], - session: AsyncSessionDep, -) -> Category: - """Create a new subcategory under an existing category.""" - new_category: Category = await crud.create_category( - db=session, - category=category, - supercategory_id=category_id, - ) - - return new_category - - -@category_router.delete( - "/{category_id}/subcategories/{subcategory_id}", - summary="Delete category", - status_code=204, -) -async def delete_subcategory(category_id: PositiveInt, subcategory_id: PositiveInt, session: AsyncSessionDep) -> None: - """Delete a subcategory by ID, including its subcategories.""" - # Validate existence of subcategory - await get_nested_model_by_id(session, Category, category_id, Category, subcategory_id, "supercategory_id") - - # Delete subcategory - await crud.delete_category(session, subcategory_id) - - -### Taxonomy routers ### -taxonomy_router = APIRouter(prefix="/taxonomies", tags=["taxonomies"]) - - -@taxonomy_router.post( - "", - response_model=TaxonomyRead, - summary="Create a new taxonomy", - status_code=201, -) -async def create_taxonomy( - taxonomy: Annotated[ - TaxonomyCreate | TaxonomyCreateWithCategories, - Body( - openapi_examples={ - "simple": { - "summary": "Basic taxonomy", - "description": "Create a taxonomy without categories", - "value": { - "name": "Materials Taxonomy", - "description": "Taxonomy for materials", - "domains": ["materials"], - "source": "DOI:10.2345/12345", - }, - }, - "nested": { - "summary": "Taxonomy with categories", - "description": "Create a taxonomy with initial category tree", - "value": { - "name": "Materials Taxonomy", - "description": "Taxonomy for materials", - "domains": ["materials"], - "source": "DOI:10.2345/12345", - "categories": [ - { - "name": "Metals", - "description": "All kinds of metals", - "subcategories": [{"name": "Ferrous metals", "description": "Iron and its alloys"}], - } - ], - }, - }, - } - ), - ], - session: AsyncSessionDep, -) -> Taxonomy: - """Create a new taxonomy, optionally with categories.""" - return await crud.create_taxonomy(session, taxonomy) - - -@taxonomy_router.patch("/{taxonomy_id}", response_model=TaxonomyRead, summary="Update taxonomy") -async def update_taxonomy( - taxonomy_id: PositiveInt, - taxonomy: Annotated[ - TaxonomyUpdate, - Body( - openapi_examples={ - "simple": { - "summary": "Update basic info", - "value": {"name": "Updated Materials Taxonomy", "description": "Updated taxonomy for materials"}, - }, - "advanced": { - "summary": "Update domain and source", - "value": {"domain": "materials", "source": "https://new-source.com/taxonomy"}, - }, - } - ), - ], - session: AsyncSessionDep, -) -> Taxonomy: - """Update an existing taxonomy.""" - return await crud.update_taxonomy(session, taxonomy_id, taxonomy) - - -@taxonomy_router.delete( - "/{taxonomy_id}", - summary="Delete taxonomy, including categories", - status_code=204, -) -async def delete_taxonomy(taxonomy_id: PositiveInt, session: AsyncSessionDep) -> None: - """Delete a taxonomy by ID, including its categories.""" - await crud.delete_taxonomy(session, taxonomy_id) - - -## Taxonomy Category routers ## -@taxonomy_router.post( - "/{taxonomy_id}/categories", - response_model=CategoryRead, - summary="Create a new category in a taxonomy", - status_code=201, -) -async def create_category_in_taxonomy( - taxonomy_id: PositiveInt, - category: Annotated[ - CategoryCreateWithinTaxonomyWithSubCategories, - Body( - openapi_examples={ - "simple": { - "summary": "Basic category", - "value": {"name": "Metals", "description": "All kinds of metals"}, - }, - "with_subcategories": { - "summary": "Category with subcategories", - "value": { - "name": "Metals", - "description": "All kinds of metals", - "subcategories": [{"name": "Steel", "description": "Steel materials"}], - }, - }, - } - ), - ], - session: AsyncSessionDep, -) -> Category: - """Create a new category in a taxonomy, optionally with subcategories.""" - new_category: Category = await crud.create_category( - db=session, - category=category, - taxonomy_id=taxonomy_id, - ) - - return new_category - - -@taxonomy_router.delete( - "/{taxonomy_id}/categories/{category_id}", - summary="Delete category in a taxonomy", - status_code=204, -) -async def delete_category_in_taxonomy( - taxonomy_id: PositiveInt, category_id: PositiveInt, session: AsyncSessionDep -) -> None: - """Delete a category by ID, including its subcategories.""" - # Validate existence of taxonomy and category - await get_nested_model_by_id(session, Taxonomy, taxonomy_id, Category, category_id, "taxonomy_id") - - # Delete category - await crud.delete_category(session, category_id) - - -### Material routers ### - -material_router = APIRouter(prefix="/materials", tags=["materials"]) - - -## POST routers ## -@material_router.post( - "", - response_model=MaterialRead, - summary="Create a new material, optionally with category assignments", - status_code=201, -) -async def create_material( - material: Annotated[ - MaterialCreate | MaterialCreateWithCategories, - Body( - openapi_examples={ - "simple": { - "summary": "Basic material", - "description": "Create a material without categories", - "value": { - "name": "Steel", - "description": "Common structural steel", - "density_kg_m3": 7850, - "source": "EN 10025-2", - "is_crm": False, - }, - }, - "with_categories": { - "summary": "Material with categories", - "description": "Create a material with category assignments", - "value": { - "name": "Steel", - "description": "Common structural steel", - "density_kg_m3": 7850, - "source": "EN 10025-2", - "is_crm": False, - "category_ids": [1, 2], # e.g., Metals, Ferrous Metals - }, - }, - } - ), - ], - session: AsyncSessionDep, -) -> Material: - """Create a new material, optionally with category assignments.""" - return await crud.create_material(session, material) - - -## PATCH routers ## -@material_router.patch("/{material_id}", response_model=MaterialRead, summary="Update material") -async def update_material( - material_id: PositiveInt, - material: Annotated[ - MaterialUpdate, - Body( - openapi_examples={ - "simple": { - "summary": "Update basic info", - "value": {"name": "Carbon Steel", "description": "Updated description for steel"}, - }, - "properties": { - "summary": "Update properties", - "value": {"density_kg_m3": 7870, "source": "Updated standard", "is_crm": True}, - }, - } - ), - ], - session: AsyncSessionDep, -) -> Material: - """Update an existing material.""" - return await crud.update_material(session, material_id, material) - - -## DELETE routers ## -@material_router.delete( - "/{material_id}", - responses={ - 204: { - "description": "Successfully deleted material", - }, - 404: {"description": "Material not found"}, - }, -) -async def delete_material(material_id: PositiveInt, session: AsyncSessionDep) -> None: - """Delete a material.""" - await crud.delete_material(session, material_id) - - -## Material Category routers ## -@material_router.post( - "/{material_id}/categories", - response_model=list[CategoryRead], - summary="Add multiple categories to the material", - status_code=201, -) -async def add_categories_to_material( - material_id: PositiveInt, - category_ids: Annotated[ - set[PositiveInt], - Body( - description="Category IDs to assign to the material", - default_factory=set, - examples=[[1, 2, 3]], - ), - ], - session: AsyncSessionDep, -) -> Sequence[Category]: - """Add multiple categories to the material.""" - return await crud.add_categories_to_material(session, material_id, category_ids) - - -@material_router.post( - "/{material_id}/categories/{category_id}", - response_model=CategoryRead, - summary="Add a category to the material.", - status_code=201, -) -async def add_category_to_material( - material_id: PositiveInt, - category_id: Annotated[ - PositiveInt, - Path(description="ID of category to add to the material"), - ], - session: AsyncSessionDep, -) -> Category: - """Add a category to the material.""" - return await crud.add_category_to_material(session, material_id, category_id) - - -@material_router.delete( - "/{material_id}/categories", - status_code=204, - summary="Remove multiple categories from the material", -) -async def remove_categories_from_material_bulk( - material_id: PositiveInt, - category_ids: Annotated[ - set[PositiveInt], - Body( - description="Category IDs to remove from the material", - default_factory=set, - examples=[[1, 2, 3]], - ), - ], - session: AsyncSessionDep, -) -> None: - """Remove multiple categories from the material.""" - await crud.remove_categories_from_material(session, material_id, category_ids) - - -@material_router.delete( - "/{material_id}/categories/{category_id}", - status_code=204, - summary="Remove a category from the material", -) -async def remove_category_from_material( - material_id: PositiveInt, - category_id: Annotated[ - PositiveInt, - Path( - description="ID of category to remove from the material", - ), - ], - session: AsyncSessionDep, -) -> None: - """Remove a category from the material.""" - return await crud.remove_categories_from_material(session, material_id, category_id) - - -## Material Storage routers ## -add_storage_routes( - router=material_router, - parent_api_model_name=Material.get_api_model_name(), - files_crud=crud.material_files_crud, - images_crud=crud.material_images_crud, - include_methods={StorageRouteMethod.POST, StorageRouteMethod.DELETE}, - modify_auth_dep=current_active_superuser, # Only superusers can edit Material files -) - -### ProductType routers ### - -product_type_router = APIRouter(prefix="/product-types", tags=["product-types"]) - +@router.post("/cache/clear/{namespace}", summary="Clear cache by namespace") +async def clear_cache_by_namespace( + namespace: Annotated[CacheNamespace, Path(description="Cache namespace to clear")], +) -> dict[str, str]: + """Clear cached responses for a specific namespace.""" + await clear_cache_namespace(namespace) + return {"status": "cleared", "namespace": namespace} -## Basic CRUD routers ## -@product_type_router.post("", response_model=ProductTypeRead, summary="Create product type", status_code=201) -async def create_product_type( - product_type: Annotated[ - ProductTypeCreateWithCategories, - Body( - openapi_examples={ - "simple": { - "summary": "Basic product type", - "description": "Create a product type without categories", - "value": {"name": "Smartphone", "description": "Mobile phone with smart capabilities"}, - }, - "with_categories": { - "summary": "Product type with categories", - "description": "Create a product type and assign it to categories", - "value": { - "name": "Smartphone", - "description": "Mobile phone with smart capabilities", - "category_ids": [1, 2], - }, - }, - } - ), - ], - session: AsyncSessionDep, -) -> ProductType: - """Create a new product type, optionally assigning it to categories.""" - return await crud.create_product_type(session, product_type) - - -@product_type_router.patch("/{product_type_id}", response_model=ProductTypeRead, summary="Update product type") -async def update_product_type( - product_type_id: PositiveInt, - product_type: Annotated[ - ProductTypeUpdate, - Body( - openapi_examples={ - "name": {"summary": "Update name", "value": {"name": "Mobile Phone"}}, - "description": { - "summary": "Update description", - "value": {"description": "Updated description for mobile phones"}, - }, - } - ), - ], - session: AsyncSessionDep, -) -> ProductType: - """Update an existing product type.""" - return await crud.update_product_type(session, product_type_id, product_type) - - -## DELETE routers ## -@product_type_router.delete( - "/{product_type_id}", - responses={ - 204: { - "description": "Successfully deleted product_type", - }, - 404: {"description": "ProductType not found"}, - }, - status_code=204, -) -async def delete_product_type(product_type_id: PositiveInt, session: AsyncSessionDep) -> None: - """Delete a product type.""" - await crud.delete_product_type(session, product_type_id) - - -## ProductType Category routers ## -# TODO: deduplicate category routers for materials and product types and move to the common.router_factories module - - -@product_type_router.post( - "/{product_type_id}/categories", - response_model=list[CategoryRead], - summary="Add multiple categories to the product type", - status_code=201, -) -async def add_categories_to_product_type_bulk( - product_type_id: PositiveInt, - category_ids: Annotated[ - set[PositiveInt], - Body( - description="Category IDs to assign to the product type", - default_factory=set, - examples=[[1, 2, 3]], - ), - ], - session: AsyncSessionDep, -) -> Sequence[Category]: - """Add multiple categories to the product type.""" - return await crud.add_categories_to_product_type(session, product_type_id, category_ids) - - -@product_type_router.post( - "/{product_type_id}/categories/{category_id}", - response_model=CategoryRead, - summary="Add an existing category to the product type", - status_code=201, -) -async def add_categories_to_product_type( - product_type_id: PositiveInt, - category_id: Annotated[ - PositiveInt, - Path(description="ID of category to add to the product type"), - ], - session: AsyncSessionDep, -) -> Category: - """Add an existing category to the product type.""" - return await crud.add_category_to_product_type(session, product_type_id, category_id) - - -@product_type_router.delete( - "/{product_type_id}/categories", - status_code=204, - summary="Remove multiple categories from the product type", -) -async def remove_categories_from_product_type_bulk( - product_type_id: PositiveInt, - category_ids: Annotated[ - set[PositiveInt], - Body( - description="Category IDs to remove from the product type", - default_factory=set, - examples=[[1, 2, 3]], - ), - ], - session: AsyncSessionDep, -) -> None: - """Remove multiple categories from the product type.""" - await crud.remove_categories_from_product_type(session, product_type_id, category_ids) - - -@product_type_router.delete( - "/{product_type_id}/categories/{category_id}", - status_code=204, - summary="Remove a category from the product type", -) -async def remove_categories_from_product_type( - product_type_id: PositiveInt, - category_id: Annotated[ - PositiveInt, - Path( - description="ID of category to remove from the product type", - ), - ], - session: AsyncSessionDep, -) -> None: - """Remove a category from the product type.""" - return await crud.remove_categories_from_product_type(session, product_type_id, category_id) - - -## ProductType Storage routers ## -add_storage_routes( - router=product_type_router, - parent_api_model_name=ProductType.get_api_model_name(), - files_crud=crud.product_type_files, - images_crud=crud.product_type_images, - include_methods={StorageRouteMethod.POST, StorageRouteMethod.DELETE}, - modify_auth_dep=current_active_superuser, # Only superusers can edit ProductType files -) -### Router inclusion ### router.include_router(category_router) router.include_router(taxonomy_router) router.include_router(material_router) diff --git a/backend/app/api/background_data/routers/admin_categories.py b/backend/app/api/background_data/routers/admin_categories.py new file mode 100644 index 00000000..5af7c52a --- /dev/null +++ b/backend/app/api/background_data/routers/admin_categories.py @@ -0,0 +1,63 @@ +"""Admin category routers for background data.""" + +from __future__ import annotations + +from typing import Annotated + +from fastapi import APIRouter, Body +from pydantic import PositiveInt + +from app.api.background_data import crud +from app.api.background_data.models import Category +from app.api.background_data.schemas import ( + CategoryCreateWithinCategoryWithSubCategories, + CategoryCreateWithSubCategories, + CategoryRead, + CategoryUpdate, +) +from app.api.common.crud.base import get_nested_model_by_id +from app.api.common.routers.dependencies import AsyncSessionDep + +router = APIRouter(prefix="/categories", tags=["categories"]) + + +@router.post("", response_model=CategoryRead, summary="Create a new category", status_code=201) +async def create_category( + category: Annotated[CategoryCreateWithSubCategories, Body()], + session: AsyncSessionDep, +) -> Category: + """Create a new category, optionally with subcategories.""" + return await crud.create_category(session, category) + + +@router.patch("/{category_id}", response_model=CategoryRead, summary="Update category") +async def update_category( + category_id: PositiveInt, + category: Annotated[CategoryUpdate, Body()], + session: AsyncSessionDep, +) -> Category: + """Update an existing category.""" + return await crud.update_category(session, category_id, category) + + +@router.delete("/{category_id}", summary="Delete category", status_code=204) +async def delete_category(category_id: PositiveInt, session: AsyncSessionDep) -> None: + """Delete a category by ID, including its subcategories.""" + await crud.delete_category(session, category_id) + + +@router.post("/{category_id}/subcategories", response_model=CategoryRead, status_code=201) +async def create_subcategory( + category_id: PositiveInt, + category: Annotated[CategoryCreateWithinCategoryWithSubCategories, Body()], + session: AsyncSessionDep, +) -> Category: + """Create a new subcategory under an existing category.""" + return await crud.create_category(db=session, category=category, supercategory_id=category_id) + + +@router.delete("/{category_id}/subcategories/{subcategory_id}", summary="Delete category", status_code=204) +async def delete_subcategory(category_id: PositiveInt, subcategory_id: PositiveInt, session: AsyncSessionDep) -> None: + """Delete a subcategory by ID, including its subcategories.""" + await get_nested_model_by_id(session, Category, category_id, Category, subcategory_id, "supercategory_id") + await crud.delete_category(session, subcategory_id) diff --git a/backend/app/api/background_data/routers/admin_materials.py b/backend/app/api/background_data/routers/admin_materials.py new file mode 100644 index 00000000..c12d0866 --- /dev/null +++ b/backend/app/api/background_data/routers/admin_materials.py @@ -0,0 +1,46 @@ +"""Admin material routers for background data.""" + +from __future__ import annotations + +from fastapi import APIRouter + +from app.api.auth.dependencies import current_active_superuser +from app.api.background_data import crud +from app.api.background_data.models import Material +from app.api.background_data.router_factories import add_basic_admin_crud_routes, add_linked_category_write_routes +from app.api.background_data.schemas import MaterialCreateWithCategories, MaterialRead, MaterialUpdate +from app.api.file_storage.router_factories import StorageRouteMethod, add_storage_routes + +router = APIRouter(prefix="/materials", tags=["materials"]) + +add_basic_admin_crud_routes( + router, + model_label="material", + path_param="material_id", + response_model=MaterialRead, + create_schema=MaterialCreateWithCategories, + update_schema=MaterialUpdate, + create_handler=crud.create_material, + update_handler=crud.update_material, + delete_handler=crud.delete_material, +) + + +add_linked_category_write_routes( + router, + parent_path_param="material_id", + parent_label="material", + add_categories=crud.add_categories_to_material, + add_category=crud.add_category_to_material, + remove_categories=crud.remove_categories_from_material, +) + + +add_storage_routes( + router=router, + parent_api_model_name=Material.get_api_model_name(), + files_crud=crud.material_files_crud, + images_crud=crud.material_images_crud, + include_methods={StorageRouteMethod.POST, StorageRouteMethod.DELETE}, + modify_auth_dep=current_active_superuser, +) diff --git a/backend/app/api/background_data/routers/admin_product_types.py b/backend/app/api/background_data/routers/admin_product_types.py new file mode 100644 index 00000000..33564ac5 --- /dev/null +++ b/backend/app/api/background_data/routers/admin_product_types.py @@ -0,0 +1,46 @@ +"""Admin product-type routers for background data.""" + +from __future__ import annotations + +from fastapi import APIRouter + +from app.api.auth.dependencies import current_active_superuser +from app.api.background_data import crud +from app.api.background_data.models import ProductType +from app.api.background_data.router_factories import add_basic_admin_crud_routes, add_linked_category_write_routes +from app.api.background_data.schemas import ProductTypeCreateWithCategories, ProductTypeRead, ProductTypeUpdate +from app.api.file_storage.router_factories import StorageRouteMethod, add_storage_routes + +router = APIRouter(prefix="/product-types", tags=["product-types"]) + +add_basic_admin_crud_routes( + router, + model_label="product type", + path_param="product_type_id", + response_model=ProductTypeRead, + create_schema=ProductTypeCreateWithCategories, + update_schema=ProductTypeUpdate, + create_handler=crud.create_product_type, + update_handler=crud.update_product_type, + delete_handler=crud.delete_product_type, +) + + +add_linked_category_write_routes( + router, + parent_path_param="product_type_id", + parent_label="product type", + add_categories=crud.add_categories_to_product_type, + add_category=crud.add_category_to_product_type, + remove_categories=crud.remove_categories_from_product_type, +) + + +add_storage_routes( + router=router, + parent_api_model_name=ProductType.get_api_model_name(), + files_crud=crud.product_type_files, + images_crud=crud.product_type_images, + include_methods={StorageRouteMethod.POST, StorageRouteMethod.DELETE}, + modify_auth_dep=current_active_superuser, +) diff --git a/backend/app/api/background_data/routers/admin_taxonomies.py b/backend/app/api/background_data/routers/admin_taxonomies.py new file mode 100644 index 00000000..15ce9009 --- /dev/null +++ b/backend/app/api/background_data/routers/admin_taxonomies.py @@ -0,0 +1,72 @@ +"""Admin taxonomy routers for background data.""" + +from __future__ import annotations + +from typing import Annotated + +from fastapi import APIRouter, Body +from pydantic import PositiveInt + +from app.api.background_data import crud +from app.api.background_data.models import Category, Taxonomy +from app.api.background_data.schemas import ( + CategoryCreateWithinTaxonomyWithSubCategories, + CategoryRead, + TaxonomyCreate, + TaxonomyCreateWithCategories, + TaxonomyRead, + TaxonomyUpdate, +) +from app.api.common.crud.base import get_nested_model_by_id +from app.api.common.routers.dependencies import AsyncSessionDep + +router = APIRouter(prefix="/taxonomies", tags=["taxonomies"]) + + +@router.post("", response_model=TaxonomyRead, summary="Create a new taxonomy", status_code=201) +async def create_taxonomy( + taxonomy: Annotated[TaxonomyCreate | TaxonomyCreateWithCategories, Body()], + session: AsyncSessionDep, +) -> Taxonomy: + """Create a new taxonomy, optionally with categories.""" + return await crud.create_taxonomy(session, taxonomy) + + +@router.patch("/{taxonomy_id}", response_model=TaxonomyRead, summary="Update taxonomy") +async def update_taxonomy( + taxonomy_id: PositiveInt, + taxonomy: Annotated[TaxonomyUpdate, Body()], + session: AsyncSessionDep, +) -> Taxonomy: + """Update an existing taxonomy.""" + return await crud.update_taxonomy(session, taxonomy_id, taxonomy) + + +@router.delete("/{taxonomy_id}", summary="Delete taxonomy, including categories", status_code=204) +async def delete_taxonomy(taxonomy_id: PositiveInt, session: AsyncSessionDep) -> None: + """Delete a taxonomy by ID, including its categories.""" + await crud.delete_taxonomy(session, taxonomy_id) + + +@router.post( + "/{taxonomy_id}/categories", + response_model=CategoryRead, + summary="Create a new category in a taxonomy", + status_code=201, +) +async def create_category_in_taxonomy( + taxonomy_id: PositiveInt, + category: Annotated[CategoryCreateWithinTaxonomyWithSubCategories, Body()], + session: AsyncSessionDep, +) -> Category: + """Create a new category in a taxonomy, optionally with subcategories.""" + return await crud.create_category(db=session, category=category, taxonomy_id=taxonomy_id) + + +@router.delete("/{taxonomy_id}/categories/{category_id}", summary="Delete category in a taxonomy", status_code=204) +async def delete_category_in_taxonomy( + taxonomy_id: PositiveInt, category_id: PositiveInt, session: AsyncSessionDep +) -> None: + """Delete a category by ID, including its subcategories.""" + await get_nested_model_by_id(session, Taxonomy, taxonomy_id, Category, category_id, "taxonomy_id") + await crud.delete_category(session, category_id) diff --git a/backend/app/api/background_data/routers/public.py b/backend/app/api/background_data/routers/public.py index eb0406be..896c0875 100644 --- a/backend/app/api/background_data/routers/public.py +++ b/backend/app/api/background_data/routers/public.py @@ -1,1058 +1,19 @@ -"""Admin routers for background data models.""" +"""Public background-data router composition.""" -from collections.abc import Sequence -from typing import Annotated +from fastapi import APIRouter -from fastapi import APIRouter, Path, Query -from pydantic import PositiveInt -from sqlmodel import select +from app.api.background_data.routers.public_categories import router as category_router +from app.api.background_data.routers.public_materials import router as material_router +from app.api.background_data.routers.public_product_types import router as product_type_router +from app.api.background_data.routers.public_support import RecursionDepthQueryParam +from app.api.background_data.routers.public_taxonomies import router as taxonomy_router +from app.api.background_data.routers.public_units import router as unit_router -from app.api.background_data import crud -from app.api.background_data.dependencies import ( - CategoryFilterDep, - CategoryFilterWithRelationshipsDep, - MaterialFilterWithRelationshipsDep, - ProductTypeFilterWithRelationshipsDep, - TaxonomyFilterDep, -) -from app.api.background_data.models import ( - Category, - CategoryMaterialLink, - CategoryProductTypeLink, - Material, - ProductType, - Taxonomy, -) -from app.api.background_data.schemas import ( - CategoryRead, - CategoryReadAsSubCategoryWithRecursiveSubCategories, - CategoryReadWithRecursiveSubCategories, - CategoryReadWithRelationshipsAndFlatSubCategories, - MaterialReadWithRelationships, - ProductTypeReadWithRelationships, - TaxonomyRead, -) -from app.api.common.crud.associations import get_linked_model_by_id, get_linked_models -from app.api.common.crud.base import get_model_by_id, get_models, get_nested_model_by_id -from app.api.common.routers.dependencies import AsyncSessionDep -from app.api.common.routers.openapi import PublicAPIRouter -from app.api.file_storage.router_factories import StorageRouteMethod, add_storage_routes - -# TODO: Extract common logic and turn into router-factory functions. -# See FileStorageRouterFactory in common/router_factories.py for an example. - -# TODO: Improve HTTP method choices for linked resources -# (e.g., POST vs PATCH for adding categories to material, or DELETE vs. PATCH for removing categories) - -# TODO: Improve HTTP status codes (e.g., 201 for creation) and error handling. -# TODO: Consider supporting comma-separated list of relationships to include, -# TODO: Add paging and sorting to filters - - -# Initialize API router router = APIRouter() - -### Category routers ### -category_router = PublicAPIRouter(prefix="/categories", tags=["categories"]) - - -## Utilities ## -def convert_subcategories_to_read_model( - subcategories: list[Category], max_depth: int = 1, current_depth: int = 0 -) -> list[CategoryReadAsSubCategoryWithRecursiveSubCategories]: - """Convert subcategories to read model recursively.""" - if current_depth >= max_depth: - return [] - - return [ - CategoryReadAsSubCategoryWithRecursiveSubCategories.model_validate( - category, - update={ - "subcategories": convert_subcategories_to_read_model( - category.subcategories or [], max_depth, current_depth + 1 - ) - }, - ) - for category in subcategories - ] - - -RecursionDepthQueryParam = Annotated[int, Query(ge=1, le=5, description="Maximum recursion depth")] - - -## GET routers ## -@category_router.get( - "", - response_model=list[CategoryReadWithRelationshipsAndFlatSubCategories], - summary="Get all categories with optional filtering and relationships", - responses={ - 200: { - "description": "List of categories", - "content": { - "application/json": { - "examples": { - "basic": { - "summary": "Basic categories", - "value": [ - { - "id": 1, - "name": "Metals", - "description": "All metals", - "materials": [], - "product_types": [], - "subcategories": [], - } - ], - }, - "with_relationships": { - "summary": "With relationships", - "value": [ - { - "id": 1, - "name": "Metals", - "materials": [{"id": 1, "name": "Steel"}], - "product_types": [{"id": 1, "name": "Metal Chair"}], - "subcategories": [{"id": 2, "name": "Ferrous Metals"}], - } - ], - }, - } - } - }, - } - }, -) -async def get_categories( - session: AsyncSessionDep, - category_filter: CategoryFilterWithRelationshipsDep, - # TODO: Create include Query param factory - include: Annotated[ - set[str] | None, - Query( - description="Relationships to include", - openapi_examples={ - "none": {"value": []}, - "materials": {"value": ["materials"]}, - "all": {"value": ["materials", "product_types", "subcategories"]}, - }, - ), - ] = None, -) -> Sequence[Category]: - """Get all categories with specified relationships.""" - return await get_models(session, Category, include_relationships=include, model_filter=category_filter) - - -@category_router.get( - "/tree", - response_model=list[CategoryReadWithRecursiveSubCategories], - summary="Get categories tree", - responses={ - 200: { - "description": "Category tree with subcategories", - "content": { - "application/json": { - "examples": { - "simple_tree": { - "summary": "Simple category tree", - "value": [ - { - "id": 1, - "name": "Metals", - "description": "All kinds of metals", - "subcategories": [], - }, - { - "id": 2, - "name": "Plastics", - "description": "All kinds of plastics", - "subcategories": [], - }, - ], - }, - "nested_tree": { - "summary": "Nested category tree", - "value": [ - { - "id": 1, - "name": "Metals", - "description": "All kinds of metals", - "subcategories": [ - { - "id": 2, - "name": "Ferrous metals", - "description": "Iron and its alloys", - "subcategories": [ - { - "id": 3, - "name": "Steel", - "description": "Steel alloys", - "subcategories": [], - } - ], - } - ], - }, - { - "id": 4, - "name": "Plastics", - "description": "All kinds of plastics", - "subcategories": [ - { - "id": 5, - "name": "Thermoplastics", - "description": "Plastics that can be melted and reshaped", - "subcategories": [], - } - ], - }, - ], - }, - } - } - }, - } - }, -) -async def get_categories_tree( - session: AsyncSessionDep, - category_filter: CategoryFilterWithRelationshipsDep, - recursion_depth: RecursionDepthQueryParam = 1, -) -> list[CategoryReadWithRecursiveSubCategories]: - """Get all base categories and their subcategories in a tree structure.""" - categories: Sequence[Category] = await crud.get_category_trees( - session, recursion_depth, category_filter=category_filter - ) - return [ - CategoryReadWithRecursiveSubCategories.model_validate( - category, - update={ - "subcategories": convert_subcategories_to_read_model( - category.subcategories or [], max_depth=recursion_depth - 1 - ) - }, - ) - for category in categories - ] - - -@category_router.get( - "/{category_id}", - response_model=CategoryReadWithRelationshipsAndFlatSubCategories, - responses={ - 200: { - "description": "Category found", - "content": { - "application/json": { - "examples": { - "basic": { - "summary": "Basic category", - "value": { - "id": 1, - "name": "Metals", - "materials": [], - "product_types": [], - "subcategories": [], - }, - }, - "with_relationships": { - "summary": "With relationships", - "value": { - "id": 1, - "name": "Metals", - "materials": [{"id": 1, "name": "Steel"}], - "product_types": [{"id": 1, "name": "Metal Chair"}], - "subcategories": [{"id": 2, "name": "Ferrous Metals"}], - }, - }, - } - } - }, - }, - 404: { - "description": "Category not found", - "content": {"application/json": {"example": {"detail": "Category with id 999 not found"}}}, - }, - }, -) -async def get_category( - session: AsyncSessionDep, - category_id: PositiveInt, - include: Annotated[ - set[str] | None, - Query( - description="Relationships to include", - openapi_examples={ - "none": {"value": []}, - "materials": {"value": ["materials"]}, - "all": {"value": ["materials", "product_types", "subcategories"]}, - }, - ), - ] = None, -) -> Category: - """Get category by ID with specified relationships.""" - return await get_model_by_id(session, Category, category_id, include_relationships=include) - - -## Subcategory routers ## -@category_router.get( - "{category_id}/subcategories", - response_model=list[CategoryReadWithRelationshipsAndFlatSubCategories], - summary="Get category subcategories with optional filtering and relationships", -) -async def get_subcategories( - category_id: Annotated[PositiveInt, Path(description="Category ID")], - category_filter: CategoryFilterDep, - session: AsyncSessionDep, - include: Annotated[ - set[str] | None, - Query( - description="Relationships to include", - openapi_examples={ - "none": {"value": []}, - "materials": {"value": ["materials"]}, - "all": {"value": ["materials", "product_types", "subcategories"]}, - }, - ), - ] = None, -) -> Sequence[Category]: - """Get all categories with specified relationships.""" - # Validate existence of category - await get_model_by_id(session, Category, category_id) - - # Get subcategories - statement = select(Category).where(Category.supercategory_id == category_id) - return await get_models( - session, Category, include_relationships=include, model_filter=category_filter, statement=statement - ) - - -@category_router.get( - "/{category_id}/subcategories/tree", - summary="Get category subtree", - response_model=list[CategoryReadWithRecursiveSubCategories], - responses={ - 200: { - "description": "Category tree with subcategories", - "content": { - "application/json": { - "examples": { - "stub_tree": { - "summary": "Category stub tree", - "value": {}, - }, - "nested_tree": { - "summary": "Nested category tree", - "value": { - "id": 2, - "name": "Ferrous metals", - "description": "Iron and its alloys", - "subcategories": [ - { - "id": 3, - "name": "Steel", - "description": "Steel alloys", - "subcategories": [], - } - ], - }, - }, - } - }, - }, - }, - 404: { - "description": "Category not found", - "content": {"application/json": {"example": {"detail": "Category with id 99 not found"}}}, - }, - }, -) -async def get_category_subtree( - category_id: PositiveInt, - category_filter: CategoryFilterDep, - session: AsyncSessionDep, - recursion_depth: RecursionDepthQueryParam = 1, -) -> list[CategoryReadWithRecursiveSubCategories]: - """Get a category subcategories in a tree structure, up to a specified depth.""" - categories: Sequence[Category] = await crud.get_category_trees( - session, recursion_depth=recursion_depth, supercategory_id=category_id, category_filter=category_filter - ) - return [ - CategoryReadWithRecursiveSubCategories.model_validate( - category, - update={ - "subcategories": convert_subcategories_to_read_model( - category.subcategories or [], max_depth=recursion_depth - 1 - ) - }, - ) - for category in categories - ] - - -@category_router.get( - "/{category_id}/subcategories/{subcategory_id}", - response_model=CategoryReadWithRelationshipsAndFlatSubCategories, - summary="Get subcategory by ID", -) -async def get_subcategory( - category_id: PositiveInt, - subcategory_id: PositiveInt, - session: AsyncSessionDep, - include: Annotated[ - set[str] | None, - Query( - description="Relationships to include", - openapi_examples={ - "none": {"value": []}, - "materials": {"value": ["materials"]}, - "all": {"value": ["materials", "product_types", "subcategories"]}, - }, - ), - ] = None, -) -> Category: - """Get subcategory by ID with specified relationships.""" - return await get_nested_model_by_id( - session, Category, category_id, Category, subcategory_id, "supercategory_id", include_relationships=include - ) - - -### Taxonomy routers ### -taxonomy_router = PublicAPIRouter(prefix="/taxonomies", tags=["taxonomies"]) - - -## GET routers ## -@taxonomy_router.get( - "", - response_model=list[TaxonomyRead], - summary="Get all taxonomies with optional filtering and base categories", - responses={ - 200: { - "description": "List of taxonomies", - "content": { - "application/json": { - "examples": { - "basic": { - "summary": "Basic taxonomies", - "value": [ - { - "id": 1, - "name": "Materials", - "description": "Materials taxonomy", - "domains": ["materials"], - "categories": [], - } - ], - }, - "with_categories": { - "summary": "With categories", - "value": [{"id": 1, "name": "Materials", "categories": [{"id": 1, "name": "Metals"}]}], - }, - } - } - }, - } - }, -) -async def get_taxonomies( - taxonomy_filter: TaxonomyFilterDep, - session: AsyncSessionDep, - *, - include_base_categories: Annotated[ - bool, - Query(description="Whether to include base categories"), - ] = False, -) -> Sequence[Taxonomy]: - """Get all taxonomies with specified relationships.""" - return await crud.get_taxonomies( - session, taxonomy_filter=taxonomy_filter, include_base_categories=include_base_categories - ) - - -@taxonomy_router.get( - "/{taxonomy_id}", - response_model=TaxonomyRead, - responses={ - 200: { - "description": "Taxonomy found", - "content": { - "application/json": { - "examples": { - "basic": { - "summary": "Basic taxonomy", - "value": {"id": 1, "name": "Materials", "categories": []}, - }, - "with_categories": { - "summary": "With categories", - "value": {"id": 1, "name": "Materials", "categories": [{"id": 1, "name": "Metals"}]}, - }, - } - } - }, - }, - 404: { - "description": "Taxonomy not found", - "content": {"application/json": {"example": {"detail": "Taxonomy with id 999 not found"}}}, - }, - }, -) -async def get_taxonomy( - taxonomy_id: PositiveInt, - session: AsyncSessionDep, - *, - include_base_categories: Annotated[ - bool, - Query(description="Whether to include base categories"), - ] = False, -) -> Taxonomy: - """Get taxonomy by ID with base categories.""" - return await crud.get_taxonomy_by_id(session, taxonomy_id, include_base_categories=include_base_categories) - - -## Taxonomy Category routers ## -@taxonomy_router.get( - "/{taxonomy_id}/categories", - response_model=list[CategoryReadWithRecursiveSubCategories], - summary="Get the categories of a taxonomy", - responses={ - 200: { - "description": "Taxonomy with category tree", - "content": { - "application/json": { - "examples": { - "simple_tree": { - "summary": "Simple taxonomy", - "value": { - "id": 1, - "name": "Metals", - "description": "All kinds of metals", - "subcategories": [], - }, - }, - "nested_tree": { - "summary": "Taxonomy with nested categories", - "value": { - "id": 1, - "name": "Metals", - "description": "All kinds of metals", - "subcategories": [ - { - "id": 2, - "name": "Ferrous metals", - "description": "Iron and its alloys", - "subcategories": [ - { - "id": 3, - "name": "Steel", - "description": "Steel alloys", - "subcategories": [], - } - ], - } - ], - }, - }, - }, - } - }, - }, - }, -) -async def get_taxonomy_category_tree( - taxonomy_id: PositiveInt, - session: AsyncSessionDep, - category_filter: CategoryFilterDep, - recursion_depth: RecursionDepthQueryParam = 1, -) -> list[CategoryReadWithRecursiveSubCategories]: - """Get a taxonomy with its category tree structure.""" - categories: Sequence[Category] = await crud.get_category_trees( - session, recursion_depth, taxonomy_id=taxonomy_id, category_filter=category_filter - ) - return [ - CategoryReadWithRecursiveSubCategories.model_validate( - category, - update={ - "subcategories": convert_subcategories_to_read_model( - category.subcategories or [], max_depth=recursion_depth - 1 - ) - }, - ) - for category in categories - ] - - -@taxonomy_router.get( - "/{taxonomy_id}/categories/{category_id}", - response_model=CategoryRead, - summary="Get taxonomy category by ID", -) -async def get_taxonomy_category( - taxonomy_id: PositiveInt, - category_id: PositiveInt, - session: AsyncSessionDep, - include: Annotated[ - set[str] | None, - Query( - description="Relationships to include", - openapi_examples={ - "none": {"value": []}, - "materials": {"value": ["materials"]}, - "all": {"value": ["materials", "product_types", "subcategories"]}, - }, - ), - ] = None, -) -> Category: - """Get category by ID with specified relationships.""" - return await get_nested_model_by_id( - session, Taxonomy, taxonomy_id, Category, category_id, "taxonomy_id", include_relationships=include - ) - - -### Material routers ### -material_router = PublicAPIRouter(prefix="/materials", tags=["materials"]) - - -## GET routers ## -@material_router.get( - "", - response_model=list[MaterialReadWithRelationships], - summary="Get all materials with optional relationships", - responses={ - 200: { - "description": "List of materials", - "content": { - "application/json": { - "examples": { - "basic": { - "summary": "Materials without relationships", - "value": [ - { - "id": 1, - "name": "Steel", - "description": "Common structural steel", - "categories": [], - "product_links": [], - "images": [], - "files": [], - } - ], - }, - "with_categories": { - "summary": "Materials with categories", - "value": [ - { - "id": 1, - "name": "Steel", - "categories": [{"id": 1, "name": "Metals"}], - "product_links": [], - "images": [], - "files": [], - } - ], - }, - } - } - }, - } - }, -) -async def get_materials( - session: AsyncSessionDep, - material_filter: MaterialFilterWithRelationshipsDep, - include: Annotated[ - set[str] | None, - Query( - description="Relationships to include", - openapi_examples={ - "none": {"value": []}, - "categories": {"value": {"categories"}}, - "all": {"value": ["categories", "files", "images", "product_links"]}, - }, - ), - ] = None, -) -> Sequence[Material]: - """Get all materials with specified relationships.""" - return await get_models(session, Material, include_relationships=include, model_filter=material_filter) - - -@material_router.get( - "/{material_id}", - response_model=MaterialReadWithRelationships, - responses={ - 200: { - "description": "Material found", - "content": { - "application/json": { - "examples": { - "basic": { - "summary": "Basic material", - "value": { - "id": 1, - "name": "Steel", - "description": "Common structural steel", - "density_kg_m3": 7850, - "created_at": "2025-09-22T14:30:45Z", - "updated_at": "2025-09-22T14:30:45Z", - }, - }, - "with_categories": { - "summary": "With categories", - "value": { - "id": 1, - "name": "Steel", - "description": "Common structural steel", - "density_kg_m3": 7850, - "created_at": "2025-09-22T14:30:45Z", - "updated_at": "2025-09-22T14:30:45Z", - "categories": [ - { - "id": 1, - "name": "Metals", - "description": "All kinds of metals", - "taxonomy_id": 1, - "super_category_id": None, - } - ], - }, - }, - "with_all": { - "summary": "All relationships", - "value": { - "id": 1, - "name": "Steel", - "description": "Common structural steel", - "density_kg_m3": 7850, - "created_at": "2025-09-22T14:30:45Z", - "updated_at": "2025-09-22T14:30:45Z", - "categories": [ - { - "id": 1, - "name": "Metals", - "description": "All kinds of metals", - "taxonomy_id": 1, - "super_category_id": None, - } - ], - "images": [{"id": 1, "url": "/images/steel.jpg"}], - "files": [{"id": 1, "url": "/files/steel.csv"}], - }, - }, - } - } - }, - }, - 404: { - "description": "Material not found", - "content": {"application/json": {"example": {"detail": "Material with id 999 not found"}}}, - }, - }, -) -async def get_material( - session: AsyncSessionDep, - material_id: PositiveInt, - include: Annotated[ - set[str] | None, - Query( - description="Relationships to include", - openapi_examples={ - "none": {"value": []}, - "categories": {"value": ["categories"]}, - "all": {"value": ["categories", "images", "files"]}, - }, - ), - ] = None, -) -> Material: - """Get material by ID with specified relationships.""" - return await get_model_by_id(session, Material, model_id=material_id, include_relationships=include) - - -## Material Category routers ## -@material_router.get( - "/{material_id}/categories", - response_model=list[CategoryRead], - summary="View categories of material", -) -async def get_categories_for_material( - material_id: PositiveInt, - session: AsyncSessionDep, - include: Annotated[ - set[str] | None, - Query( - description="Relationships to include", - openapi_examples={ - "none": {"value": []}, - "taxonomy": {"value": ["taxonomy"]}, - "all": {"value": ["taxonomy", "subcategories"]}, - }, - ), - ], - category_filter: CategoryFilterDep, -) -> Sequence[Category]: - """View categories of a material.""" - return await get_linked_models( - session, - Material, - material_id, - Category, - CategoryMaterialLink, - "material_id", - include_relationships=include, - model_filter=category_filter, - ) - - -@material_router.get( - "/{material_id}/categories/{category_id}", - response_model=CategoryRead, - summary="Get category by ID", -) -async def get_category_for_material( - material_id: PositiveInt, - category_id: PositiveInt, - include: Annotated[ - set[str] | None, - Query( - description="Relationships to include", - openapi_examples={ - "none": {"value": []}, - "taxonomy": {"value": ["taxonomy"]}, - "all": {"value": ["taxonomy", "subcategories"]}, - }, - ), - ], - session: AsyncSessionDep, -) -> Category: - """Get a category by ID for a specific material.""" - return await get_linked_model_by_id( - session, - Material, - material_id, - Category, - category_id, - CategoryMaterialLink, - "material_id", - "category_id", - include=include, - ) - - -## Material Storage routers ## -add_storage_routes( - router=material_router, - parent_api_model_name=Material.get_api_model_name(), - files_crud=crud.material_files_crud, - images_crud=crud.material_images_crud, - include_methods={StorageRouteMethod.GET}, # Non-superusers can only read Material files -) - -### ProductType routers ### -product_type_router = PublicAPIRouter(prefix="/product-types", tags=["product-types"]) - - -## Basic CRUD routers ## -@product_type_router.get( - "", - summary="Get all product types", - responses={ - 200: { - "description": "List of product types", - "content": { - "application/json": { - "examples": { - "basic": { - "summary": "Product types without relationships", - "value": [ - { - "id": 1, - "name": "Chair", - "description": "Basic chair", - "categories": [], - "products": [], - "images": [], - "files": [], - } - ], - }, - "with_categories": { - "summary": "Product types with categories", - "value": [ - { - "id": 1, - "name": "Chair", - "categories": [{"id": 1, "name": "Furniture"}], - "products": [], - "images": [], - "files": [], - } - ], - }, - } - } - }, - } - }, -) -async def get_product_types( - session: AsyncSessionDep, - product_type_filter: ProductTypeFilterWithRelationshipsDep, - include: Annotated[ - set[str] | None, # TODO: Consider supporting comma-separated list of relationships to include - Query( - description="Relationships to include", - openapi_examples={ - "none": {"value": []}, - "categories": {"value": {"categories"}}, - "all": {"value": ["categories", "files", "images", "product_links"]}, - }, - ), - ] = None, -) -> Sequence[ProductType]: - """Get a list of all product types.""" - return await get_models(session, ProductType, include_relationships=include, model_filter=product_type_filter) - - -@product_type_router.get( - "/{product_type_id}", - response_model=ProductTypeReadWithRelationships, - summary="Get product type by ID", - responses={ - 200: { - "description": "Product type found", - "content": { - "application/json": { - "examples": { - "basic": { - "summary": "Basic product type", - "value": { - "id": 1, - "name": "Chair", - "description": "Basic chair", - "categories": [], - "products": [], - "images": [], - "files": [], - }, - }, - "with_relationships": { - "summary": "With relationships", - "value": { - "id": 1, - "name": "Chair", - "categories": [{"id": 1, "name": "Furniture"}], - "products": [{"id": 1, "name": "IKEA Chair"}], - "images": [{"id": 1, "url": "/images/chair.jpg"}], - }, - }, - } - } - }, - }, - 404: { - "description": "Product type not found", - "content": {"application/json": {"example": {"detail": "ProductType with id 999 not found"}}}, - }, - }, -) -async def get_product_type( - session: AsyncSessionDep, - product_type_id: PositiveInt, - include: Annotated[ - set[str] | None, - Query( - description="Relationships to include", - openapi_examples={ - "none": {"value": []}, - "categories": {"value": ["categories"]}, - "all": {"value": ["categories", "images", "files"]}, - }, - ), - ] = None, -) -> ProductType: - """Get a single product type by ID with its categories and products.""" - return await get_model_by_id(session, ProductType, product_type_id, include_relationships=include) - - -## ProductType Category routers ## -# TODO: deduplicate category routers for materials and product types and move to the common.router_factories module -@product_type_router.get( - "/{product_type_id}/categories", - response_model=list[CategoryRead], - summary="View categories of product type", -) -async def get_categories_for_product_type( - product_type_id: PositiveInt, - session: AsyncSessionDep, - include: Annotated[ - set[str] | None, - Query( - description="Relationships to include", - openapi_examples={ - "none": {"value": []}, - "taxonomy": {"value": ["taxonomy"]}, - "all": {"value": ["taxonomy", "subcategories"]}, - }, - ), - ], - category_filter: CategoryFilterDep, -) -> Sequence[Category]: - """View categories of a product type.""" - return await get_linked_models( - session, - ProductType, - product_type_id, - Category, - CategoryProductTypeLink, - "product_type_id", - include_relationships=include, - model_filter=category_filter, - ) - - -@product_type_router.get( - "/{product_type_id}/categories/{category_id}", - response_model=CategoryRead, - summary="Get category by ID", -) -async def get_category_for_product_type( - product_type_id: PositiveInt, - category_id: PositiveInt, - include: Annotated[ - set[str] | None, - Query( - description="Relationships to include", - openapi_examples={ - "none": {"value": []}, - "taxonomy": {"value": ["taxonomy"]}, - "all": {"value": ["taxonomy", "subcategories"]}, - }, - ), - ], - session: AsyncSessionDep, -) -> Category: - """Get a category by ID for a product type.""" - return await get_linked_model_by_id( - session, - ProductType, - product_type_id, - Category, - category_id, - CategoryProductTypeLink, - "product_type_id", - "category_id", - include=include, - ) - - -## ProductType Storage routers ## -add_storage_routes( - router=product_type_router, - parent_api_model_name=ProductType.get_api_model_name(), - files_crud=crud.product_type_files, - images_crud=crud.product_type_images, - include_methods={StorageRouteMethod.GET}, # Non-superusers can only read ProductType files -) - -### Router inclusion ### router.include_router(category_router) router.include_router(taxonomy_router) router.include_router(material_router) router.include_router(product_type_router) +router.include_router(unit_router) + +__all__ = ["RecursionDepthQueryParam", "router"] diff --git a/backend/app/api/background_data/routers/public_categories.py b/backend/app/api/background_data/routers/public_categories.py new file mode 100644 index 00000000..4ad00d3e --- /dev/null +++ b/backend/app/api/background_data/routers/public_categories.py @@ -0,0 +1,177 @@ +"""Public category routers for background data.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Annotated + +from fastapi import Path +from fastapi_pagination import Page +from pydantic import PositiveInt +from sqlmodel import select + +from app.api.background_data import crud +from app.api.background_data.dependencies import CategoryFilterDep, CategoryFilterWithRelationshipsDep +from app.api.background_data.models import Category +from app.api.background_data.router_factories import CategoryIncludeExamples, relationship_include_query +from app.api.background_data.routers.public_support import ( + BackgroundDataAPIRouter, + RecursionDepthQueryParam, + convert_subcategories_to_read_model, +) +from app.api.background_data.schemas import ( + CategoryReadWithRecursiveSubCategories, + CategoryReadWithRelationshipsAndFlatSubCategories, +) +from app.api.common.routers.dependencies import AsyncSessionDep +from app.api.common.routers.read_helpers import ( + get_model_response, + get_nested_model_response, + list_models_response, +) + +if TYPE_CHECKING: + from collections.abc import Sequence + +router = BackgroundDataAPIRouter(prefix="/categories", tags=["categories"]) + + +@router.get( + "", + response_model=Page[CategoryReadWithRelationshipsAndFlatSubCategories], + summary="Get all categories with optional filtering and relationships", +) +async def get_categories( + session: AsyncSessionDep, + category_filter: CategoryFilterWithRelationshipsDep, + include: Annotated[set[str] | None, relationship_include_query(openapi_examples=CategoryIncludeExamples)] = None, +) -> Page[Category]: + """Get all categories with specified relationships.""" + return await list_models_response( + session, + Category, + include_relationships=include, + model_filter=category_filter, + read_schema=CategoryReadWithRelationshipsAndFlatSubCategories, + ) + + +@router.get( + "/tree", + response_model=list[CategoryReadWithRecursiveSubCategories], + summary="Get categories tree", +) +async def get_categories_tree( + session: AsyncSessionDep, + category_filter: CategoryFilterWithRelationshipsDep, + recursion_depth: RecursionDepthQueryParam = 1, +) -> list[CategoryReadWithRecursiveSubCategories]: + """Get all base categories and their subcategories in a tree structure.""" + categories: Sequence[Category] = await crud.get_category_trees( + session, recursion_depth, category_filter=category_filter + ) + return [ + CategoryReadWithRecursiveSubCategories.model_validate( + category, + update={ + "subcategories": convert_subcategories_to_read_model( + category.subcategories or [], max_depth=recursion_depth - 1 + ) + }, + ) + for category in categories + ] + + +@router.get( + "/{category_id}", + response_model=CategoryReadWithRelationshipsAndFlatSubCategories, +) +async def get_category( + session: AsyncSessionDep, + category_id: PositiveInt, + include: Annotated[set[str] | None, relationship_include_query(openapi_examples=CategoryIncludeExamples)] = None, +) -> Category: + """Get category by ID with specified relationships.""" + return await get_model_response( + session, + Category, + category_id, + include_relationships=include, + read_schema=CategoryReadWithRelationshipsAndFlatSubCategories, + ) + + +@router.get( + "{category_id}/subcategories", + response_model=Page[CategoryReadWithRelationshipsAndFlatSubCategories], + summary="Get category subcategories with optional filtering and relationships", +) +async def get_subcategories( + category_id: Annotated[PositiveInt, Path(description="Category ID")], + category_filter: CategoryFilterDep, + session: AsyncSessionDep, + include: Annotated[set[str] | None, relationship_include_query(openapi_examples=CategoryIncludeExamples)] = None, +) -> Page[Category]: + """Get paginated subcategories of a category with specified relationships.""" + await get_model_response(session, Category, category_id) + statement = select(Category).where(Category.supercategory_id == category_id) + return await list_models_response( + session, + Category, + include_relationships=include, + model_filter=category_filter, + statement=statement, + read_schema=CategoryReadWithRelationshipsAndFlatSubCategories, + ) + + +@router.get( + "/{category_id}/subcategories/tree", + summary="Get category subtree", + response_model=list[CategoryReadWithRecursiveSubCategories], +) +async def get_category_subtree( + category_id: PositiveInt, + category_filter: CategoryFilterDep, + session: AsyncSessionDep, + recursion_depth: RecursionDepthQueryParam = 1, +) -> list[CategoryReadWithRecursiveSubCategories]: + """Get a category subcategories in a tree structure, up to a specified depth.""" + categories: Sequence[Category] = await crud.get_category_trees( + session, recursion_depth=recursion_depth, supercategory_id=category_id, category_filter=category_filter + ) + return [ + CategoryReadWithRecursiveSubCategories.model_validate( + category, + update={ + "subcategories": convert_subcategories_to_read_model( + category.subcategories or [], max_depth=recursion_depth - 1 + ) + }, + ) + for category in categories + ] + + +@router.get( + "/{category_id}/subcategories/{subcategory_id}", + response_model=CategoryReadWithRelationshipsAndFlatSubCategories, + summary="Get subcategory by ID", +) +async def get_subcategory( + category_id: PositiveInt, + subcategory_id: PositiveInt, + session: AsyncSessionDep, + include: Annotated[set[str] | None, relationship_include_query(openapi_examples=CategoryIncludeExamples)] = None, +) -> Category: + """Get subcategory by ID with specified relationships.""" + return await get_nested_model_response( + session, + Category, + category_id, + Category, + subcategory_id, + "supercategory_id", + include_relationships=include, + read_schema=CategoryReadWithRelationshipsAndFlatSubCategories, + ) diff --git a/backend/app/api/background_data/routers/public_materials.py b/backend/app/api/background_data/routers/public_materials.py new file mode 100644 index 00000000..85df2138 --- /dev/null +++ b/backend/app/api/background_data/routers/public_materials.py @@ -0,0 +1,103 @@ +"""Public material routers for background data.""" + +from __future__ import annotations + +from typing import Annotated + +from fastapi_pagination import Page +from pydantic import PositiveInt + +from app.api.background_data import crud +from app.api.background_data.dependencies import MaterialFilterWithRelationshipsDep +from app.api.background_data.models import Category, CategoryMaterialLink, Material +from app.api.background_data.router_factories import ( + MaterialIncludeExamples, + add_linked_category_read_routes, + relationship_include_query, +) +from app.api.background_data.routers.public_support import BackgroundDataAPIRouter +from app.api.background_data.schemas import CategoryRead, MaterialReadWithRelationships +from app.api.common.crud.associations import get_linked_model_by_id, get_linked_models +from app.api.common.routers.dependencies import AsyncSessionDep +from app.api.common.routers.read_helpers import get_model_response, list_models_response +from app.api.file_storage.router_factories import StorageRouteMethod, add_storage_routes + +router = BackgroundDataAPIRouter(prefix="/materials", tags=["materials"]) + + +@router.get( + "", + response_model=Page[MaterialReadWithRelationships], + summary="Get all materials with optional relationships", +) +async def get_materials( + session: AsyncSessionDep, + material_filter: MaterialFilterWithRelationshipsDep, + include: Annotated[set[str] | None, relationship_include_query(openapi_examples=MaterialIncludeExamples)] = None, +) -> Page[Material]: + """Get all materials with specified relationships.""" + return await list_models_response( + session, + Material, + include_relationships=include, + model_filter=material_filter, + read_schema=MaterialReadWithRelationships, + ) + + +@router.get( + "/{material_id}", + response_model=MaterialReadWithRelationships, +) +async def get_material( + session: AsyncSessionDep, + material_id: PositiveInt, + include: Annotated[set[str] | None, relationship_include_query(openapi_examples=MaterialIncludeExamples)] = None, +) -> Material: + """Get material by ID with specified relationships.""" + return await get_model_response( + session, + Material, + model_id=material_id, + include_relationships=include, + read_schema=MaterialReadWithRelationships, + ) + + +add_linked_category_read_routes( + router, + parent_path_param="material_id", + parent_label="material", + get_categories=lambda session, parent_id, include, category_filter: get_linked_models( + session, + Material, + parent_id, + Category, + CategoryMaterialLink, + "material_id", + include_relationships=include, + model_filter=category_filter, + read_schema=CategoryRead, + ), + get_category=lambda session, parent_id, category_id, include: get_linked_model_by_id( + session, + Material, + parent_id, + Category, + category_id, + CategoryMaterialLink, + "material_id", + "category_id", + include=include, + read_schema=CategoryRead, + ), +) + + +add_storage_routes( + router=router, + parent_api_model_name=Material.get_api_model_name(), + files_crud=crud.material_files_crud, + images_crud=crud.material_images_crud, + include_methods={StorageRouteMethod.GET}, +) diff --git a/backend/app/api/background_data/routers/public_product_types.py b/backend/app/api/background_data/routers/public_product_types.py new file mode 100644 index 00000000..61e2668b --- /dev/null +++ b/backend/app/api/background_data/routers/public_product_types.py @@ -0,0 +1,104 @@ +"""Public product-type routers for background data.""" + +from __future__ import annotations + +from typing import Annotated + +from fastapi_pagination import Page +from pydantic import PositiveInt + +from app.api.background_data import crud +from app.api.background_data.dependencies import ProductTypeFilterWithRelationshipsDep +from app.api.background_data.models import Category, CategoryProductTypeLink, ProductType +from app.api.background_data.router_factories import ( + MaterialIncludeExamples, + add_linked_category_read_routes, + relationship_include_query, +) +from app.api.background_data.routers.public_support import BackgroundDataAPIRouter +from app.api.background_data.schemas import CategoryRead, ProductTypeReadWithRelationships +from app.api.common.crud.associations import get_linked_model_by_id, get_linked_models +from app.api.common.routers.dependencies import AsyncSessionDep +from app.api.common.routers.read_helpers import get_model_response, list_models_response +from app.api.file_storage.router_factories import StorageRouteMethod, add_storage_routes + +router = BackgroundDataAPIRouter(prefix="/product-types", tags=["product-types"]) + + +@router.get( + "", + response_model=Page[ProductTypeReadWithRelationships], + summary="Get all product types", +) +async def get_product_types( + session: AsyncSessionDep, + product_type_filter: ProductTypeFilterWithRelationshipsDep, + include: Annotated[set[str] | None, relationship_include_query(openapi_examples=MaterialIncludeExamples)] = None, +) -> Page[ProductType]: + """Get a list of all product types.""" + return await list_models_response( + session, + ProductType, + include_relationships=include, + model_filter=product_type_filter, + read_schema=ProductTypeReadWithRelationships, + ) + + +@router.get( + "/{product_type_id}", + response_model=ProductTypeReadWithRelationships, + summary="Get product type by ID", +) +async def get_product_type( + session: AsyncSessionDep, + product_type_id: PositiveInt, + include: Annotated[set[str] | None, relationship_include_query(openapi_examples=MaterialIncludeExamples)] = None, +) -> ProductType: + """Get a single product type by ID with its categories and products.""" + return await get_model_response( + session, + ProductType, + product_type_id, + include_relationships=include, + read_schema=ProductTypeReadWithRelationships, + ) + + +add_linked_category_read_routes( + router, + parent_path_param="product_type_id", + parent_label="product type", + get_categories=lambda session, parent_id, include, category_filter: get_linked_models( + session, + ProductType, + parent_id, + Category, + CategoryProductTypeLink, + "product_type_id", + include_relationships=include, + model_filter=category_filter, + read_schema=CategoryRead, + ), + get_category=lambda session, parent_id, category_id, include: get_linked_model_by_id( + session, + ProductType, + parent_id, + Category, + category_id, + CategoryProductTypeLink, + "product_type_id", + "category_id", + include=include, + read_schema=CategoryRead, + ), +) + + +add_storage_routes( + router=router, + parent_api_model_name=ProductType.get_api_model_name(), + files_crud=crud.product_type_files, + images_crud=crud.product_type_images, + include_methods={StorageRouteMethod.GET}, +) diff --git a/backend/app/api/background_data/routers/public_support.py b/backend/app/api/background_data/routers/public_support.py new file mode 100644 index 00000000..5bb764b5 --- /dev/null +++ b/backend/app/api/background_data/routers/public_support.py @@ -0,0 +1,63 @@ +"""Shared utilities for public background-data routers.""" + +from __future__ import annotations + +from http import HTTPMethod +from typing import TYPE_CHECKING, Annotated, cast + +from fastapi import Query +from fastapi.types import DecoratedCallable +from fastapi_cache.decorator import cache + +from app.api.background_data.models import Category +from app.api.background_data.schemas import CategoryReadAsSubCategoryWithRecursiveSubCategories +from app.api.common.routers.openapi import PublicAPIRouter +from app.core.config import CacheNamespace, settings + +if TYPE_CHECKING: + from collections.abc import Callable + from typing import Any + + +class BackgroundDataAPIRouter(PublicAPIRouter): + """Public background data router that caches all GET endpoints.""" + + def api_route(self, path: str, *args: Any, **kwargs: Any) -> Callable[[DecoratedCallable], DecoratedCallable]: # noqa: ANN401 # Any-typed (kw)args are expected by the parent method signatures + """Override api_route to apply caching to all GET endpoints.""" + methods = {method.upper() for method in (kwargs.get("methods") or [])} + decorator = super().api_route(path, *args, **kwargs) + + if HTTPMethod.GET.value not in methods: + return decorator + + def wrapper(func: DecoratedCallable) -> DecoratedCallable: + cached = cache( + expire=settings.cache.ttls[CacheNamespace.BACKGROUND_DATA], + namespace=CacheNamespace.BACKGROUND_DATA, + )(func) + return cast("DecoratedCallable", decorator(cached)) + + return wrapper + + +def convert_subcategories_to_read_model( + subcategories: list[Category], max_depth: int = 1, current_depth: int = 0 +) -> list[CategoryReadAsSubCategoryWithRecursiveSubCategories]: + """Convert subcategories to read model recursively.""" + if current_depth >= max_depth: + return [] + + return [ + CategoryReadAsSubCategoryWithRecursiveSubCategories.model_validate( + category, + update={ + "subcategories": convert_subcategories_to_read_model( + category.subcategories or [], max_depth, current_depth + 1 + ) + }, + ) + for category in subcategories + ] + + +RecursionDepthQueryParam = Annotated[int, Query(ge=1, le=5, description="Maximum recursion depth")] diff --git a/backend/app/api/background_data/routers/public_taxonomies.py b/backend/app/api/background_data/routers/public_taxonomies.py new file mode 100644 index 00000000..e509e38d --- /dev/null +++ b/backend/app/api/background_data/routers/public_taxonomies.py @@ -0,0 +1,130 @@ +"""Public taxonomy routers for background data.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Annotated, cast + +from fastapi import Depends +from fastapi_pagination import Page, Params, create_page +from pydantic import PositiveInt +from sqlmodel import select + +from app.api.background_data import crud +from app.api.background_data.dependencies import CategoryFilterDep, TaxonomyFilterDep +from app.api.background_data.models import Category, Taxonomy +from app.api.background_data.router_factories import CategoryIncludeExamples, relationship_include_query +from app.api.background_data.routers.public_support import ( + BackgroundDataAPIRouter, + RecursionDepthQueryParam, + convert_subcategories_to_read_model, +) +from app.api.background_data.schemas import CategoryRead, CategoryReadWithRecursiveSubCategories, TaxonomyRead +from app.api.common.routers.dependencies import AsyncSessionDep +from app.api.common.routers.read_helpers import ( + get_model_response, + get_nested_model_response, + list_models_response, +) + +if TYPE_CHECKING: + from collections.abc import Sequence + +router = BackgroundDataAPIRouter(prefix="/taxonomies", tags=["taxonomies"]) + + +@router.get("", response_model=Page[TaxonomyRead]) +async def get_taxonomies(taxonomy_filter: TaxonomyFilterDep, session: AsyncSessionDep) -> Page[TaxonomyRead]: + """Get all taxonomies with optional filtering.""" + page = await list_models_response(session, Taxonomy, model_filter=taxonomy_filter, read_schema=TaxonomyRead) + return cast("Page[TaxonomyRead]", page) + + +@router.get("/{taxonomy_id}", response_model=TaxonomyRead) +async def get_taxonomy(taxonomy_id: PositiveInt, session: AsyncSessionDep) -> TaxonomyRead: + """Get taxonomy by ID.""" + taxonomy = await get_model_response(session, Taxonomy, taxonomy_id, read_schema=TaxonomyRead) + return TaxonomyRead.model_validate(taxonomy) + + +@router.get( + "/{taxonomy_id}/categories/tree", + response_model=Page[CategoryReadWithRecursiveSubCategories], + summary="Get the category tree of a taxonomy", +) +async def get_taxonomy_category_tree( + taxonomy_id: PositiveInt, + session: AsyncSessionDep, + category_filter: CategoryFilterDep, + params: Params = Depends(), + recursion_depth: RecursionDepthQueryParam = 1, +) -> Page[CategoryReadWithRecursiveSubCategories]: + """Get paginated top-level categories of a taxonomy with their recursive subcategory trees.""" + categories: Sequence[Category] = await crud.get_category_trees( + session, + recursion_depth, + taxonomy_id=taxonomy_id, + category_filter=category_filter, + ) + tree_items = [ + CategoryReadWithRecursiveSubCategories.model_validate( + category, + update={ + "subcategories": convert_subcategories_to_read_model( + category.subcategories or [], max_depth=recursion_depth - 1 + ) + }, + ) + for category in categories + ] + return cast( + "Page[CategoryReadWithRecursiveSubCategories]", + create_page(tree_items, total=len(tree_items), params=params), + ) + + +@router.get( + "/{taxonomy_id}/categories", + response_model=Page[CategoryRead], + summary="View categories of taxonomy", +) +async def get_taxonomy_categories( + taxonomy_id: PositiveInt, + session: AsyncSessionDep, + category_filter: CategoryFilterDep, + include: Annotated[set[str] | None, relationship_include_query(openapi_examples=CategoryIncludeExamples)] = None, +) -> Page[Category]: + """Get taxonomy categories with optional filtering.""" + await get_model_response(session, Taxonomy, taxonomy_id) + statement = select(Category).where(Category.taxonomy_id == taxonomy_id) + return await list_models_response( + session, + Category, + include_relationships=include, + model_filter=category_filter, + statement=statement, + read_schema=CategoryRead, + ) + + +@router.get( + "/{taxonomy_id}/categories/{category_id}", + response_model=CategoryRead, + summary="Get category by ID", +) +async def get_taxonomy_category_by_id( + taxonomy_id: PositiveInt, + category_id: PositiveInt, + session: AsyncSessionDep, + include: Annotated[set[str] | None, relationship_include_query(openapi_examples=CategoryIncludeExamples)] = None, +) -> Category: + """Get a taxonomy category by ID.""" + return await get_nested_model_response( + session, + Taxonomy, + taxonomy_id, + Category, + category_id, + "taxonomy_id", + include_relationships=include, + read_schema=CategoryRead, + ) diff --git a/backend/app/api/background_data/routers/public_units.py b/backend/app/api/background_data/routers/public_units.py new file mode 100644 index 00000000..ecc4099d --- /dev/null +++ b/backend/app/api/background_data/routers/public_units.py @@ -0,0 +1,13 @@ +"""Public unit router for background data.""" + +from app.api.common.models.enums import Unit + +from .public_support import BackgroundDataAPIRouter + +router = BackgroundDataAPIRouter(prefix="/units", tags=["units"], include_in_schema=True) + + +@router.get("") +async def get_units() -> list[str]: + """Get a list of available units.""" + return [unit.value for unit in Unit] diff --git a/backend/app/api/background_data/schemas.py b/backend/app/api/background_data/schemas.py index b82b2808..e04f3e7c 100644 --- a/backend/app/api/background_data/schemas.py +++ b/backend/app/api/background_data/schemas.py @@ -13,6 +13,7 @@ from app.api.common.schemas.base import ( BaseCreateSchema, BaseReadSchema, + BaseReadSchemaWithTimeStamp, BaseUpdateSchema, MaterialRead, ProductRead, @@ -33,8 +34,8 @@ class CategoryCreateWithinCategoryWithSubCategories(BaseCreateSchema, CategoryBa """Schema for creating a new category within a category, with optional subcategories.""" # Database model has a None default, but Pydantic model has empty set default for consistent API type handling - subcategories: set["CategoryCreateWithinCategoryWithSubCategories"] = Field( - default_factory=set, + subcategories: list[CategoryCreateWithinCategoryWithSubCategories] = Field( + default_factory=list, description="List of subcategories", ) @@ -59,7 +60,7 @@ class CategoryCreateWithSubCategories(CategoryCreateWithinTaxonomyWithSubCategor class CategoryReadAsSubCategory(BaseReadSchema, CategoryBase): """Schema for reading subcategory information.""" - model_config: ConfigDict = ConfigDict( # pyright: ignore [reportIncompatibleVariableOverride] # This is not a type override, see https://github.com/fastapi/sqlmodel/discussions/855 + model_config: ConfigDict = ConfigDict( json_schema_extra={ "examples": [ { @@ -78,7 +79,7 @@ class CategoryRead(CategoryReadAsSubCategory): taxonomy_id: PositiveInt = Field(description="ID of the taxonomy") supercategory_id: PositiveInt | None = None - model_config: ConfigDict = ConfigDict( # pyright: ignore [reportIncompatibleVariableOverride] # This is not a type override, see + model_config: ConfigDict = ConfigDict( json_schema_extra={ "examples": [ { @@ -97,7 +98,7 @@ class CategoryReadWithRelationships(CategoryRead): """Schema for reading category information with all relationships.""" materials: list[MaterialRead] = Field(default_factory=list, description="List of materials linked to the category") - product_types: list["ProductTypeRead"] = Field( + product_types: list[ProductTypeRead] = Field( default_factory=list, description="List of product types linked to the category" ) @@ -111,11 +112,11 @@ class CategoryReadWithRelationshipsAndFlatSubCategories(CategoryReadWithRelation class CategoryReadAsSubCategoryWithRecursiveSubCategories(CategoryReadAsSubCategory): """Schema for reading category information with recursive subcategories.""" - subcategories: list["CategoryReadAsSubCategoryWithRecursiveSubCategories"] = Field( + subcategories: list[CategoryReadAsSubCategoryWithRecursiveSubCategories] = Field( default_factory=list, description="List of subcategories" ) - model_config: ConfigDict = ConfigDict( # pyright: ignore [reportIncompatibleVariableOverride] # This is not a type override, see https://github.com/fastapi/sqlmodel/discussions/855 + model_config: ConfigDict = ConfigDict( json_schema_extra={ "examples": [ { @@ -168,7 +169,7 @@ class CategoryUpdate(BaseUpdateSchema): name: str | None = Field(default=None, min_length=2, max_length=100, description="Name of the category") description: str | None = Field(default=None, max_length=500, description="Description of the category") - model_config: ConfigDict = ConfigDict( # pyright: ignore [reportIncompatibleVariableOverride] # This is not a type override, see https://github.com/fastapi/sqlmodel/discussions/855 + model_config: ConfigDict = ConfigDict( { "json_schema_extra": { "examples": [ @@ -191,23 +192,23 @@ class TaxonomyCreate(BaseCreateSchema, TaxonomyBase): class TaxonomyCreateWithCategories(BaseCreateSchema, TaxonomyBase): """Schema for creating a new taxonomy, optionally with new categories.""" - categories: set[CategoryCreateWithinTaxonomyWithSubCategories] = Field( - default_factory=set, description="Set of subcategories" + categories: list[CategoryCreateWithinTaxonomyWithSubCategories] = Field( + default_factory=list, description="Set of subcategories" ) ## Read Schemas ## -class TaxonomyRead(BaseReadSchema, TaxonomyBase): +class TaxonomyRead(BaseReadSchemaWithTimeStamp, TaxonomyBase): """Schema for reading minimal taxonomy information.""" - model_config: ConfigDict = ConfigDict( # pyright: ignore [reportIncompatibleVariableOverride] # This is not a type override, see https://github.com/fastapi/sqlmodel/discussions/855 + model_config: ConfigDict = ConfigDict( { "json_schema_extra": { "examples": [ { "name": "Materials Taxonomy", "description": "Taxonomy for materials", - "domain": "materials", + "domains": ["materials"], "source": "DOI:10.2345/12345", } ] @@ -223,14 +224,14 @@ class TaxonomyReadWithCategoryTree(TaxonomyRead): default_factory=set, description="Set of categories in the taxonomy" ) - model_config: ConfigDict = ConfigDict( # pyright: ignore [reportIncompatibleVariableOverride] # This is not a type override, see https://github.com/fastapi/sqlmodel/discussions/855 + model_config: ConfigDict = ConfigDict( { "json_schema_extra": { "examples": [ { "name": "Materials Taxonomy", "description": "Taxonomy for materials", - "domain": "materials", + "domains": ["materials"], "source": "DOI:10.2345/12345", "categories": [ { @@ -260,7 +261,8 @@ class TaxonomyUpdate(BaseUpdateSchema): version: str | None = Field(default=None, min_length=1, max_length=50) description: str | None = Field(default=None, max_length=500) domains: set[TaxonomyDomain] | None = Field( - description="Domains of the taxonomy, e.g. {" + f"{', '.join([d.value for d in TaxonomyDomain][:3])}" + "}" + default=None, + description="Domains of the taxonomy, e.g. {" + f"{', '.join([d.value for d in TaxonomyDomain][:3])}" + "}", ) source: str | None = Field(default=None, max_length=50, description="Source of the taxonomy data") @@ -299,12 +301,10 @@ class MaterialReadWithRelationships(MaterialRead): class MaterialUpdate(BaseUpdateSchema): """Schema for a partial update of a material.""" - name: str | None = Field(default=None, min_length=2, max_length=100, description="Name of the Material") - description: str | None = Field(default=None, max_length=500, description="Description of the Material") + name: str | None = Field(default=None, min_length=2, max_length=100) + description: str | None = Field(default=None, max_length=500) source: str | None = Field( - default=None, - max_length=50, - description="Source of the material data, e.g. URL, IRI or citation key", + default=None, max_length=50, description="Source of the material data, e.g. URL, IRI or citation key" ) density_kg_m3: float | None = Field(default=None, gt=0, description="Volumetric density (kg/mΒ³) ") is_crm: bool | None = Field(default=None, description="Is this material a Critical Raw Material (CRM)?") @@ -319,7 +319,7 @@ class ProductTypeCreate(BaseCreateSchema, ProductTypeBase): class ProductTypeCreateWithCategories(BaseCreateSchema, ProductTypeBase): """Schema for creating a product type with links to existing categories.""" - category_ids: set[int] = Field(default_factory=set, description="List of category IDs") + category_ids: set[int] = Field(default_factory=set) ## Read Schemas ## @@ -330,19 +330,15 @@ class ProductTypeRead(BaseReadSchema, ProductTypeBase): class ProductTypeReadWithRelationships(ProductTypeRead): """Schema for reading product type information with all relationships.""" - products: list[ProductRead] = Field( - default_factory=list, description="List of products that have this product type" - ) - categories: list[CategoryRead] = Field( - default_factory=list, description="List of categories linked to the product type" - ) - images: list[ImageRead] = Field(default_factory=list, description="List of images for the product type") - files: list[FileRead] = Field(default_factory=list, description="List of files for the product type") + products: list[ProductRead] = Field(default_factory=list) + categories: list[CategoryRead] = Field(default_factory=list) + images: list[ImageRead] = Field(default_factory=list) + files: list[FileRead] = Field(default_factory=list) ## Update Schemas ## class ProductTypeUpdate(BaseUpdateSchema): """Schema for a partial update of a product type.""" - name: str | None = Field(default=None, min_length=2, max_length=100, description="Name of the Product Type.") - description: str | None = Field(default=None, max_length=500, description="Description of the Product Type.") + name: str | None = Field(default=None, min_length=2, max_length=100) + description: str | None = Field(default=None, max_length=500) diff --git a/backend/app/api/common/crud/associations.py b/backend/app/api/common/crud/associations.py index 926fc6b4..d646db33 100644 --- a/backend/app/api/common/crud/associations.py +++ b/backend/app/api/common/crud/associations.py @@ -1,18 +1,23 @@ """CRUD utility functions for association models between many-to-many relationships.""" -from collections.abc import Sequence -from enum import Enum +# ruff: noqa: PLR0913 + +from enum import StrEnum from typing import TYPE_CHECKING, overload -from uuid import UUID -from fastapi_filter.contrib.sqlalchemy import Filter +from pydantic import BaseModel from sqlmodel import select from sqlmodel.ext.asyncio.session import AsyncSession from app.api.common.crud.base import get_model_by_id, get_models +from app.api.common.exceptions import BadRequestError from app.api.common.models.custom_types import DT, IDT, LMT, MT if TYPE_CHECKING: + from collections.abc import Sequence + from uuid import UUID + + from fastapi_filter.contrib.sqlalchemy import Filter from sqlmodel.sql._expression_select_cls import SelectOfScalar @@ -43,11 +48,11 @@ async def get_linking_model_with_ids_if_it_exists( if not result: model_name: str = model_type.get_api_model_name().name_capital err_msg: str = f"{model_name} with {id1_field} {id1} and {id2_field} {id2} not found" - raise ValueError(err_msg) + raise BadRequestError(err_msg) return result -class LinkedModelReturnType(str, Enum): +class LinkedModelReturnType(StrEnum): """Enum for linked model return types.""" DEPENDENT = "dependent" @@ -67,6 +72,7 @@ async def get_linked_model_by_id( *, return_type: LinkedModelReturnType = LinkedModelReturnType.DEPENDENT, include: set[str] | None = None, + read_schema: type[BaseModel] | None = None, ) -> DT: ... @@ -83,6 +89,7 @@ async def get_linked_model_by_id( *, return_type: LinkedModelReturnType = LinkedModelReturnType.LINK, include: set[str] | None = None, + read_schema: type[BaseModel] | None = None, ) -> LMT: ... @@ -98,6 +105,7 @@ async def get_linked_model_by_id( *, return_type: LinkedModelReturnType = LinkedModelReturnType.DEPENDENT, include: set[str] | None = None, + read_schema: type[BaseModel] | None = None, ) -> DT | LMT: """Get dependent or linking model via linking table relationship. @@ -112,21 +120,28 @@ async def get_linked_model_by_id( dependent_link_field: Dependent ID field in link model return_type: Type of result to return (dependent model or linking model) include: Optional relationships to include + read_schema: Optional schema to validate relationships against """ # Validate both models exist await get_model_by_id(db, parent_model, parent_id) - dependent: DT = await get_model_by_id(db, dependent_model, dependent_id, include_relationships=include) + dependent: DT = await get_model_by_id( + db, + dependent_model, + dependent_id, + include_relationships=include, + read_schema=read_schema, + ) # Validate link exists try: link: LMT = await get_linking_model_with_ids_if_it_exists( db, link_model, parent_id, dependent_id, parent_link_field, dependent_link_field ) - except ValueError as e: + except BadRequestError as e: dependent_model_name: str = dependent_model.get_api_model_name().name_capital parent_model_name: str = parent_model.get_api_model_name().name_capital err_msg: str = f"{dependent_model_name} is not linked to {parent_model_name}" - raise ValueError(err_msg) from e + raise BadRequestError(err_msg) from e return link if return_type == LinkedModelReturnType.LINK else dependent @@ -141,6 +156,7 @@ async def get_linked_models( *, include_relationships: set[str] | None = None, model_filter: Filter | None = None, + read_schema: type[BaseModel] | None = None, ) -> Sequence[DT]: """Get all linked dependent models for a parent.""" # Validate parent exists @@ -153,7 +169,12 @@ async def get_linked_models( # Get filtered models with includes return await get_models( - db, dependent_model, include_relationships=include_relationships, model_filter=model_filter, statement=statement + db, + dependent_model, + include_relationships=include_relationships, + model_filter=model_filter, + statement=statement, + read_schema=read_schema, ) diff --git a/backend/app/api/common/crud/base.py b/backend/app/api/common/crud/base.py index 2864aaaf..8b58ed65 100644 --- a/backend/app/api/common/crud/base.py +++ b/backend/app/api/common/crud/base.py @@ -1,23 +1,24 @@ """Base CRUD operations for SQLAlchemy models.""" +# spell-checker: disable isouter -from collections.abc import Sequence +from typing import TYPE_CHECKING, Any, cast from fastapi_filter.contrib.sqlalchemy import Filter -from fastapi_pagination import Page -from fastapi_pagination.ext.sqlmodel import apaginate -from sqlalchemy import Select +from fastapi_pagination import create_page +from fastapi_pagination.api import resolve_params +from pydantic import BaseModel +from sqlalchemy import func from sqlmodel import select from sqlmodel.ext.asyncio.session import AsyncSession from sqlmodel.sql._expression_select_cls import SelectOfScalar -from app.api.common.crud.exceptions import DependentModelOwnershipError -from app.api.common.crud.utils import ( - AttributeSettingStrategy, - add_relationship_options, - set_empty_relationships, - validate_model_with_id_exists, -) -from app.api.common.models.custom_types import DT, IDT, MT +from app.api.common.crud.exceptions import CRUDConfigurationError, DependentModelOwnershipError +from app.api.common.crud.utils import add_relationship_options, clear_unloaded_relationships, ensure_model_exists +from app.api.common.models.custom_types import DT, IDT, MT, FetchedModelT + +if TYPE_CHECKING: + from fastapi_pagination import Page + from fastapi_pagination.bases import AbstractParams def should_apply_filter(filter_obj: Filter) -> bool: @@ -32,11 +33,11 @@ def should_apply_filter(filter_obj: Filter) -> bool: def add_filter_joins( - statement: Select, + statement: SelectOfScalar[MT], model: type[MT], filter_obj: Filter, path: list[str] | None = None, -) -> Select: +) -> SelectOfScalar[MT]: """Recursively add joins for filter relationships.""" path = path or [] @@ -81,11 +82,19 @@ def get_models_query( include_relationships: set[str] | None = None, model_filter: Filter | None = None, statement: SelectOfScalar[MT] | None = None, - read_schema: type[MT] | None = None, -) -> tuple[SelectOfScalar[MT], dict[str, bool]]: - """Generic function to get models with optional filtering and relationships. + read_schema: type[BaseModel] | None = None, +) -> tuple[SelectOfScalar[MT], set[str]]: + """Build a query for fetching models with optional filtering and relationships. + + Args: + model: The model class to query + include_relationships: Set of relationship names to eagerly load + model_filter: Optional filter to apply + statement: Optional base statement (defaults to select(model)) + read_schema: Optional schema to validate relationships against - It returns the SQLAlchemy statement and relationship info. + Returns: + tuple: (SQLAlchemy statement, set of excluded relationship names) """ if statement is None: statement = select(model) @@ -95,8 +104,12 @@ def get_models_query( statement = add_filter_joins(statement, model, model_filter) # Apply the filter statement = model_filter.filter(statement) + # Apply sorting if `order_by` was provided (guard attribute access on Filter models) + if getattr(model_filter, "order_by", None): + sort_func = getattr(model_filter, "sort", None) + if callable(sort_func): + statement = sort_func(statement) - relationships_to_exclude = [] statement, relationships_to_exclude = add_relationship_options( statement, model, include_relationships, read_schema=read_schema ) @@ -111,17 +124,31 @@ async def get_models( include_relationships: set[str] | None = None, model_filter: Filter | None = None, statement: SelectOfScalar[MT] | None = None, -) -> Sequence[MT]: - """Generic function to get models with optional filtering and relationships.""" + read_schema: type[BaseModel] | None = None, +) -> list[FetchedModelT]: + """Get models with optional filtering and relationships. + + Args: + db: Database session + model: Model class to query + include_relationships: Set of relationship names to eagerly load + model_filter: Optional filter to apply + statement: Optional base statement + read_schema: Optional schema to validate relationships against + + Returns: + list[FetchedModelT]: List of model instances with guaranteed IDs + """ statement, relationships_to_exclude = get_models_query( model, include_relationships=include_relationships, model_filter=model_filter, statement=statement, + read_schema=read_schema, ) - result: Sequence[MT] = (await db.exec(statement)).unique().all() + result: list[MT] = list((await db.exec(statement)).unique().all()) - return set_empty_relationships(result, relationships_to_exclude) + return cast("list[FetchedModelT]", clear_unloaded_relationships(result, relationships_to_exclude)) async def get_paginated_models( @@ -131,9 +158,21 @@ async def get_paginated_models( include_relationships: set[str] | None = None, model_filter: Filter | None = None, statement: SelectOfScalar[MT] | None = None, - read_schema: type[MT] | None = None, -) -> Page[Sequence[DT]]: - """Generic function to get paginated models with optional filtering and relationships.""" + read_schema: type[BaseModel] | None = None, +) -> Page[Any]: + """Get paginated models with optional filtering and relationships. + + Args: + db: Database session + model: Model class to query + include_relationships: Set of relationship names to eagerly load + model_filter: Optional filter to apply + statement: Optional base statement + read_schema: Optional schema to validate relationships against + + Returns: + Page[DT]: Paginated results + """ statement, relationships_to_exclude = get_models_query( model, include_relationships=include_relationships, @@ -142,43 +181,81 @@ async def get_paginated_models( read_schema=read_schema, ) - result_page: Page[Sequence[DT]] = await apaginate(db, statement, params=None) + result_page = await paginate_with_exec(db, statement) - result_page.items = set_empty_relationships( - result_page.items, relationships_to_exclude, setattr_strat=AttributeSettingStrategy.PYDANTIC + # Clear unloaded relationships for serialization + result_page.items = cast( + "list[FetchedModelT]", + clear_unloaded_relationships(result_page.items, relationships_to_exclude, db=db), ) return result_page +async def paginate_with_exec( + db: AsyncSession, + statement: SelectOfScalar[Any], + params: AbstractParams | None = None, +) -> Page[Any]: + """Paginate a SQLModel select statement using `session.exec()` instead of `session.execute()`.""" + resolved_params = resolve_params(params) + raw_params = resolved_params.to_raw_params() + + total = None + if raw_params.include_total: + count_query = select(func.count()).select_from(statement.order_by(None).subquery()) + total = cast("int", (await db.exec(count_query)).one()) + + limit = getattr(raw_params, "limit", None) + offset = getattr(raw_params, "offset", None) + paginated_statement = statement + if limit is not None: + paginated_statement = paginated_statement.limit(limit) + if offset is not None: + paginated_statement = paginated_statement.offset(offset) + items = list((await db.exec(paginated_statement)).unique().all()) + + return cast("Page[Any]", create_page(items, total=total, params=resolved_params)) + + async def get_model_by_id( - db: AsyncSession, model: type[MT], model_id: IDT, *, include_relationships: set[str] | None = None -) -> MT: - """Generic function to get a model by ID with specified relationships. + db: AsyncSession, + model: type[MT], + model_id: IDT, + *, + include_relationships: set[str] | None = None, + read_schema: type[BaseModel] | None = None, +) -> FetchedModelT: + """Get a model by ID with specified relationships. Args: - db: AsyncSession for database operations - model: The SQLAlchemy model class + db: Database session + model: The model class to query model_id: ID of the model instance to retrieve - include_relationships: Optional set of relationship names to include + include_relationships: Optional set of relationship names to eagerly load + read_schema: Optional schema to validate relationships against Returns: - Model instance + FetchedModelT: Model instance with guaranteed ID + + Raises: + CRUDConfigurationError: If model doesn't have an id field + ModelNotFoundError: If model with given ID doesn't exist """ if not hasattr(model, "id"): err_msg: str = f"Model {model} does not have an id field." - raise ValueError(err_msg) + raise CRUDConfigurationError(err_msg) - statement: SelectOfScalar[MT] = select(model).where( - model.id == model_id # TODO: Fix this type error by creating a custom database model type that has id. - ) + statement: SelectOfScalar[MT] = select(model).where(model.id == model_id) - statement, relationships_to_exclude = add_relationship_options(statement, model, include_relationships) + statement, relationships_to_exclude = add_relationship_options( + statement, model, include_relationships, read_schema=read_schema + ) result: MT | None = (await db.exec(statement)).unique().one_or_none() - result = validate_model_with_id_exists(result, model, model_id) - return set_empty_relationships(result, relationships_to_exclude) + result = ensure_model_exists(result, model, model_id) + return clear_unloaded_relationships(result, relationships_to_exclude, db=db) async def get_nested_model_by_id( @@ -190,7 +267,8 @@ async def get_nested_model_by_id( parent_fk_name: str, *, include_relationships: set[str] | None = None, -) -> DT: + read_schema: type[BaseModel] | None = None, +) -> FetchedModelT: """Get nested model by checking foreign key relationship. Args: @@ -200,25 +278,35 @@ async def get_nested_model_by_id( dependent_model: Dependent model class dependent_id: Dependent ID parent_fk_name: Name of parent foreign key in dependent model - include_relationships: Optional relationships to include + include_relationships: Optional relationships to eagerly load + read_schema: Optional schema to validate relationships against + + Returns: + FetchedModelT: Dependent model instance with guaranteed ID + + Raises: + CRUDConfigurationError: If dependent model doesn't have the specified foreign key + DependentModelOwnershipError: If dependent doesn't belong to parent """ dependent_model_name = dependent_model.get_api_model_name().name_capital - parent_model_name = parent_model.get_api_model_name().name_capital # Validate foreign key exists on dependent if not hasattr(dependent_model, parent_fk_name): err_msg: str = f"{dependent_model_name} does not have a {parent_fk_name} field" - raise KeyError(err_msg) + raise CRUDConfigurationError(err_msg) # Get both models and validate existence await get_model_by_id(db, parent_model, parent_id) - dependent: DT = await get_model_by_id( - db, dependent_model, dependent_id, include_relationships=include_relationships + dependent: FetchedModelT = await get_model_by_id( + db, + dependent_model, + dependent_id, + include_relationships=include_relationships, + read_schema=read_schema, ) # Check relationship if getattr(dependent, parent_fk_name) != parent_id: - err_msg = f"{dependent_model_name} {dependent_id} does not belong to {parent_model_name} {parent_id}" raise DependentModelOwnershipError( dependent_model=dependent_model, dependent_id=dependent_id, diff --git a/backend/app/api/common/crud/exceptions.py b/backend/app/api/common/crud/exceptions.py index af1a2d85..7a7d71c4 100644 --- a/backend/app/api/common/crud/exceptions.py +++ b/backend/app/api/common/crud/exceptions.py @@ -1,15 +1,16 @@ """Custom exceptions for CRUD operations.""" -from fastapi import status +from typing import TYPE_CHECKING -from app.api.common.exceptions import APIError +from app.api.common.exceptions import BadRequestError, ConflictError, InternalServerError, NotFoundError from app.api.common.models.custom_types import IDT, MT +if TYPE_CHECKING: + from collections.abc import Iterable -class ModelNotFoundError(APIError): - """Exception raised when a model is not found in the database.""" - http_status_code = status.HTTP_404_NOT_FOUND +class ModelNotFoundError(NotFoundError): + """Exception raised when a model is not found in the database.""" def __init__(self, model_type: type[MT] | None = None, model_id: IDT | None = None) -> None: self.model_type = model_type @@ -21,11 +22,9 @@ def __init__(self, model_type: type[MT] | None = None, model_id: IDT | None = No ) -class DependentModelOwnershipError(APIError): +class DependentModelOwnershipError(BadRequestError): """Exception raised when a dependent model does not belong to the specified parent model.""" - http_status_code = status.HTTP_400_BAD_REQUEST - def __init__( self, dependent_model: type[MT], @@ -42,3 +41,42 @@ def __init__( f"{parent_model_name} with ID {parent_id}." ) ) + + +class CRUDConfigurationError(InternalServerError): + """Exception raised when shared CRUD helpers are misconfigured for a model.""" + + def __init__(self, message: str) -> None: + super().__init__(message=message, log_message=message) + + +class ModelsNotFoundError(NotFoundError): + """Exception raised when one or more requested models do not exist.""" + + def __init__(self, model_type: type[MT], missing_ids: Iterable[IDT]) -> None: + model_name = model_type.get_api_model_name().plural_capital + formatted_ids = ", ".join(map(str, sorted(missing_ids))) + super().__init__(message=f"The following {model_name} do not exist: {formatted_ids}") + + +class NoLinkedItemsError(BadRequestError): + """Exception raised when a parent model has no linked items to operate on.""" + + def __init__(self, model_name_plural: str) -> None: + super().__init__(message=f"No {model_name_plural.lower()} are assigned") + + +class LinkedItemsAlreadyAssignedError(ConflictError): + """Exception raised when attempting to add already-linked items.""" + + def __init__(self, model_name_plural: str, duplicate_ids: Iterable[IDT]) -> None: + formatted_ids = ", ".join(map(str, sorted(duplicate_ids))) + super().__init__(message=f"{model_name_plural} with id {formatted_ids} are already assigned") + + +class LinkedItemsMissingError(NotFoundError): + """Exception raised when expected linked items are missing.""" + + def __init__(self, model_name_plural: str, missing_ids: Iterable[IDT]) -> None: + formatted_ids = ", ".join(map(str, sorted(missing_ids))) + super().__init__(message=f"{model_name_plural} with id {formatted_ids} not found") diff --git a/backend/app/api/common/crud/persistence.py b/backend/app/api/common/crud/persistence.py new file mode 100644 index 00000000..2d479762 --- /dev/null +++ b/backend/app/api/common/crud/persistence.py @@ -0,0 +1,56 @@ +"""Shared persistence helpers for SQLModel CRUD operations.""" + +from typing import Protocol + +from sqlmodel.ext.asyncio.session import AsyncSession + + +class SupportsModelDump(Protocol): + """Schema protocol for SQLModel update payloads.""" + + def model_dump( + self, + *, + exclude_unset: bool = False, + exclude: set[str] | None = None, + ) -> dict[str, object]: + """Return payload values for persistence.""" + ... + + +class SupportsSQLModelUpdate(Protocol): + """Model protocol for SQLModel update operations.""" + + def sqlmodel_update(self, obj: dict[str, object]) -> None: + """Apply a partial update to a SQLModel instance.""" + ... + + +async def commit_and_refresh[ModelT]( + db: AsyncSession, + db_model: ModelT, + *, + add_before_commit: bool = True, +) -> ModelT: + """Commit the current transaction and refresh one model instance.""" + if add_before_commit: + db.add(db_model) + await db.commit() + await db.refresh(db_model) + return db_model + + +async def update_and_commit[ModelT: SupportsSQLModelUpdate]( + db: AsyncSession, + db_model: ModelT, + payload: SupportsModelDump, +) -> ModelT: + """Apply a partial update and persist the result.""" + db_model.sqlmodel_update(payload.model_dump(exclude_unset=True)) + return await commit_and_refresh(db, db_model) + + +async def delete_and_commit(db: AsyncSession, db_model: object) -> None: + """Delete one model instance and commit the transaction.""" + await db.delete(db_model) + await db.commit() diff --git a/backend/app/api/common/crud/utils.py b/backend/app/api/common/crud/utils.py index 8d051bdb..224bfb39 100644 --- a/backend/app/api/common/crud/utils.py +++ b/backend/app/api/common/crud/utils.py @@ -1,37 +1,65 @@ """Common utility functions for CRUD operations.""" +# spell-checker: disable joinedload -from collections.abc import Sequence -from enum import Enum -from typing import TYPE_CHECKING, Any, overload -from uuid import UUID +from enum import StrEnum +from typing import TYPE_CHECKING, Any, cast from pydantic import BaseModel from sqlalchemy import inspect -from sqlalchemy.orm import joinedload, selectinload -from sqlalchemy.orm.attributes import set_committed_value +from sqlalchemy.orm import joinedload, noload, selectinload +from sqlalchemy.orm.attributes import QueryableAttribute from sqlmodel import col, select from sqlmodel.ext.asyncio.session import AsyncSession from sqlmodel.sql._expression_select_cls import SelectOfScalar from app.api.background_data.models import Material, ProductType -from app.api.common.crud.exceptions import ModelNotFoundError +from app.api.common.crud.exceptions import ( + CRUDConfigurationError, + LinkedItemsAlreadyAssignedError, + LinkedItemsMissingError, + ModelNotFoundError, + ModelsNotFoundError, + NoLinkedItemsError, +) +from app.api.common.exceptions import BadRequestError from app.api.common.models.base import CustomBase -from app.api.common.models.custom_types import ET, IDT, MT +from app.api.common.models.custom_types import ET, IDT, MT, FetchedModelT, HasDBID from app.api.data_collection.models import Product -from app.api.file_storage.models.models import FileParentType, ImageParentType +from app.api.file_storage.models.models import MediaParentType if TYPE_CHECKING: - from sqlalchemy.orm.mapper import Mapper + from collections.abc import Sequence + from uuid import UUID ### SQLALchemy Select Utilities ### -class RelationshipLoadStrategy(str, Enum): +class RelationshipLoadStrategy(StrEnum): """Loading strategies for relationships in SQLAlchemy queries.""" SELECTIN = "selectin" JOINED = "joined" +def _get_model_relationships(model: type[MT]) -> dict[str, tuple[QueryableAttribute[Any], bool]]: + """Get all relationships from a model with their collection status. + + Args: + model: The model class to inspect + + Returns: + dict: {relationship_name: (relationship_attribute, is_collection)} + """ + mapper = inspect(model) + if not mapper: + return {} + + relationships: dict[str, tuple[QueryableAttribute[Any], bool]] = {} + for rel in mapper.relationships: + relationships[rel.key] = (cast("QueryableAttribute[Any]", getattr(model, rel.key)), rel.uselist) + + return relationships + + def add_relationship_options( statement: SelectOfScalar, model: type[MT], @@ -39,227 +67,187 @@ def add_relationship_options( *, read_schema: type[BaseModel] | None = None, load_strategy: RelationshipLoadStrategy = RelationshipLoadStrategy.SELECTIN, -) -> tuple[SelectOfScalar, dict[str, bool]]: - """Add selectinload options and return info about relationships to exclude. +) -> tuple[SelectOfScalar, set[str]]: + """Add eager loading options for relationships and return unloaded relationship names. + + Args: + statement: SQLAlchemy select statement + model: Model class to load relationships for + include: Set of relationship names to eagerly load + read_schema: Optional schema to filter relationships + load_strategy: Strategy for loading (selectin or joined) Returns: - tuple: (modified statement, dict of {rel_name: is_collection} to exclude) + tuple: (modified statement, set of excluded relationship names) """ - # Get all relationships from the database model in one pass - inspector: Mapper[Any] = inspect(model, raiseerr=True) - all_db_rels = {rel.key: (getattr(model, rel.key), rel.uselist) for rel in inspector.relationships} + # Get all relationships from the model + all_db_rels = _get_model_relationships(model) # Determine which relationships are in scope (db ∩ schema) - in_scope_rels = ( + in_scope_rel_names = ( {name for name in all_db_rels if name in read_schema.model_fields} if read_schema else set(all_db_rels.keys()) ) # Valid relationships to include (user_input ∩ in_scope) - to_include = set(include or []) & in_scope_rels + to_include = (set(include) if include else set()) & in_scope_rel_names - # Add selectinload for included relationships + # Add eager loading for included relationships for rel_name in to_include: rel_attr = all_db_rels[rel_name][0] option = joinedload(rel_attr) if load_strategy == RelationshipLoadStrategy.JOINED else selectinload(rel_attr) statement = statement.options(option) - # Build exclusion dict (in_scope - included) - relationships_to_exclude = { - rel_name: all_db_rels[rel_name][1] # rel_name: is_collection - for rel_name in (in_scope_rels - to_include) - } + # Only suppress unincluded relationships for explicit response-shaping call sites. + # Internal CRUD/business logic frequently fetches models without a read schema and + # still expects normal ORM relationship access to work. + relationships_to_exclude: set[str] = set() + if read_schema is not None: + relationships_to_exclude = in_scope_rel_names - to_include + for rel_name in relationships_to_exclude: + rel_attr = all_db_rels[rel_name][0] + statement = statement.options(noload(rel_attr)) return statement, relationships_to_exclude -# HACK: This is a quick way to set relationships to empty values in SQLAlchemy models. -# Ideally we make a clear distinction between database model and Pydantic models throughout the codebase via typing. -class AttributeSettingStrategy(str, Enum): - """Model type for relationship setting strategy.""" - - SQLALCHEMY = "sqlalchemy" # SQLAlchemy method (uses set_committed_value) - PYDANTIC = "pydantic" # Pydantic method (uses setattr) - +def clear_unloaded_relationships[T]( + results: T, + relationships_to_clear: set[str], + db: AsyncSession | None = None, +) -> T: + """Compatibility hook for historical call sites. -@overload -def set_empty_relationships(results: MT, relationships_to_exclude: ..., setattr_strat: ...) -> MT: ... - - -@overload -def set_empty_relationships( - results: Sequence[MT], relationships_to_exclude: ..., setattr_strat: ... -) -> Sequence[MT]: ... - - -def set_empty_relationships( - results: MT | Sequence[MT], - relationships_to_exclude: dict[str, bool], - setattr_strat: AttributeSettingStrategy = AttributeSettingStrategy.SQLALCHEMY, -) -> MT | Sequence[MT]: - """Set relationships to empty values for SQLAlchemy models. - - Args: - results: Single model instance or sequence of instances - relationships_to_exclude: Dict of {rel_name: is_collection} to set to empty - setattr_strat: Strategy for setting attributes (SQLAlchemy or Pydantic) - - Returns: - MT | Sequence[MT]: Original result(s) with empty relationships set + Relationship suppression is now handled at query time by `add_relationship_options` + via `noload`, so no post-query mutation is needed here. """ - if not results or not relationships_to_exclude: - return results - - # Process single item or sequence - items = results if isinstance(results, Sequence) else [results] - - for item in items: - for rel_name, is_collection in relationships_to_exclude.items(): - if setattr_strat == AttributeSettingStrategy.PYDANTIC: - # Use setattr to set the attribute directly - setattr(item, rel_name, [] if is_collection else None) - elif setattr_strat == AttributeSettingStrategy.SQLALCHEMY: - # Settattr cannot be used directly on SQLAlchemy models as they are linked to the session - set_committed_value(item, rel_name, [] if is_collection else None) - else: - err_msg = f"Invalid setting strategy: {setattr_strat}" - raise ValueError(err_msg) - + del relationships_to_clear, db return results ### Error Handling Utilities ### -def validate_model_with_id_exists(db_get_response: MT | None, model_type: type[MT], model_id: IDT) -> MT: - """Validate that a model with a given id from a db.get() response exists. +def ensure_model_exists(db_result: MT | None, model_type: type[MT], model_id: IDT) -> FetchedModelT: + """Ensure a model with a given ID exists, providing type-safe return. Args: - db_get_response: Model instance to check - model_type: Type of the model instance + db_result: Model instance from database query (may be None) + model_type: Type of the model class model_id: ID that was queried Returns: - MT: The model instance if it exists + FetchedModelT: The model instance with guaranteed ID Raises: ModelNotFoundError: If model instance is None """ - if not db_get_response: + if not db_result: raise ModelNotFoundError(model_type, model_id) - return db_get_response + return cast("FetchedModelT", db_result) -async def db_get_model_with_id_if_it_exists(db: AsyncSession, model_type: type[MT], model_id: IDT) -> MT: - """Get a model instance with a given id if it exists in the database. +async def get_model_or_404(db: AsyncSession, model_type: type[MT], model_id: IDT) -> FetchedModelT: + """Get a model by ID or raise 404 error. Args: - db: AsyncSession to use for the database query - model_type: Type of the model instance - model_id: ID that was queried + db: AsyncSession for database operations + model_type: Type of the model class + model_id: ID to fetch Returns: - MT: The model instance if it exists - Raises: - ModelNotFoundError if the model is not found + FetchedModelT: The model instance with guaranteed ID + Raises: + ModelNotFoundError: If the model is not found """ - return validate_model_with_id_exists(await db.get(model_type, model_id), model_type, model_id) + result = await db.get(model_type, model_id) + return ensure_model_exists(result, model_type, model_id) -async def db_get_models_with_ids_if_they_exist( +async def get_models_by_ids_or_404( db: AsyncSession, model_type: type[MT], model_ids: set[int] | set[UUID] -) -> Sequence[MT]: - """Get model instances with given ids, throwing error if any don't exist. +) -> list[FetchedModelT]: + """Get multiple models by IDs, raising error if any don't exist. Args: - db: AsyncSession to use for the database query - model_type: Type of the model instance - model_ids: IDs that must exist + db: AsyncSession for database operations + model_type: Type of the model class + model_ids: Set of IDs that must all exist Returns: - Sequence[MT]: The model instances + list[FetchedModelT]: The model instances with guaranteed IDs Raises: - ValueError: If any requested ID doesn't exist + CRUDConfigurationError: If model type doesn't have an id field + ModelsNotFoundError: If any requested ID doesn't exist """ if not hasattr(model_type, "id"): err_msg = f"{model_type} does not have an 'id' attribute" - raise ValueError(err_msg) + raise CRUDConfigurationError(err_msg) - # TODO: Fix typing issues by implementing databasemodel typevar in utils.typing statement = select(model_type).where(col(model_type.id).in_(model_ids)) - found_models = (await db.exec(statement)).all() + found_models = list((await db.exec(statement)).all()) + fetched_models = cast("list[FetchedModelT]", found_models) if len(found_models) != len(model_ids): - found_ids: set[int] | set[UUID] = {model.id for model in found_models} - missing_ids = model_ids - found_ids - err_msg = f"The following {model_type.get_api_model_name().plural_capital} do not exist: {missing_ids}" - raise ValueError(err_msg) + found_ids: set[int | UUID] = {cast("int | UUID", model.id) for model in fetched_models} + missing_ids = cast("set[int | UUID]", model_ids) - found_ids + raise ModelsNotFoundError(model_type, missing_ids) - return found_models + return fetched_models -def validate_no_duplicate_linked_items( - new_ids: set[int] | set[UUID], existing_items: Sequence[MT] | None, model_name_plural: str, id_field: str = "id" +### Linked Item Validation ### +def validate_linked_items( + item_ids: set[int] | set[UUID], + existing_items: Sequence[HasDBID] | None, + model_name_plural: str, + *, + check_duplicates: bool = True, + check_existence: bool = True, ) -> None: - """Validate that no linked items are already assigned. + """Validate linked items for both duplicates and existence. Args: - new_ids: Set of new IDs to validate + item_ids: Set of IDs to validate existing_items: Sequence of existing items to check against model_name_plural: Name of the item model for error messages + check_duplicates: Whether to check if items are already assigned + check_existence: Whether to check if items exist in the list id_field: Field name for the ID in the model (default: "id") Raises: - ValueError: If any items are duplicates + NoLinkedItemsError: If no items exist + LinkedItemsAlreadyAssignedError: If items are duplicates + LinkedItemsMissingError: If items don't exist """ if not existing_items: - err_msg = f"No {model_name_plural.lower()} are assigned" - raise ValueError() + raise NoLinkedItemsError(model_name_plural) - existing_ids = {getattr(item, id_field) for item in existing_items} - duplicates = new_ids & existing_ids - if duplicates: - err_msg = f"{model_name_plural} with id {set_to_str(duplicates)} are already assigned" - raise ValueError(err_msg) + existing_ids = {item.db_id for item in existing_items} + if check_duplicates: + duplicates = item_ids & existing_ids + if duplicates: + raise LinkedItemsAlreadyAssignedError(model_name_plural, duplicates) -def validate_linked_items_exist( - item_ids: set[int] | set[UUID], existing_items: Sequence[MT] | None, model_name_plural: str, id_field: str = "id" -) -> None: - """Validate that all item IDs exist in the given items. - - Args: - item_ids: IDs to validate - existing_items: Items to check against - model_name_plural: Name of the item model for error messages - id_field: Field name for the ID in the model (default: "id") - - Raises: - ValueError: If items don't exist or no items are assigned - """ - if not existing_items: - err_msg = f"No {model_name_plural.lower()} are assigned" - raise ValueError(err_msg) - - existing_ids = {getattr(item, id_field) for item in existing_items} - missing = item_ids - existing_ids - if missing: - err_msg = f"{model_name_plural} with id {set_to_str(missing)} not found" - raise ValueError(err_msg) + if check_existence: + missing = item_ids - existing_ids + if missing: + raise LinkedItemsMissingError(model_name_plural, missing) -### Printing Utilities ### -def set_to_str(set_: set[Any]) -> str: - """Convert a set of strings to a comma-separated string.""" - return ", ".join(map(str, set_)) +### Formatting Utilities ### +def format_id_set(id_set: set[Any]) -> str: + """Format a set of IDs as a comma-separated string.""" + return ", ".join(map(str, sorted(id_set))) -def enum_set_to_str(set_: set[ET]) -> str: - """Convert a set of enum types to a comma-separated string.""" - return ", ".join(str(e.value) for e in set_) +def enum_format_id_set(enum_set: set[ET]) -> str: + """Format a set of enum values as a comma-separated string.""" + return ", ".join(str(e.value) for e in sorted(enum_set, key=lambda x: x.value)) ### Parent Type Utilities ### -def get_file_parent_type_model(parent_type: FileParentType | ImageParentType) -> type[CustomBase]: +def get_file_parent_type_model(parent_type: MediaParentType) -> type[CustomBase]: """Return the model for the given parent type. Utility function to avoid circular imports.""" if parent_type == parent_type.PRODUCT: return Product @@ -268,4 +256,39 @@ def get_file_parent_type_model(parent_type: FileParentType | ImageParentType) -> if parent_type == parent_type.MATERIAL: return Material err_msg = f"Invalid parent type: {parent_type}" - raise ValueError(err_msg) + raise BadRequestError(err_msg) + + +### Backward Compatibility (Refactored) ### +# The previous aliases were removed in favor of using the base functions directly. +# The following helpers are kept for readability but simplified. + + +def validate_no_duplicate_linked_items( + new_ids: set[int] | set[UUID], + existing_items: Sequence[HasDBID] | None, + model_name_plural: str, +) -> None: + """Validate that new items are not already in the existing items list.""" + validate_linked_items( + new_ids, + existing_items, + model_name_plural, + check_duplicates=True, + check_existence=False, + ) + + +def validate_linked_items_exist( + item_ids: set[int] | set[UUID], + existing_items: Sequence[HasDBID] | None, + model_name_plural: str, +) -> None: + """Validate that all item_ids are present in existing_items.""" + validate_linked_items( + item_ids, + existing_items, + model_name_plural, + check_duplicates=False, + check_existence=True, + ) diff --git a/backend/app/api/common/exceptions.py b/backend/app/api/common/exceptions.py index 4810b020..2a5b78c7 100644 --- a/backend/app/api/common/exceptions.py +++ b/backend/app/api/common/exceptions.py @@ -1,4 +1,4 @@ -"""Base API exception.""" +"""Base API exception types.""" from fastapi import status @@ -9,7 +9,71 @@ class APIError(Exception): # Default status code for API errors. Can be overridden in subclasses. http_status_code = status.HTTP_500_INTERNAL_SERVER_ERROR - def __init__(self, message: str, details: str | None = None): + def __init__(self, message: str, details: str | None = None, *, log_message: str | None = None): self.message = message self.details = details + self.log_message = log_message or message super().__init__(message) + + +class BadRequestError(APIError): + """Exception raised when the client supplied invalid data.""" + + http_status_code = status.HTTP_400_BAD_REQUEST + + +class UnauthorizedError(APIError): + """Exception raised when authentication is required or invalid.""" + + http_status_code = status.HTTP_401_UNAUTHORIZED + + +class ForbiddenError(APIError): + """Exception raised when the current user is not allowed to perform an action.""" + + http_status_code = status.HTTP_403_FORBIDDEN + + +class NotFoundError(APIError): + """Exception raised when a requested resource does not exist.""" + + http_status_code = status.HTTP_404_NOT_FOUND + + +class ConflictError(APIError): + """Exception raised when the requested change conflicts with current state.""" + + http_status_code = status.HTTP_409_CONFLICT + + +class FailedDependencyError(APIError): + """Exception raised when an upstream or dependent system returns unusable data.""" + + http_status_code = status.HTTP_424_FAILED_DEPENDENCY + + +class PayloadTooLargeError(APIError): + """Exception raised when a request payload exceeds configured limits.""" + + http_status_code = status.HTTP_413_CONTENT_TOO_LARGE + + +class ServiceUnavailableError(APIError): + """Exception raised when a required service is temporarily unavailable.""" + + http_status_code = status.HTTP_503_SERVICE_UNAVAILABLE + + +class InternalServerError(APIError): + """Exception raised for unexpected internal application errors.""" + + http_status_code = status.HTTP_500_INTERNAL_SERVER_ERROR + + def __init__( + self, + message: str = "Internal server error", + details: str | None = None, + *, + log_message: str | None = None, + ) -> None: + super().__init__(message=message, details=details, log_message=log_message) diff --git a/backend/app/api/common/models/associations.py b/backend/app/api/common/models/associations.py index e0da3a43..80e96188 100644 --- a/backend/app/api/common/models/associations.py +++ b/backend/app/api/common/models/associations.py @@ -1,16 +1,10 @@ """Linking tables for cross-module many-to-many relationships.""" -from typing import TYPE_CHECKING +from sqlmodel import Column, Enum, Field -from sqlmodel import Column, Enum, Field, Relationship - -from app.api.common.models.base import CustomLinkingModelBase, TimeStampMixinBare +from app.api.common.models.base import CustomLinkingModelBase from app.api.common.models.enums import Unit -if TYPE_CHECKING: - from app.api.background_data.models import Material - from app.api.data_collection.models import Product - ### Material-Product Association Models ### class MaterialProductLinkBase(CustomLinkingModelBase): @@ -22,20 +16,3 @@ class MaterialProductLinkBase(CustomLinkingModelBase): sa_column=Column(Enum(Unit)), description=f"Unit of the quantity, e.g. {', '.join([u.value for u in Unit][:3])}", ) - - -class MaterialProductLink(MaterialProductLinkBase, TimeStampMixinBare, table=True): - """Association table to link Material with Product.""" - - material_id: int = Field( - foreign_key="material.id", primary_key=True, description="ID of the material in the product" - ) - product_id: int = Field( - foreign_key="product.id", primary_key=True, description="ID of the product with the material" - ) - - material: "Material" = Relationship(back_populates="product_links", sa_relationship_kwargs={"lazy": "selectin"}) - product: "Product" = Relationship(back_populates="bill_of_materials", sa_relationship_kwargs={"lazy": "selectin"}) - - def __str__(self) -> str: - return f"{self.quantity} {self.unit} of {self.material.name} in {self.product.name}" diff --git a/backend/app/api/common/models/base.py b/backend/app/api/common/models/base.py index 1a43b9c8..0970df91 100644 --- a/backend/app/api/common/models/base.py +++ b/backend/app/api/common/models/base.py @@ -1,24 +1,25 @@ """Base model and generic mixins for SQLModel models.""" import re -from datetime import datetime +from dataclasses import dataclass +from datetime import datetime # noqa: TC003 # Used in runtime for ORM mapping, not just for type annotations from enum import Enum from functools import cached_property -from typing import Any, ClassVar, Generic, Self, TypeVar +from typing import Any, ClassVar, Self, cast -from pydantic import BaseModel, ConfigDict, computed_field, model_validator -from sqlalchemy import TIMESTAMP, func +from pydantic import UUID4, ConfigDict, model_validator +from sqlalchemy import DateTime, func from sqlalchemy.dialects.postgresql import JSONB from sqlmodel import Column, Field, SQLModel ### Base Model ### -class APIModelName(BaseModel): - """Mixin to add models names for naming in API routes and documentation.""" +@dataclass +class APIModelName: + """Holds derived names for a model β€” used in API routes and documentation.""" name_camel: str # The base name is expected to be in CamelCase - @computed_field @cached_property def plural_camel(self) -> str: """Get the plural form of the model name. @@ -27,34 +28,34 @@ def plural_camel(self) -> str: """ return self.pluralize(self.name_camel) - @computed_field @cached_property def name_capital(self) -> str: + """Get the model name in Capital Case for display in documentation and error messages.""" return self.camel_to_capital(self.name_camel) - @computed_field @cached_property def plural_capital(self) -> str: + """Get the plural model name in Capital Case for display in documentation and error messages.""" return self.camel_to_capital(self.plural_camel) - @computed_field @cached_property def name_slug(self) -> str: + """Get the model name in slug-case for use in URL paths.""" return self.camel_to_slug(self.name_camel) - @computed_field @cached_property def plural_slug(self) -> str: + """Get the plural model name in slug-case for use in URL paths.""" return self.camel_to_slug(self.plural_camel) - @computed_field @cached_property def name_snake(self) -> str: + """Get the model name in snake_case for use in variable names and database table names.""" return self.camel_to_snake(self.name_camel) - @computed_field @cached_property def plural_snake(self) -> str: + """Get the plural model name in snake_case for use in variable names and database table names.""" return self.camel_to_snake(self.plural_camel) @staticmethod @@ -102,29 +103,55 @@ def get_api_model_name(cls) -> APIModelName: class CustomBase(CustomBaseBare, SQLModel): """Base class for all models.""" - api_model_name: ClassVar[APIModelName | None] = None # The name of the model used in API routes - - @classmethod - def get_api_model_name(cls) -> APIModelName: - """Initialize api_model_name for the class.""" - if cls.api_model_name is None: - cls.api_model_name = APIModelName(name_camel=cls.__name__) - return cls.api_model_name - class CustomLinkingModelBase(CustomBase): """Base class for linking models.""" -# TODO: Separate schema and database model base classes. Schema models should inherit from Pydantic's BaseModel. -# Database models should inherit from SQLModel. -class CustomDatabaseModelBase(CustomBase, SQLModel): - """Base class for models with database tables.""" +class UUIDPrimaryKeyMixin: + """Mixin for models with a UUID primary key. - id: int = Field( - default=None, - primary_key=True, - ) + Provides a type-safe ``db_id`` property that returns the UUID without ``None``, + for use at call sites where the model is guaranteed to have been persisted to the DB. + The underlying ``id`` field remains ``UUID4 | None`` to satisfy SQLModel/pydantic + compatibility requirements (see https://github.com/fastapi/sqlmodel/issues/1623). + """ + + id: UUID4 | None # Annotation only β€” actual SQLModel Field defined in the concrete model + + @property + def db_id(self) -> UUID4: + """Return the non-None UUID primary key for a persisted model instance. + + Raises: + ValueError: If ``id`` is ``None``, i.e. the model has not been committed to the DB. + """ + if self.id is None: + msg = f"{type(self).__name__} has no ID β€” the instance may not have been committed to the DB yet." + raise ValueError(msg) + return self.id + + +class IntPrimaryKeyMixin: + """Mixin for models with an integer primary key. + + Provides a type-safe ``db_id`` property that returns the integer without ``None``, + for use at call sites where the model is guaranteed to have been persisted to the DB. + """ + + id: int | None # Annotation only β€” actual SQLModel Field defined in the concrete model + + @property + def db_id(self) -> int: + """Return the non-None integer primary key for a persisted model instance. + + Raises: + ValueError: If ``id`` is ``None``, i.e. the model has not been committed to the DB. + """ + if self.id is None: + msg = f"{type(self).__name__} has no ID β€” the instance may not have been committed to the DB yet." + raise ValueError(msg) + return self.id ### Mixins ### @@ -138,41 +165,44 @@ class TimeStampMixinBare: created_at: datetime | None = Field( default=None, - sa_type=TIMESTAMP(timezone=True), # pyright: ignore [reportArgumentType] # SQLModel mixins with SQLAlchemy Column specifications are complicated, see https://github.com/fastapi/sqlmodel/discussions/743 + sa_type=cast("Any", DateTime(timezone=True)), sa_column_kwargs={"server_default": func.now()}, ) updated_at: datetime | None = Field( default=None, - sa_type=TIMESTAMP(timezone=True), # pyright: ignore [reportArgumentType] - sa_column_kwargs={"server_default": func.now(), "onupdate": func.now()}, + sa_type=cast("Any", DateTime(timezone=True)), + sa_column_kwargs={"server_default": func.now(), "onupdate": func.now()}, # spell-checker: ignore onupdate ) ## Quasi-Polymorphic Associations ## -# Generic type for parent type enumeration -ParentTypeEnum = TypeVar("ParentTypeEnum", bound=Enum) +class SingleParentMixin[ParentTypeEnum: Enum](SQLModel): + """Mixin to ensure an object belongs to exactly one parent. + ``ParentTypeEnum`` must be a ``StrEnum`` whose values are the snake_case names of the + parent model tables (e.g. ``"product"``, ``"material"``). The mixin derives the + corresponding foreign-key field names automatically (e.g. ``product_id``). + """ -class SingleParentMixin[ParentTypeEnum](SQLModel): - """Mixin to ensure an object belongs to exactly one parent.""" - - # TODO: Implement improved polymorphic associations in SQLModel after this issue is resolved: https://github.com/fastapi/sqlmodel/pull/1226 + # TODO: Replace with proper polymorphic associations once the upstream SQLModel issue is + # resolved: https://github.com/fastapi/sqlmodel/pull/1226 - parent_type: ParentTypeEnum # Type of the parent object. To be overridden by derived classes. + parent_type: ParentTypeEnum - model_config: ConfigDict = ConfigDict(arbitrary_types_allowed=True) # pyright: ignore [reportIncompatibleVariableOverride] # This is not a type override, see https://github.com/fastapi/sqlmodel/discussions/855 + model_config: ConfigDict = ConfigDict(arbitrary_types_allowed=True) @classmethod def get_parent_type_description(cls, enum_class: type[Enum]) -> str: """Generate description string for parent_type field using actual enum class.""" return f"Type of the parent object, e.g. {', '.join(t.value for t in enum_class)}" - @cached_property + @property def possible_parent_fields(self) -> list[str]: """Get all possible parent ID field names.""" - return [f"{t.value!s}_id" for t in type(self.parent_type)] + enum_class = type(self.parent_type) + return [f"{t.value!s}_id" for t in enum_class] - @cached_property + @property def set_parent_fields(self) -> list[str]: """Get currently set parent ID field names.""" return [field for field in self.possible_parent_fields if getattr(self, field, None) is not None] @@ -191,7 +221,7 @@ def validate_single_parent(self) -> Self: return self - @cached_property + @property def parent_id(self) -> int: """Get the ID of the current parent object.""" field = f"{self.parent_type.value!s}_id" diff --git a/backend/app/api/common/models/custom_fields.py b/backend/app/api/common/models/custom_fields.py deleted file mode 100644 index 343940e1..00000000 --- a/backend/app/api/common/models/custom_fields.py +++ /dev/null @@ -1,9 +0,0 @@ -"""Custom Pydantic fields for database models.""" - -from typing import Annotated - -from pydantic import AnyUrl, HttpUrl, PlainSerializer - -# HTTP URL that is stored as string in the database. -HttpUrlInDB = Annotated[HttpUrl, PlainSerializer(lambda x: str(x), return_type=str)] -AnyUrlInDB = Annotated[AnyUrl, PlainSerializer(lambda x: str(x), return_type=str)] diff --git a/backend/app/api/common/models/custom_types.py b/backend/app/api/common/models/custom_types.py index 392c0098..23bb81d3 100644 --- a/backend/app/api/common/models/custom_types.py +++ b/backend/app/api/common/models/custom_types.py @@ -1,29 +1,42 @@ """Common typing utilities for the application.""" from enum import Enum -from typing import TypeVar +from typing import Protocol, TypeVar from uuid import UUID from fastapi_filter.contrib.sqlalchemy import Filter from app.api.common.models.base import CustomBaseBare, CustomLinkingModelBase -### Type aliases ### -# Type alias for ID types -IDT = int | UUID + +### Protocols ### +class HasDBID(Protocol): + """Protocol for models with a non-None db_id field.""" + + @property + def db_id(self) -> int | UUID: + """ID of the model as stored in the database. Guaranteed non-None at runtime.""" + ... + ### TypeVars ### -# TypeVar for models +# ID type β€” constrains parameters that accept either integer or UUID primary keys +IDT = TypeVar("IDT", bound=int | UUID) + +# Any model (id may be None β€” not yet persisted) MT = TypeVar("MT", bound=CustomBaseBare) -# Typevar for dependent models +# Model returned from a DB query (id guaranteed non-None at runtime) +FetchedModelT = TypeVar("FetchedModelT", bound=HasDBID) + +# Dependent model in a nested relationship DT = TypeVar("DT", bound=CustomBaseBare) -# Typevar for linking models +# Linking / association model LMT = TypeVar("LMT", bound=CustomLinkingModelBase) -# Typevar for Enum classes +# Enum subclass ET = TypeVar("ET", bound=Enum) -# Typevar for Filter classes +# FastAPI-Filter subclass β€” reserved for future use in generic filter helpers FT = TypeVar("FT", bound=Filter) diff --git a/backend/app/api/common/routers/__init__.py b/backend/app/api/common/routers/__init__.py index e69de29b..8cf44283 100644 --- a/backend/app/api/common/routers/__init__.py +++ b/backend/app/api/common/routers/__init__.py @@ -0,0 +1 @@ +"""General routes and route-utilities for the API.""" diff --git a/backend/app/api/common/routers/dependencies.py b/backend/app/api/common/routers/dependencies.py index 6ab56aaa..18a555c0 100644 --- a/backend/app/api/common/routers/dependencies.py +++ b/backend/app/api/common/routers/dependencies.py @@ -2,10 +2,24 @@ from typing import Annotated -from fastapi import Depends +from fastapi import Depends, Request +from httpx import AsyncClient from sqlmodel.ext.asyncio.session import AsyncSession +from app.api.common.exceptions import ServiceUnavailableError from app.core.database import get_async_session # FastAPI dependency for getting an asynchronous database session AsyncSessionDep = Annotated[AsyncSession, Depends(get_async_session)] + + +def get_external_http_client(request: Request) -> AsyncClient: + """Return the shared outbound HTTP client from application state.""" + http_client = getattr(request.app.state, "http_client", None) + if http_client is None: + msg = "Outbound HTTP client is not available." + raise ServiceUnavailableError(msg) + return http_client + + +ExternalHTTPClientDep = Annotated[AsyncClient, Depends(get_external_http_client)] diff --git a/backend/app/api/common/routers/exceptions.py b/backend/app/api/common/routers/exceptions.py index ca1a21e2..f56868e5 100644 --- a/backend/app/api/common/routers/exceptions.py +++ b/backend/app/api/common/routers/exceptions.py @@ -1,17 +1,20 @@ -"""FastAPI exception handlers to raise HTTP errors for common exceptions.""" +"""FastAPI exception handlers for API and framework exceptions.""" -import logging -from collections.abc import Awaitable, Callable +from typing import TYPE_CHECKING -from fastapi import FastAPI, Request, status +from fastapi import FastAPI, Request, Response, status from fastapi.responses import JSONResponse +from loguru import logger from pydantic import ValidationError +from slowapi import _rate_limit_exceeded_handler +from slowapi.errors import RateLimitExceeded from app.api.common.exceptions import APIError -### Generic exception handlers ### +if TYPE_CHECKING: + from collections.abc import Awaitable, Callable -logger = logging.getLogger() +### Generic exception handlers ### def create_exception_handler( @@ -25,37 +28,45 @@ async def handler(_: Request, exc: Exception) -> JSONResponse: detail = {"message": exc.message} if exc.details: detail["details"] = exc.details + log_message = exc.log_message else: status_code = default_status_code - detail = {"message": str(exc)} + detail = {"message": "Internal server error"} if status_code >= 500 else {"message": str(exc)} + log_message = str(exc) - # TODO: Add traceback location to log message (perhaps easier by just using loguru) # Log based on status code severity. Can be made more granular if needed. if status_code >= 500: - logger.error("%s: %s", exc.__class__.__name__, str(exc), exc_info=exc) + logger.opt(exception=True).error(f"{exc.__class__.__name__}: {log_message}") elif status_code >= 400 and status_code != 404: - logger.warning("%s: %s", exc.__class__.__name__, str(exc)) + logger.warning(f"{exc.__class__.__name__}: {log_message}") else: - logger.info("%s: %s", exc.__class__.__name__, str(exc)) + logger.info(f"{exc.__class__.__name__}: {log_message}") return JSONResponse(status_code=status_code, content={"detail": detail}) return handler +def rate_limit_handler(request: Request, exc: Exception) -> Response: + """Wrapper for the SlowAPI rate limit handler to ensure correct exception type is passed.""" + if not isinstance(exc, RateLimitExceeded): + msg = "Rate limit handler called with wrong exception type" + raise TypeError(msg) + return _rate_limit_exceeded_handler(request, exc) + + ### Exception handler registration ### def register_exception_handlers(app: FastAPI) -> None: """Register all exception handlers with the FastAPI app.""" - # TODO: When going public, any errors resulting from internal server logic - # should be logged and not exposed to the client, instead returning a 500 error with a generic message. - # Custom API exceptions app.add_exception_handler(APIError, create_exception_handler()) - # Standard Python exceptions - # TODO: These should be replaced with custom exceptions + # SlowAPI rate limiting + app.add_exception_handler(RateLimitExceeded, rate_limit_handler) + + # Temporary compatibility handler for legacy domain validation paths. + # Avoid catching RuntimeError broadly so programmer errors still surface normally. app.add_exception_handler(ValueError, create_exception_handler(status.HTTP_400_BAD_REQUEST)) - app.add_exception_handler(RuntimeError, create_exception_handler(status.HTTP_500_INTERNAL_SERVER_ERROR)) # NOTE: This is a validation error for internal logic, not for user input app.add_exception_handler(ValidationError, create_exception_handler(status.HTTP_500_INTERNAL_SERVER_ERROR)) diff --git a/backend/app/api/common/routers/file_mounts.py b/backend/app/api/common/routers/file_mounts.py new file mode 100644 index 00000000..efb5ea39 --- /dev/null +++ b/backend/app/api/common/routers/file_mounts.py @@ -0,0 +1,50 @@ +"""File mounts and static file routes for the application.""" + +from fastapi import FastAPI +from fastapi.responses import RedirectResponse +from fastapi.staticfiles import StaticFiles + +from app.core.config import settings + +FAVICON_ROUTE = "/favicon.ico" + + +def mount_static_directories(app: FastAPI) -> None: + """Mount static file directories to the FastAPI application. + + Args: + app: FastAPI application instance + """ + # Mount the uploads directory if it exists. Note: if this is called + # from lifespan, the directory should have been ensured already. + if settings.uploads_path.exists(): + app.mount("/uploads", StaticFiles(directory=settings.uploads_path), name="uploads") + else: + err_msg = ( + f"Uploads path '{settings.uploads_path}' does not exist. Ensure storage directories are created at startup." + ) + raise RuntimeError(err_msg) + + # Static files directory is part of the repo and should exist; mount + # it if present, otherwise skip to avoid raising at import time. + if settings.static_files_path.exists(): + app.mount("/static", StaticFiles(directory=settings.static_files_path), name="static") + else: + err_msg = ( + f"Static files path '{settings.static_files_path}' does not exist." + " Ensure storage directories are created at startup." + ) + raise RuntimeError(err_msg) + + +def register_favicon_route(app: FastAPI) -> None: + """Register favicon redirect route. + + Args: + app: FastAPI application instance + """ + + @app.get(FAVICON_ROUTE, include_in_schema=False) + async def favicon() -> RedirectResponse: + """Redirect favicon requests to static files.""" + return RedirectResponse(url="/static/favicon.ico") diff --git a/backend/app/api/common/routers/health.py b/backend/app/api/common/routers/health.py new file mode 100644 index 00000000..1385eb1d --- /dev/null +++ b/backend/app/api/common/routers/health.py @@ -0,0 +1,98 @@ +"""Health check and readiness probe endpoints.""" + +import asyncio +import logging + +from fastapi import APIRouter, Request +from fastapi.responses import JSONResponse +from sqlalchemy import text +from sqlalchemy.exc import SQLAlchemyError + +from app.core.database import async_engine +from app.core.redis import ping_redis + +HEALTHY_STATUS = "healthy" +UNHEALTHY_STATUS = "unhealthy" + +logger = logging.getLogger(__name__) + +router = APIRouter(tags=["health"]) + + +def healthy_check() -> dict[str, str]: + """Return a healthy check payload.""" + return {"status": HEALTHY_STATUS} + + +def unhealthy_check(error: str) -> dict[str, str]: + """Return an unhealthy check payload with error details.""" + return {"status": UNHEALTHY_STATUS, "error": error} + + +async def check_database() -> dict[str, str]: + """Check PostgreSQL database connectivity.""" + try: + async with async_engine.connect() as conn: + result = await conn.execute(text("SELECT 1")) + if result.scalar_one() != 1: + return unhealthy_check("Database SELECT 1 returned unexpected result") + return healthy_check() + except (SQLAlchemyError, OSError, RuntimeError) as e: + logger.exception("Database health check failed") + return unhealthy_check(str(e)) + + +async def check_redis(request: Request) -> dict[str, str]: + """Check Redis cache connectivity.""" + redis_client = request.app.state.redis if hasattr(request.app.state, "redis") else None + + if redis_client is None: + return unhealthy_check("Redis client not initialized") + + try: + ping = await ping_redis(redis_client) + if ping: + return healthy_check() + return unhealthy_check("Redis ping returned False") + except (OSError, RuntimeError, TimeoutError) as e: + logger.exception("Redis health check failed") + return unhealthy_check(str(e)) + + +async def perform_health_checks(request: Request) -> dict[str, dict[str, str]]: + """Perform parallel health checks for all service dependencies.""" + database_check, redis_check = await asyncio.gather(check_database(), check_redis(request), return_exceptions=False) + + return { + "database": database_check, + "redis": redis_check, + } + + +@router.get("/live", include_in_schema=False) +async def liveness_probe() -> JSONResponse: + """Liveness probe: signals the container is running.""" + return JSONResponse(content={"status": "alive"}, status_code=200) + + +@router.get("/health", include_in_schema=False) +async def readiness_probe(request: Request) -> JSONResponse: + """Readiness probe: signals the application is ready to serve requests. + + Performs health checks on all dependencies (database, Redis). + Returns HTTP 200 only if all dependencies are healthy. + Returns HTTP 503 if any dependency is unhealthy. + """ + checks = await perform_health_checks(request) + + # Determine overall status + all_healthy = all(check.get("status") == HEALTHY_STATUS for check in checks.values()) + overall_status = HEALTHY_STATUS if all_healthy else UNHEALTHY_STATUS + status_code = 200 if all_healthy else 503 + + response_data = { + "status": overall_status, + "checks": checks, + } + + return JSONResponse(content=response_data, status_code=status_code) diff --git a/backend/app/api/common/routers/main.py b/backend/app/api/common/routers/main.py index 98dec8e2..ee07c64e 100644 --- a/backend/app/api/common/routers/main.py +++ b/backend/app/api/common/routers/main.py @@ -6,6 +6,7 @@ from app.api.background_data.routers.admin import router as background_data_admin_router from app.api.background_data.routers.public import router as background_data_public_router from app.api.data_collection.routers import router as data_collection_router +from app.api.file_storage.routers import router as file_storage_router from app.api.newsletter.routers import router as newsletter_backend_router from app.api.plugins.rpi_cam.routers.main import router as rpi_cam_router @@ -16,6 +17,7 @@ background_data_admin_router, background_data_public_router, data_collection_router, + file_storage_router, *auth_routers, rpi_cam_router, newsletter_backend_router, diff --git a/backend/app/api/common/routers/openapi.py b/backend/app/api/common/routers/openapi.py index 45e8b078..9e205a24 100644 --- a/backend/app/api/common/routers/openapi.py +++ b/backend/app/api/common/routers/openapi.py @@ -1,19 +1,24 @@ """Utilities for including or excluding endpoints in the public OpenAPI schema and documentation.""" -from collections.abc import Callable -from typing import Any +from typing import TYPE_CHECKING -from asyncache import cached -from cachetools import LRUCache from fastapi import APIRouter, FastAPI, Security from fastapi.openapi.docs import get_redoc_html, get_swagger_ui_html from fastapi.openapi.utils import get_openapi from fastapi.responses import HTMLResponse from fastapi.routing import APIRoute from fastapi.types import DecoratedCallable +from fastapi_cache.decorator import cache from app.api.auth.dependencies import current_active_superuser -from app.api.common.config import settings +from app.api.common.config import settings as api_settings +from app.api.common.routers.file_mounts import FAVICON_ROUTE +from app.core.cache import HTMLCoder +from app.core.config import CacheNamespace, settings + +if TYPE_CHECKING: + from collections.abc import Callable + from typing import Any ### Constants ### OPENAPI_PUBLIC_INCLUSION_EXTENSION: str = "x-public" @@ -26,25 +31,17 @@ class PublicAPIRouter(APIRouter): Example: public_router = PublicAPIRouter(prefix="/products", tags=["products"]) """ - def api_route( - self, path: str, *args: Any, **kwargs: Any - ) -> Callable[[DecoratedCallable], DecoratedCallable]: # Allow Any-typed (kw)args as this is an override + def api_route(self, path: str, *args: Any, **kwargs: Any) -> Callable[[DecoratedCallable], DecoratedCallable]: # noqa: ANN401 # Any-typed (kw)args are expected by the parent method signatures + """Override the default api_route method to add the public inclusion extension to the OpenAPI schema.""" existing_extra = kwargs.get("openapi_extra") or {} kwargs["openapi_extra"] = {**existing_extra, OPENAPI_PUBLIC_INCLUSION_EXTENSION: True} return super().api_route(path, *args, **kwargs) def public_endpoint(router_method: Callable) -> Callable: - """Wrapper function to mark an endpoint method as public. - - Example: product_router = APIRouter() - get = public_endpoint(product_router.get) - post = public_endpoint(product_router.post) - """ + """Wrapper function to mark an endpoint method as public.""" - def wrapper( - *args: Any, **kwargs: Any - ) -> Callable[[DecoratedCallable], DecoratedCallable]: # Allow Any-typed (kw)args as this is a wrapper + def wrapper(*args: Any, **kwargs: Any) -> Callable[[DecoratedCallable], DecoratedCallable]: # noqa: ANN401 # Any-typed (kw)args are expected by the parent method signatures existing_extra = kwargs.get("openapi_extra") or {} kwargs["openapi_extra"] = {**existing_extra, OPENAPI_PUBLIC_INCLUSION_EXTENSION: True} return router_method(*args, **kwargs) @@ -64,11 +61,11 @@ def mark_router_routes_public(router: APIRouter) -> None: def get_filtered_openapi_schema(app: FastAPI) -> dict[str, Any]: """Generate OpenAPI schema with only public endpoints.""" openapi_schema: dict[str, Any] = get_openapi( - title=settings.public_docs.title, - version=settings.public_docs.version, - description=settings.public_docs.description, + title=api_settings.public_docs.title, + version=api_settings.public_docs.version, + description=api_settings.public_docs.description, routes=app.routes, - license_info=settings.public_docs.license_info, + license_info=api_settings.public_docs.license_info, ) paths = openapi_schema["paths"] @@ -85,7 +82,7 @@ def get_filtered_openapi_schema(app: FastAPI) -> dict[str, Any]: openapi_schema["paths"] = filtered_paths # Add tag groups for better organization in Redoc - openapi_schema["x-tagGroups"] = settings.public_docs.x_tag_groups + openapi_schema["x-tagGroups"] = api_settings.public_docs.x_tag_groups return openapi_schema @@ -96,19 +93,25 @@ def init_openapi_docs(app: FastAPI) -> FastAPI: # Public documentation @public_docs_router.get("/openapi.json") - @cached(LRUCache(maxsize=1)) - async def get_openapi_schema() -> dict[str, Any]: + @cache(expire=settings.cache.ttls[CacheNamespace.DOCS]) + async def get_openapi_schema() -> dict: return get_filtered_openapi_schema(app) - @cached(LRUCache(maxsize=1)) @public_docs_router.get("/docs") + @cache(expire=settings.cache.ttls[CacheNamespace.DOCS], coder=HTMLCoder) async def get_swagger_docs() -> HTMLResponse: - return get_swagger_ui_html(openapi_url="/openapi.json", title="Public API Documentation") + return get_swagger_ui_html( + openapi_url="/openapi.json", + title="Public API Documentation", + swagger_favicon_url=FAVICON_ROUTE, + ) - @cached(LRUCache(maxsize=1)) @public_docs_router.get("/redoc") + @cache(expire=settings.cache.ttls[CacheNamespace.DOCS], coder=HTMLCoder) async def get_redoc_docs() -> HTMLResponse: - return get_redoc_html(openapi_url="/openapi.json", title="Public API Documentation - ReDoc") + return get_redoc_html( + openapi_url="/openapi.json", title="Public API Documentation - ReDoc", redoc_favicon_url=FAVICON_ROUTE + ) app.include_router(public_docs_router) @@ -116,23 +119,29 @@ async def get_redoc_docs() -> HTMLResponse: full_docs_router = APIRouter(prefix="", dependencies=[Security(current_active_superuser)], include_in_schema=False) @full_docs_router.get("/openapi_full.json") - @cached(LRUCache(maxsize=1)) - async def get_full_openapi() -> dict[str, Any]: + @cache(expire=settings.cache.ttls[CacheNamespace.DOCS]) + async def get_full_openapi() -> dict: return get_openapi( - title=settings.full_docs.title, - version=settings.full_docs.version, - description=settings.full_docs.description, + title=api_settings.full_docs.title, + version=api_settings.full_docs.version, + description=api_settings.full_docs.description, routes=app.routes, - license_info=settings.full_docs.license_info, + license_info=api_settings.full_docs.license_info, ) @full_docs_router.get("/docs/full") + @cache(expire=settings.cache.ttls[CacheNamespace.DOCS], coder=HTMLCoder) async def get_full_swagger_docs() -> HTMLResponse: - return get_swagger_ui_html(openapi_url="/openapi_full.json", title="Full API Documentation") + return get_swagger_ui_html( + openapi_url="/openapi_full.json", title="Full API Documentation", swagger_favicon_url=FAVICON_ROUTE + ) @full_docs_router.get("/redoc/full") + @cache(expire=settings.cache.ttls[CacheNamespace.DOCS], coder=HTMLCoder) async def get_full_redoc_docs() -> HTMLResponse: - return get_redoc_html(openapi_url="/openapi_full.json", title="Full API Documentation") + return get_redoc_html( + openapi_url="/openapi_full.json", title="Full API Documentation", redoc_favicon_url=FAVICON_ROUTE + ) app.include_router(full_docs_router) diff --git a/backend/app/api/common/routers/query_params.py b/backend/app/api/common/routers/query_params.py new file mode 100644 index 00000000..90015716 --- /dev/null +++ b/backend/app/api/common/routers/query_params.py @@ -0,0 +1,23 @@ +"""Reusable router query parameter helpers.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from fastapi import Query + +if TYPE_CHECKING: + from typing import Any + + +def relationship_include_query(*, openapi_examples: dict[str, Any]) -> object: + """Build a reusable relationship include query parameter.""" + return Query( + description="Relationships to include", + openapi_examples=openapi_examples, + ) + + +def boolean_flag_query(*, description: str) -> object: + """Build a reusable boolean query flag definition.""" + return Query(description=description) diff --git a/backend/app/api/common/routers/read_helpers.py b/backend/app/api/common/routers/read_helpers.py new file mode 100644 index 00000000..ea902199 --- /dev/null +++ b/backend/app/api/common/routers/read_helpers.py @@ -0,0 +1,104 @@ +"""Shared read-handler helpers for list/detail router endpoints.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING +from uuid import UUID + +from app.api.common.crud.base import get_model_by_id, get_models, get_nested_model_by_id, get_paginated_models +from app.api.common.models.base import CustomBaseBare + +if TYPE_CHECKING: + from collections.abc import Sequence + + from fastapi_filter.contrib.sqlalchemy import Filter + from fastapi_pagination import Page + from pydantic import BaseModel + from sqlmodel.ext.asyncio.session import AsyncSession + from sqlmodel.sql._expression_select_cls import SelectOfScalar + + +async def list_models_response[ModelT: CustomBaseBare]( + session: AsyncSession, + model: type[ModelT], + *, + include_relationships: set[str] | None = None, + model_filter: Filter | None = None, + read_schema: type[BaseModel] | None = None, + statement: SelectOfScalar[ModelT] | None = None, +) -> Page[ModelT]: + """Return a paginated list response for a model-backed endpoint.""" + return await get_paginated_models( + session, + model, + include_relationships=include_relationships, + model_filter=model_filter, + read_schema=read_schema, + statement=statement, + ) + + +async def get_model_response[ModelT: CustomBaseBare, ModelIDT: int | UUID]( + session: AsyncSession, + model: type[ModelT], + model_id: ModelIDT, + *, + include_relationships: set[str] | None = None, + read_schema: type[BaseModel] | None = None, +) -> ModelT: + """Return a single model response for a detail endpoint.""" + return await get_model_by_id( + session, + model, + model_id, + include_relationships=include_relationships, + read_schema=read_schema, + ) + + +async def list_models_sequence_response[ModelT: CustomBaseBare]( + session: AsyncSession, + model: type[ModelT], + *, + include_relationships: set[str] | None = None, + model_filter: Filter | None = None, + read_schema: type[BaseModel] | None = None, + statement: SelectOfScalar[ModelT] | None = None, +) -> Sequence[ModelT]: + """Return a non-paginated list response for a model-backed endpoint.""" + return await get_models( + session, + model, + include_relationships=include_relationships, + model_filter=model_filter, + statement=statement, + read_schema=read_schema, + ) + + +async def get_nested_model_response[ + ParentModelT: CustomBaseBare, + DependentModelT: CustomBaseBare, + ModelIDT: int | UUID, +]( + session: AsyncSession, + parent_model: type[ParentModelT], + parent_id: ModelIDT, + dependent_model: type[DependentModelT], + dependent_id: ModelIDT, + foreign_key_attr: str, + *, + include_relationships: set[str] | None = None, + read_schema: type[BaseModel] | None = None, +) -> DependentModelT: + """Return a nested model response for dependent child resources.""" + return await get_nested_model_by_id( + session, + parent_model, + parent_id, + dependent_model, + dependent_id, + foreign_key_attr, + include_relationships=include_relationships, + read_schema=read_schema, + ) diff --git a/backend/app/api/common/schemas/base.py b/backend/app/api/common/schemas/base.py index 0fb4f95e..6fa50098 100644 --- a/backend/app/api/common/schemas/base.py +++ b/backend/app/api/common/schemas/base.py @@ -14,7 +14,7 @@ from app.api.background_data.models import MaterialBase from app.api.common.models.base import TimeStampMixinBare -from app.api.data_collection.models import ProductBase +from app.api.data_collection.base import ProductBase ### Common Validation ### @@ -83,8 +83,11 @@ class ProductRead(BaseReadSchemaWithTimeStamp, ProductBase): product_type_id: PositiveInt | None = None owner_id: UUID4 + owner_username: str | None = None - # HACK: Include parent id and mount_in_parent in base product read schema + thumbnail_url: str | None = None + + # Include component metadata here because the same read schema serves both base products and components. # TODO: separate components and base products on the model level parent_id: PositiveInt | None = None amount_in_parent: int | None = Field(default=None, description="Quantity within parent product") diff --git a/backend/app/api/common/schemas/custom_fields.py b/backend/app/api/common/schemas/custom_fields.py new file mode 100644 index 00000000..e9837458 --- /dev/null +++ b/backend/app/api/common/schemas/custom_fields.py @@ -0,0 +1,9 @@ +"""Shared fields for DTO schemas.""" + +from typing import Annotated + +from pydantic import AnyUrl, HttpUrl, PlainSerializer, StringConstraints + +# HTTP URL that is stored as string in the database. +type HttpUrlToDB = Annotated[HttpUrl, PlainSerializer(str, return_type=str), StringConstraints(max_length=250)] +type AnyUrlToDB = Annotated[AnyUrl, PlainSerializer(str, return_type=str), StringConstraints(max_length=250)] diff --git a/backend/app/api/data_collection/base.py b/backend/app/api/data_collection/base.py new file mode 100644 index 00000000..486588e9 --- /dev/null +++ b/backend/app/api/data_collection/base.py @@ -0,0 +1,96 @@ +"""Base model classes for data collection β€” split out to avoid circular imports. + +These classes have no heavy ORM dependencies (no relationships, foreign keys, or +other model imports) and can therefore be imported by common/schemas/base.py +without triggering the full data_collection/models.py import chain. +""" + +import logging +from datetime import UTC, datetime +from functools import cached_property +from typing import TYPE_CHECKING + +from pydantic import computed_field, model_validator +from sqlalchemy import TIMESTAMP +from sqlmodel import Column, Field + +from app.api.common.models.base import CustomBase + +if TYPE_CHECKING: + from typing import Self + +logger = logging.getLogger(__name__) + + +### Validation Utilities ### +def validate_start_and_end_time(start_time: datetime, end_time: datetime | None) -> None: + """Validate that end time is after start time if both are set.""" + if start_time and end_time and end_time < start_time: + err_msg: str = f"End time {end_time:%Y-%m-%d %H:%M} must be after start time {start_time:%Y-%m-%d %H:%M}" + raise ValueError(err_msg) + + +### Properties Base Models ### +class PhysicalPropertiesBase(CustomBase): + """Base model to store physical properties of a product.""" + + weight_g: float | None = Field(default=None, gt=0) + height_cm: float | None = Field(default=None, gt=0) + width_cm: float | None = Field(default=None, gt=0) + depth_cm: float | None = Field(default=None, gt=0) + + # Computed properties + @computed_field + @cached_property + def volume_cm3(self) -> float | None: + """Calculate the volume of the product.""" + if self.height_cm is None or self.width_cm is None or self.depth_cm is None: + logger.warning("All dimensions must be set to calculate the volume.") + return None + return self.height_cm * self.width_cm * self.depth_cm + + +class CircularityPropertiesBase(CustomBase): + """Base model to store circularity properties of a product.""" + + # Recyclability + recyclability_observation: str | None = Field(default=None, max_length=500) + recyclability_comment: str | None = Field(default=None, max_length=100) + recyclability_reference: str | None = Field(default=None, max_length=100) + + # Repairability + repairability_observation: str | None = Field(default=None, max_length=500) + repairability_comment: str | None = Field(default=None, max_length=100) + repairability_reference: str | None = Field(default=None, max_length=100) + + # Remanufacturability + remanufacturability_observation: str | None = Field(default=None, max_length=500) + remanufacturability_comment: str | None = Field(default=None, max_length=100) + remanufacturability_reference: str | None = Field(default=None, max_length=100) + + +### Product Base Model ### +class ProductBase(CustomBase): + """Basic model to store product information.""" + + name: str = Field(index=True, min_length=2, max_length=100) + description: str | None = Field(default=None, max_length=500) + brand: str | None = Field(default=None, max_length=100) + model: str | None = Field(default=None, max_length=100) + + # Dismantling information + dismantling_notes: str | None = Field( + default=None, max_length=500, description="Notes on the dismantling process of the product." + ) + + dismantling_time_start: datetime = Field( + sa_column=Column(TIMESTAMP(timezone=True), nullable=False), default_factory=lambda: datetime.now(UTC) + ) + dismantling_time_end: datetime | None = Field(default=None, sa_column=Column(TIMESTAMP(timezone=True))) + + # Time validation + @model_validator(mode="after") + def validate_times(self) -> Self: + """Ensure end time is after start time if both are set.""" + validate_start_and_end_time(self.dismantling_time_start, self.dismantling_time_end) + return self diff --git a/backend/app/api/data_collection/crud.py b/backend/app/api/data_collection/crud.py index 52d75708..7cb00f05 100644 --- a/backend/app/api/data_collection/crud.py +++ b/backend/app/api/data_collection/crud.py @@ -1,36 +1,49 @@ """CRUD operations for the models related to data collection.""" -from collections.abc import Sequence -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, cast from pydantic import UUID4 -from sqlalchemy import Delete, delete from sqlalchemy.orm import selectinload +from sqlalchemy.orm.attributes import QueryableAttribute from sqlmodel import col, select from sqlmodel.ext.asyncio.session import AsyncSession from sqlmodel.sql._expression_select_cls import SelectOfScalar -from app.api.auth.models import User from app.api.background_data.models import ( Material, ProductType, ) from app.api.common.crud.associations import get_linking_model_with_ids_if_it_exists +from app.api.common.crud.base import get_model_by_id +from app.api.common.crud.persistence import ( + SupportsModelDump, + commit_and_refresh, + delete_and_commit, + update_and_commit, +) from app.api.common.crud.utils import ( - db_get_model_with_id_if_it_exists, - db_get_models_with_ids_if_they_exist, + get_models_by_ids_or_404, validate_linked_items_exist, validate_no_duplicate_linked_items, ) -from app.api.common.models.associations import MaterialProductLink +from app.api.common.exceptions import InternalServerError from app.api.common.schemas.associations import ( MaterialProductLinkCreateWithinProduct, MaterialProductLinkCreateWithinProductAndMaterial, MaterialProductLinkUpdate, ) +from app.api.data_collection.exceptions import ( + MaterialIDRequiredError, + ProductOwnerRequiredError, + ProductPropertyAlreadyExistsError, + ProductPropertyNotFoundError, + ProductTreeMissingContentError, +) from app.api.data_collection.filters import ProductFilterWithRelationships -from app.api.data_collection.models import PhysicalProperties, Product +from app.api.data_collection.models import CircularityProperties, MaterialProductLink, PhysicalProperties, Product from app.api.data_collection.schemas import ( + CircularityPropertiesCreate, + CircularityPropertiesUpdate, ComponentCreateWithComponents, PhysicalPropertiesCreate, PhysicalPropertiesUpdate, @@ -38,34 +51,151 @@ ProductUpdate, ProductUpdateWithProperties, ) -from app.api.file_storage.crud import ParentStorageOperations, create_file, create_image, delete_file, delete_image +from app.api.file_storage.crud import ( + ParentStorageOperations, + file_storage_service, + image_storage_service, +) from app.api.file_storage.filters import FileFilter, ImageFilter -from app.api.file_storage.models.models import File, FileParentType, Image, ImageParentType, Video +from app.api.file_storage.models.models import File, Image, MediaParentType, Video from app.api.file_storage.schemas import ( FileCreate, ImageCreateFromForm, ) if TYPE_CHECKING: - from pydantic import EmailStr + from collections.abc import Sequence + from sqlmodel.sql._expression_select_cls import SelectOfScalar -# NOTE: GET operations are implemented in the crud.common.base module -# TODO: Implement ownership checks for products and files -# TODO: Consider wether or not this should be a simple ownership check -# or if users can do get operations on any objects owned by members of the same organization + +async def _get_product_with_relationship( + db: AsyncSession, + product_id: int, + relationship_name: str, +) -> Product: + """Fetch a product with one explicit relationship loaded.""" + return await get_model_by_id(db, Product, product_id, include_relationships={relationship_name}) + + +def _require_product_relationship[PropertyT: PhysicalProperties | CircularityProperties]( + product: Product, + *, + relationship_name: str, + not_found_label: str, +) -> PropertyT: + """Return a loaded one-to-one product relation or raise a consistent error.""" + db_property = cast("PropertyT | None", getattr(product, relationship_name)) + if db_property is None: + raise ProductPropertyNotFoundError(not_found_label, product.id) + return db_property + + +async def _create_product_property[ + PropertyT: PhysicalProperties | CircularityProperties, + CreateSchemaT: SupportsModelDump, +]( + db: AsyncSession, + *, + product_id: int, + payload: CreateSchemaT, + property_model: type[PropertyT], + relationship_name: str, + already_exists_label: str, +) -> PropertyT: + """Create a one-to-one product property row if it does not already exist.""" + product = await _get_product_with_relationship(db, product_id, relationship_name) + if getattr(product, relationship_name): + raise ProductPropertyAlreadyExistsError(product_id, already_exists_label) + + db_property = property_model(**payload.model_dump(), product_id=product_id) + setattr(product, relationship_name, db_property) + return await commit_and_refresh(db, db_property) + + +async def _update_product_property[ + PropertyT: PhysicalProperties | CircularityProperties, + UpdateSchemaT: SupportsModelDump, +]( + db: AsyncSession, + *, + product_id: int, + payload: UpdateSchemaT, + relationship_name: str, + not_found_label: str, +) -> PropertyT: + """Update a one-to-one product property row.""" + product = await _get_product_with_relationship(db, product_id, relationship_name) + db_property = _require_product_relationship( + product, + relationship_name=relationship_name, + not_found_label=not_found_label, + ) + return await update_and_commit(db, db_property, payload) + + +async def _delete_product_property( + db: AsyncSession, + *, + product: Product, + relationship_name: str, + not_found_label: str, +) -> None: + """Delete a one-to-one product property row.""" + db_property = _require_product_relationship( + product, + relationship_name=relationship_name, + not_found_label=not_found_label, + ) + await delete_and_commit(db, db_property) + + +def _normalize_material_ids(material_ids: int | set[int]) -> set[int]: + """Normalize a single material ID into the set-based CRUD interface.""" + return {material_ids} if isinstance(material_ids, int) else material_ids + + +async def _get_product_with_bill_of_materials(db: AsyncSession, product_id: int) -> Product: + """Fetch a product with its bill of materials loaded.""" + return await get_model_by_id(db, Product, product_id, include_relationships={"bill_of_materials"}) + + +async def _validate_product_material_links( + db: AsyncSession, + product_id: int, + material_ids: int | set[int], +) -> tuple[Product, set[int]]: + """Validate that the product and referenced materials exist.""" + normalized_material_ids = _normalize_material_ids(material_ids) + product = await _get_product_with_bill_of_materials(db, product_id) + await get_models_by_ids_or_404(db, Material, normalized_material_ids) + return product, normalized_material_ids + + +async def _get_material_links_for_product( + db: AsyncSession, + product_id: int, + material_ids: set[int], +) -> Sequence[MaterialProductLink]: + """Fetch material-product links for a product and a set of material IDs.""" + statement = ( + select(MaterialProductLink) + .where(col(MaterialProductLink.product_id) == product_id) + .where(col(MaterialProductLink.material_id).in_(material_ids)) + ) + results = await db.exec(statement) + return results.all() ### PhysicalProperty CRUD operations ### async def get_physical_properties(db: AsyncSession, product_id: int) -> PhysicalProperties: """Get physical properties for a product.""" - product: Product = await db_get_model_with_id_if_it_exists(db, Product, product_id) - - if not product.physical_properties: - err_msg: str = f"Physical properties for product with id {product_id} not found" - raise ValueError(err_msg) - - return product.physical_properties + product = await _get_product_with_relationship(db, product_id, "physical_properties") + return _require_product_relationship( + product, + relationship_name="physical_properties", + not_found_label="Physical properties", + ) async def create_physical_properties( @@ -74,52 +204,87 @@ async def create_physical_properties( product_id: int, ) -> PhysicalProperties: """Create physical properties for a product.""" - # Validate that product exists and doesn't have physical properties - product: Product = await db_get_model_with_id_if_it_exists(db, Product, product_id) - if product.physical_properties: - err_msg: str = f"Product with id {product_id} already has physical properties" - raise ValueError(err_msg) - - # Create physical properties - db_physical_property = PhysicalProperties( - **physical_properties.model_dump(), + return await _create_product_property( + db, product_id=product_id, + payload=physical_properties, + property_model=PhysicalProperties, + relationship_name="physical_properties", + already_exists_label="physical properties", ) - db.add(db_physical_property) - await db.commit() - await db.refresh(db_physical_property) - - return db_physical_property async def update_physical_properties( db: AsyncSession, product_id: int, physical_properties: PhysicalPropertiesUpdate ) -> PhysicalProperties: """Update physical properties for a product.""" - # Validate that product exists and has physical properties - product: Product = await db_get_model_with_id_if_it_exists(db, Product, product_id) - if not (db_physical_properties := product.physical_properties): - err_msg: EmailStr = f"Physical properties for product with id {product_id} not found" - raise ValueError(err_msg) - - physical_properties_data: dict[str, Any] = physical_properties.model_dump(exclude_unset=True) - db_physical_properties.sqlmodel_update(physical_properties_data) - - db.add(db_physical_properties) - await db.commit() - await db.refresh(db_physical_properties) - return db_physical_properties + return await _update_product_property( + db, + product_id=product_id, + payload=physical_properties, + relationship_name="physical_properties", + not_found_label="Physical properties", + ) async def delete_physical_properties(db: AsyncSession, product: Product) -> None: """Delete physical properties for a product.""" - # Validate that product exists and has physical properties - if not (db_physical_properties := product.physical_properties): - err_msg: EmailStr = f"Physical properties for product with id {product.id} not found" - raise ValueError(err_msg) + await _delete_product_property( + db, + product=product, + relationship_name="physical_properties", + not_found_label="Physical properties", + ) - await db.delete(db_physical_properties) - await db.commit() + +### CircularityProperty CRUD operations ### +async def get_circularity_properties(db: AsyncSession, product_id: int) -> CircularityProperties: + """Get circularity properties for a product.""" + product = await _get_product_with_relationship(db, product_id, "circularity_properties") + return _require_product_relationship( + product, + relationship_name="circularity_properties", + not_found_label="Circularity properties", + ) + + +async def create_circularity_properties( + db: AsyncSession, + circularity_properties: CircularityPropertiesCreate, + product_id: int, +) -> CircularityProperties: + """Create circularity properties for a product.""" + return await _create_product_property( + db, + product_id=product_id, + payload=circularity_properties, + property_model=CircularityProperties, + relationship_name="circularity_properties", + already_exists_label="circularity properties", + ) + + +async def update_circularity_properties( + db: AsyncSession, product_id: int, circularity_properties: CircularityPropertiesUpdate +) -> CircularityProperties: + """Update circularity properties for a product.""" + return await _update_product_property( + db, + product_id=product_id, + payload=circularity_properties, + relationship_name="circularity_properties", + not_found_label="Circularity properties", + ) + + +async def delete_circularity_properties(db: AsyncSession, product: Product) -> None: + """Delete circularity properties for a product.""" + await _delete_product_property( + db, + product=product, + relationship_name="circularity_properties", + not_found_label="Circularity properties", + ) ### Product CRUD operations ### @@ -137,181 +302,186 @@ async def get_product_trees( """ # Validate that parent product exists if parent_id: - await db_get_model_with_id_if_it_exists(db, Product, parent_id) + await get_model_by_id(db, Product, parent_id) statement: SelectOfScalar[Product] = ( select(Product) .where(Product.parent_id == parent_id) - .options(selectinload(Product.components, recursion_depth=recursion_depth)) + .options( + selectinload(cast("QueryableAttribute[Any]", Product.components), recursion_depth=recursion_depth), + selectinload(cast("QueryableAttribute[Any]", Product.product_type)), + selectinload(cast("QueryableAttribute[Any]", Product.videos)), + selectinload(cast("QueryableAttribute[Any]", Product.files)), + selectinload(cast("QueryableAttribute[Any]", Product.images)), + ) ) if product_filter: statement = product_filter.filter(statement) - return (await db.exec(statement)).all() + return list((await db.exec(statement)).all()) -# TODO: refactor this function and create_product to use a common function for creating components. -# See the category CRUD functions for an example. -async def create_component( - db: AsyncSession, - component: ComponentCreateWithComponents, - parent_product_id: int, - *, - _is_recursive_call: bool = False, # Flag to track recursive calls - owner_id: UUID4 | None = None, -) -> Product: - """Add a component to a product.""" - # Validate bill of materials - if not component.bill_of_materials and not component.components: - err_msg: str = "Product needs materials or components" - raise ValueError(err_msg) - - if not _is_recursive_call: - # Validate that parent product exists and fetch its owner ID - db_parent_product = await db_get_model_with_id_if_it_exists(db, Product, parent_product_id) - owner_id = db_parent_product.owner_id - - # Create component - component_data: dict[str, Any] = component.model_dump( +def _product_payload( + product_data: ProductCreateWithComponents | ComponentCreateWithComponents, +) -> dict[str, Any]: + """Return the shared payload used to create a product or component.""" + return product_data.model_dump( exclude={ "components", "owner_id", "physical_properties", + "circularity_properties", "videos", "bill_of_materials", } ) - db_component = Product( - **component_data, - parent_id=parent_product_id, - owner_id=owner_id, # pyright: ignore[reportArgumentType] # owner ID is guaranteed by database fetch above + + +async def _create_product_record( + db: AsyncSession, + product_data: ProductCreateWithComponents | ComponentCreateWithComponents, + *, + owner_id: UUID4, + parent_product: Product | None = None, +) -> Product: + """Create the base Product row and flush it so dependent rows can reference it.""" + db_product = Product( + **_product_payload(product_data), + owner_id=owner_id, + parent=parent_product, ) - db.add(db_component) - await db.flush() # Assign component ID - - # Create properties - if component.physical_properties: - db_physical_property = PhysicalProperties( - **component.physical_properties.model_dump(), - product_id=db_component.id, # pyright: ignore[reportArgumentType] # component ID is guaranteed by database flush above - ) + db.add(db_product) + await db.flush() + return db_product + + +def _create_product_properties( + db: AsyncSession, + product_data: ProductCreateWithComponents | ComponentCreateWithComponents, + db_product: Product, +) -> None: + """Create one-to-one product property rows when present.""" + if product_data.physical_properties: + db_physical_property = PhysicalProperties(**product_data.physical_properties.model_dump()) + db_physical_property.product = db_product db.add(db_physical_property) - # Create videos - if component.videos: - for video in component.videos: - db_video = Video( - **video.model_dump(), - product_id=db_component.id, - ) - db.add(db_video) - - # Create bill of materials - if component.bill_of_materials: - # Validate materials exist - material_ids = {material.material_id for material in component.bill_of_materials} - await db_get_models_with_ids_if_they_exist(db, Material, material_ids) - - # Create material-product links - db.add_all( - MaterialProductLink(**material.model_dump(), product_id=db_component.id) # pyright: ignore[reportArgumentType] # product ID is guaranteed by database flush above - for material in component.bill_of_materials - ) + if product_data.circularity_properties: + db_circularity_property = CircularityProperties(**product_data.circularity_properties.model_dump()) + db_circularity_property.product = db_product + db.add(db_circularity_property) - # Create subcomponents recursively - if component.components: - for subcomponent in component.components: - await create_component( - db, - subcomponent, - parent_product_id=db_component.id, # pyright: ignore[reportArgumentType] # component ID is guaranteed by database flush above - owner_id=owner_id, - _is_recursive_call=True, - ) - # Commit only when it's not a recursive call - if not _is_recursive_call: - await db.commit() - await db.refresh(db_component) +def _create_product_videos( + db: AsyncSession, + product_data: ProductCreateWithComponents | ComponentCreateWithComponents, + db_product: Product, +) -> None: + """Create video rows linked to the product.""" + if not product_data.videos: + return - return db_component + if db_product.videos is None: + db_product.videos = [] + for video in product_data.videos: + db_video = Video(**video.model_dump()) + db_product.videos.append(db_video) + db.add(db_video) -async def create_product( + +async def _create_product_bill_of_materials( db: AsyncSession, - product: ProductCreateWithComponents, + product_data: ProductCreateWithComponents | ComponentCreateWithComponents, + db_product: Product, +) -> None: + """Create bill-of-materials rows linked to the product.""" + if not product_data.bill_of_materials: + return + + material_ids = {material.material_id for material in product_data.bill_of_materials} + await get_models_by_ids_or_404(db, Material, material_ids) + + db.add_all( + MaterialProductLink(**material.model_dump(), product=db_product) for material in product_data.bill_of_materials + ) + + +async def _create_product_components( + db: AsyncSession, + product_data: ProductCreateWithComponents | ComponentCreateWithComponents, + *, owner_id: UUID4, -) -> Product: - """Create a new product in the database.""" - # Validate that product type exists - if product.product_type_id: - await db_get_model_with_id_if_it_exists(db, ProductType, product.product_type_id) + db_product: Product, +) -> None: + """Recursively create child components for a product.""" + for component in product_data.components: + await _create_product_tree(db, component, owner_id=owner_id, parent_product=db_product) - # Validate that owner exists - # TODO: Replace all these existence and auth checks with dependencies on the router level - await db_get_model_with_id_if_it_exists(db, User, owner_id) - # Create product - product_data: dict[str, Any] = product.model_dump( - exclude={ - "components", - "physical_properties", - "videos", - "bill_of_materials", - } - ) - db_product = Product(**product_data, owner_id=owner_id) +async def _create_product_tree( + db: AsyncSession, + product_data: ProductCreateWithComponents | ComponentCreateWithComponents, + *, + owner_id: UUID4 | None = None, + parent_product: Product | None = None, +) -> Product: + if not product_data.bill_of_materials and not product_data.components: + raise ProductTreeMissingContentError - db.add(db_product) - await db.flush() # Assign product ID + if owner_id is None: + raise ProductOwnerRequiredError - # Create properties - if product.physical_properties: - db_physical_properties = PhysicalProperties( - **product.physical_properties.model_dump(), - product_id=db_product.id, # pyright: ignore[reportArgumentType] # product ID is guaranteed by database flush above - ) - db.add(db_physical_properties) - - # Create videos - if product.videos: - for video in product.videos: - db_video = Video( - **video.model_dump(), - product_id=db_product.id, - ) - db.add(db_video) - - # Create bill of materials - if product.bill_of_materials: - # Validate materials exist - material_ids: set[int] = {material.material_id for material in product.bill_of_materials} - await db_get_models_with_ids_if_they_exist(db, Material, material_ids) - - # Create material-product links - db.add_all( - MaterialProductLink(**material.model_dump(), product_id=db_product.id) # pyright: ignore[reportArgumentType] # product ID is guaranteed by database flush above - for material in product.bill_of_materials - ) + db_product = await _create_product_record(db, product_data, owner_id=owner_id, parent_product=parent_product) + _create_product_properties(db, product_data, db_product) + _create_product_videos(db, product_data, db_product) + await _create_product_bill_of_materials(db, product_data, db_product) + await _create_product_components(db, product_data, owner_id=owner_id, db_product=db_product) + + return db_product - # TODO: Support creation of images and files within product creation - # Create components recursively - if product.components: - for component in product.components: - await create_component( - db, - component, - parent_product_id=db_product.id, # pyright: ignore[reportArgumentType] # component ID is guaranteed by database flush above - owner_id=owner_id, - _is_recursive_call=True, - ) +async def _create_and_persist_product_tree( + db: AsyncSession, + product_data: ProductCreateWithComponents | ComponentCreateWithComponents, + *, + owner_id: UUID4 | None, + parent_product: Product | None = None, +) -> Product: + """Create a product tree and persist the root row.""" + if parent_product is None: + db_product = await _create_product_tree(db, product_data, owner_id=owner_id) + else: + db_product = await _create_product_tree(db, product_data, owner_id=owner_id, parent_product=parent_product) await db.commit() await db.refresh(db_product) return db_product +async def create_component( + db: AsyncSession, + component: ComponentCreateWithComponents, + parent_product: Product, +) -> Product: + """Add a component to a product.""" + return await _create_and_persist_product_tree( + db, + component, + owner_id=parent_product.owner_id, + parent_product=parent_product, + ) + + +async def create_product( + db: AsyncSession, + product: ProductCreateWithComponents, + owner_id: UUID4 | None, +) -> Product: + """Create a new product in the database.""" + return await _create_and_persist_product_tree(db, product, owner_id=owner_id) + + async def update_product( db: AsyncSession, product_id: int, product: ProductUpdate | ProductUpdateWithProperties ) -> Product: @@ -321,29 +491,31 @@ async def update_product( # product by id on the CRUD layer, to reduce the load on the DB, for all RUD operations in the app # Validate that product exists - db_product = await db_get_model_with_id_if_it_exists(db, Product, product_id) + db_product = await get_model_by_id(db, Product, product_id) # Validate that product type exists if product.product_type_id: - await db_get_model_with_id_if_it_exists(db, ProductType, product.product_type_id) + await get_model_by_id(db, ProductType, product.product_type_id) - product_data: dict[str, Any] = product.model_dump(exclude_unset=True, exclude={"physical_properties"}) + product_data: dict[str, Any] = product.model_dump( + exclude_unset=True, exclude={"physical_properties", "circularity_properties"} + ) db_product.sqlmodel_update(product_data) # Update properties - if isinstance(product, ProductUpdateWithProperties) and product.physical_properties: - await update_physical_properties(db, product_id, product.physical_properties) + if isinstance(product, ProductUpdateWithProperties): + if product.physical_properties: + await update_physical_properties(db, product_id, product.physical_properties) + if product.circularity_properties: + await update_circularity_properties(db, product_id, product.circularity_properties) - db.add(db_product) - await db.commit() - await db.refresh(db_product) - return db_product + return await commit_and_refresh(db, db_product) async def delete_product(db: AsyncSession, product_id: int) -> None: """Delete a product from the database.""" # Validate that product exists - db_product = await db_get_model_with_id_if_it_exists(db, Product, product_id) + db_product = await get_model_by_id(db, Product, product_id) # Delete stored files await product_files_crud.delete_all(db, product_id) @@ -357,19 +529,17 @@ async def delete_product(db: AsyncSession, product_id: int) -> None: product_files_crud = ParentStorageOperations[Product, File, FileCreate, FileFilter]( parent_model=Product, storage_model=File, - parent_type=FileParentType.PRODUCT, + parent_type=MediaParentType.PRODUCT, parent_field="product_id", - create_func=create_file, - delete_func=delete_file, + storage_service=file_storage_service, ) product_images_crud = ParentStorageOperations[Product, Image, ImageCreateFromForm, ImageFilter]( parent_model=Product, storage_model=Image, - parent_type=ImageParentType.PRODUCT, + parent_type=MediaParentType.PRODUCT, parent_field="product_id", - create_func=create_image, - delete_func=delete_image, + storage_service=image_storage_service, ) @@ -378,16 +548,12 @@ async def add_materials_to_product( db: AsyncSession, product_id: int, material_links: list[MaterialProductLinkCreateWithinProduct] ) -> list[MaterialProductLink]: """Add materials to a product.""" - # Validate that product exists - db_product = await db_get_model_with_id_if_it_exists(db, Product, product_id) - - # Validate materials exist material_ids: set[int] = {material_link.material_id for material_link in material_links} - await db_get_models_with_ids_if_they_exist(db, Material, material_ids) + db_product, normalized_material_ids = await _validate_product_material_links(db, product_id, material_ids) # Validate no duplicate materials if db_product.bill_of_materials: - validate_no_duplicate_linked_items(material_ids, db_product.bill_of_materials, "Materials", "material_id") + validate_no_duplicate_linked_items(normalized_material_ids, db_product.bill_of_materials, "Materials") # Create material-product links db_material_product_links: list[MaterialProductLink] = [ @@ -410,8 +576,7 @@ async def add_material_to_product( """Add a material to a product.""" if isinstance(material_link, MaterialProductLinkCreateWithinProductAndMaterial): if material_id is None: - err_msg: str = "Material ID is required for this operation" - raise ValueError(err_msg) + raise MaterialIDRequiredError # Cast to MaterialProductLinkCreateWithinProduct material_link = MaterialProductLinkCreateWithinProduct(material_id=material_id, **material_link.model_dump()) @@ -424,7 +589,7 @@ async def add_material_to_product( f"Database integrity error: Expected 1 material with id {material_link.material_id}," f" got {len(db_material_link_list)}" ) - raise RuntimeError(err_msg) + raise InternalServerError(log_message=err_msg) return db_material_link_list[0] @@ -433,8 +598,7 @@ async def update_material_within_product( db: AsyncSession, product_id: int, material_id: int, material_link: MaterialProductLinkUpdate ) -> MaterialProductLink: """Update material in a product bill of materials.""" - # Validate that product exists - await db_get_model_with_id_if_it_exists(db, Product, product_id) + await _get_product_with_bill_of_materials(db, product_id) # Validate that material exists in the product db_material_link: MaterialProductLink = await get_linking_model_with_ids_if_it_exists( @@ -447,42 +611,19 @@ async def update_material_within_product( ) # Update material link - db_material_link.sqlmodel_update(material_link.model_dump(exclude_unset=True)) - - db.add(db_material_link) - await db.commit() - await db.refresh(db_material_link) - return db_material_link + return await update_and_commit(db, db_material_link, material_link) async def remove_materials_from_product(db: AsyncSession, product_id: int, material_ids: int | set[int]) -> None: """Remove materials from a product.""" - # Convert single material ID to list - if isinstance(material_ids, int): - material_ids = {material_ids} - - # Validate that product exists - product = await db_get_model_with_id_if_it_exists(db, Product, product_id) - - # Validate materials exist - await db_get_models_with_ids_if_they_exist(db, MaterialProductLink, material_ids) + product, normalized_material_ids = await _validate_product_material_links(db, product_id, material_ids) # Validate materials are actually assigned to the product - validate_linked_items_exist(material_ids, product.bill_of_materials, "Materials", "material_id") - - statement: Delete = ( - delete(MaterialProductLink) - .where(col(MaterialProductLink.product_id) == product_id) - .where(col(MaterialProductLink.material_id).in_(material_ids)) - ) - await db.execute(statement) - await db.commit() + validate_linked_items_exist(normalized_material_ids, product.bill_of_materials, "Materials") + # Fetch material-product links to delete + # Delete each material-product link + for material_link in await _get_material_links_for_product(db, product_id, normalized_material_ids): + await db.delete(material_link) -### Ancillary Search CRUD operations ### -async def get_unique_product_brands(db: AsyncSession) -> list[str]: - """Get all unique product brands.""" - statement = select(Product.brand).distinct().order_by(Product.brand).where(Product.brand.is_not(None)) - results = (await db.exec(statement)).all() - unique_brands = sorted({brand.strip().title() for brand in results if brand and brand.strip()}) - return unique_brands + await db.commit() diff --git a/backend/app/api/data_collection/dependencies.py b/backend/app/api/data_collection/dependencies.py index 666577a3..2cc0b6a1 100644 --- a/backend/app/api/data_collection/dependencies.py +++ b/backend/app/api/data_collection/dependencies.py @@ -8,8 +8,7 @@ from app.api.auth.dependencies import CurrentActiveVerifiedUserDep from app.api.auth.exceptions import UserOwnershipError -from app.api.common.crud.utils import db_get_model_with_id_if_it_exists -from app.api.common.models.custom_types import IDT +from app.api.common.crud.utils import get_model_or_404 from app.api.common.routers.dependencies import AsyncSessionDep from app.api.data_collection.filters import MaterialProductLinkFilter, ProductFilterWithRelationships from app.api.data_collection.models import Product @@ -27,7 +26,7 @@ async def get_product_by_id( session: AsyncSessionDep, ) -> Product: """Verify that a product with a given ID exists.""" - return await db_get_model_with_id_if_it_exists(session, Product, product_id) + return await get_model_or_404(session, Product, product_id) ProductByIDDep = Annotated[Product, Depends(get_product_by_id)] @@ -38,14 +37,14 @@ async def get_user_owned_product( current_user: CurrentActiveVerifiedUserDep, ) -> Product: """Verify that the current user owns the specified product.""" - if product.owner_id == current_user.id: + if product.owner_id == current_user.db_id or current_user.is_superuser: return product - raise UserOwnershipError(model_type=Product, model_id=product.id, user_id=current_user.id) from None + raise UserOwnershipError(model_type=Product, model_id=product.db_id, user_id=current_user.db_id) from None UserOwnedProductDep = Annotated[Product, Depends(get_user_owned_product)] -async def get_user_owned_product_id(user_owned_product: UserOwnedProductDep) -> IDT | None: +async def get_user_owned_product_id(user_owned_product: UserOwnedProductDep) -> int | None: """Get the ID of a user owned product.""" return user_owned_product.id diff --git a/backend/app/api/data_collection/exceptions.py b/backend/app/api/data_collection/exceptions.py new file mode 100644 index 00000000..a15f6136 --- /dev/null +++ b/backend/app/api/data_collection/exceptions.py @@ -0,0 +1,42 @@ +"""Custom exceptions for data collection CRUD and router flows.""" + +from app.api.common.exceptions import BadRequestError, ConflictError, NotFoundError + + +class ProductPropertyNotFoundError(NotFoundError): + """Raised when a product is missing a requested one-to-one property object.""" + + def __init__(self, property_label: str, product_id: int | None) -> None: + super().__init__(f"{property_label} for product with id {product_id} not found") + + +class ProductPropertyAlreadyExistsError(ConflictError): + """Raised when attempting to create a one-to-one property that already exists.""" + + def __init__(self, product_id: int, property_label: str) -> None: + super().__init__(f"Product with id {product_id} already has {property_label}") + + +class InvalidProductTreeError(BadRequestError): + """Raised when a product/component tree payload is structurally invalid.""" + + +class ProductTreeMissingContentError(InvalidProductTreeError): + """Raised when a product tree has neither materials nor components.""" + + def __init__(self) -> None: + super().__init__("Product needs materials or components") + + +class ProductOwnerRequiredError(InvalidProductTreeError): + """Raised when product tree creation is attempted without an owner.""" + + def __init__(self) -> None: + super().__init__("Product owner_id must be set before creating a product or component.") + + +class MaterialIDRequiredError(BadRequestError): + """Raised when a nested material operation requires an explicit material id.""" + + def __init__(self) -> None: + super().__init__("Material ID is required for this operation") diff --git a/backend/app/api/data_collection/filters.py b/backend/app/api/data_collection/filters.py index c01d8011..91c8dcbe 100644 --- a/backend/app/api/data_collection/filters.py +++ b/backend/app/api/data_collection/filters.py @@ -1,13 +1,34 @@ """FastAPI-Filter classes for filtering database queries.""" -from datetime import datetime +from __future__ import annotations + +from datetime import datetime # noqa: TC003 # Runtime import is required for FastAPI-Filter field definitions +from typing import TYPE_CHECKING, Any, Literal, Protocol, cast from fastapi_filter import FilterDepends, with_prefix from fastapi_filter.contrib.sqlalchemy import Filter +from sqlalchemy import ColumnElement, desc, func, or_ +from sqlmodel import select from app.api.background_data.filters import MaterialFilter, ProductTypeFilter -from app.api.common.models.associations import MaterialProductLink -from app.api.data_collection.models import PhysicalProperties, Product +from app.api.data_collection.models import MaterialProductLink, PhysicalProperties, Product + +if TYPE_CHECKING: + from collections.abc import Callable + + from sqlalchemy import Select + + +class SearchableColumn(Protocol): + """Minimal column interface needed for trigram search clauses.""" + + def op(self, opstring: str) -> Callable[[object], ColumnElement[bool]]: + """Build a custom SQL operator expression.""" + ... + + def is_not(self, other: object) -> ColumnElement[bool]: + """Build an IS NOT comparison expression.""" + ... ### Association Model Filters ### @@ -31,8 +52,8 @@ class Constants(Filter.Constants): class PhysicalPropertiesFilter(Filter): """FastAPI-filter class for Physical Properties filtering.""" - weight_kg__gte: float | None = None - weight_kg__lte: float | None = None + weight_g__gte: float | None = None + weight_g__lte: float | None = None height_cm__gte: float | None = None height_cm__lte: float | None = None width_cm__gte: float | None = None @@ -46,31 +67,96 @@ class Constants(Filter.Constants): model = PhysicalProperties -## Product Filters ## +### TS Vector Search Filter for Product ### +def build_product_search_clause( + search: str, + brand_field: SearchableColumn, + search_vector_col: ColumnElement[Any], + name_field: SearchableColumn | None = None, +) -> ColumnElement[bool]: + """Reusable WHERE clause for searching products (tsvector + trigram on brand, optionally name).""" + ts_query = func.websearch_to_tsquery("english", search) + conditions = [search_vector_col.op("@@")(ts_query), brand_field.op("%")(search)] + if name_field is not None: + conditions.append(name_field.op("%")(search)) + return or_(*conditions) + + +def build_brand_search_clause(search: str) -> ColumnElement[bool]: + """Reusable WHERE clause for searching brands (tsvector + trigram).""" + return build_product_search_clause( + search, + cast("SearchableColumn", Product.brand), + cast("ColumnElement[Any]", Product.search_vector), + ) + + +# Constants for ordering +ORDER_DESC: Literal["desc"] = "desc" + + +def get_brand_search_statement(search: str | None = None, order: Literal["asc", "desc"] = "asc") -> Select: + """Return a SQLModel select statement for normalized, distinct brands with optional search and order.""" + brand_expr = func.trim(func.lower(Product.brand)).label("brand_norm") + statement = select(brand_expr).where(cast("SearchableColumn", Product.brand).is_not(None)) + if search: + statement = statement.where(build_brand_search_clause(search.strip())) + return statement.distinct().order_by(desc(brand_expr) if order == ORDER_DESC else brand_expr) + + +### Product Filters ### class ProductFilter(Filter): """FastAPI-filter class for Product.""" name__ilike: str | None = None description__ilike: str | None = None brand__ilike: str | None = None + brand__in: list[str] | None = None model__ilike: str | None = None dismantling_time_start__gte: datetime | None = None dismantling_time_start__lte: datetime | None = None dismantling_time_end__gte: datetime | None = None dismantling_time_end__lte: datetime | None = None + created_at__gte: datetime | None = None + created_at__lte: datetime | None = None + updated_at__gte: datetime | None = None + updated_at__lte: datetime | None = None search: str | None = None + order_by: list[str] | None = None + class Constants(Filter.Constants): """FilterAPI class configuration.""" model = Product - search_model_fields: list[str] = [ # noqa: RUF012 # Standard FastAPI-filter class override - "name", - "description", - "brand", - "model", - ] + # search_model_fields intentionally omitted β€” search is handled by the + # overridden filter() method below using tsvector + trigram indexes. + + def filter(self, query: Any) -> Any: # noqa: ANN401 # Any-typed query is expected by the parent method signature + """Apply filters, replacing the default ILIKE search with tsvector + trigram search.""" + # Temporarily clear search before delegating to super() β€” fastapi-filter would otherwise + # try getattr(Product, 'search') and raise AttributeError since we removed search_model_fields. + search = self.search + object.__setattr__(self, "search", None) + query = super().filter(query) + object.__setattr__(self, "search", search) + + if self.search: + clause = build_product_search_clause( + self.search, + cast("SearchableColumn", Product.brand), + cast("ColumnElement[Any]", Product.search_vector), + name_field=cast("SearchableColumn", Product.name), + ) + query = query.where(clause).order_by( + func.ts_rank( + cast("SearchableColumn", Product.search_vector), + func.websearch_to_tsquery("english", self.search), + ).desc() + ) + + return query class ProductFilterWithRelationships(ProductFilter): diff --git a/backend/app/api/data_collection/models.py b/backend/app/api/data_collection/models.py index 9afe6fa3..869fb757 100644 --- a/backend/app/api/data_collection/models.py +++ b/backend/app/api/data_collection/models.py @@ -1,101 +1,87 @@ """Database models for data collection on products.""" +# spell-checker: ignore trgm -import logging -from datetime import UTC, datetime from functools import cached_property -from typing import TYPE_CHECKING, Optional, Self +from typing import ( # Needed for runtime ORM mapping, not just for type annotations + TYPE_CHECKING, + Optional, + Self, +) from pydantic import UUID4, ConfigDict, computed_field, model_validator -from sqlalchemy import TIMESTAMP +from sqlalchemy import Computed, Index, asc +from sqlalchemy.dialects.postgresql import TSVECTOR from sqlalchemy.ext.asyncio import AsyncSession -from sqlmodel import Column, Field, Relationship +from sqlalchemy.orm import MappedSQLExpression, column_property +from sqlmodel import Column, Field, Relationship, col, select -from app.api.common.models.associations import MaterialProductLink -from app.api.common.models.base import CustomBase, TimeStampMixinBare - -if TYPE_CHECKING: - from app.api.auth.models import User - from app.api.background_data.models import ProductType - from app.api.file_storage.models.models import File, Image, Video - - -# Initialize logger -logger = logging.getLogger(__name__) - - -### Validation Utilities ### -def validate_start_and_end_time(start_time: datetime, end_time: datetime | None) -> None: - """Validate that end time is after start time if both are set.""" - if start_time and end_time and end_time < start_time: - err_msg: str = f"End time {end_time:%Y-%m-%d %H:%M} must be after start time {start_time:%Y-%m-%d %H:%M}" - raise ValueError(err_msg) +from app.api.auth.models import User +from app.api.background_data.models import Material, ProductType +from app.api.common.models.associations import MaterialProductLinkBase +from app.api.common.models.base import IntPrimaryKeyMixin, TimeStampMixinBare +from app.api.data_collection.base import ( + CircularityPropertiesBase, + PhysicalPropertiesBase, + ProductBase, +) +from app.api.file_storage.models.models import File, Image, MediaParentType, Video ### Properties Models ### -class PhysicalPropertiesBase(CustomBase): - """Base model to store physical properties of a product.""" +class PhysicalProperties(PhysicalPropertiesBase, IntPrimaryKeyMixin, TimeStampMixinBare, table=True): + """Model to store physical properties of a product.""" - weight_kg: float | None = Field(default=None, gt=0) - height_cm: float | None = Field(default=None, gt=0) - width_cm: float | None = Field(default=None, gt=0) - depth_cm: float | None = Field(default=None, gt=0) + id: int | None = Field(default=None, primary_key=True) - # Computed properties - @computed_field - @cached_property - def volume_cm3(self) -> float | None: - """Calculate the volume of the product.""" - if self.height_cm is None or self.width_cm is None or self.depth_cm is None: - logger.warning("All dimensions must be set to calculate the volume.") - return None - return self.height_cm * self.width_cm * self.depth_cm + # One-to-one relationships + product_id: int = Field(foreign_key="product.id") + product: Product = Relationship(back_populates="physical_properties") -class PhysicalProperties(PhysicalPropertiesBase, TimeStampMixinBare, table=True): - """Model to store physical properties of a product.""" +class CircularityProperties(CircularityPropertiesBase, IntPrimaryKeyMixin, TimeStampMixinBare, table=True): + """Model to store circularity properties of a product.""" id: int | None = Field(default=None, primary_key=True) # One-to-one relationships product_id: int = Field(foreign_key="product.id") - product: "Product" = Relationship(back_populates="physical_properties") + product: Product = Relationship(back_populates="circularity_properties") ### Product Model ### -class ProductBase(CustomBase): - """Basic model to store product information.""" - - name: str = Field(index=True, min_length=2, max_length=100) - description: str | None = Field(default=None, max_length=500) - brand: str | None = Field(default=None, max_length=100) - model: str | None = Field(default=None, max_length=100) - # Dismantling information - dismantling_notes: str | None = Field( - default=None, max_length=500, description="Notes on the dismantling process of the product." - ) - dismantling_time_start: datetime = Field( - sa_column=Column(TIMESTAMP(timezone=True), nullable=False), default_factory=lambda: datetime.now(UTC) - ) - dismantling_time_end: datetime | None = Field(default=None, sa_column=Column(TIMESTAMP(timezone=True))) +class Product(ProductBase, IntPrimaryKeyMixin, TimeStampMixinBare, table=True): + """Database model for product information.""" - # Time validation - @model_validator(mode="after") - def validate_times(self) -> Self: - """Ensure end time is after start time if both are set.""" - validate_start_and_end_time(self.dismantling_time_start, self.dismantling_time_end) - return self + id: int | None = Field(default=None, primary_key=True) + __table_args__ = ( + Index("product_search_vector_idx", "search_vector", postgresql_using="gin"), + Index("product_name_trgm_idx", "name", postgresql_using="gin", postgresql_ops={"name": "gin_trgm_ops"}), + Index("product_brand_trgm_idx", "brand", postgresql_using="gin", postgresql_ops={"brand": "gin_trgm_ops"}), + ) -class Product(ProductBase, TimeStampMixinBare, table=True): - """Database model for product information.""" + search_vector: str | None = Field( + default=None, + exclude=True, + sa_column=Column( + TSVECTOR(), + Computed( + "to_tsvector('english', coalesce(name, '') || ' ' || coalesce(description, '') || ' ' || " + "coalesce(brand, '') || ' ' || coalesce(model, ''))", + persisted=True, + ), + ), + ) - id: int | None = Field(default=None, primary_key=True) + if TYPE_CHECKING: + # Populated at runtime via `column_property` below. + first_image_id: MappedSQLExpression[UUID4 | None] # Self-referential relationship for hierarchy parent_id: int | None = Field(default=None, foreign_key="product.id") - parent: Optional["Product"] = Relationship( + parent: Optional["Product"] = Relationship( # noqa: UP037, UP045 # `Optional` and quotes needed for proper sqlalchemy mapping back_populates="components", sa_relationship_kwargs={ "uselist": False, @@ -105,7 +91,7 @@ class Product(ProductBase, TimeStampMixinBare, table=True): }, ) amount_in_parent: int | None = Field(default=None, description="Quantity within parent product") - components: list["Product"] | None = Relationship( + components: list[Product] | None = Relationship( back_populates="parent", cascade_delete=True, sa_relationship_kwargs={"lazy": "selectin", "join_depth": 1}, # Eagerly load linked parent product @@ -115,29 +101,40 @@ class Product(ProductBase, TimeStampMixinBare, table=True): physical_properties: PhysicalProperties | None = Relationship( back_populates="product", cascade_delete=True, sa_relationship_kwargs={"uselist": False, "lazy": "selectin"} ) + circularity_properties: CircularityProperties | None = Relationship( + back_populates="product", cascade_delete=True, sa_relationship_kwargs={"uselist": False, "lazy": "selectin"} + ) # Many-to-one relationships - files: list["File"] | None = Relationship(back_populates="product", cascade_delete=True) - images: list["Image"] | None = Relationship( - back_populates="product", cascade_delete=True, sa_relationship_kwargs={"lazy": "subquery"} - ) - videos: list["Video"] | None = Relationship(back_populates="product", cascade_delete=True) + files: list[File] | None = Relationship(cascade_delete=True) + images: list[Image] | None = Relationship(cascade_delete=True, sa_relationship_kwargs={"lazy": "subquery"}) + videos: list[Video] | None = Relationship(cascade_delete=True) # One-to-many relationships owner_id: UUID4 = Field(foreign_key="user.id") - owner: "User" = Relationship( - back_populates="products", sa_relationship_kwargs={"uselist": False, "lazy": "selectin"} + owner: User = Relationship( + sa_relationship_kwargs={ + "uselist": False, + "lazy": "selectin", + "foreign_keys": "[Product.owner_id]", + }, ) product_type_id: int | None = Field(default=None, foreign_key="producttype.id") - product_type: "ProductType" = Relationship(back_populates="products", sa_relationship_kwargs={"uselist": False}) + product_type: ProductType = Relationship(sa_relationship_kwargs={"uselist": False}) # Many-to-many relationships - bill_of_materials: list[MaterialProductLink] | None = Relationship( + bill_of_materials: list["MaterialProductLink"] | None = Relationship( # noqa: UP037 # forward ref β€” class defined below back_populates="product", sa_relationship_kwargs={"lazy": "selectin"}, cascade_delete=True ) - # Helper methods + @property + def thumbnail_url(self) -> str | None: + """Return thumbnail URL from the first image.""" + if first_image_id := self.first_image_id: + return f"/images/{first_image_id}/resized?width=200" + return None + @computed_field @cached_property def is_leaf_node(self) -> bool: @@ -150,13 +147,12 @@ def is_base_product(self) -> bool: """Check if the product is a base product (no parent).""" return self.parent_id is None - # TODO: move this validation to the CRUD and schema layers - + # TODO: move this validation to the CRUD and schema layers. def has_cycles(self) -> bool: """Check if the product hierarchy contains cycles.""" visited = set() - def visit(node: "Product") -> bool: + def visit(node: Product) -> bool: if node.id in visited: return True # Cycle detected visited.add(node.id) @@ -172,7 +168,7 @@ def visit(node: "Product") -> bool: def components_resolve_to_materials(self) -> bool: """Ensure all leaf components have a non-empty bill of materials.""" - def check(node: "Product") -> bool: + def check(node: Product) -> bool: if not node.components: # Leaf node if not node.bill_of_materials: @@ -187,6 +183,7 @@ def check(node: "Product") -> bool: @model_validator(mode="after") def validate_product(self) -> Self: + """Validate the product hierarchy and bill of materials constraints.""" components: list[Product] | None = self.components bill_of_materials: list[MaterialProductLink] | None = self.bill_of_materials amount_in_parent: int | None = self.amount_in_parent @@ -261,7 +258,46 @@ async def traverse(product: Product, quantity_multiplier: float) -> None: await traverse(self, 1.0) return total_materials - model_config: ConfigDict = ConfigDict(arbitrary_types_allowed=True) # pyright: ignore [reportIncompatibleVariableOverride] # This is not a type override, see https://github.com/fastapi/sqlmodel/discussions/855 + model_config: ConfigDict = ConfigDict(arbitrary_types_allowed=True) + + @property + def owner_username(self) -> str | None: + """Return the owner's username. Always available since owner is selectin-loaded.""" + return self.owner.username if self.owner else None def __str__(self): return f"{self.name} (id: {self.id})" + + +Product.first_image_id = column_property( + select(Image.id) + .where(Image.parent_type == MediaParentType.PRODUCT) + .where(Image.product_id == Product.id) + .correlate_except(Image) + .order_by(asc(col(Image.created_at))) + .limit(1) + .scalar_subquery() +) + + +### MaterialProductLink β€” lives here so Product and Material are both in scope ### +class MaterialProductLink(MaterialProductLinkBase, TimeStampMixinBare, table=True): + """Association table to link Material with Product.""" + + material_id: int = Field( + foreign_key="material.id", primary_key=True, description="ID of the material in the product" + ) + product_id: int = Field( + foreign_key="product.id", primary_key=True, description="ID of the product with the material" + ) + + material: Material = Relationship(sa_relationship_kwargs={"lazy": "selectin"}) + product: Product = Relationship(back_populates="bill_of_materials", sa_relationship_kwargs={"lazy": "selectin"}) + + @property + def db_id(self) -> int: + """Alias for material_id to satisfy HasDBID protocol.""" + return self.material_id + + def __str__(self) -> str: + return f"{self.quantity} {self.unit} of {self.material.name} in {self.product.name}" diff --git a/backend/app/api/data_collection/product_mutation_routers.py b/backend/app/api/data_collection/product_mutation_routers.py new file mode 100644 index 00000000..54eb16f5 --- /dev/null +++ b/backend/app/api/data_collection/product_mutation_routers.py @@ -0,0 +1,229 @@ +"""Mutation-focused routers for product endpoints.""" + +from __future__ import annotations + +from typing import Annotated + +from fastapi import Body +from pydantic import PositiveInt + +from app.api.auth.dependencies import CurrentActiveVerifiedUserDep +from app.api.common.crud.base import get_nested_model_by_id +from app.api.common.routers.dependencies import AsyncSessionDep +from app.api.common.routers.openapi import PublicAPIRouter +from app.api.common.schemas.base import ProductRead +from app.api.data_collection import crud +from app.api.data_collection.dependencies import UserOwnedProductDep, get_user_owned_product_id +from app.api.data_collection.models import Product +from app.api.data_collection.schemas import ( + ComponentCreateWithComponents, + ComponentReadWithRecursiveComponents, + ProductCreateWithComponents, + ProductReadWithProperties, + ProductUpdate, + ProductUpdateWithProperties, +) +from app.api.file_storage.router_factories import StorageRouteMethod, add_storage_routes + +product_mutation_router = PublicAPIRouter(prefix="/products", tags=["products"]) + + +@product_mutation_router.post( + "", + response_model=ProductRead, + summary="Create a new product, optionally with components", + status_code=201, +) +async def create_product( + product: Annotated[ + ProductCreateWithComponents, + Body( + description="Product to create", + openapi_examples={ + "basic": { + "summary": "Basic product without components", + "value": { + "name": "Office Chair", + "description": "Complete chair assembly", + "brand": "Brand 1", + "model": "Model 1", + "dismantling_time_start": "2025-09-22T14:30:45Z", + "dismantling_time_end": "2025-09-22T16:30:45Z", + "product_type_id": 1, + "physical_properties": { + "weight_g": 2000, + "height_cm": 150, + "width_cm": 70, + "depth_cm": 50, + }, + "videos": [ + {"url": "https://www.youtube.com/watch?v=123456789", "description": "Disassembly video"} + ], + "bill_of_materials": [ + {"quantity": 15, "unit": "g", "material_id": 1}, + {"quantity": 5, "unit": "g", "material_id": 2}, + ], + }, + }, + "with_components": { + "summary": "Product with components", + "value": { + "name": "Office Chair", + "description": "Complete chair assembly", + "brand": "Brand 1", + "model": "Model 1", + "dismantling_time_start": "2025-09-22T14:30:45Z", + "dismantling_time_end": "2025-09-22T16:30:45Z", + "product_type_id": 1, + "physical_properties": { + "weight_g": 20000, + "height_cm": 150, + "width_cm": 70, + "depth_cm": 50, + }, + "videos": [ + {"url": "https://www.youtube.com/watch?v=123456789", "description": "Disassembly video"} + ], + "o": 1, + "components": [ + { + "name": "Office Chair Seat", + "description": "Seat assembly", + "brand": "Brand 2", + "model": "Model 2", + "dismantling_time_start": "2025-09-22T14:30:45Z", + "dismantling_time_end": "2025-09-22T16:30:45Z", + "amount_in_parent": 1, + "product_type_id": 2, + "physical_properties": { + "weight_g": 5000, + "height_cm": 50, + "width_cm": 40, + "depth_cm": 30, + }, + "components": [ + { + "name": "Seat Cushion", + "description": "Seat cushion assembly", + "amount_in_parent": 1, + "physical_properties": { + "weight_g": 2000, + "height_cm": 10, + "width_cm": 40, + "depth_cm": 30, + }, + "product_type_id": 3, + "bill_of_materials": [ + {"quantity": 1.5, "unit": "g", "material_id": 1}, + {"quantity": 0.5, "unit": "g", "material_id": 2}, + ], + } + ], + } + ], + }, + }, + }, + ), + ], + current_user: CurrentActiveVerifiedUserDep, + session: AsyncSessionDep, +) -> Product: + """Create a new product.""" + return await crud.create_product(session, product, current_user.db_id) + + +@product_mutation_router.patch("/{product_id}", response_model=ProductReadWithProperties, summary="Update product") +async def update_product( + product_update: ProductUpdate | ProductUpdateWithProperties, + db_product: UserOwnedProductDep, + session: AsyncSessionDep, +) -> Product: + """Update an existing product.""" + return await crud.update_product(session, db_product.db_id, product_update) + + +@product_mutation_router.delete( + "/{product_id}", + status_code=204, + summary="Delete product", +) +async def delete_product(db_product: UserOwnedProductDep, session: AsyncSessionDep) -> None: + """Delete a product, including components.""" + await crud.delete_product(session, db_product.db_id) + + +@product_mutation_router.post( + "/{product_id}/components", + response_model=ComponentReadWithRecursiveComponents, + status_code=201, + summary="Create a new component in a product", +) +async def add_component_to_product( + db_product: UserOwnedProductDep, + component: Annotated[ + ComponentCreateWithComponents, + Body( + openapi_examples={ + "simple": { + "summary": "Basic component", + "description": "Create a component without subcomponents", + "value": { + "name": "Seat Assembly", + "description": "Chair seat component", + "amount_in_parent": 1, + "bill_of_materials": [{"material_id": 1, "quantity": 0.5, "unit": "g"}], + }, + }, + "nested": { + "summary": "Component with subcomponents", + "description": "Create a component with nested subcomponents", + "value": { + "name": "Seat Assembly", + "description": "Chair seat with cushion", + "amount_in_parent": 1, + "components": [ + { + "name": "Cushion", + "description": "Foam cushion", + "amount_in_parent": 1, + "bill_of_materials": [{"material_id": 2, "quantity": 0.3, "unit": "g"}], + } + ], + }, + }, + } + ), + ], + session: AsyncSessionDep, +) -> Product: + """Create a new component in an existing product.""" + return await crud.create_component( + db=session, + component=component, + parent_product=db_product, + ) + + +@product_mutation_router.delete( + "/{product_id}/components/{component_id}", + status_code=204, + summary="Delete product component", +) +async def delete_product_component( + db_product: UserOwnedProductDep, component_id: PositiveInt, session: AsyncSessionDep +) -> None: + """Delete a component in a product, including subcomponents.""" + await get_nested_model_by_id(session, Product, db_product.db_id, Product, component_id, "parent_id") + await crud.delete_product(session, component_id) + + +add_storage_routes( + router=product_mutation_router, + parent_api_model_name=Product.get_api_model_name(), + files_crud=crud.product_files_crud, + images_crud=crud.product_images_crud, + include_methods={StorageRouteMethod.GET, StorageRouteMethod.POST, StorageRouteMethod.DELETE}, + read_parent_auth_dep=None, + modify_parent_auth_dep=get_user_owned_product_id, +) diff --git a/backend/app/api/data_collection/product_read_routers.py b/backend/app/api/data_collection/product_read_routers.py new file mode 100644 index 00000000..e8cbf8ab --- /dev/null +++ b/backend/app/api/data_collection/product_read_routers.py @@ -0,0 +1,258 @@ +"""Read-focused routers for product and component endpoints.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Annotated + +from fastapi import HTTPException, Request +from fastapi.responses import RedirectResponse +from fastapi_pagination.links import Page +from pydantic import UUID4, PositiveInt +from sqlmodel import col, select + +from app.api.auth.dependencies import CurrentActiveUserDep +from app.api.background_data.routers.public import RecursionDepthQueryParam +from app.api.common.routers.dependencies import AsyncSessionDep +from app.api.common.routers.openapi import PublicAPIRouter +from app.api.common.routers.read_helpers import ( + get_model_response, + get_nested_model_response, + list_models_response, + list_models_sequence_response, +) +from app.api.data_collection import crud +from app.api.data_collection.dependencies import ProductFilterWithRelationshipsDep +from app.api.data_collection.models import Product +from app.api.data_collection.router_helpers import ( + include_components_as_base_products_query, + product_include_query, +) +from app.api.data_collection.schemas import ( + ComponentReadWithRecursiveComponents, + ProductReadWithRecursiveComponents, + ProductReadWithRelationshipsAndFlatComponents, +) + +if TYPE_CHECKING: + from collections.abc import Sequence + + from sqlmodel.sql._expression_select_cls import SelectOfScalar + +user_product_redirect_router = PublicAPIRouter(prefix="/users/me/products", tags=["products"]) +user_product_router = PublicAPIRouter(prefix="/users/{user_id}/products", tags=["products"]) +product_read_router = PublicAPIRouter(prefix="/products", tags=["products"]) + + +def convert_components_to_read_model( + components: list[Product], max_depth: int = 1, current_depth: int = 0 +) -> list[ComponentReadWithRecursiveComponents]: + """Convert component ORM rows to the recursive read schema.""" + if current_depth >= max_depth: + return [] + + return [ + ComponentReadWithRecursiveComponents.model_validate( + component, + update={ + "components": convert_components_to_read_model(component.components or [], max_depth, current_depth + 1) + }, + ) + for component in components + ] + + +@user_product_redirect_router.get( + "", + response_class=RedirectResponse, + status_code=307, + summary="Redirect to user's products", +) +async def redirect_to_current_user_products( + current_user: CurrentActiveUserDep, + request: Request, +) -> RedirectResponse: + """Redirect /users/me/products to /users/{id}/products for better caching.""" + query_string = str(request.url.query) + redirect_url = f"/users/{current_user.id}/products" + if query_string: + redirect_url += f"?{query_string}" + return RedirectResponse(url=redirect_url, status_code=307) + + +@user_product_router.get( + "", + response_model=Page[ProductReadWithRelationshipsAndFlatComponents], + summary="Get products collected by a user", +) +async def get_user_products( + user_id: UUID4, + session: AsyncSessionDep, + current_user: CurrentActiveUserDep, + product_filter: ProductFilterWithRelationshipsDep, + include: Annotated[set[str] | None, product_include_query()] = None, + *, + include_components_as_base_products: Annotated[bool | None, include_components_as_base_products_query()] = None, +) -> Page[Product]: + """Get products collected by a specific user.""" + if user_id != current_user.db_id and not current_user.is_superuser: + raise HTTPException(status_code=403, detail="Not authorized to view this user's products") + + statement = select(Product).where(Product.owner_id == user_id) + if not include_components_as_base_products: + statement = statement.where(col(Product.parent_id).is_(None)) + + return await list_models_response( + session, + Product, + include_relationships=include, + model_filter=product_filter, + statement=statement, + read_schema=ProductReadWithRelationshipsAndFlatComponents, + ) + + +@product_read_router.get( + "", + response_model=Page[ProductReadWithRelationshipsAndFlatComponents], + summary="Get all products with optional relationships", +) +async def get_products( + session: AsyncSessionDep, + product_filter: ProductFilterWithRelationshipsDep, + include: Annotated[set[str] | None, product_include_query()] = None, + *, + include_components_as_base_products: Annotated[bool | None, include_components_as_base_products_query()] = None, +) -> Page[Product]: + """Get all products with specified relationships.""" + if include_components_as_base_products: + statement: SelectOfScalar[Product] = select(Product) + else: + statement = select(Product).where(col(Product.parent_id).is_(None)) + + return await list_models_response( + session, + Product, + include_relationships=include, + model_filter=product_filter, + statement=statement, + read_schema=ProductReadWithRelationshipsAndFlatComponents, + ) + + +@product_read_router.get( + "/tree", + response_model=list[ProductReadWithRecursiveComponents], + summary="Get products tree", +) +async def get_products_tree( + session: AsyncSessionDep, + product_filter: ProductFilterWithRelationshipsDep, + recursion_depth: RecursionDepthQueryParam = 1, +) -> list[ProductReadWithRecursiveComponents]: + """Get all base products and their components in a tree structure.""" + products: Sequence[Product] = await crud.get_product_trees( + session, recursion_depth=recursion_depth, product_filter=product_filter + ) + return [ + ProductReadWithRecursiveComponents.model_validate( + product, + update={ + "components": convert_components_to_read_model(product.components or [], max_depth=recursion_depth - 1) + }, + ) + for product in products + ] + + +@product_read_router.get( + "/{product_id}", + response_model=ProductReadWithRelationshipsAndFlatComponents, + summary="Get product by ID", +) +async def get_product( + session: AsyncSessionDep, + product_id: PositiveInt, + include: Annotated[set[str] | None, product_include_query()] = None, +) -> Product: + """Get product by ID with specified relationships.""" + return await get_model_response( + session, + Product, + product_id, + include_relationships=include, + read_schema=ProductReadWithRelationshipsAndFlatComponents, + ) + + +@product_read_router.get( + "/{product_id}/components/tree", + summary="Get product component subtree", + response_model=list[ComponentReadWithRecursiveComponents], +) +async def get_product_subtree( + session: AsyncSessionDep, + product_id: PositiveInt, + product_filter: ProductFilterWithRelationshipsDep, + recursion_depth: RecursionDepthQueryParam = 1, +) -> list[ComponentReadWithRecursiveComponents]: + """Get a product's components in a tree structure, up to a specified depth.""" + products: Sequence[Product] = await crud.get_product_trees( + session, recursion_depth=recursion_depth, parent_id=product_id, product_filter=product_filter + ) + return [ + ComponentReadWithRecursiveComponents.model_validate( + product, + update={ + "components": convert_components_to_read_model(product.components or [], max_depth=recursion_depth - 1) + }, + ) + for product in products + ] + + +@product_read_router.get( + "/{product_id}/components", + response_model=list[ProductReadWithRelationshipsAndFlatComponents], + summary="Get product components", +) +async def get_product_components( + session: AsyncSessionDep, + product_id: PositiveInt, + product_filter: ProductFilterWithRelationshipsDep, + include: Annotated[set[str] | None, product_include_query()] = None, +) -> Sequence[Product]: + """Get all components of a product.""" + await get_model_response(session, Product, product_id) + return await list_models_sequence_response( + session, + Product, + include_relationships=include, + model_filter=product_filter, + statement=select(Product).where(Product.parent_id == product_id), + read_schema=ProductReadWithRelationshipsAndFlatComponents, + ) + + +@product_read_router.get( + "/{product_id}/components/{component_id}", + response_model=ProductReadWithRelationshipsAndFlatComponents, + summary="Get product component by ID", +) +async def get_product_component( + product_id: PositiveInt, + component_id: PositiveInt, + *, + include: Annotated[set[str] | None, product_include_query()] = None, + session: AsyncSessionDep, +) -> Product: + """Get component by ID with specified relationships.""" + return await get_nested_model_response( + session, + Product, + product_id, + Product, + component_id, + "parent_id", + include_relationships=include, + read_schema=ProductReadWithRelationshipsAndFlatComponents, + ) diff --git a/backend/app/api/data_collection/product_related_routers.py b/backend/app/api/data_collection/product_related_routers.py new file mode 100644 index 00000000..fa9f93d3 --- /dev/null +++ b/backend/app/api/data_collection/product_related_routers.py @@ -0,0 +1,299 @@ +"""Routers for product-related resources like properties, videos, and materials.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Annotated + +from fastapi import Body, Path +from fastapi_filter import FilterDepends +from pydantic import PositiveInt +from sqlmodel import select + +from app.api.background_data.models import Material +from app.api.common.crud.associations import get_linking_model_with_ids_if_it_exists +from app.api.common.crud.base import get_models, get_nested_model_by_id +from app.api.common.crud.utils import get_model_or_404 +from app.api.common.routers.dependencies import AsyncSessionDep +from app.api.common.routers.openapi import PublicAPIRouter +from app.api.common.schemas.associations import ( + MaterialProductLinkCreateWithinProduct, + MaterialProductLinkCreateWithinProductAndMaterial, + MaterialProductLinkReadWithinProduct, + MaterialProductLinkUpdate, +) +from app.api.data_collection import crud +from app.api.data_collection.dependencies import MaterialProductLinkFilterDep, ProductByIDDep, UserOwnedProductDep +from app.api.data_collection.models import ( + MaterialProductLink, + Product, +) +from app.api.data_collection.router_helpers import add_product_property_routes +from app.api.data_collection.schemas import ( + CircularityPropertiesCreate, + CircularityPropertiesRead, + CircularityPropertiesUpdate, + PhysicalPropertiesCreate, + PhysicalPropertiesRead, + PhysicalPropertiesUpdate, +) +from app.api.file_storage.filters import VideoFilter +from app.api.file_storage.models.models import Video +from app.api.file_storage.schemas import VideoCreateWithinProduct, VideoReadWithinProduct, VideoUpdateWithinProduct +from app.api.file_storage.video_crud import create_video, delete_video, update_video + +if TYPE_CHECKING: + from collections.abc import Sequence + + from sqlmodel.sql._expression_select_cls import SelectOfScalar + +product_related_router = PublicAPIRouter(prefix="/products", tags=["products"]) + +add_product_property_routes( + product_related_router, + path_segment="physical_properties", + resource_label="physical properties", + read_model=PhysicalPropertiesRead, + create_model=PhysicalPropertiesCreate, + update_model=PhysicalPropertiesUpdate, + get_handler=crud.get_physical_properties, + create_handler=crud.create_physical_properties, + update_handler=crud.update_physical_properties, + delete_handler=crud.delete_physical_properties, +) + +add_product_property_routes( + product_related_router, + path_segment="circularity_properties", + resource_label="circularity properties", + read_model=CircularityPropertiesRead, + create_model=CircularityPropertiesCreate, + update_model=CircularityPropertiesUpdate, + get_handler=crud.get_circularity_properties, + create_handler=crud.create_circularity_properties, + update_handler=crud.update_circularity_properties, + delete_handler=crud.delete_circularity_properties, +) + + +@product_related_router.get( + "/{product_id}/videos", + response_model=list[VideoReadWithinProduct], + summary="Get all videos for a product", +) +async def get_product_videos( + session: AsyncSessionDep, + product: ProductByIDDep, + video_filter: VideoFilter = FilterDepends(VideoFilter), +) -> Sequence[Video]: + """Get all videos associated with a specific product.""" + statement: SelectOfScalar[Video] = select(Video).where(Video.product_id == product.db_id) + return await get_models( + session, + Video, + model_filter=video_filter, + statement=statement, + ) + + +@product_related_router.get( + "/{product_id}/videos/{video_id}", + response_model=VideoReadWithinProduct, + summary="Get video by ID", +) +async def get_product_video( + product_id: PositiveInt, + video_id: PositiveInt, + session: AsyncSessionDep, +) -> Video: + """Get a video associated with a specific product.""" + return await get_nested_model_by_id(session, Product, product_id, Video, video_id, "product_id") + + +@product_related_router.post( + "/{product_id}/videos", + response_model=VideoReadWithinProduct, + status_code=201, + summary="Create a new video for a product", +) +async def create_product_video( + product: UserOwnedProductDep, + video: VideoCreateWithinProduct, + session: AsyncSessionDep, +) -> Video: + """Create a new video associated with a specific product.""" + return await create_video(session, video, product_id=product.db_id) + + +@product_related_router.patch( + "/{product_id}/videos/{video_id}", + response_model=VideoReadWithinProduct, + summary="Update video by ID", +) +async def update_product_video( + product: UserOwnedProductDep, + video_id: PositiveInt, + video_update: VideoUpdateWithinProduct, + session: AsyncSessionDep, +) -> Video: + """Update a video associated with a specific product.""" + await get_nested_model_by_id(session, Product, product.db_id, Video, video_id, "product_id") + return await update_video(session, video_id, video_update) + + +@product_related_router.delete( + "/{product_id}/videos/{video_id}", + status_code=204, + summary="Delete video by ID", +) +async def delete_product_video(product: UserOwnedProductDep, video_id: PositiveInt, session: AsyncSessionDep) -> None: + """Delete a video associated with a specific product.""" + await get_nested_model_by_id(session, Product, product.db_id, Video, video_id, "product_id") + await delete_video(session, video_id) + + +@product_related_router.get( + "/{product_id}/materials", + response_model=list[MaterialProductLinkReadWithinProduct], + summary="Get product bill of materials", +) +async def get_product_bill_of_materials( + session: AsyncSessionDep, + product_id: PositiveInt, + material_filter: MaterialProductLinkFilterDep, +) -> Sequence[MaterialProductLink]: + """Get bill of materials for a product.""" + await get_model_or_404(session, Product, product_id) + statement: SelectOfScalar[MaterialProductLink] = ( + select(MaterialProductLink).join(Material).where(MaterialProductLink.product_id == product_id) + ) + return await get_models( + session, + MaterialProductLink, + model_filter=material_filter, + statement=statement, + ) + + +@product_related_router.get( + "/{product_id}/materials/{material_id}", + response_model=MaterialProductLinkReadWithinProduct, + summary="Get material in product bill of materials", +) +async def get_material_in_product_bill_of_materials( + product_id: PositiveInt, + material_id: PositiveInt, + session: AsyncSessionDep, +) -> MaterialProductLink: + """Get a material in a product's bill of materials.""" + return await get_linking_model_with_ids_if_it_exists( + session, + MaterialProductLink, + product_id, + material_id, + "product_id", + "material_id", + ) + + +@product_related_router.post( + "/{product_id}/materials", + response_model=list[MaterialProductLinkReadWithinProduct], + status_code=201, + summary="Add multiple materials to product bill of materials", +) +async def add_materials_to_product( + product: UserOwnedProductDep, + materials: Annotated[ + list[MaterialProductLinkCreateWithinProduct], + Body( + description="List of materials-product links to add to the product", + examples=[ + [ + {"material_id": 1, "quantity": 5, "unit": "g"}, + {"material_id": 2, "quantity": 10, "unit": "g"}, + ] + ], + ), + ], + session: AsyncSessionDep, +) -> list[MaterialProductLink]: + """Add multiple materials to a product's bill of materials.""" + return await crud.add_materials_to_product(session, product.db_id, materials) + + +@product_related_router.post( + "/{product_id}/materials/{material_id}", + response_model=MaterialProductLinkReadWithinProduct, + status_code=201, + summary="Add single material to product bill of materials", +) +async def add_material_to_product( + product: UserOwnedProductDep, + material_id: Annotated[ + PositiveInt, + Path(description="ID of material to add to the product", examples=[1]), + ], + material_link: Annotated[ + MaterialProductLinkCreateWithinProductAndMaterial, + Body( + description="Material-product link details", + examples=[[{"quantity": 5, "unit": "g"}]], + ), + ], + session: AsyncSessionDep, +) -> MaterialProductLink: + """Add a single material to a product's bill of materials.""" + return await crud.add_material_to_product(session, product.db_id, material_link, material_id=material_id) + + +@product_related_router.patch( + "/{product_id}/materials/{material_id}", + response_model=MaterialProductLinkReadWithinProduct, + summary="Update material in product bill of materials", +) +async def update_product_bill_of_materials( + product: UserOwnedProductDep, + material_id: PositiveInt, + material: MaterialProductLinkUpdate, + session: AsyncSessionDep, +) -> MaterialProductLink: + """Update material in bill of materials for a product.""" + return await crud.update_material_within_product(session, product.db_id, material_id, material) + + +@product_related_router.delete( + "/{product_id}/materials/{material_id}", + status_code=204, + summary="Remove single material from product bill of materials", +) +async def remove_material_from_product( + product: UserOwnedProductDep, + material_id: Annotated[ + PositiveInt, + Path(description="ID of material to remove from the product"), + ], + session: AsyncSessionDep, +) -> None: + """Remove a single material from a product's bill of materials.""" + await crud.remove_materials_from_product(session, product.db_id, {material_id}) + + +@product_related_router.delete( + "/{product_id}/materials", + status_code=204, + summary="Remove multiple materials from product bill of materials", +) +async def remove_materials_from_product_bulk( + product: UserOwnedProductDep, + material_ids: Annotated[ + set[PositiveInt], + Body( + description="Material IDs to remove from the product", + default_factory=set, + examples=[[1, 2, 3]], + ), + ], + session: AsyncSessionDep, +) -> None: + """Remove multiple materials from a product's bill of materials.""" + await crud.remove_materials_from_product(session, product.db_id, material_ids) diff --git a/backend/app/api/data_collection/router_helpers.py b/backend/app/api/data_collection/router_helpers.py new file mode 100644 index 00000000..d695afda --- /dev/null +++ b/backend/app/api/data_collection/router_helpers.py @@ -0,0 +1,125 @@ +"""Shared query helpers and OpenAPI examples for data collection routers.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Annotated, cast + +from fastapi import APIRouter, Body +from pydantic import BaseModel, PositiveInt + +from app.api.common.routers.dependencies import AsyncSessionDep +from app.api.common.routers.query_params import boolean_flag_query, relationship_include_query +from app.api.data_collection.dependencies import UserOwnedProductDep + +if TYPE_CHECKING: + from collections.abc import Awaitable, Callable + + from fastapi.openapi.models import Example + +PRODUCT_INCLUDE_EXAMPLES = cast( + "dict[str, Example]", + { + "none": {"value": []}, + "properties": {"value": ["physical_properties", "circularity_properties"]}, + "materials": {"value": ["bill_of_materials"]}, + "media": {"value": ["images", "videos", "files"]}, + "components": {"value": ["components"]}, + "all": { + "value": [ + "physical_properties", + "circularity_properties", + "images", + "videos", + "files", + "product_type", + "bill_of_materials", + "components", + ] + }, + }, +) + + +def product_include_query() -> object: + """Build the reusable product relationship include query definition.""" + return relationship_include_query(openapi_examples=PRODUCT_INCLUDE_EXAMPLES) + + +def include_components_as_base_products_query() -> object: + """Build the reusable query flag for returning component rows as base products.""" + return boolean_flag_query(description="Whether to include components as base products in the response") + + +def add_product_property_routes[ReadModelT: BaseModel, CreateModelT: BaseModel, UpdateModelT: BaseModel]( + router: APIRouter, + *, + path_segment: str, + resource_label: str, + read_model: type[ReadModelT], + create_model: type[CreateModelT], + update_model: type[UpdateModelT], + get_handler: Callable[[AsyncSessionDep, int], Awaitable[ReadModelT]], + create_handler: Callable[[AsyncSessionDep, CreateModelT, int], Awaitable[ReadModelT]], + update_handler: Callable[[AsyncSessionDep, int, UpdateModelT], Awaitable[ReadModelT]], + delete_handler: Callable[[AsyncSessionDep, UserOwnedProductDep], Awaitable[None]], +) -> None: + """Add the standard product property GET/POST/PATCH/DELETE routes.""" + + async def get_property(product_id: PositiveInt, session: AsyncSessionDep) -> ReadModelT: + return await get_handler(session, product_id) + + async def create_property( + product: UserOwnedProductDep, + session: AsyncSessionDep, + properties: Annotated[dict[str, object], Body(...)], + ) -> ReadModelT: + return await create_handler(session, create_model.model_validate(properties), product.db_id) + + async def update_property( + product: UserOwnedProductDep, + session: AsyncSessionDep, + properties: Annotated[dict[str, object], Body(...)], + ) -> ReadModelT: + return await update_handler(session, product.db_id, update_model.model_validate(properties)) + + async def delete_property( + product: UserOwnedProductDep, + session: AsyncSessionDep, + ) -> None: + await delete_handler(session, product) + + route_name = path_segment.removesuffix("_properties") + get_property.__name__ = f"get_product_{route_name}" + create_property.__name__ = f"create_product_{route_name}" + update_property.__name__ = f"update_product_{route_name}" + delete_property.__name__ = f"delete_product_{route_name}" + + router.add_api_route( + f"/{{product_id}}/{path_segment}", + get_property, + methods=["GET"], + response_model=read_model, + summary=f"Get product {resource_label}", + ) + router.add_api_route( + f"/{{product_id}}/{path_segment}", + create_property, + methods=["POST"], + response_model=read_model, + status_code=201, + summary=f"Create product {resource_label}", + ) + router.add_api_route( + f"/{{product_id}}/{path_segment}", + update_property, + methods=["PATCH"], + response_model=read_model, + summary=f"Update product {resource_label}", + ) + router.add_api_route( + f"/{{product_id}}/{path_segment}", + delete_property, + methods=["DELETE"], + status_code=204, + summary=f"Delete product {resource_label}", + ) diff --git a/backend/app/api/data_collection/routers.py b/backend/app/api/data_collection/routers.py index c70f2ddf..fc6ed3d2 100644 --- a/backend/app/api/data_collection/routers.py +++ b/backend/app/api/data_collection/routers.py @@ -1,71 +1,22 @@ """Routers for data collection models.""" -from collections.abc import Sequence -from typing import TYPE_CHECKING, Annotated +from typing import TYPE_CHECKING, Annotated, Literal, cast -from asyncache import cached -from cachetools import LRUCache, TTLCache -from fastapi import APIRouter, Body, HTTPException, Path, Query, Request -from fastapi.responses import RedirectResponse -from fastapi_filter import FilterDepends +from fastapi import APIRouter, Query +from fastapi_cache.decorator import cache from fastapi_pagination.links import Page -from pydantic import UUID4, PositiveInt -from sqlmodel import select -from app.api.auth.dependencies import CurrentActiveVerifiedUserDep -from app.api.background_data.models import Material -from app.api.background_data.routers.public import RecursionDepthQueryParam -from app.api.common.crud.associations import ( - get_linking_model_with_ids_if_it_exists, -) -from app.api.common.crud.base import ( - get_model_by_id, - get_models, - get_nested_model_by_id, - get_paginated_models, -) -from app.api.common.crud.utils import db_get_model_with_id_if_it_exists -from app.api.common.models.associations import MaterialProductLink -from app.api.common.models.enums import Unit +from app.api.common.crud.base import paginate_with_exec from app.api.common.routers.dependencies import AsyncSessionDep from app.api.common.routers.openapi import PublicAPIRouter -from app.api.common.schemas.associations import ( - MaterialProductLinkCreateWithinProduct, - MaterialProductLinkCreateWithinProductAndMaterial, - MaterialProductLinkReadWithinProduct, - MaterialProductLinkUpdate, -) -from app.api.common.schemas.base import ProductRead -from app.api.data_collection import crud -from app.api.data_collection.dependencies import ( - MaterialProductLinkFilterDep, - ProductByIDDep, - ProductFilterWithRelationshipsDep, - UserOwnedProductDep, - get_user_owned_product_id, -) -from app.api.data_collection.models import ( - PhysicalProperties, - Product, -) -from app.api.data_collection.schemas import ( - ComponentCreateWithComponents, - ComponentReadWithRecursiveComponents, - PhysicalPropertiesCreate, - PhysicalPropertiesRead, - PhysicalPropertiesUpdate, - ProductCreateWithComponents, - ProductReadWithProperties, - ProductReadWithRecursiveComponents, - ProductReadWithRelationshipsAndFlatComponents, - ProductUpdate, - ProductUpdateWithProperties, +from app.api.data_collection.filters import get_brand_search_statement +from app.api.data_collection.product_mutation_routers import product_mutation_router +from app.api.data_collection.product_read_routers import ( + product_read_router, + user_product_redirect_router, + user_product_router, ) -from app.api.file_storage.crud import create_video, delete_video -from app.api.file_storage.filters import VideoFilter -from app.api.file_storage.models.models import Video -from app.api.file_storage.router_factories import StorageRouteMethod, add_storage_routes -from app.api.file_storage.schemas import VideoCreateWithinProduct, VideoReadWithinProduct +from app.api.data_collection.product_related_routers import product_related_router if TYPE_CHECKING: from sqlmodel.sql._expression_select_cls import SelectOfScalar @@ -74,997 +25,33 @@ router = APIRouter() -## User Product routers ## -user_product_redirect_router = PublicAPIRouter(prefix="/users/me/products", tags=["products"]) - - -@user_product_redirect_router.get( - "", - response_class=RedirectResponse, - status_code=307, # Temporary redirect that preserves method and body - summary="Redirect to user's products", -) -async def redirect_to_current_user_products( - current_user: CurrentActiveVerifiedUserDep, - request: Request, -) -> RedirectResponse: - """Redirect /users/me/products to /users/{id}/products for better caching.""" - # Preserve query parameters - query_string = str(request.url.query) - redirect_url = f"/users/{current_user.id}/products" - if query_string: - redirect_url += f"?{query_string}" - return RedirectResponse(url=redirect_url, status_code=307) - - -user_product_router = PublicAPIRouter(prefix="/users/{user_id}/products", tags=["products"]) - - -@user_product_router.get( - "", - response_model=list[ProductReadWithRelationshipsAndFlatComponents], - summary="Get products collected by a user", -) -async def get_user_products( - user_id: UUID4, - session: AsyncSessionDep, - current_user: CurrentActiveVerifiedUserDep, - product_filter: ProductFilterWithRelationshipsDep, - include: Annotated[ - set[str] | None, - Query( - description="Relationships to include", - openapi_examples={ - "none": {"value": {}}, - "properties": {"value": {"physical_properties"}}, - "materials": {"value": {"bill_of_materials"}}, - "components": {"value": {"components"}}, - "media": {"value": {"images", "videos", "files"}}, - "all": { - "value": { - "physical_properties", - "images", - "videos", - "files", - "product_type", - "bill_of_materials", - "components", - } - }, - }, - ), - ] = None, -) -> Sequence[Product]: - """Get products collected by a specific user.""" - # NOTE: If needed, we can open up this endpoint to any user by removing this ownership check - if user_id != current_user.id and not current_user.is_superuser: - raise HTTPException(status_code=403, detail="Not authorized to view this user's products") - - return await get_models( - session, - Product, - include_relationships=include, - model_filter=product_filter, - statement=(select(Product).where(Product.owner_id == user_id)), - ) - - -### Product Routers ### -product_router = PublicAPIRouter(prefix="/products", tags=["products"]) - - -## Utility functions ## -def convert_components_to_read_model( - components: list[Product], max_depth: int = 1, current_depth: int = 0 -) -> list[ComponentReadWithRecursiveComponents]: - """Convert components to read model recursively.""" - if current_depth >= max_depth: - return [] - - return [ - ComponentReadWithRecursiveComponents.model_validate( - component, - update={ - "components": convert_components_to_read_model(component.components or [], max_depth, current_depth + 1) - }, - ) - for component in components - ] - - -## GET routers ## -@product_router.get( - "", - response_model=Page[ProductReadWithRelationshipsAndFlatComponents], - summary="Get all products with optional relationships", -) -async def get_products( - session: AsyncSessionDep, - product_filter: ProductFilterWithRelationshipsDep, - include: Annotated[ - set[str] | None, - Query( - description="Relationships to include", - openapi_examples={ - "none": {"value": []}, - "properties": {"value": ["physical_properties"]}, - "materials": {"value": ["bill_of_materials"]}, - "media": {"value": ["images", "videos", "files"]}, - "components": {"value": ["components"]}, - "all": { - "value": [ - "physical_properties", - "images", - "videos", - "files", - "product_type", - "bill_of_materials", - "components", - ] - }, - }, - ), - ] = None, - *, - include_components_as_base_products: Annotated[ - bool | None, - Query(description="Whether to include components as base products in the response"), - ] = None, -) -> Page[Sequence[ProductReadWithRelationshipsAndFlatComponents]]: - """Get all products with specified relationships. - - Relationships that can be included: - - physical_properties: Physical measurements and attributes - - images: Product images - - videos: Product videos - - files: Related documents - - product_type: Type classification - - bill_of_materials: Material composition - """ - # TODO: Instead of this hacky parameter, distinguish between base products and components on the model level - # For now, only return base products (those without a parent) - if include_components_as_base_products: - statement: SelectOfScalar[Product] = select(Product) - else: - statement: SelectOfScalar[Product] = select(Product).where(Product.parent_id == None) - - if product_filter: - statement = product_filter.filter(statement) - - return await get_paginated_models( - session, - Product, - include_relationships=include, - model_filter=product_filter, - statement=statement, - read_schema=ProductReadWithRelationshipsAndFlatComponents, - ) - - -@product_router.get( - "/tree", - response_model=list[ProductReadWithRecursiveComponents], - summary="Get products tree", - responses={ - 200: { - "description": "Product tree with components", - "content": { - "application/json": { - "examples": { - "simple_tree": { - "summary": "Simple product tree", - "value": [ - { - "id": 1, - "name": "Office Chair", - "description": "Complete chair assembly", - "components": [], - } - ], - }, - "nested_tree": { - "summary": "Nested product tree", - "value": [ - { - "id": 1, - "name": "Office Chair", - "description": "Complete chair assembly", - "components": [ - { - "id": 2, - "name": "Seat Assembly", - "description": "Chair seat", - "components": [ - { - "id": 3, - "name": "Cushion", - "description": "Foam cushion", - "components": [], - } - ], - } - ], - } - ], - }, - } - } - }, - } - }, -) -async def get_products_tree( - session: AsyncSessionDep, - product_filter: ProductFilterWithRelationshipsDep, - recursion_depth: RecursionDepthQueryParam = 1, -) -> list[ProductReadWithRecursiveComponents]: - """Get all base products and their components in a tree structure.""" - products: Sequence[Product] = await crud.get_product_trees( - session, recursion_depth=recursion_depth, product_filter=product_filter - ) - return [ - ProductReadWithRecursiveComponents.model_validate( - product, - update={ - "components": convert_components_to_read_model(product.components or [], max_depth=recursion_depth - 1) - }, - ) - for product in products - ] - - -@product_router.get( - "/{product_id}", - response_model=ProductReadWithRelationshipsAndFlatComponents, - summary="Get product by ID", -) -async def get_product( - session: AsyncSessionDep, - product_id: PositiveInt, - include: Annotated[ - set[str] | None, - Query( - description="Relationships to include", - openapi_examples={ - "none": {"value": []}, - "properties": {"value": ["physical_properties"]}, - "materials": {"value": ["bill_of_materials"]}, - "media": {"value": ["images", "videos", "files"]}, - "components": {"value": ["components"]}, - "all": { - "value": [ - "physical_properties", - "images", - "videos", - "files", - "product_type", - "bill_of_materials", - "components", - ] - }, - }, - ), - ] = None, -) -> Product: - """Get product by ID with specified relationships. - - Relationships that can be included: - - physical_properties: Physical measurements and attributes - - images: Product images - - videos: Product videos - - files: Related documents - - product_type: Type classification - - bill_of_materials: Material composition - """ - return await get_model_by_id(session, Product, product_id, include_relationships=include) - - -## POST routers ## -@product_router.post( - "", - response_model=ProductRead, - summary="Create a new product, optionally with components", - status_code=201, -) -async def create_product( - product: Annotated[ - ProductCreateWithComponents, - Body( - description="Product to create", - openapi_examples={ - "basic": { - "summary": "Basic product without components", - "value": { - "name": "Office Chair", - "description": "Complete chair assembly", - "brand": "Brand 1", - "model": "Model 1", - "dismantling_time_start": "2025-09-22T14:30:45Z", - "dismantling_time_end": "2025-09-22T16:30:45Z", - "product_type_id": 1, - "physical_properties": { - "weight_kg": 20, - "height_cm": 150, - "width_cm": 70, - "depth_cm": 50, - }, - "videos": [ - {"url": "https://www.youtube.com/watch?v=123456789", "description": "Disassembly video"} - ], - "bill_of_materials": [ - {"quantity": 15, "unit": "kg", "material_id": 1}, - {"quantity": 5, "unit": "kg", "material_id": 2}, - ], - }, - }, - "with_components": { - "summary": "Product with components", - "value": { - "name": "Office Chair", - "description": "Complete chair assembly", - "brand": "Brand 1", - "model": "Model 1", - "dismantling_time_start": "2025-09-22T14:30:45Z", - "dismantling_time_end": "2025-09-22T16:30:45Z", - "product_type_id": 1, - "physical_properties": { - "weight_kg": 20, - "height_cm": 150, - "width_cm": 70, - "depth_cm": 50, - }, - "videos": [ - {"url": "https://www.youtube.com/watch?v=123456789", "description": "Disassembly video"} - ], - "o": 1, - "components": [ - { - "name": "Office Chair Seat", - "description": "Seat assembly", - "brand": "Brand 2", - "model": "Model 2", - "dismantling_time_start": "2025-09-22T14:30:45Z", - "dismantling_time_end": "2025-09-22T16:30:45Z", - "amount_in_parent": 1, - "product_type_id": 2, - "physical_properties": { - "weight_kg": 5, - "height_cm": 50, - "width_cm": 40, - "depth_cm": 30, - }, - "components": [ - { - "name": "Seat Cushion", - "description": "Seat cushion assembly", - "amount_in_parent": 1, - "physical_properties": { - "weight_kg": 2, - "height_cm": 10, - "width_cm": 40, - "depth_cm": 30, - }, - "product_type_id": 3, - "bill_of_materials": [ - {"quantity": 1.5, "unit": "kg", "material_id": 1}, - {"quantity": 0.5, "unit": "kg", "material_id": 2}, - ], - } - ], - } - ], - }, - }, - }, - ), - ], - current_user: CurrentActiveVerifiedUserDep, - session: AsyncSessionDep, -) -> Product: - """Create a new product.""" - return await crud.create_product(session, product, current_user.id) - - -## PATCH routers ## -@product_router.patch("/{product_id}", response_model=ProductReadWithProperties, summary="Update product") -async def update_product( - product_update: ProductUpdate | ProductUpdateWithProperties, - db_product: UserOwnedProductDep, - session: AsyncSessionDep, -) -> Product: - """Update an existing product.""" - return await crud.update_product(session, db_product.id, product_update) - - -## DELETE routers ## -@product_router.delete( - "/{product_id}", - status_code=204, - summary="Delete product", -) -async def delete_product(db_product: UserOwnedProductDep, session: AsyncSessionDep) -> None: - """Delete a product, including components.""" - await crud.delete_product(session, db_product.id) - - -## Product Component routers ## -@product_router.get( - "/{product_id}/components/tree", - summary="Get product component subtree", - response_model=list[ComponentReadWithRecursiveComponents], - responses={ - 200: { - "description": "Product tree with components", - "content": { - "application/json": { - "examples": { - "stub_tree": { - "summary": "Product without components", - "value": [], - }, - "nested_tree": { - "summary": "Nested component tree", - "value": [ - { - "id": 2, - "name": "Seat Assembly", - "description": "Chair seat", - "components": [ - { - "id": 3, - "name": "Cushion", - "description": "Foam cushion", - "components": [], - } - ], - } - ], - }, - } - }, - }, - }, - 404: { - "description": "Product not found", - "content": {"application/json": {"example": {"detail": "Product with id 999 not found"}}}, - }, - }, -) -async def get_product_subtree( - session: AsyncSessionDep, - product_id: PositiveInt, - product_filter: ProductFilterWithRelationshipsDep, - recursion_depth: RecursionDepthQueryParam = 1, -) -> list[ComponentReadWithRecursiveComponents]: - """Get a product's components in a tree structure, up to a specified depth.""" - products: Sequence[Product] = await crud.get_product_trees( - session, recursion_depth=recursion_depth, parent_id=product_id, product_filter=product_filter - ) - - return [ - ComponentReadWithRecursiveComponents.model_validate( - product, - update={ - "components": convert_components_to_read_model(product.components or [], max_depth=recursion_depth - 1) - }, - ) - for product in products - ] - - -@product_router.get( - "/{product_id}/components", - response_model=list[ProductReadWithRelationshipsAndFlatComponents], - summary="Get product components", -) -async def get_product_components( - session: AsyncSessionDep, - product_id: PositiveInt, - product_filter: ProductFilterWithRelationshipsDep, - include: Annotated[ - set[str] | None, - Query( - description="Relationships to include", - openapi_examples={ - "none": {"value": []}, - "properties": {"value": ["physical_properties"]}, - "materials": {"value": ["bill_of_materials"]}, - "media": {"value": ["images", "videos", "files"]}, - "components": {"value": ["components"]}, - "all": { - "value": [ - "physical_properties", - "images", - "videos", - "files", - "product_type", - "bill_of_materials", - "components", - ] - }, - }, - ), - ] = None, -) -> Sequence[Product]: - """Get all components of a product.""" - # Validate existence of product - await get_model_by_id(session, Product, product_id) - - # Get components - return await get_models( - session, - Product, - include_relationships=include, - model_filter=product_filter, - statement=(select(Product).where(Product.parent_id == product_id)), - ) - - -@product_router.get( - "/{product_id}/components/{component_id}", - response_model=ProductReadWithRelationshipsAndFlatComponents, - summary="Get product component by ID", -) -async def get_product_component( - product_id: PositiveInt, - component_id: PositiveInt, - *, - include: Annotated[ - set[str] | None, - Query( - description="Relationships to include", - openapi_examples={ - "none": {"value": []}, - "properties": {"value": ["physical_properties"]}, - "materials": {"value": ["bill_of_materials"]}, - "media": {"value": ["images", "videos", "files"]}, - "components": {"value": ["components"]}, - "all": { - "value": [ - "physical_properties", - "images", - "videos", - "files", - "product_type", - "bill_of_materials", - "components", - ] - }, - }, - ), - ] = None, - session: AsyncSessionDep, -) -> Product: - """Get component by ID with specified relationships.""" - return await get_nested_model_by_id( - session, Product, product_id, Product, component_id, "parent_id", include_relationships=include - ) - - -@product_router.post( - "/{product_id}/components", - response_model=ComponentReadWithRecursiveComponents, - status_code=201, - summary="Create a new component in a product", -) -async def add_component_to_product( - db_product: UserOwnedProductDep, - component: Annotated[ - ComponentCreateWithComponents, - Body( - openapi_examples={ - "simple": { - "summary": "Basic component", - "description": "Create a component without subcomponents", - "value": { - "name": "Seat Assembly", - "description": "Chair seat component", - "amount_in_parent": 1, - "bill_of_materials": [{"material_id": 1, "quantity": 0.5, "unit": "kg"}], - }, - }, - "nested": { - "summary": "Component with subcomponents", - "description": "Create a component with nested subcomponents", - "value": { - "name": "Seat Assembly", - "description": "Chair seat with cushion", - "amount_in_parent": 1, - "components": [ - { - "name": "Cushion", - "description": "Foam cushion", - "amount_in_parent": 1, - "bill_of_materials": [{"material_id": 2, "quantity": 0.3, "unit": "kg"}], - } - ], - }, - }, - } - ), - ], - session: AsyncSessionDep, -) -> Product: - """Create a new component in an existing product.""" - return await crud.create_component( - db=session, - component=component, - parent_product_id=db_product.id, - owner_id=None, - ) - - -@product_router.delete( - "/{product_id}/components/{component_id}", - status_code=204, - summary="Delete product component", -) -async def delete_product_component( - db_product: UserOwnedProductDep, component_id: PositiveInt, session: AsyncSessionDep -) -> None: - """Delete a component in a product, including subcomponents.""" - # Validate existence of product and component - await get_nested_model_by_id(session, Product, db_product.id, Product, component_id, "parent_id") - - # Delete category - await crud.delete_product(session, component_id) - - -## Product Storage routers ## -add_storage_routes( - router=product_router, - parent_api_model_name=Product.get_api_model_name(), - files_crud=crud.product_files_crud, - images_crud=crud.product_images_crud, - include_methods={StorageRouteMethod.GET, StorageRouteMethod.POST, StorageRouteMethod.DELETE}, - read_parent_auth_dep=None, - # TODO: Build ownership check for modification operations - modify_parent_auth_dep=get_user_owned_product_id, -) - - -## Product Property routers ## -@product_router.get( - "/{product_id}/physical_properties", - response_model=PhysicalPropertiesRead, - summary="Get product physical properties", -) -async def get_product_physical_properties(product_id: PositiveInt, session: AsyncSessionDep) -> PhysicalProperties: - """Get physical properties for a product.""" - return await crud.get_physical_properties(session, product_id) - - -@product_router.post( - "/{product_id}/physical_properties", - response_model=PhysicalPropertiesRead, - status_code=201, - summary="Create product physical properties", -) -async def create_product_physical_properties( - product: UserOwnedProductDep, - properties: PhysicalPropertiesCreate, - session: AsyncSessionDep, -) -> PhysicalProperties: - """Create physical properties for a product.""" - return await crud.create_physical_properties(session, properties, product.id) - - -@product_router.patch( - "/{product_id}/physical_properties", - response_model=PhysicalPropertiesRead, - summary="Update product physical properties", -) -async def update_product_physical_properties( - product: UserOwnedProductDep, - properties: PhysicalPropertiesUpdate, - session: AsyncSessionDep, -) -> PhysicalProperties: - """Update physical properties for a product.""" - return await crud.update_physical_properties(session, product.id, properties) - - -@product_router.delete( - "/{product_id}/physical_properties", - status_code=204, - summary="Delete product physical properties", -) -async def delete_product_physical_properties( - product: UserOwnedProductDep, - session: AsyncSessionDep, -) -> None: - """Delete physical properties for a product.""" - await crud.delete_physical_properties(session, product) - - -## Product Video routers ## -@product_router.get( - "/{product_id}/videos", - response_model=list[VideoReadWithinProduct], - summary="Get all videos for a product", - responses={ - 200: { - "description": "List of videos", - "content": { - "application/json": { - "examples": { - "basic": { - "summary": "Videos for a product", - "value": [ - { - "id": 1, - "url": "https://example.com/video1", - "description": "Product disassembly video", - } - ], - } - } - } - }, - }, - 404: { - "description": "Product not found", - "content": {"application/json": {"example": {"detail": "Product with id 999 not found"}}}, - }, - }, -) -async def get_product_videos( - session: AsyncSessionDep, - product: ProductByIDDep, - video_filter: VideoFilter = FilterDepends(VideoFilter), # noqa: B008 # FilterDepends is a valid Depends wrapper -) -> Sequence[Video]: - """Get all videos associated with a specific product.""" - # Create statement to filter by product_id - statement: SelectOfScalar[Video] = select(Video).where(Video.product_id == product.id) - - return await get_models( - session, - Video, - model_filter=video_filter, - statement=statement, - ) - - -@product_router.get( - "/{product_id}/videos/{video_id}", - response_model=VideoReadWithinProduct, - summary="Get video by ID", -) -async def get_product_video( - product_id: PositiveInt, - video_id: PositiveInt, - session: AsyncSessionDep, -) -> Video: - """Get a video associated with a specific product.""" - return await get_nested_model_by_id(session, Product, product_id, Video, video_id, "product_id") - - -@product_router.post( - "/{product_id}/videos", - response_model=VideoReadWithinProduct, - status_code=201, - summary="Create a new video for a product", - responses={ - 201: { - "description": "Video created successfully", - "content": { - "application/json": { - "example": { - "id": 1, - "url": "https://example.com/video1", - "description": "Product disassembly video", - } - } - }, - }, - 404: { - "description": "Product not found", - "content": {"application/json": {"example": {"detail": "Product with id 999 not found"}}}, - }, - }, -) -async def create_product_video( - product: UserOwnedProductDep, - video: VideoCreateWithinProduct, - session: AsyncSessionDep, -) -> Video: - """Create a new video associated with a specific product.""" - return await create_video(session, video, product_id=product.id) - - -@product_router.delete( - "/{product_id}/videos/{video_id}", - status_code=204, - summary="Delete video by ID", -) -async def delete_product_video(product: UserOwnedProductDep, video_id: PositiveInt, session: AsyncSessionDep) -> None: - """Delete a video associated with a specific product.""" - # Validate existence of product and video - await get_nested_model_by_id(session, Product, product.id, Video, video_id, "product_id") - - # Delete video - await delete_video(session, video_id) - - -## Product Bill of Material routers ## -@product_router.get( - "/{product_id}/materials", - response_model=list[MaterialProductLinkReadWithinProduct], - summary="Get product bill of materials", -) -async def get_product_bill_of_materials( - session: AsyncSessionDep, - product_id: PositiveInt, - material_filter: MaterialProductLinkFilterDep, -) -> Sequence[MaterialProductLink]: - """Get bill of materials for a product.""" - # Validate existence of product - await db_get_model_with_id_if_it_exists(session, Product, product_id) - - statement: SelectOfScalar[MaterialProductLink] = ( - select(MaterialProductLink).join(Material).where(MaterialProductLink.product_id == product_id) - ) - - return await get_models( - session, - MaterialProductLink, - model_filter=material_filter, - statement=statement, - ) - - -@product_router.get( - "/{product_id}/materials/{material_id}", - response_model=MaterialProductLinkReadWithinProduct, - summary="Get material in product bill of materials", -) -async def get_material_in_product_bill_of_materials( - product_id: PositiveInt, - material_id: PositiveInt, - session: AsyncSessionDep, -) -> MaterialProductLink: - """Get a material in a product's bill of materials.""" - return await get_linking_model_with_ids_if_it_exists( - session, - MaterialProductLink, - product_id, - material_id, - "product_id", - "material_id", - ) - - -@product_router.post( - "/{product_id}/materials", - response_model=list[MaterialProductLinkReadWithinProduct], - status_code=201, - summary="Add multiple materials to product bill of materials", -) -async def add_materials_to_product( - product: UserOwnedProductDep, - materials: Annotated[ - list[MaterialProductLinkCreateWithinProduct], - Body( - description="List of materials-product links to add to the product", - examples=[ - [ - {"material_id": 1, "quantity": 5, "unit": "kg"}, - {"material_id": 2, "quantity": 10, "unit": "kg"}, - ] - ], - ), - ], - session: AsyncSessionDep, -) -> list[MaterialProductLink]: - """Add multiple materials to a product's bill of materials.""" - return await crud.add_materials_to_product(session, product.id, materials) - - -@product_router.post( - "/{product_id}/materials/{material_id}", - response_model=MaterialProductLinkReadWithinProduct, - status_code=201, - summary="Add single material to product bill of materials", -) -async def add_material_to_product( - product: UserOwnedProductDep, - material_id: Annotated[ - PositiveInt, - Path(description="ID of material to add to the product", examples=[1]), - ], - material_link: Annotated[ - MaterialProductLinkCreateWithinProductAndMaterial, - Body( - description="Material-product link details", - examples=[[{"quantity": 5, "unit": "kg"}]], - ), - ], - session: AsyncSessionDep, -) -> MaterialProductLink: - """Add a single material to a product's bill of materials.""" - return await crud.add_material_to_product(session, product.id, material_link, material_id=material_id) - - -@product_router.patch( - "/{product_id}/materials/{material_id}", - response_model=MaterialProductLinkReadWithinProduct, - summary="Update material in product bill of materials", -) -async def update_product_bill_of_materials( - product: UserOwnedProductDep, - material_id: PositiveInt, - material: MaterialProductLinkUpdate, - session: AsyncSessionDep, -) -> MaterialProductLink: - """Update material in bill of materials for a product.""" - return await crud.update_material_within_product(session, product.id, material_id, material) - - -@product_router.delete( - "/{product_id}/materials/{material_id}", - status_code=204, - summary="Remove single material from product bill of materials", -) -async def remove_material_from_product( - product: UserOwnedProductDep, - material_id: Annotated[ - PositiveInt, - Path(description="ID of material to remove from the product"), - ], - session: AsyncSessionDep, -) -> None: - """Remove a single material from a product's bill of materials.""" - await crud.remove_materials_from_product(session, product.id, {material_id}) - - -@product_router.delete( - "/{product_id}/materials", - status_code=204, - summary="Remove multiple materials from product bill of materials", -) -async def remove_materials_from_product_bulk( - product: UserOwnedProductDep, - material_ids: Annotated[ - set[PositiveInt], - Body( - description="Material IDs to remove from the product", - default_factory=set, - examples=[[1, 2, 3]], - ), - ], - session: AsyncSessionDep, -) -> None: - """Remove multiple materials from a product's bill of materials.""" - await crud.remove_materials_from_product(session, product.id, material_ids) - - ### Ancillary Search Routers ### search_router = PublicAPIRouter(prefix="", include_in_schema=True) -@search_router.get("/brands") -@cached(cache=TTLCache(maxsize=1, ttl=60)) +@search_router.get( + "/brands", + response_model=Page[str], + summary="Get paginated list of unique product brands", +) +@cache(expire=60) async def get_brands( session: AsyncSessionDep, -) -> Sequence[str]: - """Get a list of unique product brands.""" - return await crud.get_unique_product_brands(session) - - -### Unit Routers ### -unit_router = PublicAPIRouter(prefix="/units", tags=["units"], include_in_schema=True) - - -@unit_router.get("") -@cached(LRUCache(maxsize=1)) # Cache units, as they are defined on app startup and do not change -async def get_units() -> list[str]: - """Get a list of available units.""" - return [unit.value for unit in Unit] + search: Annotated[str | None, Query(description="Search brand (case-insensitive)")] = None, + order: Annotated[Literal["asc", "desc"], Query(description="Sort order: 'asc' or 'desc'")] = "asc", +) -> Page[str]: + """Get a paginated, searchable and orderable list of unique product brands.""" + statement = get_brand_search_statement(search=search, order=order) + page = await paginate_with_exec(session, cast("SelectOfScalar[str]", statement)) + page.items = [brand.title() for brand in page.items if brand] + return cast("Page[str]", page) ### Router inclusion ### router.include_router(user_product_redirect_router) router.include_router(user_product_router) -router.include_router(product_router) +router.include_router(product_read_router) +router.include_router(product_mutation_router) +router.include_router(product_related_router) router.include_router(search_router) -router.include_router(unit_router) diff --git a/backend/app/api/data_collection/schemas.py b/backend/app/api/data_collection/schemas.py index 6306902f..0f04a2d1 100644 --- a/backend/app/api/data_collection/schemas.py +++ b/backend/app/api/data_collection/schemas.py @@ -1,8 +1,7 @@ """Pydantic models used to validate CRUD operations for data collection data.""" -from collections.abc import Collection from datetime import UTC, datetime, timedelta -from typing import Annotated, Self +from typing import TYPE_CHECKING, Annotated, Self from pydantic import ( AfterValidator, @@ -26,7 +25,8 @@ ComponentRead, ProductRead, ) -from app.api.data_collection.models import ( +from app.api.data_collection.base import ( + CircularityPropertiesBase, PhysicalPropertiesBase, ProductBase, ) @@ -37,6 +37,9 @@ VideoReadWithinProduct, ) +if TYPE_CHECKING: + from collections.abc import Collection + ### Constants ### @@ -75,7 +78,7 @@ class PhysicalPropertiesCreate(BaseCreateSchema, PhysicalPropertiesBase): """Schema for creating physical properties.""" model_config: ConfigDict = ConfigDict( - json_schema_extra={"examples": [{"weight_kg": 20, "height_cm": 150, "width_cm": 70, "depth_cm": 50}]} + json_schema_extra={"examples": [{"weight_g": 20000, "height_cm": 150, "width_cm": 70, "depth_cm": 50}]} ) @@ -83,14 +86,79 @@ class PhysicalPropertiesRead(BaseReadSchemaWithTimeStamp, PhysicalPropertiesBase """Schema for reading physical properties.""" model_config: ConfigDict = ConfigDict( - json_schema_extra={"examples": [{"id": 1, "weight_kg": 20, "height_cm": 150, "width_cm": 70, "depth_cm": 50}]} + json_schema_extra={"examples": [{"id": 1, "weight_g": 20000, "height_cm": 150, "width_cm": 70, "depth_cm": 50}]} ) class PhysicalPropertiesUpdate(BaseUpdateSchema, PhysicalPropertiesBase): """Schema for updating physical properties.""" - model_config: ConfigDict = ConfigDict(json_schema_extra={"examples": [{"weight_kg": 15, "height_cm": 120}]}) + model_config: ConfigDict = ConfigDict(json_schema_extra={"examples": [{"weight_g": 15000, "height_cm": 120}]}) + + +class CircularityPropertiesCreate(BaseCreateSchema, CircularityPropertiesBase): + """Schema for creating circularity properties.""" + + model_config: ConfigDict = ConfigDict( + json_schema_extra={ + "examples": [ + { + "recyclability_observation": "The product can be easily disassembled and materials separated", + "recyclability_comment": "High recyclability rating", + "recyclability_reference": "ISO 14021:2016", + "repairability_observation": "Components are modular and can be replaced individually", + "repairability_comment": "Good repairability score", + "repairability_reference": "EN 45554:2020", + "remanufacturability_observation": "Core components can be refurbished and reused", + "remanufacturability_comment": "Suitable for remanufacturing", + "remanufacturability_reference": "BS 8887-2:2009", + } + ] + } + ) + + +class CircularityPropertiesRead(BaseReadSchemaWithTimeStamp, CircularityPropertiesBase): + """Schema for reading circularity properties.""" + + model_config: ConfigDict = ConfigDict( + json_schema_extra={ + "examples": [ + { + "id": 1, + "recyclability_observation": "The product can be easily disassembled and materials separated", + "recyclability_comment": "High recyclability rating", + "recyclability_reference": "ISO 14021:2016", + "repairability_observation": "Components are modular and can be replaced individually", + "repairability_comment": "Good repairability score", + "repairability_reference": "EN 45554:2020", + "remanufacturability_observation": "Core components can be refurbished and reused", + "remanufacturability_comment": "Suitable for remanufacturing", + "remanufacturability_reference": "BS 8887-2:2009", + } + ] + } + ) + + +class CircularityPropertiesUpdate(BaseUpdateSchema, CircularityPropertiesBase): + """Schema for updating circularity properties.""" + + # Make all fields optional for updates + recyclability_observation: str | None = Field(default=None, max_length=500) + repairability_observation: str | None = Field(default=None, max_length=500) + remanufacturability_observation: str | None = Field(default=None, max_length=500) + + model_config: ConfigDict = ConfigDict( + json_schema_extra={ + "examples": [ + { + "recyclability_observation": "Updated observation on recyclability", + "recyclability_comment": "Updated comment", + } + ] + } + ) ### Product Schemas ### @@ -128,6 +196,9 @@ class ProductCreateWithRelationships(ProductCreateBase): physical_properties: PhysicalPropertiesCreate | None = Field( default=None, description="Physical properties of the product" ) + circularity_properties: CircularityPropertiesCreate | None = Field( + default=None, description="Circularity properties of the product" + ) videos: list[VideoCreateWithinProduct] = Field(default_factory=list, description="Disassembly videos") bill_of_materials: list[MaterialProductLinkCreateWithinProduct] = Field( @@ -150,7 +221,7 @@ class ProductCreateBaseProduct(ProductCreateWithRelationships): "dismantling_time_end": "2025-09-22T16:30:45Z", "product_type_id": 1, "physical_properties": { - "weight_kg": 20, + "weight_g": 20000, "height_cm": 150, "width_cm": 70, "depth_cm": 50, @@ -159,8 +230,8 @@ class ProductCreateBaseProduct(ProductCreateWithRelationships): {"url": "https://www.youtube.com/watch?v=123456789", "description": "Disassembly video"} ], "bill_of_materials": [ - {"quantity": 0.3, "unit": "kg", "material_id": 1}, - {"quantity": 0.1, "unit": "kg", "material_id": 2}, + {"quantity": 0.3, "unit": "g", "material_id": 1}, + {"quantity": 0.1, "unit": "g", "material_id": 2}, ], } ] @@ -187,12 +258,13 @@ class ComponentCreateWithComponents(ComponentCreate): """ # Recursive components - components: list["ComponentCreateWithComponents"] = Field( + components: list[ComponentCreateWithComponents] = Field( default_factory=list, description="Set of component products" ) @model_validator(mode="after") def has_material_or_components(self) -> Self: + """Validation to ensure product has either materials or components.""" validate_material_or_components(self.bill_of_materials, self.components) return self @@ -210,6 +282,7 @@ class ProductCreateWithComponents(ProductCreateBaseProduct): @model_validator(mode="after") def has_material_or_components(self) -> Self: + """Validation to ensure product has either materials or components.""" validate_material_or_components(self.bill_of_materials, self.components) return self @@ -222,6 +295,7 @@ class ProductReadWithProperties(ProductRead): """Schema for reading product information with all properties.""" physical_properties: PhysicalPropertiesRead | None = None + circularity_properties: CircularityPropertiesRead | None = None class ProductReadWithRelationships(ProductReadWithProperties): @@ -235,17 +309,24 @@ class ProductReadWithRelationships(ProductReadWithProperties): default_factory=list, description="Bill of materials with quantities and units" ) + @model_validator(mode="after") + def populate_thumbnail_url_from_images(self) -> Self: + """Fill thumbnail_url from the first image when the field is otherwise unset.""" + if self.thumbnail_url is None and self.images: + self.thumbnail_url = self.images[0].image_url + return self + class ProductReadWithRelationshipsAndFlatComponents(ProductReadWithRelationships): """Schema for reading product information with one level of components.""" - components: list["ComponentRead"] = Field(default_factory=list, description="List of component products") + components: list[ComponentRead] = Field(default_factory=list, description="List of component products") class ComponentReadWithRecursiveComponents(ComponentRead): """Schema for reading product information with recursive components.""" - components: list["ComponentReadWithRecursiveComponents"] = Field( + components: list[ComponentReadWithRecursiveComponents] = Field( default_factory=list, description="List of component products" ) @@ -290,3 +371,4 @@ class ProductUpdateWithProperties(ProductUpdate): """Schema for a partial update of a product with properties.""" physical_properties: PhysicalPropertiesUpdate | None = None + circularity_properties: CircularityPropertiesUpdate | None = None diff --git a/backend/app/api/file_storage/cleanup.py b/backend/app/api/file_storage/cleanup.py new file mode 100644 index 00000000..e587fa2f --- /dev/null +++ b/backend/app/api/file_storage/cleanup.py @@ -0,0 +1,106 @@ +"""Core logic for cleaning up unreferenced files in storage.""" + +import logging +import time +from typing import Any, cast + +from anyio import Path as AnyIOPath +from sqlmodel import select +from sqlmodel.ext.asyncio.session import AsyncSession + +from app.api.file_storage.models.models import File, Image +from app.core.config import settings + +logger = logging.getLogger(__name__) + + +async def get_referenced_files(session: AsyncSession) -> set[AnyIOPath]: + """Get all file paths referenced in the database. + + Returns: + Set of absolute Paths to referenced files. + """ + referenced_paths: set[AnyIOPath] = set() + + file_stmt = select(cast("Any", File.file)) + files = (await session.exec(file_stmt)).all() + for f in files: + if f and hasattr(f, "path"): + referenced_paths.add(await AnyIOPath(f.path).resolve()) + + image_stmt = select(cast("Any", Image.file)) + images = (await session.exec(image_stmt)).all() + for img in images: + if img and hasattr(img, "path"): + referenced_paths.add(await AnyIOPath(img.path).resolve()) + + return referenced_paths + + +async def get_files_on_disk() -> set[AnyIOPath]: + """Get all file paths on disk in the upload directories that are old enough to delete. + + Only files older than ``settings.file_cleanup_min_file_age_minutes`` are + included. This grace period prevents a Time-of-Check to Time-of-Use race + where a file has been written to disk but whose database record has not yet committed. + + Returns: + Set of absolute Paths to eligible files on disk. + """ + files_on_disk: set[AnyIOPath] = set() + min_age_seconds = settings.file_cleanup_min_file_age_minutes * 60 + now = time.time() + + for storage_dir in [settings.file_storage_path, settings.image_storage_path]: + dir_path = AnyIOPath(storage_dir) + if await dir_path.exists(): + async for path in dir_path.rglob("*"): + if await path.is_file(): + stat = await path.stat() + if now - stat.st_mtime >= min_age_seconds: + files_on_disk.add(await path.resolve()) + + return files_on_disk + + +async def get_unreferenced_files(session: AsyncSession) -> list[AnyIOPath]: + """Identify files on disk that are not referenced in the database. + + Returns: + Sorted list of absolute Paths to unreferenced files. + """ + referenced = await get_referenced_files(session) + on_disk = await get_files_on_disk() + return cast("list[AnyIOPath]", sorted(on_disk - referenced, key=str)) + + +async def cleanup_unreferenced_files(session: AsyncSession, *, dry_run: bool = True) -> list[AnyIOPath]: + """Delete files from disk that are not referenced in the database. + + Args: + session: AsyncSession to use for database queries. + dry_run: If True, only log what would be deleted without actually deleting. + + Returns: + List of Paths that were (or would have been) deleted. + """ + unreferenced = await get_unreferenced_files(session) + + if not unreferenced: + logger.info("No unreferenced files found.") + return [] + + if dry_run: + logger.info("Dry run: Found %d unreferenced files to delete:", len(unreferenced)) + for path in unreferenced: + logger.info(" [DRY RUN] Would delete: %s", path) + else: + logger.info("Cleaning up %d unreferenced files...", len(unreferenced)) + for path in unreferenced: + try: + await AnyIOPath(str(path)).unlink() + logger.info(" Deleted: %s", path) + except OSError: + logger.exception(" Failed to delete %s", path) + + return unreferenced diff --git a/backend/app/api/file_storage/crud.py b/backend/app/api/file_storage/crud.py index d07bcdbb..0aed18b3 100644 --- a/backend/app/api/file_storage/crud.py +++ b/backend/app/api/file_storage/crud.py @@ -2,10 +2,10 @@ import logging import uuid -from collections.abc import Callable, Sequence from pathlib import Path -from typing import Any, Generic, TypeVar +from typing import TYPE_CHECKING, Any, TypeVar, cast, overload +from anyio import Path as AnyIOPath from anyio import to_thread from fastapi import UploadFile from fastapi_filter.contrib.sqlalchemy import Filter @@ -13,27 +13,44 @@ from slugify import slugify from sqlmodel import select from sqlmodel.ext.asyncio.session import AsyncSession +from sqlmodel.sql.expression import SelectOfScalar from app.api.common.crud.base import get_models -from app.api.common.crud.utils import db_get_model_with_id_if_it_exists, get_file_parent_type_model +from app.api.common.crud.exceptions import ModelNotFoundError +from app.api.common.crud.persistence import SupportsModelDump, update_and_commit +from app.api.common.crud.utils import get_file_parent_type_model, get_model_or_404 from app.api.common.models.custom_types import MT -from app.api.data_collection.models import Product -from app.api.file_storage.exceptions import FastAPIStorageFileNotFoundError, ModelFileNotFoundError +from app.api.file_storage.exceptions import ( + FastAPIStorageFileNotFoundError, + ModelFileNotFoundError, + ParentStorageOwnershipError, + UploadTooLargeError, +) from app.api.file_storage.filters import FileFilter, ImageFilter -from app.api.file_storage.models.models import File, FileParentType, Image, ImageParentType, Video +from app.api.file_storage.models.models import File, Image, MediaParentType +from app.api.file_storage.models.storage import get_storage +from app.api.file_storage.presentation import storage_item_exists, stored_file_path from app.api.file_storage.schemas import ( + MAX_FILE_SIZE_MB, + MAX_IMAGE_SIZE_MB, FileCreate, FileUpdate, ImageCreateFromForm, ImageCreateInternal, ImageUpdate, - VideoCreate, - VideoCreateWithinProduct, - VideoUpdate, ) +from app.core.config import settings +from app.core.images import process_image_for_storage + +if TYPE_CHECKING: + from collections.abc import Sequence + from typing import BinaryIO logger = logging.getLogger(__name__) +StorageModel = File | Image +StorageCreateSchema = FileCreate | ImageCreateFromForm | ImageCreateInternal + ### Common utilities ### def sanitize_filename(filename: str, max_length: int = 42) -> str: @@ -41,28 +58,26 @@ def sanitize_filename(filename: str, max_length: int = 42) -> str: path = Path(filename) name = path.name - # Reverse order to remove last suffix first for suffix in path.suffixes[::-1]: name = name.removesuffix(suffix) sanitized_filename = slugify( - name[:-1] + "_" if len(name) > max_length else name, lowercase=False, max_length=max_length, word_boundary=True + name[:-1] + "_" if len(name) > max_length else name, + lowercase=False, + max_length=max_length, + word_boundary=True, ) return f"{sanitized_filename}{''.join(path.suffixes)}" -def process_uploadfile_name( - file: UploadFile, -) -> tuple[UploadFile, UUID4, str]: +def process_uploadfile_name(file: UploadFile) -> tuple[UploadFile, UUID4, str]: """Process an UploadFile for storing in the database.""" if file.filename is None: err_msg = "File name is empty." raise ValueError(err_msg) - # Extract and truncate original filename - original_filename: str = sanitize_filename(file.filename) - + original_filename = sanitize_filename(file.filename) file_id = uuid.uuid4() file.filename = f"{file_id.hex}_{original_filename}" return file, file_id, original_filename @@ -70,331 +85,379 @@ def process_uploadfile_name( async def delete_file_from_storage(file_path: Path) -> None: """Delete a file from the filesystem.""" - if file_path.exists(): - await to_thread.run_sync(file_path.unlink) + async_path = AnyIOPath(str(file_path)) + if await async_path.exists(): + await async_path.unlink() -### File CRUD operations ### -## Basic CRUD operations ## -async def get_files(db: AsyncSession, *, file_filter: FileFilter | None = None) -> Sequence[File]: - """Get all files from the database.""" - # TODO: Handle missing files in storage - return await get_models(db, File, model_filter=file_filter) +async def _ensure_parent_exists(db: AsyncSession, parent_type: MediaParentType, parent_id: int) -> None: + """Validate that the target parent record exists.""" + parent_model = get_file_parent_type_model(parent_type) + await get_model_or_404(db, parent_model, parent_id) -async def get_file(db: AsyncSession, file_id: UUID4) -> File: - """Get a file from the database.""" - try: - return await db_get_model_with_id_if_it_exists(db, File, file_id) - except FastAPIStorageFileNotFoundError as e: - raise ModelFileNotFoundError(File, file_id, details=e.message) from e +def _measure_file_size(file: BinaryIO) -> int: + """Measure a binary file object without changing its current position.""" + current_position = file.tell() + file.seek(0, 2) + file_size = file.tell() + file.seek(current_position) + return file_size -async def create_file(db: AsyncSession, file_data: FileCreate) -> File: - """Create a new file in the database and save it.""" - if file_data.file.filename is None: - err_msg = "File name is empty" +async def _validate_upload_size(upload_file: UploadFile, max_size_mb: int) -> None: + """Validate upload size, even when UploadFile.size is unavailable.""" + file_size = upload_file.size + if file_size is None: + file_size = await to_thread.run_sync(_measure_file_size, upload_file.file) + + if file_size == 0: + err_msg = "File size is zero." raise ValueError(err_msg) + if file_size > max_size_mb * 1024 * 1024: + raise UploadTooLargeError(file_size_bytes=file_size, max_size_mb=max_size_mb) - # Generate ID before creating File - file_data.file, file_id, original_filename = process_uploadfile_name(file_data.file) - # Verify parent exists (will raise ModelNotFoundError if not) - parent_model = get_file_parent_type_model(file_data.parent_type) - await db_get_model_with_id_if_it_exists(db, parent_model, file_data.parent_id) +def _build_storage_instance[StorageModelT: StorageModel]( + *, + model: type[StorageModelT], + file_id: UUID4, + original_filename: str, + stored_name: str, + payload: StorageCreateSchema, +) -> StorageModelT: + """Create a storage model instance from an upload payload.""" + item_kwargs: dict[str, Any] = { + "id": file_id, + "description": payload.description, + "filename": original_filename, + "file": stored_name, + "parent_type": payload.parent_type, + } + if isinstance(payload, ImageCreateFromForm | ImageCreateInternal): + item_kwargs["image_metadata"] = payload.image_metadata + + db_item = model(**item_kwargs) + db_item.set_parent(payload.parent_type, payload.parent_id) + return db_item + + +async def _process_created_image(db: AsyncSession, db_image: Image) -> Image: + """Post-process a stored image and roll back the record on processing failures.""" + image_path = stored_file_path(db_image) + if image_path is None: + return db_image - db_file = File( - id=file_id, - description=file_data.description, - filename=original_filename, - file=file_data.file, # pyright: ignore [reportArgumentType] # Incoming UploadFile cannot be preemptively cast to FileType because of how FastAPI-storages works. - parent_type=file_data.parent_type, - ) + try: + await to_thread.run_sync(process_image_for_storage, image_path) + except (ValueError, OSError) as e: + logger.warning("Image processing failed for image %s, rolling back: %s", db_image.db_id, e) + await delete_image(db, db_image.db_id) + raise ValueError(str(e)) from e - # Set parent id - db_file.set_parent(file_data.parent_type, file_data.parent_id) + return db_image - db.add(db_file) - await db.commit() - await db.refresh(db_file) - return db_file +async def _get_storage_item_or_raise[StorageModelT: StorageModel]( + db: AsyncSession, + model: type[StorageModelT], + item_id: UUID4, +) -> StorageModelT: + """Fetch a storage item and normalize storage-related lookup errors.""" + try: + return await get_model_or_404(db, model, item_id) + except (FastAPIStorageFileNotFoundError, ModelFileNotFoundError) as e: + raise ModelFileNotFoundError(model, item_id, details=str(e)) from e -async def update_file(db: AsyncSession, file_id: UUID4, file: FileUpdate) -> File: - """Update an existing file in the database.""" - try: - db_file = await db_get_model_with_id_if_it_exists(db, File, file_id) - except FastAPIStorageFileNotFoundError as e: - raise ModelFileNotFoundError(File, file_id, details=e.message) from e - file_data: dict[str, Any] = file.model_dump(exclude_unset=True) - db_file.sqlmodel_update(file_data) +async def _update_storage_item[StorageModelT: StorageModel, UpdateSchemaT: SupportsModelDump]( + db: AsyncSession, + model: type[StorageModelT], + item_id: UUID4, + update_payload: UpdateSchemaT, +) -> StorageModelT: + """Update a storage item after resolving storage-specific lookup failures.""" + db_item = await _get_storage_item_or_raise(db, model, item_id) + return await update_and_commit(db, db_item, update_payload) - db.add(db_file) - await db.commit() - await db.refresh(db_file) +class StoredMediaService[StorageModelT: StorageModel, CreateSchemaT: StorageCreateSchema]: + """Explicit service for create/delete operations on stored media.""" - return db_file + def __init__( + self, + *, + model: type[StorageModelT], + max_size_mb: int, + ) -> None: + self.model = model + self.max_size_mb = max_size_mb + + async def write_upload(self, upload_file: UploadFile, filename: str) -> str: + """Persist an uploaded file to storage.""" + msg = "Subclasses must implement write_upload()." + raise NotImplementedError(msg) + + async def after_create(self, db: AsyncSession, item: StorageModelT) -> StorageModelT: + """Hook for post-create processing.""" + del db + return item + + async def create(self, db: AsyncSession, payload: CreateSchemaT) -> StorageModelT: + """Create a file-backed model, store the upload, and persist the DB row.""" + if payload.file.filename is None: + err_msg = "File name is empty" + raise ValueError(err_msg) + await _validate_upload_size(payload.file, self.max_size_mb) + payload.file, file_id, original_filename = process_uploadfile_name(payload.file) + await _ensure_parent_exists(db, payload.parent_type, payload.parent_id) + + stored_name = await self.write_upload(payload.file, cast("str", payload.file.filename)) + db_item = _build_storage_instance( + model=self.model, + file_id=file_id, + original_filename=original_filename, + stored_name=stored_name, + payload=payload, + ) -async def delete_file(db: AsyncSession, file_id: UUID4) -> None: - """Delete a file from the database and remove it from storage.""" - try: - db_file = await db_get_model_with_id_if_it_exists(db, File, file_id) - file_path = Path(db_file.file.path) if db_file.file else None - except (FastAPIStorageFileNotFoundError, ModelFileNotFoundError) as e: - # File missing from storage but exists in DB - proceed with DB cleanup - # TODO: Test this scenario - db_file = await db.get(File, file_id) - file_path = None - logger.warning("File %s not found in storage: %s. File instance will be deleted from the database.", file_id, e) + db.add(db_item) + await db.commit() + await db.refresh(db_item) + return await self.after_create(db, db_item) - await db.delete(db_file) - await db.commit() + async def delete(self, db: AsyncSession, item_id: UUID4) -> None: + """Delete a file-backed model and best-effort clean up its storage file.""" + try: + db_item = await get_model_or_404(db, self.model, item_id) + file_path = stored_file_path(db_item) + except (FastAPIStorageFileNotFoundError, ModelFileNotFoundError) as e: + db_item = await db.get(self.model, item_id) + file_path = None + logger.warning( + "%s %s not found in storage: %s. Deleting database row only.", + self.model.__name__, + item_id, + e, + ) + + if db_item is None: + raise ModelNotFoundError(self.model, item_id) + + await db.delete(db_item) + await db.commit() - if file_path: - await delete_file_from_storage(file_path) + if file_path: + await delete_file_from_storage(file_path) -### Image CRUD operations ### -## Basic CRUD operations ## -async def get_images(db: AsyncSession, *, image_filter: ImageFilter | None = None) -> Sequence[Image]: - """Get all images from the database.""" - # TODO: Handle missing files in storage - return await get_models(db, Image, model_filter=image_filter) +class FileStorageService(StoredMediaService[File, FileCreate]): + """Service for generic file storage.""" + def __init__(self) -> None: + super().__init__(model=File, max_size_mb=MAX_FILE_SIZE_MB) -async def get_image(db: AsyncSession, image_id: UUID4) -> Image: - """Get an image from the database.""" - try: - return await db_get_model_with_id_if_it_exists(db, Image, image_id) - except FastAPIStorageFileNotFoundError as e: - raise ModelFileNotFoundError(Image, image_id, details=e.message) from e + async def write_upload(self, upload_file: UploadFile, filename: str) -> str: + """Persist a generic file upload.""" + return await get_storage(settings.file_storage_path).write_upload(upload_file, filename) -async def create_image(db: AsyncSession, image_data: ImageCreateFromForm | ImageCreateInternal) -> Image: - """Create a new image in the database and save it.""" - if image_data.file.filename is None: - err_msg = "File name is empty" - raise ValueError(err_msg) +class ImageStorageService(StoredMediaService[Image, ImageCreateFromForm | ImageCreateInternal]): + """Service for image storage and post-processing.""" - # Generate ID before creating File to store in local filesystem - image_data.file, image_id, original_filename = process_uploadfile_name(image_data.file) + def __init__(self) -> None: + super().__init__(model=Image, max_size_mb=MAX_IMAGE_SIZE_MB) - # Verify parent exists (will raise ModelNotFoundError if not) - parent_model = get_file_parent_type_model(image_data.parent_type) - await db_get_model_with_id_if_it_exists(db, parent_model, image_data.parent_id) + async def write_upload(self, upload_file: UploadFile, filename: str) -> str: + """Persist an image upload.""" + return await get_storage(settings.image_storage_path).write_image_upload(upload_file, filename) - db_image = Image( - id=image_id, - description=image_data.description, - image_metadata=image_data.image_metadata, - filename=original_filename, - file=image_data.file, # pyright: ignore [reportArgumentType] # Incoming UploadFile cannot be preemptively cast to FileType because of how FastAPI-storages works. - parent_type=image_data.parent_type, - ) + async def after_create(self, db: AsyncSession, item: Image) -> Image: + """Process the saved image after it has been persisted.""" + return await _process_created_image(db, item) - # Set parent id - db_image.set_parent(image_data.parent_type, image_data.parent_id) - db.add(db_image) - await db.commit() - await db.refresh(db_image) +file_storage_service = FileStorageService() +image_storage_service = ImageStorageService() - return db_image +### File CRUD operations ### +async def get_files(db: AsyncSession, *, file_filter: FileFilter | None = None) -> Sequence[File]: + """Get all files from the database.""" + return await get_models(db, File, model_filter=file_filter) -async def update_image(db: AsyncSession, image_id: UUID4, image: ImageUpdate) -> Image: - """Update an existing image in the database.""" - try: - db_image: Image = await db_get_model_with_id_if_it_exists(db, Image, image_id) - except (FastAPIStorageFileNotFoundError, ModelFileNotFoundError) as e: - raise ModelFileNotFoundError(Image, image_id, details=e.message) from e - image_data: dict[str, Any] = image.model_dump(exclude_unset=True) - db_image.sqlmodel_update(image_data) +async def get_file(db: AsyncSession, file_id: UUID4) -> File: + """Get a file from the database.""" + return await _get_storage_item_or_raise(db, File, file_id) - db.add(db_image) - await db.commit() - await db.refresh(db_image) - return db_image +async def create_file(db: AsyncSession, file_data: FileCreate) -> File: + """Create a new file in the database and save it.""" + return await file_storage_service.create(db, file_data) -async def delete_image(db: AsyncSession, image_id: UUID4) -> None: - """Delete an image from the database and remove it from storage.""" - try: - db_image = await db_get_model_with_id_if_it_exists(db, Image, image_id) - file_path = Path(db_image.file.path) if db_image.file else None - except (FastAPIStorageFileNotFoundError, ModelFileNotFoundError): - # TODO: test this scenario - # File missing from storage but exists in DB - proceed with DB cleanup - db_image = await db.get(Image, image_id) - file_path = None +async def update_file(db: AsyncSession, file_id: UUID4, file: FileUpdate) -> File: + """Update an existing file in the database.""" + return await _update_storage_item(db, File, file_id, file) - await db.delete(db_image) - await db.commit() - if file_path: - await delete_file_from_storage(file_path) +async def delete_file(db: AsyncSession, file_id: UUID4) -> None: + """Delete a file from the database and remove it from storage.""" + await file_storage_service.delete(db, file_id) -### Video CRUD operations ### -async def create_video( - db: AsyncSession, - video: VideoCreate | VideoCreateWithinProduct, - product_id: int | None = None, - *, - commit: bool = True, -) -> Video: - """Create a new video in the database, optionally linked to a product.""" - if isinstance(video, VideoCreate): - product_id = video.product_id - if product_id: - await db_get_model_with_id_if_it_exists(db, Product, product_id) - - db_video = Video( - **video.model_dump(exclude={"product_id"}), - product_id=product_id, - ) - db.add(db_video) +### Image CRUD operations ### +async def get_images(db: AsyncSession, *, image_filter: ImageFilter | None = None) -> Sequence[Image]: + """Get all images from the database.""" + return await get_models(db, Image, model_filter=image_filter) - if commit: - await db.commit() - await db.refresh(db_video) - else: - await db.flush() - return db_video +async def get_image(db: AsyncSession, image_id: UUID4) -> Image: + """Get an image from the database.""" + return await _get_storage_item_or_raise(db, Image, image_id) -async def update_video(db: AsyncSession, video_id: int, video: VideoUpdate) -> Video: - """Update an existing video in the database.""" - db_video: Video = await db_get_model_with_id_if_it_exists(db, Video, video_id) +async def create_image(db: AsyncSession, image_data: ImageCreateFromForm | ImageCreateInternal) -> Image: + """Create a new image in the database and save it.""" + return await image_storage_service.create(db, image_data) - db_video.sqlmodel_update(video.model_dump(exclude_unset=True)) - db.add(db_video) - await db.commit() - await db.refresh(db_video) - return db_video +async def update_image(db: AsyncSession, image_id: UUID4, image: ImageUpdate) -> Image: + """Update an existing image in the database.""" + return await _update_storage_item(db, Image, image_id, image) -async def delete_video(db: AsyncSession, video_id: int) -> None: - """Delete a video from the database.""" - db_video: Video = await db_get_model_with_id_if_it_exists(db, Video, video_id) - await db.delete(db_video) - await db.commit() +async def delete_image(db: AsyncSession, image_id: UUID4) -> None: + """Delete an image from the database and remove it from storage.""" + await image_storage_service.delete(db, image_id) ### Parent CRUD operations ### -StorageModel = TypeVar("StorageModel", File, Image) -CreateSchema = TypeVar("CreateSchema", FileCreate, ImageCreateFromForm) -FilterType = TypeVar("FilterType", bound=Filter) +P = TypeVar("P") +S = TypeVar("S", File, Image) +C = TypeVar("C", FileCreate, ImageCreateFromForm) +F = TypeVar("F", bound=Filter) +StorageServiceT = FileStorageService | ImageStorageService -class ParentStorageOperations[MT, StorageModel, CreateSchema, FilterType]: - """Generic Create, Read, and Delete operations for managing files/images attached to a parent model.""" +class ParentStorageOperations[P, S, C, F]: + """Parent-scoped operations for file-backed models.""" def __init__( self, parent_model: type[MT], - storage_model: type[StorageModel], - parent_type: FileParentType | ImageParentType, + storage_model: type[File | Image], + parent_type: MediaParentType, parent_field: str, - create_func: Callable, - delete_func: Callable, - ): + storage_service: StorageServiceT, + ) -> None: self.parent_model = parent_model self.storage_model = storage_model self.parent_type = parent_type self.parent_field = parent_field - self._create = create_func - self._delete = delete_func - - async def get_all( - self, - db: AsyncSession, - parent_id: int, - *, - filter_params: FilterType | None = None, - ) -> Sequence[StorageModel]: - """Get all storage items for a parent.""" - # TODO: Handle missing files in storage - # Verify parent exists - await db_get_model_with_id_if_it_exists(db, self.parent_model, parent_id) - - statement = select(self.storage_model).where( - getattr(self.storage_model, self.parent_field) == parent_id, - self.storage_model.parent_type == self.parent_type, - ) - - if filter_params: - statement = filter_params.filter(statement) + self.storage_service = storage_service - return (await db.exec(statement)).all() + async def _ensure_parent_exists(self, db: AsyncSession, parent_id: int) -> None: + """Validate that the scoped parent record exists.""" + await get_model_or_404(db, self.parent_model, parent_id) - async def get_by_id(self, db: AsyncSession, parent_id: int, item_id: UUID4) -> StorageModel: - """Get a specific storage item for a parent.""" - # Verify parent exists - await db_get_model_with_id_if_it_exists(db, self.parent_model, parent_id) + def _validate_parent_scope(self, parent_id: int, item_data: C) -> None: + """Ensure the payload is already scoped to this parent.""" + create_schema = cast("FileCreate | ImageCreateFromForm | ImageCreateInternal", item_data) + if create_schema.parent_id != parent_id: + err_msg = f"Parent ID mismatch: expected {parent_id}, got {create_schema.parent_id}" + raise ValueError(err_msg) + if create_schema.parent_type != self.parent_type: + err_msg = f"Parent type mismatch: expected {self.parent_type}, got {create_schema.parent_type}" + raise ValueError(err_msg) - storage_model_name: str = self.storage_model.get_api_model_name().name_capital - parent_model_name: str = self.parent_model.get_api_model_name().name_capital + def _build_parent_statement(self) -> SelectOfScalar: + """Build the base query for storage items owned by this parent type.""" + return select(self.storage_model).where(self.storage_model.parent_type == self.parent_type) - # Get item and verify ownership + async def _get_owned_item(self, db: AsyncSession, parent_id: int, item_id: UUID4) -> File | Image: + """Fetch a storage item and verify that it belongs to the scoped parent.""" try: db_item = await db.get(self.storage_model, item_id) except (FastAPIStorageFileNotFoundError, ModelFileNotFoundError) as e: raise ModelFileNotFoundError(self.storage_model, item_id, details=str(e)) from e + if not db_item: - err_msg = f"{storage_model_name} with id {item_id} not found" - raise ValueError(err_msg) + raise ModelNotFoundError(self.storage_model, item_id) if getattr(db_item, self.parent_field) != parent_id: - err_msg: str = f"{storage_model_name} {item_id} does not belong to {parent_model_name} {parent_id}" - raise ValueError(err_msg) + raise ParentStorageOwnershipError(self.storage_model, item_id, self.parent_model, parent_id) return db_item - async def create( + async def get_all( self, db: AsyncSession, parent_id: int, - item_data: CreateSchema, - ) -> StorageModel: - """Create a new storage item for a parent.""" - # Set parent data - item_data.parent_type = self.parent_type - item_data.parent_id = parent_id + *, + filter_params: F | None = None, + ) -> Sequence[File | Image]: + """Get all storage items for a parent, excluding items with missing files.""" + await self._ensure_parent_exists(db, parent_id) - return await self._create(db, item_data) + statement = self._build_parent_statement().where( + getattr(self.storage_model, self.parent_field) == parent_id, + ) - async def delete(self, db: AsyncSession, parent_id: int, item_id: UUID4) -> None: - """Delete a storage item from a parent.""" - # Verify parent exists - await db_get_model_with_id_if_it_exists(db, self.parent_model, parent_id) + if filter_params: + statement = cast("Filter", filter_params).filter(statement) + + items = list((await db.exec(statement)).all()) + valid_items = [item for item in items if storage_item_exists(item)] + if len(valid_items) < len(items): + missing = len(items) - len(valid_items) + logger.warning( + "%d %s(s) for %s %s have missing files in storage and will be excluded from the response.", + missing, + self.storage_model.__name__, + self.parent_model.__name__, + parent_id, + ) + return valid_items + + async def get_by_id(self, db: AsyncSession, parent_id: int, item_id: UUID4) -> File | Image: + """Get a specific storage item for a parent, raising an error if the file is missing.""" + await self._ensure_parent_exists(db, parent_id) + db_item = await self._get_owned_item(db, parent_id, item_id) + + if not storage_item_exists(db_item): + raise FastAPIStorageFileNotFoundError(filename=getattr(db_item, "filename", str(item_id))) + + return db_item - # First verify the item exists and belongs to the parent - await self.get_by_id(db, parent_id, item_id) + @overload + async def create(self, db: AsyncSession, parent_id: int, item_data: FileCreate) -> File: ... - # Then delete it - await self._delete(db, item_id) + @overload + async def create(self, db: AsyncSession, parent_id: int, item_data: ImageCreateFromForm) -> Image: ... - async def delete_all(self, db: AsyncSession, parent_id: int) -> None: - """Delete all storage items associated with a parent. + async def create(self, db: AsyncSession, parent_id: int, item_data: C) -> File | Image: + """Create a new storage item for a parent.""" + self._validate_parent_scope(parent_id, item_data) + if isinstance(item_data, FileCreate): + return await cast("FileStorageService", self.storage_service).create(db, item_data) + return await cast("ImageStorageService", self.storage_service).create( + db, + cast("ImageCreateFromForm", item_data), + ) - Args: - db: Database session - parent_id: ID of parent to delete items from + async def delete(self, db: AsyncSession, parent_id: int, item_id: UUID4) -> None: + """Delete a storage item from a parent.""" + await self._ensure_parent_exists(db, parent_id) + await self._get_owned_item(db, parent_id, item_id) - Returns: - List of deleted items - """ - # Get all items for this parent - items: Sequence[StorageModel] = await self.get_all(db, parent_id) + await self.storage_service.delete(db, item_id) - # Delete each item + async def delete_all(self, db: AsyncSession, parent_id: int) -> None: + """Delete all storage items associated with a parent.""" + items = await self.get_all(db, parent_id) for item in items: - await self._delete(db, item.id) + if item.id is not None: + await self.storage_service.delete(db, item.id) diff --git a/backend/app/api/file_storage/exceptions.py b/backend/app/api/file_storage/exceptions.py index f46613aa..a3a2ca28 100644 --- a/backend/app/api/file_storage/exceptions.py +++ b/backend/app/api/file_storage/exceptions.py @@ -1,25 +1,19 @@ """Custom exceptions for file storage database models.""" -from fastapi import status - -from app.api.common.exceptions import APIError +from app.api.common.exceptions import NotFoundError, PayloadTooLargeError from app.api.common.models.custom_types import IDT, MT -class FastAPIStorageFileNotFoundError(APIError): +class FastAPIStorageFileNotFoundError(NotFoundError): """Custom error for file not found in storage.""" - http_status_code: int = status.HTTP_500_INTERNAL_SERVER_ERROR - def __init__(self, filename: str, details: str | None = None) -> None: super().__init__(message=f"File not found in storage: {filename}.", details=details) -class ModelFileNotFoundError(APIError): +class ModelFileNotFoundError(NotFoundError): """Exception raised when a file of a database model is not found in the local storage.""" - http_status_code: int = status.HTTP_500_INTERNAL_SERVER_ERROR - def __init__( self, model_type: type[MT] | None = None, model_id: IDT | None = None, details: str | None = None ) -> None: @@ -28,3 +22,23 @@ def __init__( f"{f'with id {model_id}'} not found.", details=details, ) + + +class ParentStorageOwnershipError(NotFoundError): + """Raised when a stored item does not belong to the requested parent resource.""" + + def __init__(self, storage_model: type[MT], storage_id: IDT, parent_model: type[MT], parent_id: IDT) -> None: + storage_model_name = storage_model.get_api_model_name().name_capital + parent_model_name = parent_model.get_api_model_name().name_capital + super().__init__( + message=f"{storage_model_name} with id {storage_id} not found for {parent_model_name} {parent_id}" + ) + + +class UploadTooLargeError(PayloadTooLargeError): + """Raised when an uploaded file exceeds the configured size limit.""" + + def __init__(self, *, file_size_bytes: int, max_size_mb: int) -> None: + super().__init__( + message=f"File size too large: {file_size_bytes / 1024 / 1024:.2f} MB. Maximum size: {max_size_mb} MB" + ) diff --git a/backend/app/api/file_storage/filters.py b/backend/app/api/file_storage/filters.py index 30292e3d..50286178 100644 --- a/backend/app/api/file_storage/filters.py +++ b/backend/app/api/file_storage/filters.py @@ -2,7 +2,7 @@ from fastapi_filter.contrib.sqlalchemy import Filter -from app.api.file_storage.models.models import File, FileParentType, Image, ImageParentType, Video +from app.api.file_storage.models.models import File, Image, MediaParentType, Video class FileFilter(Filter): @@ -10,7 +10,7 @@ class FileFilter(Filter): filename__ilike: str | None = None description__ilike: str | None = None - parent_type: FileParentType | None = None + parent_type: MediaParentType | None = None search: str | None = None @@ -29,7 +29,7 @@ class ImageFilter(Filter): filename__ilike: str | None = None description__ilike: str | None = None - parent_type: ImageParentType | None = None + parent_type: MediaParentType | None = None search: str | None = None diff --git a/backend/app/api/file_storage/manager.py b/backend/app/api/file_storage/manager.py new file mode 100644 index 00000000..cb1858d0 --- /dev/null +++ b/backend/app/api/file_storage/manager.py @@ -0,0 +1,38 @@ +"""Periodic background task for cleaning up unreferenced files.""" + +import logging +from typing import TYPE_CHECKING + +from app.api.file_storage.cleanup import cleanup_unreferenced_files +from app.core.background_tasks import PeriodicBackgroundTask +from app.core.config import settings + +if TYPE_CHECKING: + from collections.abc import Callable + + from sqlmodel.ext.asyncio.session import AsyncSession + +logger = logging.getLogger(__name__) + + +class FileCleanupManager(PeriodicBackgroundTask): + """Periodic background task that deletes unreferenced files from storage.""" + + def __init__(self, session_factory: Callable[[], AsyncSession]) -> None: + super().__init__(interval_seconds=settings.file_cleanup_interval_hours * 3600) + self.session_factory = session_factory + + async def initialize(self) -> None: + """Start the periodic cleanup task, unless cleanup is disabled in settings.""" + if not settings.file_cleanup_enabled: + logger.info("File cleanup is disabled (FILE_CLEANUP_ENABLED=false), skipping.") + return + logger.info("Initializing FileCleanupManager background task...") + await super().initialize() + + async def run_once(self) -> None: + """Run one cleanup pass, deleting unreferenced files from storage.""" + logger.info("Starting scheduled background file cleanup...") + async with self.session_factory() as session: + await cleanup_unreferenced_files(session, dry_run=settings.file_cleanup_dry_run) + logger.info("Finished scheduled background file cleanup.") diff --git a/backend/app/api/file_storage/models/custom_types.py b/backend/app/api/file_storage/models/custom_types.py deleted file mode 100644 index 602e7e29..00000000 --- a/backend/app/api/file_storage/models/custom_types.py +++ /dev/null @@ -1,58 +0,0 @@ -"""Custom types for FastAPI Storages models.""" - -from typing import Any, BinaryIO - -from fastapi_storages import FileSystemStorage, StorageImage -from fastapi_storages.integrations.sqlalchemy import FileType as _FileType -from fastapi_storages.integrations.sqlalchemy import ImageType as _ImageType -from sqlalchemy import Dialect - -from app.api.file_storage.exceptions import FastAPIStorageFileNotFoundError -from app.core.config import settings - - -## Custom error handling for file not found in storage -class CustomFileSystemStorage(FileSystemStorage): - """File system storage with custom error handling.""" - - def open(self, name: str) -> BinaryIO: - """Override of base class 'open' method for custom error handling.""" - try: - return super().open(name) - except FileNotFoundError as e: - details = str(e) if settings.debug else None - raise FastAPIStorageFileNotFoundError(name, details=details) from e - - -## File and Image types with custom storage paths -class FileType(_FileType): - """Custom file type with a default FileSystemStorage path. - - This supports alembic migrations on FastAPI Storages models. - """ - - def __init__( - self, *args: Any, **kwargs: Any - ) -> None: # Any-type args and kwargs are expected by the parent class signature - storage = CustomFileSystemStorage(path=str(settings.file_storage_path)) - super().__init__(*args, storage=storage, **kwargs) - - -class ImageType(_ImageType): - """Custom image type with a default FileSystemStorage path. - - This supports alembic migrations on FastAPI Storages models. - """ - - def __init__( - self, *args: Any, **kwargs: Any - ) -> None: # Any-type args and kwargs are expected by the parent class signature - storage = CustomFileSystemStorage(path=str(settings.image_storage_path)) - super().__init__(*args, storage=storage, **kwargs) - - def process_result_value(self, value: Any, dialect: Dialect) -> StorageImage | None: - """Override the default process_result_value method to raise a custom error if the file is not found.""" - try: - return super().process_result_value(value, dialect) - except FileNotFoundError as e: - raise FastAPIStorageFileNotFoundError(value, str(e)) from e diff --git a/backend/app/api/file_storage/models/models.py b/backend/app/api/file_storage/models/models.py index ae78b785..56f4d77d 100644 --- a/backend/app/api/file_storage/models/models.py +++ b/backend/app/api/file_storage/models/models.py @@ -1,171 +1,117 @@ """Database models for files, images and videos.""" import uuid -from enum import Enum -from functools import cached_property -from pathlib import Path -from typing import TYPE_CHECKING, Any, ClassVar -from urllib.parse import quote +from enum import StrEnum +from typing import TYPE_CHECKING -from markupsafe import Markup from pydantic import UUID4, ConfigDict from sqlalchemy.dialects.postgresql import JSONB -from sqlmodel import AutoString, Column, Field, Relationship +from sqlmodel import Column, Field from sqlmodel import Enum as SAEnum -from app.api.common.models.base import APIModelName, CustomBase, SingleParentMixin, TimeStampMixinBare -from app.api.common.models.custom_fields import AnyUrlInDB -from app.api.data_collection.models import Product -from app.api.file_storage.exceptions import FastAPIStorageFileNotFoundError -from app.api.file_storage.models.custom_types import FileType, ImageType -from app.core.config import settings +from app.api.common.models.base import ( + APIModelName, + CustomBase, + IntPrimaryKeyMixin, + SingleParentMixin, + TimeStampMixinBare, + UUIDPrimaryKeyMixin, +) +from app.api.file_storage.models.storage import FileType, ImageType if TYPE_CHECKING: - from app.api.background_data.models import Material, ProductType + from typing import Any, ClassVar -### Constants ### -PLACEHOLDER_IMAGE_PATH: Path = settings.static_files_path / "images " / "placeholder.png" - - -### File Model ### -class FileParentType(Enum): - """Enumeration of types that can have files.""" +### Shared parent-type enum ### +class MediaParentType(StrEnum): + """Parent entity types that can own files and images.""" PRODUCT = "product" PRODUCT_TYPE = "product_type" MATERIAL = "material" +### File Model ### class FileBase(CustomBase): """Base model for generic files stored in the local file system.""" description: str | None = Field(default=None, max_length=500, description="Description of the file") - # Class variables api_model_name: ClassVar[APIModelName | None] = APIModelName(name_camel="File") -class File(FileBase, TimeStampMixinBare, SingleParentMixin[FileParentType], table=True): +class File(FileBase, UUIDPrimaryKeyMixin, TimeStampMixinBare, SingleParentMixin[MediaParentType], table=True): """Database model for generic files stored in the local file system, using FastAPI-Storages.""" - id: UUID4 = Field(default_factory=uuid.uuid4, primary_key=True) + id: UUID4 | None = Field(default_factory=uuid.uuid4, primary_key=True, nullable=False) filename: str = Field(description="Original file name of the file. Automatically generated.") - - # TODO: Add custom file paths based on parent object (Product, year, etc.) file: FileType = Field(sa_column=Column(FileType, nullable=False), description="Local file path to the file") - # Many-to-one relationships. This is ugly but SQLModel does not play well with polymorphic associations. - # TODO: Implement improved polymorphic associations in SQLModel after this issue is resolved: https://github.com/fastapi/sqlmodel/pull/1226 - - parent_type: FileParentType = Field( - sa_column=Column(SAEnum(FileParentType), nullable=False), - description=SingleParentMixin.get_parent_type_description(FileParentType), + parent_type: MediaParentType = Field( + sa_column=Column(SAEnum(MediaParentType, name="fileparenttype"), nullable=False), + description=SingleParentMixin.get_parent_type_description(MediaParentType), ) product_id: int | None = Field(default=None, foreign_key="product.id") - product: "Product" = Relationship(back_populates="files") - material_id: int | None = Field(default=None, foreign_key="material.id") - material: "Material" = Relationship(back_populates="files") - product_type_id: int | None = Field(default=None, foreign_key="producttype.id") - product_type: "ProductType" = Relationship(back_populates="files") - - # Model configuration - model_config: ConfigDict = ConfigDict(arbitrary_types_allowed=True, use_enum_values=True) # pyright: ignore [reportIncompatibleVariableOverride] # This is not a type override, see https://github.com/fastapi/sqlmodel/discussions/855 - @cached_property - def file_url(self) -> str: - """Return the URL to the file.""" - if self.file and Path(self.file.path).exists(): - relative_path: Path = Path(self.file.path).relative_to(settings.file_storage_path) - return f"/uploads/files/{quote(str(relative_path))}" - - raise FastAPIStorageFileNotFoundError(filename=self.filename) + model_config: ConfigDict = ConfigDict(arbitrary_types_allowed=True, use_enum_values=True) ### Image Model ### - - -class ImageParentType(str, Enum): - """Enumeration of types that can have images.""" - - PRODUCT = "product" - PRODUCT_TYPE = "product_type" - MATERIAL = "material" - - class ImageBase(CustomBase): """Base model for images stored in the local file system.""" description: str | None = Field(default=None, max_length=500, description="Description of the image") image_metadata: dict[str, Any] | None = Field( - default=None, description="Image metadata as a JSON dict", sa_column=Column(JSONB) + default=None, + description="Image metadata as a JSON dict", + sa_column=Column(JSONB), ) - # Class variables api_model_name: ClassVar[APIModelName | None] = APIModelName(name_camel="Image") -class Image(ImageBase, TimeStampMixinBare, SingleParentMixin, table=True): +class Image(ImageBase, UUIDPrimaryKeyMixin, TimeStampMixinBare, SingleParentMixin[MediaParentType], table=True): """Database model for images stored in the local file system, using FastAPI-Storages.""" - id: UUID4 = Field(default_factory=uuid.uuid4, primary_key=True) + id: UUID4 | None = Field(default_factory=uuid.uuid4, primary_key=True, nullable=False) filename: str = Field(description="Original file name of the image. Automatically generated.", nullable=False) file: ImageType = Field( sa_column=Column(ImageType, nullable=False), description="Local file path to the image", ) - # Many-to-one relationships. This is ugly but SQLModel does not play well with polymorphic associations. - parent_type: ImageParentType = Field( - sa_column=Column(SAEnum(ImageParentType), nullable=False), - description=SingleParentMixin.get_parent_type_description(ImageParentType), + parent_type: MediaParentType = Field( + sa_column=Column(SAEnum(MediaParentType, name="imageparenttype"), nullable=False), + description=SingleParentMixin.get_parent_type_description(MediaParentType), ) product_id: int | None = Field(default=None, foreign_key="product.id") - product: "Product" = Relationship(back_populates="images") - material_id: int | None = Field(default=None, foreign_key="material.id") - material: "Material" = Relationship(back_populates="images") - product_type_id: int | None = Field(default=None, foreign_key="producttype.id") - product_type: "ProductType" = Relationship(back_populates="images") - - # Model configuration - model_config: ConfigDict = ConfigDict(arbitrary_types_allowed=True) # pyright: ignore [reportIncompatibleVariableOverride] # This is not a type override, see https://github.com/fastapi/sqlmodel/discussions/855 - @cached_property - def image_url(self) -> str: - """Return the URL to the image file or a placeholder if missing.""" - if self.file and Path(self.file.path).exists(): - relative_path = Path(self.file.path).relative_to(settings.image_storage_path) - return f"/uploads/images/{quote(str(relative_path))}" - return str(PLACEHOLDER_IMAGE_PATH) - - def image_preview(self, size: int = 100) -> str: - """HTML preview of the image with a specified size.""" - return Markup('').format(self.image_url, size) + model_config: ConfigDict = ConfigDict(arbitrary_types_allowed=True) ### Video Model ### class VideoBase(CustomBase): """Base model for videos stored online.""" - url: AnyUrlInDB = Field(description="URL linking to the video", sa_type=AutoString, nullable=False) + url: str = Field(description="URL linking to the video", nullable=False) title: str | None = Field(default=None, max_length=100, description="Title of the video") description: str | None = Field(default=None, max_length=500, description="Description of the video") video_metadata: dict[str, Any] | None = Field( - default=None, description="Video metadata as a JSON dict", sa_column=Column(JSONB) + default=None, + description="Video metadata as a JSON dict", + sa_column=Column(JSONB), ) -class Video(VideoBase, TimeStampMixinBare, table=True): +class Video(VideoBase, IntPrimaryKeyMixin, TimeStampMixinBare, table=True): """Database model for videos stored online.""" id: int | None = Field(default=None, primary_key=True) - - # Many-to-one relationships product_id: int = Field(foreign_key="product.id", nullable=False) - product: Product = Relationship(back_populates="videos") diff --git a/backend/app/api/file_storage/models/storage.py b/backend/app/api/file_storage/models/storage.py new file mode 100644 index 00000000..4e96dfa4 --- /dev/null +++ b/backend/app/api/file_storage/models/storage.py @@ -0,0 +1,348 @@ +"""Lightweight local file storage primitives for SQLAlchemy models.""" + +from __future__ import annotations + +import os +import re +from pathlib import Path +from typing import TYPE_CHECKING, Any, ClassVar, cast + +from anyio import open_file, to_thread +from PIL import Image +from sqlalchemy.engine.interfaces import Dialect +from sqlalchemy.types import TypeDecorator, Unicode + +from app.api.file_storage.exceptions import FastAPIStorageFileNotFoundError +from app.core.config import settings +from app.core.images import validate_image_file + +if TYPE_CHECKING: + from collections.abc import Callable + from typing import BinaryIO, Protocol, Self + + from fastapi import UploadFile + + class UploadValue(Protocol): + """Minimal protocol for uploaded files passed from FastAPI.""" + + file: BinaryIO + filename: str + + +_FILENAME_ASCII_STRIP_RE = re.compile(r"[^A-Za-z0-9_.-]") + + +def secure_filename(filename: str) -> str: + """Normalize a filename to a safe ASCII representation.""" + for sep in os.path.sep, os.path.altsep: + if sep: + filename = filename.replace(sep, " ") + + normalized_filename = _FILENAME_ASCII_STRIP_RE.sub("", "_".join(filename.split())) + return str(normalized_filename).strip("._") + + +class BaseStorage: + """Base interface for storage backends.""" + + OVERWRITE_EXISTING_FILES = True + + def get_name(self, name: str) -> str: + """Return the normalized storage name.""" + raise NotImplementedError + + def get_path(self, name: str) -> str: + """Return the absolute path for a stored file.""" + raise NotImplementedError + + def get_size(self, name: str) -> int: + """Return the file size in bytes.""" + raise NotImplementedError + + def open(self, name: str) -> BinaryIO: + """Open a stored file for reading.""" + raise NotImplementedError + + def write(self, file: BinaryIO, name: str) -> str: + """Persist an uploaded file.""" + raise NotImplementedError + + def generate_new_filename(self, filename: str) -> str: + """Generate a collision-free file name.""" + raise NotImplementedError + + +class StorageFile(str): + """String-like file wrapper returned from storage-backed columns.""" + + __slots__ = ("_name", "_storage") + + def __new__(cls, *, name: str, storage: BaseStorage) -> Self: + """Create the string value from the resolved storage path.""" + return str.__new__(cls, storage.get_path(name)) + + def __init__(self, *, name: str, storage: BaseStorage) -> None: + self._name = name + self._storage = storage + + @property + def name(self) -> str: + """File name including extension.""" + return self._storage.get_name(self._name) + + @property + def path(self) -> str: + """Absolute file path.""" + return self._storage.get_path(self._name) + + @property + def size(self) -> int: + """File size in bytes.""" + return self._storage.get_size(self._name) + + def open(self) -> BinaryIO: + """Open a binary file handle to the stored file.""" + return self._storage.open(self._name) + + def write(self, file: BinaryIO) -> str: + """Write binary file contents to storage.""" + if not self._storage.OVERWRITE_EXISTING_FILES: + self._name = self._storage.generate_new_filename(self._name) + + return self._storage.write(file=file, name=self._name) + + def __str__(self) -> str: + return self.path + + +class StorageImage(StorageFile): + """Storage file wrapper enriched with image dimensions.""" + + __slots__ = ("_height", "_width") + + def __new__(cls, *, name: str, storage: BaseStorage, height: int, width: int) -> Self: + """Create the string value from the resolved storage path.""" + del height, width + return str.__new__(cls, storage.get_path(name)) + + def __init__(self, *, name: str, storage: BaseStorage, height: int, width: int) -> None: + super().__init__(name=name, storage=storage) + self._height = height + self._width = width + + @property + def height(self) -> int: + """Image height in pixels.""" + return self._height + + @property + def width(self) -> int: + """Image width in pixels.""" + return self._width + + +class FileSystemStorage(BaseStorage): + """Filesystem-backed local storage.""" + + default_chunk_size = 64 * 1024 + + def __init__(self, path: str, *, create_path: bool = False) -> None: + self._path = Path(path) + if create_path: + self._ensure_path() + + def _ensure_path(self) -> None: + """Create the storage directory if needed.""" + self._path.mkdir(parents=True, exist_ok=True) + + def get_name(self, name: str) -> str: + """Normalize a file name for storage.""" + return secure_filename(Path(name).name) + + def get_path(self, name: str) -> str: + """Return the absolute path for a stored file.""" + return str(self._path / Path(name)) + + def get_size(self, name: str) -> int: + """Return the file size in bytes.""" + return (self._path / name).stat().st_size + + def open(self, name: str) -> BinaryIO: + """Open a stored file in binary mode.""" + return (self._path / Path(name)).open("rb") + + def write(self, file: BinaryIO, name: str) -> str: + """Write a binary file to local storage.""" + self._ensure_path() + filename = secure_filename(name) + path = self._path / Path(filename) + + file.seek(0) + with path.open("wb") as output: + while chunk := file.read(self.default_chunk_size): + output.write(chunk) + + return str(path) + + def generate_new_filename(self, filename: str) -> str: + """Generate a unique filename if collisions are not allowed.""" + counter = 0 + path = self._path / filename + stem, extension = Path(filename).stem, Path(filename).suffix + + while path.exists(): + counter += 1 + path = self._path / f"{stem}_{counter}{extension}" + + return path.name + + async def write_upload(self, upload_file: UploadFile, name: str) -> str: + """Write an uploaded file using async file I/O.""" + self._ensure_path() + filename = self.get_name(name) + path = self._path / filename + + await upload_file.seek(0) + async with await open_file(path, "wb") as output: + while chunk := await upload_file.read(self.default_chunk_size): + await output.write(chunk) + + await upload_file.close() + return filename + + async def write_image_upload(self, upload_file: UploadFile, name: str) -> str: + """Validate and write an uploaded image using async file I/O.""" + self._ensure_path() + await to_thread.run_sync(validate_image_file, upload_file.file) + + return await self.write_upload(upload_file, name) + + +class CustomFileSystemStorage(FileSystemStorage): + """Filesystem storage with custom error handling for the app.""" + + def open(self, name: str) -> BinaryIO: + """Map missing files to the API-specific not-found error.""" + try: + return super().open(name) + except FileNotFoundError as e: + details = str(e) if settings.debug else None + raise FastAPIStorageFileNotFoundError(name, details=details) from e + + +class _BaseStorageType(TypeDecorator): + """Shared SQLAlchemy type behavior for storage-backed columns.""" + + impl = Unicode + cache_ok = True + + def __init__(self, storage: BaseStorage, *args: object, **kwargs: object) -> None: + self.storage = storage + super().__init__(*args, **kwargs) + + def process_bind_param(self, value: UploadValue | None, dialect: Dialect) -> str | None: + """Persist an uploaded value and return the stored file name.""" + del dialect + if value is None: + return value + if isinstance(value, str): + return self.storage.get_name(value) + + file_obj = value.file + if len(file_obj.read(1)) != 1: + return None + + file_obj.seek(0) + try: + return self._process_upload_value(value, file_obj) + finally: + file_obj.close() + + def _process_upload_value(self, value: UploadValue, file_obj: BinaryIO) -> str: + """Persist an uploaded file-like value and return the stored name.""" + raise NotImplementedError + + +class _FileType(_BaseStorageType): + """Store uploaded files on disk and persist only the file name.""" + + def _process_upload_value(self, value: UploadValue, file_obj: BinaryIO) -> str: + file = StorageFile(name=value.filename, storage=self.storage) + file.write(file=file_obj) + return file.name + + def process_result_value(self, value: str | None, dialect: Dialect) -> StorageFile | None: + """Hydrate a database value as a storage-backed file object.""" + del dialect + if value is None: + return value + + return StorageFile(name=value, storage=self.storage) + + +class _ImageType(_BaseStorageType): + """Store uploaded images on disk and persist only the file name.""" + + def _process_upload_value(self, value: UploadValue, file_obj: BinaryIO) -> str: + validate_image_file(file_obj) + with Image.open(file_obj) as image_file: + width, height = image_file.size + + file_obj.seek(0) + image = StorageImage(name=value.filename, storage=self.storage, height=height, width=width) + image.write(file=file_obj) + return image.name + + def process_result_value(self, value: str | None, dialect: Dialect) -> StorageImage | None: + """Hydrate a database value as a storage-backed image object.""" + del dialect + if value is None: + return value + + with Image.open(self.storage.get_path(value)) as image: + width, height = image.size + + return StorageImage(name=value, storage=self.storage, height=height, width=width) + + +def get_storage(path: Path) -> CustomFileSystemStorage: + """Build a storage backend for a configured filesystem path.""" + return CustomFileSystemStorage(path=str(path)) + + +class _ConfiguredStorageTypeMixin: + """Inject a configured storage backend into a SQLAlchemy type.""" + + storage_factory: ClassVar[Callable[[], CustomFileSystemStorage]] + + def __init__(self, *args: object, **kwargs: object) -> None: + super_init = cast("Any", super().__init__) + super_init(type(self).storage_factory(), *args, **kwargs) + + +class _MissingFileReturnsNoneMixin: + """Normalize missing files to None for graceful application-level handling.""" + + def process_result_value(self, value: Any, dialect: Dialect) -> StorageImage | None: # noqa: ANN401 # Any-type value is expected by the parent class signature + try: + return cast("Any", super()).process_result_value(value, dialect) + except FileNotFoundError: + return None + + +class FileType(_ConfiguredStorageTypeMixin, _FileType): + """Custom file type with the configured local file storage.""" + + @staticmethod + def storage_factory() -> CustomFileSystemStorage: + """Build the storage backend used by file columns.""" + return get_storage(settings.file_storage_path) + + +class ImageType(_MissingFileReturnsNoneMixin, _ConfiguredStorageTypeMixin, _ImageType): + """Custom image type with the configured local image storage.""" + + @staticmethod + def storage_factory() -> CustomFileSystemStorage: + """Build the storage backend used by image columns.""" + return get_storage(settings.image_storage_path) diff --git a/backend/app/api/file_storage/presentation.py b/backend/app/api/file_storage/presentation.py new file mode 100644 index 00000000..08da2478 --- /dev/null +++ b/backend/app/api/file_storage/presentation.py @@ -0,0 +1,78 @@ +"""Presentation helpers for file storage models.""" + +from pathlib import Path +from urllib.parse import quote + +from app.api.file_storage.models.models import File, Image +from app.api.file_storage.schemas import FileReadWithinParent, ImageReadWithinParent +from app.core.config import settings + + +def stored_file_path(item: File | Image) -> Path | None: + """Return the storage path for a stored file-backed model.""" + file_field = getattr(item, "file", None) + path = getattr(file_field, "path", None) + return Path(path) if path else None + + +def storage_item_exists(item: File | Image) -> bool: + """Return whether the backing file exists on disk.""" + file_path = stored_file_path(item) + return file_path is not None and file_path.exists() + + +def build_file_url(file: File) -> str | None: + """Build the public URL for a stored file.""" + file_path = stored_file_path(file) + if file_path is None or not file_path.exists(): + return None + + relative_path = file_path.relative_to(settings.file_storage_path) + return f"/uploads/files/{quote(str(relative_path))}" + + +def build_image_url(image: Image) -> str | None: + """Build the public URL for a stored image.""" + image_path = stored_file_path(image) + if image_path is None or not image_path.exists(): + return None + + relative_path = image_path.relative_to(settings.image_storage_path) + return f"/uploads/images/{quote(str(relative_path))}" + + +def build_thumbnail_url(image: Image) -> str | None: + """Build the public thumbnail URL for an image.""" + if image.id is None or stored_file_path(image) is None: + return None + return f"/images/{image.id}/resized?width=200" + + +def serialize_file_read(file: File) -> FileReadWithinParent: + """Convert a file model to its API read schema.""" + return FileReadWithinParent.model_validate( + { + "id": file.db_id, + "description": file.description, + "filename": file.filename, + "file_url": build_file_url(file), + "created_at": file.created_at, + "updated_at": file.updated_at, + } + ) + + +def serialize_image_read(image: Image) -> ImageReadWithinParent: + """Convert an image model to its API read schema.""" + return ImageReadWithinParent.model_validate( + { + "id": image.db_id, + "description": image.description, + "image_metadata": image.image_metadata, + "filename": image.filename, + "image_url": build_image_url(image), + "thumbnail_url": build_thumbnail_url(image), + "created_at": image.created_at, + "updated_at": image.updated_at, + } + ) diff --git a/backend/app/api/file_storage/router_factories.py b/backend/app/api/file_storage/router_factories.py index 55ceda86..4bd9721d 100644 --- a/backend/app/api/file_storage/router_factories.py +++ b/backend/app/api/file_storage/router_factories.py @@ -1,8 +1,9 @@ -"""Common generator functions for routers.""" +"""Shared router builders for parent-scoped file storage endpoints.""" -from collections.abc import Callable, Sequence -from enum import Enum -from typing import Annotated, Any, TypeVar +import json +from collections.abc import Callable +from enum import StrEnum +from typing import Annotated, Any, cast from fastapi import APIRouter, Depends, Form, Path, Security, UploadFile from fastapi import File as FastAPIFile @@ -14,7 +15,8 @@ from app.api.common.routers.dependencies import AsyncSessionDep from app.api.file_storage.crud import ParentStorageOperations from app.api.file_storage.filters import FileFilter, ImageFilter -from app.api.file_storage.models.models import File, Image +from app.api.file_storage.models.models import File, Image, MediaParentType +from app.api.file_storage.presentation import serialize_file_read, serialize_image_read from app.api.file_storage.schemas import ( FileCreate, FileReadWithinParent, @@ -23,19 +25,12 @@ empty_str_to_none, ) -StorageModel = TypeVar("StorageModel", File, Image) -ReadSchema = TypeVar("ReadSchema", FileReadWithinParent, ImageReadWithinParent) -CreateSchema = TypeVar("CreateSchema", FileCreate, ImageCreateFromForm) -FilterSchema = TypeVar("FilterSchema", FileFilter, ImageFilter) +BaseDep = Callable[[], Any] +ParentIdDep = Callable[[IDT], Any] +STORAGE_EXTENSION_MAP = {"file": "csv", "image": "jpg"} -BaseDep = Callable[[], Any] # Base auth dependency -ParentIdDep = Callable[[IDT], Any] # Dependency with parent_id parameter -# Map of example extension for each storage type -STORAGE_EXTENSION_MAP: dict = {"image": "jpg", "file": "csv"} - - -class StorageRouteMethod(str, Enum): +class StorageRouteMethod(StrEnum): """Enum for storage route methods.""" GET = "get" @@ -43,230 +38,276 @@ class StorageRouteMethod(str, Enum): DELETE = "delete" -# TODO: Simplify, or split it up in read and modify factories, or just create the routes manually for clarity -def add_storage_type_routes( +def _build_passthrough_parent_dependency(parent_id_param: str, parent_title: str) -> ParentIdDep: + """Create a default dependency that simply reads the parent id from the path.""" + + async def dependency( + parent_id: Annotated[int, Path(alias=parent_id_param, description=f"ID of the {parent_title}")], + ) -> int: + return parent_id + + return dependency + + +def _storage_example(parent_title: str, *, storage_slug: str, storage_title: str) -> dict[str, Any]: + """Build a standard OpenAPI example for a storage resource.""" + ext = STORAGE_EXTENSION_MAP[storage_slug] + return { + "id": 1, + "filename": f"example.{ext}", + "description": f"{parent_title} {storage_title}", + f"{storage_slug}_url": f"/uploads/{storage_slug}s/example.{ext}", + "created_at": "2025-09-22T14:30:45Z", + "updated_at": "2025-09-22T14:30:45Z", + } + + +def _add_file_routes( router: APIRouter, *, parent_api_model_name: APIModelName, storage_crud: ParentStorageOperations, - read_schema: type[ReadSchema], - create_schema: type[CreateSchema], - filter_schema: type[FilterSchema], include_methods: set[StorageRouteMethod], - read_auth_dep: BaseDep | None = None, - read_parent_auth_dep: ParentIdDep | None = None, - modify_auth_dep: BaseDep | None = None, - modify_parent_auth_dep: ParentIdDep | None = None, + read_auth_dep: BaseDep | None, + read_parent_auth_dep: ParentIdDep | None, + modify_auth_dep: BaseDep | None, + modify_parent_auth_dep: ParentIdDep | None, ) -> None: - """Add storage routes for a specific storage type (files or images) to a router. - - Args: - router (APIRouter): The router to add the routes to. - parent_api_model_name (APIModelName): The parent model name. - storage_crud (ParentStorageOperations): The CRUD operations for the storage type. - read_schema (type[ReadSchema]): The schema to use for reading storage items. - create_schema (type[CreateSchema]): The schema to use for creating storage items. - filter_schema (type[FilterSchema]): The schema to use for filtering storage items. - include_methods (set[StorageRouteMethods] | None, optional): The methods to include in the routes. - read_auth_dep (Callable[[], Any] | None, optional): The authentication dependency for reading storage items. - Defaults to None. - read_parent_auth_dep (Callable[[IDT], Any] | None, optional): The authentication dependency for reading - storage items with a given parent_id. Defaults to None. - modify_auth_dep (Callable[[], Any] | None, optional): The authentication dependency for modifying storage items. - Defaults to None. - modify_parent_auth_dep (Callable[[IDT], Any] | None, optional): The authentication dependency for modifying - storage items with a given parent_id. Defaults to None. - """ - parent_slug_plural: str = parent_api_model_name.plural_slug - parent_title: str = parent_api_model_name.name_capital - parent_id_param: str = parent_api_model_name.name_snake + "_id" - - storage_type_title: str = read_schema.get_api_model_name().name_capital - storage_type_title_plural: str = read_schema.get_api_model_name().plural_capital - storage_type_slug: str = read_schema.get_api_model_name().name_slug - storage_type_slug_plural = read_schema.get_api_model_name().plural_slug - - storage_type = storage_type_slug - storage_ext: str = STORAGE_EXTENSION_MAP[storage_type] - - # HACK: Define null parent auth dependencies if none are provided - # TODO: Simplify storage crud and router factories - if read_parent_auth_dep is None: - - async def read_parent_auth_dep( - parent_id: Annotated[int, Path(alias=parent_id_param, description=f"ID of the {parent_title}")], - ) -> int: - return parent_id - - if modify_parent_auth_dep is None: - - async def modify_parent_auth_dep( - parent_id: Annotated[int, Path(alias=parent_id_param, description=f"ID of the {parent_title}")], - ) -> int: - return parent_id + """Register file routes for a parent resource.""" + parent_title = parent_api_model_name.name_capital + parent_id_param = f"{parent_api_model_name.name_snake}_id" + read_parent_dep = read_parent_auth_dep or _build_passthrough_parent_dependency(parent_id_param, parent_title) + modify_parent_dep = modify_parent_auth_dep or _build_passthrough_parent_dependency(parent_id_param, parent_title) + example = _storage_example(parent_title, storage_slug="file", storage_title="File") if StorageRouteMethod.GET in include_methods: @router.get( - f"/{{{parent_id_param}}}/{storage_type_slug_plural}", - description=f"Get all {storage_type_title_plural} associated with the {parent_title}", + f"/{{{parent_id_param}}}/files", + response_model=list[FileReadWithinParent], + description=f"Get all Files associated with the {parent_title}", dependencies=[Security(read_auth_dep)] if read_auth_dep else None, - response_model=list[read_schema], responses={ 200: { - "description": f"List of {storage_type_title_plural} associated with the {parent_title}", - "content": { - "application/json": { - "example": [ - { - "id": 1, - "filename": f"example.{storage_ext}", - "description": f"{parent_title} {storage_type_title}", - f"{storage_type_slug}_url": f"/uploads/{parent_slug_plural}/1/example.{storage_ext}", - "created_at": "2025-09-22T14:30:45Z", - "updated_at": "2025-09-22T14:30:45Z", - } - ] - } - }, + "description": f"List of Files associated with the {parent_title}", + "content": {"application/json": {"example": [example]}}, }, 404: {"description": f"{parent_title} not found"}, }, - summary=f"Get {parent_title} {storage_type_title_plural}", + summary=f"Get {parent_title} Files", + ) + async def get_files( + session: AsyncSessionDep, + parent_id: Annotated[int, Depends(read_parent_dep)], + item_filter: FileFilter = FilterDepends(FileFilter), + ) -> list[FileReadWithinParent]: + """Get all files associated with the parent.""" + items = await storage_crud.get_all(session, parent_id, filter_params=item_filter) + return [serialize_file_read(cast("File", item)) for item in items] + + @router.get( + f"/{{{parent_id_param}}}/files/{{file_id}}", + response_model=FileReadWithinParent, + dependencies=[Security(read_auth_dep)] if read_auth_dep else None, + description=f"Get specific {parent_title} File by ID", + responses={ + 200: {"description": "File found", "content": {"application/json": {"example": example}}}, + 404: {"description": f"{parent_title} or file not found"}, + }, + summary=f"Get specific {parent_title} File", + ) + async def get_file( + parent_id: Annotated[int, Depends(read_parent_dep)], + item_id: Annotated[UUID4, Path(alias="file_id", description="ID of the file")], + session: AsyncSessionDep, + ) -> FileReadWithinParent: + """Get a specific file associated with the parent.""" + item = await storage_crud.get_by_id(session, parent_id, item_id) + return serialize_file_read(cast("File", item)) + + if StorageRouteMethod.POST in include_methods: + + @router.post( + f"/{{{parent_id_param}}}/files", + response_model=FileReadWithinParent, + status_code=201, + dependencies=[Security(modify_auth_dep)] if modify_auth_dep else None, + description=f"Upload a new File for the {parent_title}", + responses={ + 201: { + "description": "File successfully uploaded", + "content": {"application/json": {"example": example}}, + }, + 400: {"description": "Invalid file data"}, + 404: {"description": f"{parent_title} not found"}, + }, + summary=f"Add File to {parent_title}", + ) + async def upload_file( + session: AsyncSessionDep, + parent_id: Annotated[int, Depends(modify_parent_dep)], + file: Annotated[UploadFile, FastAPIFile(description="A file to upload")], + description: Annotated[str | None, Form()] = None, + ) -> FileReadWithinParent: + """Upload a new file for the parent.""" + item_data = FileCreate( + file=file, + description=description, + parent_id=parent_id, + parent_type=MediaParentType(parent_api_model_name.name_snake), + ) + item = await storage_crud.create(session, parent_id, item_data) + return serialize_file_read(item) + + if StorageRouteMethod.DELETE in include_methods: + + @router.delete( + f"/{{{parent_id_param}}}/files/{{file_id}}", + dependencies=[Security(modify_auth_dep)] if modify_auth_dep else None, + description=f"Remove File from the {parent_title} and delete it from the storage.", + responses={ + 204: {"description": "File successfully removed"}, + 404: {"description": f"{parent_title} or file not found"}, + }, + summary=f"Remove File from {parent_title}", + status_code=204, ) - async def get_items( + async def delete_file( + parent_id: Annotated[int, Depends(modify_parent_dep)], + item_id: Annotated[UUID4, Path(alias="file_id", description="ID of the file")], session: AsyncSessionDep, - parent_id: Annotated[int, Depends(read_parent_auth_dep)], - item_filter: FilterSchema = FilterDepends(filter_schema), - ) -> Sequence[StorageModel]: - """Get all storage items associated with the parent.""" - return await storage_crud.get_all(session, parent_id, filter_params=item_filter) + ) -> None: + """Remove a file from the parent.""" + await storage_crud.delete(session, parent_id, item_id) + + +def _add_image_routes( + router: APIRouter, + *, + parent_api_model_name: APIModelName, + storage_crud: ParentStorageOperations, + include_methods: set[StorageRouteMethod], + read_auth_dep: BaseDep | None, + read_parent_auth_dep: ParentIdDep | None, + modify_auth_dep: BaseDep | None, + modify_parent_auth_dep: ParentIdDep | None, +) -> None: + """Register image routes for a parent resource.""" + parent_title = parent_api_model_name.name_capital + parent_id_param = f"{parent_api_model_name.name_snake}_id" + read_parent_dep = read_parent_auth_dep or _build_passthrough_parent_dependency(parent_id_param, parent_title) + modify_parent_dep = modify_parent_auth_dep or _build_passthrough_parent_dependency(parent_id_param, parent_title) + example = _storage_example(parent_title, storage_slug="image", storage_title="Image") + + if StorageRouteMethod.GET in include_methods: @router.get( - f"/{{{parent_id_param}}}/{storage_type_slug_plural}/{{{storage_type_slug}_id}}", + f"/{{{parent_id_param}}}/images", + response_model=list[ImageReadWithinParent], + description=f"Get all Images associated with the {parent_title}", dependencies=[Security(read_auth_dep)] if read_auth_dep else None, - description=f"Get specific {parent_title} {storage_type_title} by ID", - response_model=read_schema, responses={ 200: { - "description": f"{storage_type.title()} found", - "content": { - "application/json": { - "example": { - "id": 1, - "filename": f"example.{storage_ext}", - "description": f"{parent_title} {storage_type_title}", - f"{storage_type_slug}_url": f"/uploads/{parent_slug_plural}/1/example.{storage_ext}", - "created_at": "2025-09-22T14:30:45Z", - "updated_at": "2025-09-22T14:30:45Z", - } - } - }, + "description": f"List of Images associated with the {parent_title}", + "content": {"application/json": {"example": [example]}}, }, - 404: {"description": f"{parent_title} or {storage_type} not found"}, + 404: {"description": f"{parent_title} not found"}, + }, + summary=f"Get {parent_title} Images", + ) + async def get_images( + session: AsyncSessionDep, + parent_id: Annotated[int, Depends(read_parent_dep)], + item_filter: ImageFilter = FilterDepends(ImageFilter), + ) -> list[ImageReadWithinParent]: + """Get all images associated with the parent.""" + items = await storage_crud.get_all(session, parent_id, filter_params=item_filter) + return [serialize_image_read(cast("Image", item)) for item in items] + + @router.get( + f"/{{{parent_id_param}}}/images/{{image_id}}", + response_model=ImageReadWithinParent, + dependencies=[Security(read_auth_dep)] if read_auth_dep else None, + description=f"Get specific {parent_title} Image by ID", + responses={ + 200: {"description": "Image found", "content": {"application/json": {"example": example}}}, + 404: {"description": f"{parent_title} or image not found"}, }, - summary=f"Get specific {parent_title} {storage_type_title}", + summary=f"Get specific {parent_title} Image", ) - async def get_item( - parent_id: Annotated[int, Depends(read_parent_auth_dep)], - item_id: Annotated[UUID4, Path(alias=f"{storage_type_slug}_id", description=f"ID of the {storage_type}")], + async def get_image( + parent_id: Annotated[int, Depends(read_parent_dep)], + item_id: Annotated[UUID4, Path(alias="image_id", description="ID of the image")], session: AsyncSessionDep, - ) -> StorageModel: - """Get a specific storage item associated with the parent.""" - return await storage_crud.get_by_id(session, parent_id, item_id) + ) -> ImageReadWithinParent: + """Get a specific image associated with the parent.""" + item = await storage_crud.get_by_id(session, parent_id, item_id) + return serialize_image_read(cast("Image", item)) if StorageRouteMethod.POST in include_methods: - # HACK: This is an ugly way to differentiate between file and image uploads - common_upload_route_params = { - "path": f"/{{{parent_id_param}}}/{storage_type_slug_plural}", - "dependencies": [Security(modify_auth_dep)] if modify_auth_dep else None, - "description": f"Upload a new {storage_type_title} for the {parent_title}", - "response_model": read_schema, - "responses": { - 200: { - "description": f"{storage_type_title} successfully uploaded", - "content": { - "application/json": { - "example": { - "id": 1, - "filename": f"example.{storage_ext}", - "description": f"{parent_title} {storage_type_title}", - f"{storage_type_slug}_url": f"/uploads/{parent_slug_plural}/1/example.{storage_ext}", - "created_at": "2025-09-22T14:30:45Z", - "updated_at": "2025-09-22T14:30:45Z", - } - } - }, + + @router.post( + f"/{{{parent_id_param}}}/images", + response_model=ImageReadWithinParent, + status_code=201, + dependencies=[Security(modify_auth_dep)] if modify_auth_dep else None, + description=f"Upload a new Image for the {parent_title}", + responses={ + 201: { + "description": "Image successfully uploaded", + "content": {"application/json": {"example": example}}, }, - 400: {"description": f"Invalid {storage_type} data"}, + 400: {"description": "Invalid image data"}, 404: {"description": f"{parent_title} not found"}, }, - "summary": f"Add {storage_type_title} to {parent_title}", - } - - if create_schema is ImageCreateFromForm: - - @router.post(**common_upload_route_params) - async def upload_image( - session: AsyncSessionDep, - parent_id: Annotated[int, Depends(modify_parent_auth_dep)], - file: Annotated[UploadFile, FastAPIFile(description="An image to upload")], - description: Annotated[str | None, Form()] = None, - image_metadata: Annotated[ - str | None, - Form( - description="Image metadata in JSON string format", - examples=[r'{"foo_key": "foo_value", "bar_key": {"nested_key": "nested_value"}}'], - ), - BeforeValidator(empty_str_to_none), - ] = None, - ) -> StorageModel: - """Upload a new image for the parent. - - Note that the parent id and type setting is handled in the crud operation. - """ - item_data = ImageCreateFromForm(file=file, description=description, image_metadata=image_metadata) - return await storage_crud.create(session, parent_id, item_data) - - elif create_schema is FileCreate: - - @router.post(**common_upload_route_params) - async def upload_file( - session: AsyncSessionDep, - parent_id: Annotated[int, Depends(modify_parent_auth_dep)], - file: Annotated[UploadFile, FastAPIFile(description="A file to upload")], - description: Annotated[str | None, Form()] = None, - ) -> StorageModel: - """Upload a new file for the parent. - - Note that the parent id and type setting is handled in the crud operation. - """ - item_data = FileCreate(file=file, description=description) - return await storage_crud.create(session, parent_id, item_data) - - else: - err_msg = "Invalid create schema" - raise ValueError(err_msg) + summary=f"Add Image to {parent_title}", + ) + async def upload_image( + session: AsyncSessionDep, + parent_id: Annotated[int, Depends(modify_parent_dep)], + file: Annotated[UploadFile, FastAPIFile(description="An image to upload")], + description: Annotated[str | None, Form()] = None, + image_metadata: Annotated[ + str | None, + Form( + description="Image metadata in JSON string format", + examples=[r'{"foo_key": "foo_value", "bar_key": {"nested_key": "nested_value"}}'], + ), + BeforeValidator(empty_str_to_none), + ] = None, + ) -> ImageReadWithinParent: + """Upload a new image for the parent.""" + item_data = ImageCreateFromForm.model_validate( + { + "file": file, + "description": description, + "image_metadata": json.loads(image_metadata) if image_metadata is not None else None, + "parent_id": parent_id, + "parent_type": MediaParentType(parent_api_model_name.name_snake), + } + ) + item = await storage_crud.create(session, parent_id, item_data) + return serialize_image_read(item) if StorageRouteMethod.DELETE in include_methods: @router.delete( - f"/{{{parent_id_param}}}/{storage_type_slug_plural}/{{{storage_type_slug}_id}}", + f"/{{{parent_id_param}}}/images/{{image_id}}", dependencies=[Security(modify_auth_dep)] if modify_auth_dep else None, - description=f"Remove {storage_type_title} from the {parent_title} and delete it from the storage.", + description=f"Remove Image from the {parent_title} and delete it from the storage.", responses={ - 204: {"description": f"{storage_type.title()} successfully removed"}, - 404: {"description": f"{parent_title} or {storage_type} not found"}, + 204: {"description": "Image successfully removed"}, + 404: {"description": f"{parent_title} or image not found"}, }, - summary=f"Remove {storage_type_title} from {parent_title}", + summary=f"Remove Image from {parent_title}", status_code=204, ) - async def delete_item( - parent_id: Annotated[int, Depends(modify_parent_auth_dep)], - item_id: Annotated[UUID4, Path(alias=f"{storage_type_slug}_id", description=f"ID of the {storage_type}")], + async def delete_image( + parent_id: Annotated[int, Depends(modify_parent_dep)], + item_id: Annotated[UUID4, Path(alias="image_id", description="ID of the image")], session: AsyncSessionDep, ) -> None: - """Remove a storage item from the parent.""" + """Remove an image from the parent.""" await storage_crud.delete(session, parent_id, item_id) @@ -283,29 +324,20 @@ def add_storage_routes( modify_parent_auth_dep: ParentIdDep | None = None, ) -> None: """Add both file and image storage routes to a router.""" - # Add file routes - add_storage_type_routes( - router=router, + _add_file_routes( + router, parent_api_model_name=parent_api_model_name, storage_crud=files_crud, - read_schema=FileReadWithinParent, - create_schema=FileCreate, - filter_schema=FileFilter, include_methods=include_methods, read_auth_dep=read_auth_dep, read_parent_auth_dep=read_parent_auth_dep, modify_auth_dep=modify_auth_dep, modify_parent_auth_dep=modify_parent_auth_dep, ) - - # Add image routes - add_storage_type_routes( - router=router, + _add_image_routes( + router, parent_api_model_name=parent_api_model_name, storage_crud=images_crud, - read_schema=ImageReadWithinParent, - create_schema=ImageCreateFromForm, - filter_schema=ImageFilter, include_methods=include_methods, read_auth_dep=read_auth_dep, read_parent_auth_dep=read_parent_auth_dep, diff --git a/backend/app/api/file_storage/routers.py b/backend/app/api/file_storage/routers.py new file mode 100644 index 00000000..75254061 --- /dev/null +++ b/backend/app/api/file_storage/routers.py @@ -0,0 +1,77 @@ +"""Routers for file storage models, including image resizing.""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING, Annotated + +from anyio import Path as AsyncPath +from anyio import to_thread +from fastapi import APIRouter, HTTPException, Query, Request, Response +from pydantic import UUID4 + +from app.api.common.routers.dependencies import AsyncSessionDep +from app.api.file_storage.crud import get_image +from app.core.constants import HOUR +from app.core.images import resize_image +from app.core.logging import sanitize_log_value + +if TYPE_CHECKING: + from typing import NoReturn + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/images", tags=["images"]) + +MEDIA_TYPE_WEBP = "image/webp" + + +@router.get("/{image_id}/resized", summary="Get a resized version of an image") +async def get_resized_image( + request: Request, + image_id: UUID4, + session: AsyncSessionDep, + width: Annotated[int | None, Query(gt=0, le=2000)] = 200, + height: Annotated[int | None, Query(gt=0, le=2000)] = None, +) -> Response: + """Get a resized version of an image as WebP. + + The image is resized while maintaining its aspect ratio. + Resizing is performed in a background thread to avoid blocking the event loop. + Results are cached via HTTP Cache-Control headers for 1 hour. + """ + + def _raise_not_found(detail: str) -> NoReturn: + raise HTTPException(status_code=404, detail=detail) + + def _raise_error(detail: str, exc: Exception | None = None) -> NoReturn: + if exc: + raise HTTPException(status_code=500, detail=detail) from exc + raise HTTPException(status_code=500, detail=detail) + + try: + db_image = await get_image(session, image_id) + if not db_image.file or not db_image.file.path: + _raise_not_found("Image file not found in storage") + + image_path = AsyncPath(db_image.file.path) + if not await image_path.exists(): + _raise_not_found("Image file not found on disk") + + # Resize the image in a separate thread to avoid blocking the event loop. + # Use the capacity limiter from app.state to bound concurrent resize workers. + limiter = getattr(request.app.state, "image_resize_limiter", None) + resized_bytes = await to_thread.run_sync(resize_image, image_path, width, height, limiter=limiter) + + # Return response with HTTP cache headers for browser/CDN caching + return Response( + content=resized_bytes, + media_type=MEDIA_TYPE_WEBP, + headers={"Cache-Control": f"public, max-age={HOUR}, immutable"}, + ) + + except HTTPException: + raise + except Exception as e: + logger.exception("Error resizing image %s", sanitize_log_value(image_id)) + _raise_error("Error resizing image", exc=e) diff --git a/backend/app/api/file_storage/schemas.py b/backend/app/api/file_storage/schemas.py index ab4bf4b5..c69c9945 100644 --- a/backend/app/api/file_storage/schemas.py +++ b/backend/app/api/file_storage/schemas.py @@ -1,44 +1,28 @@ -"""Pydantic models used to validate CRUD operations for file data.""" +"""Pydantic models used to validate file storage CRUD operations.""" -from typing import Annotated, Any +from pathlib import Path +from typing import TYPE_CHECKING, Annotated, Any, cast +from urllib.parse import quote from fastapi import UploadFile -from pydantic import AfterValidator, Field, HttpUrl, Json, PositiveInt +from pydantic import AfterValidator, Field, PositiveInt, model_validator -from app.api.common.models.custom_types import IDT from app.api.common.schemas.base import BaseCreateSchema, BaseReadSchemaWithTimeStamp, BaseUpdateSchema -from app.api.file_storage.models.models import FileBase, FileParentType, ImageBase, ImageParentType, VideoBase +from app.api.common.schemas.custom_fields import AnyUrlToDB +from app.api.file_storage.models.models import FileBase, ImageBase, MediaParentType, VideoBase +from app.core.config import settings +from app.core.images import validate_image_mime_type -### Constants ### -MAX_FILE_SIZE_MB = 50 +if TYPE_CHECKING: + from os import PathLike -ALLOWED_IMAGE_MIME_TYPES: set[str] = { - "image/bmp", - "image/gif", - "image/jpeg", - "image/png", - "image/tiff", - "image/webp", -} +MAX_FILE_SIZE_MB = 50 MAX_IMAGE_SIZE_MB = 10 - - -### Common Validation ### -def validate_file_size(file: UploadFile | None, max_size_mb: int) -> UploadFile | None: - """Validate the file size against a maximum size limit.""" - if file is None: - return file - if file.size is None or file.size == 0: - err_msg: str = "File size is None or zero." - raise ValueError(err_msg) - if file.size > max_size_mb * 1024 * 1024: - err_msg: str = f"File size too large: {file.size / 1024 / 1024:.2f} MB. Maximum size: {max_size_mb} MB" - raise ValueError(err_msg) - return file +PARENT_TYPE_DESCRIPTION = f"Type of the parent object, e.g. {', '.join(parent.value for parent in MediaParentType)}" def validate_filename(file: UploadFile | None) -> UploadFile | None: - """Validate the image file name.""" + """Validate that the uploaded file has a filename.""" if file is None: return file if not file.filename: @@ -47,139 +31,184 @@ def validate_filename(file: UploadFile | None) -> UploadFile | None: return file -AT = Any # HACK: To avoid type issues +def empty_str_to_none(value: object) -> object | None: + """Convert empty strings in request form to None.""" + if value == "": + return None + return value + +def _build_storage_url(path: str | PathLike[str] | None, storage_root: Path, url_prefix: str) -> str | None: + """Build a public URL for a stored file-backed object from its filesystem path.""" + if path is None: + return None -def empty_str_to_none(v: AT) -> AT | None: - """Convert empty strings in request form to None.""" - if v == "": + file_path = Path(path) + if not file_path.exists(): return None - return v + + relative_path = file_path.relative_to(storage_root) + return f"{url_prefix}/{quote(str(relative_path))}" + + +FileUpload = Annotated[ + UploadFile, + AfterValidator(validate_filename), +] + +ImageUpload = Annotated[ + UploadFile, + AfterValidator(validate_filename), + AfterValidator(validate_image_mime_type), +] -### File Schemas ### class FileCreateWithinParent(BaseCreateSchema, FileBase): """Schema for creating a file within a parent object.""" - file: Annotated[ - UploadFile, - AfterValidator(validate_filename), - AfterValidator(lambda f: validate_file_size(f, MAX_FILE_SIZE_MB)), - ] + file: FileUpload class FileCreate(FileCreateWithinParent): """Schema for creating a file.""" - # HACK: Even though the parent_id is optional, it should be required in the request. - # It is optional to allow for the currently messy storage crud and router factories to work - parent_id: IDT | None = None - parent_type: FileParentType | None = Field( - default=None, description=f"Type of the parent object, e.g. {', '.join(t.value for t in FileParentType)}" - ) + parent_id: int = Field(description="ID of the parent object") + parent_type: MediaParentType = Field(description=PARENT_TYPE_DESCRIPTION) class FileReadWithinParent(BaseReadSchemaWithTimeStamp, FileBase): """Schema for reading file information within a parent object.""" filename: str - file_url: str + file_url: str | None + + @model_validator(mode="before") + @classmethod + def populate_file_url(cls, data: object) -> object: + """Populate ``file_url`` when validating directly from an ORM row.""" + if isinstance(data, dict): + payload = cast("dict[str, Any]", data) + if payload.get("file_url") is not None: + return payload + file_path = getattr(payload.get("file"), "path", None) + return { + **payload, + "file_url": _build_storage_url(file_path, settings.file_storage_path, "/uploads/files"), + } + + file_path = getattr(getattr(data, "file", None), "path", None) + return { + "id": getattr(data, "db_id", getattr(data, "id", None)), + "description": getattr(data, "description", None), + "filename": getattr(data, "filename", None), + "file_url": _build_storage_url(file_path, settings.file_storage_path, "/uploads/files"), + "created_at": getattr(data, "created_at", None), + "updated_at": getattr(data, "updated_at", None), + "parent_id": getattr(data, "product_id", None) + or getattr(data, "material_id", None) + or getattr(data, "product_type_id", None), + "parent_type": getattr(data, "parent_type", None), + } class FileRead(FileReadWithinParent): """Schema for reading file information.""" parent_id: PositiveInt = Field(description="ID of the parent object") - parent_type: FileParentType = Field( - description=f"Type of the parent object, e.g. {', '.join(t.value for t in FileParentType)}" - ) + parent_type: MediaParentType = Field(description=PARENT_TYPE_DESCRIPTION) class FileUpdate(BaseUpdateSchema, FileBase): """Schema for updating a file description.""" - # Only includes fields from FileBase (description) - # If the user wants to update the file or reassign to a new parent object, - # they should delete the old file and create a new one. - - -### Image Schemas ### -def validate_image_type(file: UploadFile | None) -> UploadFile | None: - """Validate the image file mime type.""" - if file is None: - return file - allowed_mime_types: set[str] = ALLOWED_IMAGE_MIME_TYPES - if file.content_type not in allowed_mime_types: - err_msg: str = f"Invalid file type: {file.content_type}. Allowed types: {', '.join(allowed_mime_types)}" - raise ValueError(err_msg) - return file - class ImageCreateInternal(BaseCreateSchema, ImageBase): """Schema for creating a new image internally, without a form upload.""" - file: Annotated[ - UploadFile, - AfterValidator(validate_filename), - AfterValidator(validate_image_type), - AfterValidator(lambda f: validate_file_size(f, MAX_IMAGE_SIZE_MB)), - ] - # HACK: Even though the parent_id is optional, it should be required in the request. - # It is optional to allow for the currently messy storage crud and router factories to work - parent_id: IDT | None = None - parent_type: ImageParentType | None = Field( - default=None, description=f"Type of the parent object, e.g. {', '.join(t.value for t in ImageParentType)}" - ) + file: ImageUpload + parent_id: int = Field(description="ID of the parent object") + parent_type: MediaParentType = Field(description=PARENT_TYPE_DESCRIPTION) class ImageCreateFromForm(ImageCreateInternal): - """Schema for creating a new image from Form data. + """Schema for creating a new image from multipart form data.""" - Parses image metadata from a JSON string in a form field, allowing file and metadata upload in one request. - """ - - # Overriding the ImageBase field to allow for JSON validation - image_metadata: Json | None = Field(default=None, description="Image metadata in JSON string format") + image_metadata: dict[str, Any] | None = Field( + default=None, + description="Image metadata in JSON string format", + ) class ImageReadWithinParent(BaseReadSchemaWithTimeStamp, ImageBase): """Schema for reading image information within a parent object.""" filename: str - image_url: str + image_url: str | None + thumbnail_url: str | None = None + + @model_validator(mode="before") + @classmethod + def populate_image_urls(cls, data: object) -> object: + """Populate image URLs when validating directly from an ORM row.""" + if isinstance(data, dict): + payload = cast("dict[str, Any]", data) + if payload.get("image_url") is not None: + return payload + file_path = getattr(payload.get("file"), "path", None) + thumbnail_url = None + if file_path is not None and Path(file_path).exists(): + thumbnail_url = f"/images/{payload.get('id')}/resized?width=200" + return { + **payload, + "image_url": _build_storage_url(file_path, settings.image_storage_path, "/uploads/images"), + "thumbnail_url": thumbnail_url, + } + + file_path = getattr(getattr(data, "file", None), "path", None) + return { + "id": getattr(data, "db_id", getattr(data, "id", None)), + "description": getattr(data, "description", None), + "image_metadata": getattr(data, "image_metadata", None), + "filename": getattr(data, "filename", None), + "image_url": _build_storage_url(file_path, settings.image_storage_path, "/uploads/images"), + "thumbnail_url": f"/images/{getattr(data, 'db_id', getattr(data, 'id', None))}/resized?width=200" + if file_path is not None and Path(file_path).exists() + else None, + "created_at": getattr(data, "created_at", None), + "updated_at": getattr(data, "updated_at", None), + "parent_id": getattr(data, "product_id", None) + or getattr(data, "material_id", None) + or getattr(data, "product_type_id", None), + "parent_type": getattr(data, "parent_type", None), + } class ImageRead(ImageReadWithinParent): """Schema for reading image information.""" parent_id: PositiveInt - parent_type: ImageParentType = Field( - description=f"Type of the object that the image belongs to, e.g. {', '.join(t.value for t in ImageParentType)}", - ) + parent_type: MediaParentType = Field(description=PARENT_TYPE_DESCRIPTION) class ImageUpdate(BaseUpdateSchema, ImageBase): """Schema for updating an image description.""" - # Only includes fields from ImageBase. - # If the user wants to update the image file or reassign to a new parent object, - # they should delete the old image and create a new one. - # TODO: Add logic to reassign to new parent object - -### Video Schemas ### class VideoCreateWithinProduct(BaseCreateSchema, VideoBase): """Schema for creating a video.""" + url: AnyUrlToDB + class VideoCreate(BaseCreateSchema, VideoBase): """Schema for creating a video.""" + url: AnyUrlToDB product_id: PositiveInt class VideoReadWithinProduct(BaseReadSchemaWithTimeStamp, VideoBase): - """Schema for reading video information.""" + """Schema for reading video information within a product.""" class VideoRead(BaseReadSchemaWithTimeStamp, VideoBase): @@ -188,11 +217,16 @@ class VideoRead(BaseReadSchemaWithTimeStamp, VideoBase): product_id: PositiveInt -class VideoUpdate(BaseUpdateSchema): - """Schema for updating a video.""" +class VideoUpdateWithinProduct(BaseUpdateSchema): + """Schema for updating a video within a product.""" - url: HttpUrl | None = Field(default=None, max_length=250, description="HTTP(S) URL linking to the video") + url: AnyUrlToDB | None = Field(default=None, description="URL linking to the video") title: str | None = Field(default=None, max_length=100, description="Title of the video") description: str | None = Field(default=None, max_length=500, description="Description of the video") video_metadata: dict[str, Any] | None = Field(default=None, description="Video metadata as a JSON dict") + + +class VideoUpdate(VideoUpdateWithinProduct): + """Schema for updating a video.""" + product_id: PositiveInt diff --git a/backend/app/api/file_storage/video_crud.py b/backend/app/api/file_storage/video_crud.py new file mode 100644 index 00000000..1bdb5a22 --- /dev/null +++ b/backend/app/api/file_storage/video_crud.py @@ -0,0 +1,48 @@ +"""CRUD operations for video models.""" + +from sqlmodel.ext.asyncio.session import AsyncSession + +from app.api.common.crud.persistence import commit_and_refresh, delete_and_commit, update_and_commit +from app.api.common.crud.utils import get_model_or_404 +from app.api.data_collection.models import Product +from app.api.file_storage.models.models import Video +from app.api.file_storage.schemas import VideoCreate, VideoCreateWithinProduct, VideoUpdate, VideoUpdateWithinProduct + + +async def create_video( + db: AsyncSession, + video: VideoCreate | VideoCreateWithinProduct, + product_id: int | None = None, + *, + commit: bool = True, +) -> Video: + """Create a new video in the database.""" + if isinstance(video, VideoCreate): + product_id = video.product_id + if product_id is None: + err_msg = "Product ID is required." + raise ValueError(err_msg) + await get_model_or_404(db, Product, product_id) + + db_video = Video( + **video.model_dump(exclude={"product_id"}), + product_id=product_id, + ) + db.add(db_video) + + if commit: + return await commit_and_refresh(db, db_video, add_before_commit=False) + await db.flush() + return db_video + + +async def update_video(db: AsyncSession, video_id: int, video: VideoUpdate | VideoUpdateWithinProduct) -> Video: + """Update an existing video in the database.""" + db_video = await get_model_or_404(db, Video, video_id) + return await update_and_commit(db, db_video, video) + + +async def delete_video(db: AsyncSession, video_id: int) -> None: + """Delete a video from the database.""" + db_video = await get_model_or_404(db, Video, video_id) + await delete_and_commit(db, db_video) diff --git a/backend/app/api/newsletter/exceptions.py b/backend/app/api/newsletter/exceptions.py new file mode 100644 index 00000000..2314212c --- /dev/null +++ b/backend/app/api/newsletter/exceptions.py @@ -0,0 +1,45 @@ +"""Custom exceptions for newsletter subscription flows.""" + +from app.api.common.exceptions import BadRequestError, NotFoundError + + +class NewsletterAlreadySubscribedError(BadRequestError): + """Raised when a confirmed subscriber tries to subscribe again.""" + + def __init__(self) -> None: + super().__init__("Already subscribed.") + + +class NewsletterConfirmationResentError(BadRequestError): + """Raised when a subscriber exists but still needs to confirm their email.""" + + def __init__(self) -> None: + super().__init__("Already subscribed, but not confirmed. A new confirmation email has been sent.") + + +class NewsletterInvalidConfirmationTokenError(BadRequestError): + """Raised when a confirmation token is invalid or expired.""" + + def __init__(self) -> None: + super().__init__("Invalid or expired confirmation link.") + + +class NewsletterInvalidUnsubscribeTokenError(BadRequestError): + """Raised when an unsubscribe token is invalid or expired.""" + + def __init__(self) -> None: + super().__init__("Invalid or expired unsubscribe link.") + + +class NewsletterSubscriberNotFoundError(NotFoundError): + """Raised when the subscriber referenced by a token no longer exists.""" + + def __init__(self) -> None: + super().__init__("Subscriber not found.") + + +class NewsletterAlreadyConfirmedError(BadRequestError): + """Raised when a subscription is already confirmed.""" + + def __init__(self) -> None: + super().__init__("Already confirmed.") diff --git a/backend/app/api/newsletter/models.py b/backend/app/api/newsletter/models.py index 1bbdc231..c6d1274d 100644 --- a/backend/app/api/newsletter/models.py +++ b/backend/app/api/newsletter/models.py @@ -1,22 +1,21 @@ """Database models related to newsletter subscribers.""" import uuid -from typing import Annotated -from pydantic import UUID4, EmailStr, StringConstraints +from pydantic import UUID4, EmailStr from sqlmodel import Field -from app.api.common.models.base import CustomBase, TimeStampMixinBare +from app.api.common.models.base import CustomBase, TimeStampMixinBare, UUIDPrimaryKeyMixin class NewsletterSubscriberBase(CustomBase): """Base schema for newsletter subscribers.""" - email: Annotated[EmailStr, StringConstraints(strip_whitespace=True)] = Field(index=True, unique=True) + email: EmailStr = Field(index=True, unique=True) -class NewsletterSubscriber(NewsletterSubscriberBase, TimeStampMixinBare, table=True): +class NewsletterSubscriber(NewsletterSubscriberBase, UUIDPrimaryKeyMixin, TimeStampMixinBare, table=True): """Database model for newsletter subscribers.""" - id: UUID4 = Field(default_factory=uuid.uuid4, primary_key=True, nullable=False) + id: UUID4 | None = Field(default_factory=uuid.uuid4, primary_key=True, nullable=False) is_confirmed: bool = Field(default=False) diff --git a/backend/app/api/newsletter/routers.py b/backend/app/api/newsletter/routers.py index 4483832c..4ae29e1e 100644 --- a/backend/app/api/newsletter/routers.py +++ b/backend/app/api/newsletter/routers.py @@ -1,17 +1,27 @@ -"""Basic newsletter subscription endpoint.""" +"""Newsletter subscription endpoints.""" -from collections.abc import Sequence from typing import Annotated -from fastapi import APIRouter, HTTPException, Security +from fastapi import APIRouter, BackgroundTasks, Security from fastapi.params import Body +from fastapi_pagination import Page from pydantic import EmailStr from sqlmodel import select -from app.api.auth.dependencies import current_active_superuser +from app.api.auth.dependencies import CurrentActiveUserDep, current_active_superuser, current_active_user +from app.api.common.crud.base import get_paginated_models +from app.api.common.crud.persistence import commit_and_refresh, delete_and_commit from app.api.common.routers.dependencies import AsyncSessionDep +from app.api.newsletter.exceptions import ( + NewsletterAlreadyConfirmedError, + NewsletterAlreadySubscribedError, + NewsletterConfirmationResentError, + NewsletterInvalidConfirmationTokenError, + NewsletterInvalidUnsubscribeTokenError, + NewsletterSubscriberNotFoundError, +) from app.api.newsletter.models import NewsletterSubscriber -from app.api.newsletter.schemas import NewsletterSubscriberRead +from app.api.newsletter.schemas import NewsletterPreferenceRead, NewsletterPreferenceUpdate, NewsletterSubscriberRead from app.api.newsletter.utils.emails import ( send_newsletter_subscription_email, send_newsletter_unsubscription_request_email, @@ -22,35 +32,43 @@ backend_router = APIRouter(prefix="/newsletter") +async def _get_subscriber_by_email(db: AsyncSessionDep, email: str) -> NewsletterSubscriber | None: + statement = select(NewsletterSubscriber).where(NewsletterSubscriber.email == email) + return (await db.exec(statement)).unique().one_or_none() + + +def _newsletter_preference_read( + *, + email: str, + subscriber: NewsletterSubscriber | None, +) -> NewsletterPreferenceRead: + return NewsletterPreferenceRead( + email=email, + subscribed=subscriber is not None, + is_confirmed=subscriber.is_confirmed if subscriber else False, + ) + + @backend_router.post("/subscribe", status_code=201, response_model=NewsletterSubscriberRead) -async def subscribe_to_newsletter(email: Annotated[EmailStr, Body()], db: AsyncSessionDep) -> NewsletterSubscriber: +async def subscribe_to_newsletter( + email: Annotated[EmailStr, Body()], db: AsyncSessionDep, background_tasks: BackgroundTasks +) -> NewsletterSubscriber: """Subscribe to the newsletter to receive updates about the app launch.""" - # Check if the email already exists - existing_subscriber = ( - (await db.exec(select(NewsletterSubscriber).where(NewsletterSubscriber.email == email))).unique().one_or_none() - ) + existing_subscriber = await _get_subscriber_by_email(db, email) if existing_subscriber: if existing_subscriber.is_confirmed: - raise HTTPException(status_code=400, detail="Already subscribed.") + raise NewsletterAlreadySubscribedError - # If not confirmed, generate new token and send email token = create_jwt_token(email, JWTType.NEWSLETTER_CONFIRMATION) - await send_newsletter_subscription_email(email, token) - raise HTTPException( - status_code=400, - detail="Already subscribed, but not confirmed. A new confirmation email has been sent.", - ) + await send_newsletter_subscription_email(email, token, background_tasks=background_tasks) + raise NewsletterConfirmationResentError - # Create new subscriber new_subscriber = NewsletterSubscriber(email=email) - db.add(new_subscriber) - await db.commit() - await db.refresh(new_subscriber) + await commit_and_refresh(db, new_subscriber) - # Send confirmation email token = create_jwt_token(email, JWTType.NEWSLETTER_CONFIRMATION) - await send_newsletter_subscription_email(email, token) + await send_newsletter_subscription_email(email, token, background_tasks=background_tasks) return new_subscriber @@ -58,47 +76,34 @@ async def subscribe_to_newsletter(email: Annotated[EmailStr, Body()], db: AsyncS @backend_router.post("/confirm", status_code=200, response_model=NewsletterSubscriberRead) async def confirm_newsletter_subscription(token: Annotated[str, Body()], db: AsyncSessionDep) -> NewsletterSubscriber: """Confirm the newsletter subscription.""" - # Verify the token email = verify_jwt_token(token, JWTType.NEWSLETTER_CONFIRMATION) if not email: - raise HTTPException(status_code=400, detail="Invalid or expired confirmation link.") + raise NewsletterInvalidConfirmationTokenError - # Check if the email is already confirmed - existing_subscriber = ( - (await db.exec(select(NewsletterSubscriber).where(NewsletterSubscriber.email == email))).unique().one_or_none() - ) + existing_subscriber = await _get_subscriber_by_email(db, email) if not existing_subscriber: - raise HTTPException(status_code=404, detail="Subscriber not found.") + raise NewsletterSubscriberNotFoundError if existing_subscriber.is_confirmed: - raise HTTPException(status_code=400, detail="Already confirmed.") + raise NewsletterAlreadyConfirmedError - # Update subscriber status to confirmed existing_subscriber.is_confirmed = True - await db.commit() - await db.refresh(existing_subscriber) - - return existing_subscriber + return await commit_and_refresh(db, existing_subscriber, add_before_commit=False) @backend_router.post("/request-unsubscribe", status_code=200) -async def request_unsubscribe(email: Annotated[EmailStr, Body()], db: AsyncSessionDep) -> dict: +async def request_unsubscribe( + email: Annotated[EmailStr, Body()], db: AsyncSessionDep, background_tasks: BackgroundTasks +) -> dict: """Request to unsubscribe by sending an email with unsubscribe link.""" - # Check if the email is subscribed - existing_subscriber = ( - (await db.exec(select(NewsletterSubscriber).where(NewsletterSubscriber.email == email))).unique().one_or_none() - ) + existing_subscriber = await _get_subscriber_by_email(db, email) if not existing_subscriber: - # Don't reveal if someone is subscribed or not for privacy reasons return {"message": "If you are subscribed, we've sent an unsubscribe link to your email."} - # Generate unsubscribe token token = create_jwt_token(email, JWTType.NEWSLETTER_UNSUBSCRIBE) - - # Send unsubscription email with the link - await send_newsletter_unsubscription_request_email(email, token) + await send_newsletter_unsubscription_request_email(email, token, background_tasks=background_tasks) return {"message": "If you are subscribed, we've sent an unsubscribe link to your email."} @@ -106,37 +111,67 @@ async def request_unsubscribe(email: Annotated[EmailStr, Body()], db: AsyncSessi @backend_router.post("/unsubscribe", status_code=204) async def unsubscribe_with_token(token: Annotated[str, Body()], db: AsyncSessionDep) -> None: """One-click unsubscribe from newsletter using a token.""" - # Verify the token email = verify_jwt_token(token, JWTType.NEWSLETTER_UNSUBSCRIBE) if not email: - raise HTTPException(status_code=400, detail="Invalid or expired unsubscribe link.") + raise NewsletterInvalidUnsubscribeTokenError - # Check if the email is subscribed - existing_subscriber = ( - (await db.exec(select(NewsletterSubscriber).where(NewsletterSubscriber.email == email))).unique().one_or_none() - ) + existing_subscriber = await _get_subscriber_by_email(db, email) if not existing_subscriber: - raise HTTPException(status_code=404, detail="Subscriber not found.") + raise NewsletterSubscriberNotFoundError + + await delete_and_commit(db, existing_subscriber) + + +### Private router for user-specific newsletter preferences ## +private_router = APIRouter(prefix="/newsletter", dependencies=[Security(current_active_user)]) + + +@private_router.get("/me", response_model=NewsletterPreferenceRead) +async def get_newsletter_preference( + current_user: CurrentActiveUserDep, db: AsyncSessionDep +) -> NewsletterPreferenceRead: + """Return the logged-in user's newsletter preference.""" + existing_subscriber = await _get_subscriber_by_email(db, current_user.email) + return _newsletter_preference_read(email=current_user.email, subscriber=existing_subscriber) + + +@private_router.put("/me", response_model=NewsletterPreferenceRead) +async def update_newsletter_preference( + preference: NewsletterPreferenceUpdate, + current_user: CurrentActiveUserDep, + db: AsyncSessionDep, +) -> NewsletterPreferenceRead: + """Update the logged-in user's newsletter preference without email verification.""" + existing_subscriber = await _get_subscriber_by_email(db, current_user.email) + + if preference.subscribed: + if existing_subscriber is None: + existing_subscriber = NewsletterSubscriber(email=current_user.email, is_confirmed=True) + else: + existing_subscriber.is_confirmed = True + await commit_and_refresh(db, existing_subscriber) + return _newsletter_preference_read(email=current_user.email, subscriber=existing_subscriber) + + if existing_subscriber is not None: + await delete_and_commit(db, existing_subscriber) - # Remove subscriber - await db.delete(existing_subscriber) - await db.commit() + return _newsletter_preference_read(email=current_user.email, subscriber=None) ### Admin router ### admin_router = APIRouter(prefix="/admin/newsletter", dependencies=[Security(current_active_superuser)]) -@admin_router.get("/subscribers", response_model=Sequence[NewsletterSubscriberRead]) -async def get_subscribers(db: AsyncSessionDep) -> Sequence[NewsletterSubscriber]: +@admin_router.get("/subscribers", response_model=Page[NewsletterSubscriberRead]) +async def get_subscribers(db: AsyncSessionDep) -> Page[NewsletterSubscriber]: """Get all newsletter subscribers. Only accessible by superusers.""" - subscribers = await db.exec(select(NewsletterSubscriber)) - return subscribers.all() + return await get_paginated_models(db, NewsletterSubscriber, read_schema=NewsletterSubscriberRead) ### Router registration ### router = APIRouter() router.include_router(backend_router) +router.include_router(private_router) router.include_router(admin_router) diff --git a/backend/app/api/newsletter/schemas.py b/backend/app/api/newsletter/schemas.py index cacc5bd9..85727a40 100644 --- a/backend/app/api/newsletter/schemas.py +++ b/backend/app/api/newsletter/schemas.py @@ -1,16 +1,34 @@ """DTO schemas for newsletter subscribers.""" -from pydantic import Field +from typing import Annotated -from app.api.common.schemas.base import BaseCreateSchema, BaseReadSchemaWithTimeStamp +from pydantic import BaseModel, EmailStr, Field, StringConstraints + +from app.api.common.schemas.base import BaseCreateSchema, BaseReadSchemaWithTimeStamp, BaseUpdateSchema from app.api.newsletter.models import NewsletterSubscriberBase class NewsletterSubscriberCreate(BaseCreateSchema, NewsletterSubscriberBase): """Create schema for newsletter subscribers.""" + email: Annotated[EmailStr, StringConstraints(strip_whitespace=True)] = Field() + class NewsletterSubscriberRead(BaseReadSchemaWithTimeStamp, NewsletterSubscriberBase): """Read schema for newsletter subscribers.""" is_confirmed: bool = Field() + + +class NewsletterPreferenceRead(BaseModel): + """Read schema for a logged-in user's newsletter preference.""" + + email: EmailStr = Field() + subscribed: bool = Field() + is_confirmed: bool = Field() + + +class NewsletterPreferenceUpdate(BaseUpdateSchema): + """Update schema for a logged-in user's newsletter preference.""" + + subscribed: bool = Field() diff --git a/backend/app/api/newsletter/utils/emails.py b/backend/app/api/newsletter/utils/emails.py index 32b44597..cc4d85c5 100644 --- a/backend/app/api/newsletter/utils/emails.py +++ b/backend/app/api/newsletter/utils/emails.py @@ -1,71 +1,72 @@ """Email sending utilities for the newsletter service.""" -from app.api.auth.utils.programmatic_emails import TextContentType, generate_token_link, send_email +from fastapi import BackgroundTasks +from pydantic import EmailStr + +from app.api.auth.utils.programmatic_emails import generate_token_link, send_email_with_template from app.api.newsletter.utils.tokens import JWTType, create_jwt_token +from app.core.config import settings as core_settings -async def send_newsletter_subscription_email(to_email: str, token: str) -> None: +async def send_newsletter_subscription_email( + to_email: EmailStr, + token: str, + background_tasks: BackgroundTasks | None = None, +) -> None: """Send a newsletter subscription email.""" subject = "Reverse Engineering Lab: Confirm Your Newsletter Subscription" - # TODO: Dynamically generate the confirmation link based on the frontend URL tree - # Alternatively, send the frontend-side link to the backend as a parameter - confirmation_link = generate_token_link(token, "newsletter/confirm") - - body = f""" -Hello, - -Thank you for subscribing to the Reverse Engineering Lab newsletter! - -Please confirm your subscription by clicking [here]({confirmation_link}). - -This link will expire in 24 hours. - -We'll keep you updated with our progress and let you know when the full application is launched. - -Best regards, - -The Reverse Engineering Lab Team - """ - await send_email(to_email, subject, body, content_type=TextContentType.MARKDOWN) - - -async def send_newsletter(to_email: str, subject: str, content: str) -> None: - """Send newsletter with proper unsubscribe headers.""" + confirmation_link = generate_token_link(token, "newsletter/confirm", base_url=core_settings.frontend_web_url) + + await send_email_with_template( + to_email=to_email, + subject=subject, + template_name="newsletter_subscription.html", + template_body={ + "confirmation_link": confirmation_link, + }, + background_tasks=background_tasks, + ) + + +async def send_newsletter( + to_email: EmailStr, + subject: str, + content: str, + background_tasks: BackgroundTasks | None = None, +) -> None: + """Send newsletter with proper unsubscribe link.""" # Create unsubscribe token and link token = create_jwt_token(to_email, JWTType.NEWSLETTER_UNSUBSCRIBE) - unsubscribe_link = generate_token_link(token, "newsletter/unsubscribe") - - # Add footer with unsubscribe link - body = f""" - {content} - ---- -You're receiving this email because you subscribed to the Reverse Engineering Lab newsletter. -To unsubscribe, click [here]({unsubscribe_link}) - """ - - # Add List-Unsubscribe header for email clients that support it - headers = {"List-Unsubscribe": f"<{unsubscribe_link}>", "List-Unsubscribe-Post": "List-Unsubscribe=One-Click"} - - await send_email(to_email, subject, body, content_type=TextContentType.MARKDOWN, headers=headers) - - -async def send_newsletter_unsubscription_request_email(to_email: str, token: str) -> None: + unsubscribe_link = generate_token_link(token, "newsletter/unsubscribe", base_url=core_settings.frontend_web_url) + + await send_email_with_template( + to_email=to_email, + subject=subject, + template_name="newsletter.html", + template_body={ + "subject": subject, + "content": content, + "unsubscribe_link": unsubscribe_link, + }, + background_tasks=background_tasks, + ) + + +async def send_newsletter_unsubscription_request_email( + to_email: EmailStr, + token: str, + background_tasks: BackgroundTasks | None = None, +) -> None: """Send an email with unsubscribe link.""" subject = "Reverse Engineering Lab: Unsubscribe Request" - unsubscribe_link = generate_token_link(token, "newsletter/unsubscribe") - - body = f""" -Hello, - -We received a request to unsubscribe this email address from the Reverse Engineering Lab newsletter. - -If you made this request, please click [here]({unsubscribe_link}) to unsubscribe. - -If you did not request to unsubscribe, you can safely ignore this email. - -Best regards, - -The Reverse Engineering Lab Team - """ - await send_email(to_email, subject, body, content_type=TextContentType.MARKDOWN) + unsubscribe_link = generate_token_link(token, "newsletter/unsubscribe", base_url=core_settings.frontend_web_url) + + await send_email_with_template( + to_email=to_email, + subject=subject, + template_name="newsletter_unsubscribe.html", + template_body={ + "unsubscribe_link": unsubscribe_link, + }, + background_tasks=background_tasks, + ) diff --git a/backend/app/api/newsletter/utils/tokens.py b/backend/app/api/newsletter/utils/tokens.py index 04ba19cd..be57b9ec 100644 --- a/backend/app/api/newsletter/utils/tokens.py +++ b/backend/app/api/newsletter/utils/tokens.py @@ -1,16 +1,18 @@ """Service for creating and verifying JWT tokens for newsletter confirmation.""" from datetime import UTC, datetime, timedelta -from enum import Enum +from enum import StrEnum import jwt +from pydantic import SecretStr from app.api.auth.config import settings ALGORITHM = "HS256" # Algorithm used for JWT encoding/decoding +SECRET: SecretStr = settings.newsletter_secret -class JWTType(str, Enum): +class JWTType(StrEnum): """Enum for different newsletter-related JWT types.""" NEWSLETTER_CONFIRMATION = "newsletter_confirmation" @@ -33,15 +35,15 @@ def create_jwt_token(email: str, token_type: JWTType) -> str: """Create a JWT token for newsletter confirmation.""" expiration = datetime.now(UTC) + timedelta(seconds=token_type.expiration_seconds) payload = {"sub": email, "exp": expiration, "type": token_type.value} - return jwt.encode(payload, settings.newsletter_secret, algorithm=ALGORITHM) + return jwt.encode(payload, SECRET.get_secret_value(), algorithm=ALGORITHM) def verify_jwt_token(token: str, expected_token_type: JWTType) -> str | None: """Verify the JWT token and return the email if valid.""" try: - payload = jwt.decode(token, settings.newsletter_secret, algorithms=[ALGORITHM]) + payload = jwt.decode(token, SECRET.get_secret_value(), algorithms=[ALGORITHM]) if payload["type"] != expected_token_type.value: return None return payload["sub"] # Returns the email address from the token - except (jwt.PyJWTError, KeyError): + except jwt.PyJWTError, KeyError: return None diff --git a/backend/app/api/plugins/rpi_cam/config.py b/backend/app/api/plugins/rpi_cam/config.py index 4d407232..eec8fbb2 100644 --- a/backend/app/api/plugins/rpi_cam/config.py +++ b/backend/app/api/plugins/rpi_cam/config.py @@ -1,22 +1,14 @@ """Configuration for the Raspberry Pi Camera plugin.""" -from pathlib import Path +from app.core.env import RelabBaseSettings -from pydantic_settings import BaseSettings, SettingsConfigDict -# Set the project base directory and .env file -BASE_DIR: Path = (Path(__file__).parents[4]).resolve() - - -class RPiCamSettings(BaseSettings): +class RPiCamSettings(RelabBaseSettings): """Settings class to store settings related to the Raspberry Pi Camera plugin.""" # Authentication settings rpi_cam_plugin_secret: str = "" - # Initialize the settings configuration from the .env file - model_config = SettingsConfigDict(env_file=BASE_DIR / ".env", extra="ignore") - api_key_header_name: str = "X-API-Key" diff --git a/backend/app/api/plugins/rpi_cam/crud.py b/backend/app/api/plugins/rpi_cam/crud.py index f682c058..43052bfe 100644 --- a/backend/app/api/plugins/rpi_cam/crud.py +++ b/backend/app/api/plugins/rpi_cam/crud.py @@ -3,13 +3,12 @@ from pydantic import UUID4 from sqlmodel.ext.asyncio.session import AsyncSession -from app.api.common.utils import get_user_owned_object +from app.api.common.crud.persistence import commit_and_refresh, update_and_commit from app.api.plugins.rpi_cam.models import Camera from app.api.plugins.rpi_cam.schemas import CameraCreate, CameraUpdate from app.api.plugins.rpi_cam.utils.encryption import encrypt_str, generate_api_key -### CRUD Operations ### async def create_camera(db: AsyncSession, camera: CameraCreate, owner_id: UUID4) -> Camera: """Create a new camera in the database.""" # Generate api key @@ -30,45 +29,37 @@ async def create_camera(db: AsyncSession, camera: CameraCreate, owner_id: UUID4) if auth_header_dict: db_camera.set_auth_headers(auth_header_dict) - # Save to database - db.add(db_camera) - await db.commit() - await db.refresh(db_camera) + return await commit_and_refresh(db, db_camera) - return db_camera - -async def update_camera(db: AsyncSession, db_camera: Camera, camera_in: CameraUpdate) -> Camera: +async def update_camera( + db: AsyncSession, + db_camera: Camera, + camera_in: CameraUpdate, + *, + new_owner_id: UUID4 | None = None, +) -> Camera: """Update an existing camera in the database.""" # Extract camera data and auth headers camera_data = camera_in.model_dump(exclude_unset=True) auth_header_dict = camera_data.pop("auth_headers", None) + camera_data.pop("owner_id", None) - db_camera.sqlmodel_update(camera_data) + if new_owner_id is not None: + db_camera.owner_id = new_owner_id # Update auth headers if provided if auth_header_dict: db_camera.set_auth_headers(auth_header_dict) - # Save to database - db.add(db_camera) - await db.commit() - await db.refresh(db_camera) - return db_camera + camera_in_without_auth_headers = CameraUpdate.model_validate(camera_data) + return await update_and_commit(db, db_camera, camera_in_without_auth_headers) -async def regenerate_camera_api_key(db: AsyncSession, camera_id: UUID4, owner_id: UUID4) -> Camera: +async def regenerate_camera_api_key(db: AsyncSession, db_camera: Camera) -> Camera: """Regenerate API key for an existing camera.""" - # Validate ownership - db_camera = await get_user_owned_object(db, Camera, camera_id, owner_id) - # Generate and encrypt new API key new_api_key = generate_api_key() db_camera.encrypted_api_key = encrypt_str(new_api_key) - # Save to database - db.add(db_camera) - await db.commit() - await db.refresh(db_camera) - - return db_camera + return await commit_and_refresh(db, db_camera) diff --git a/backend/app/api/plugins/rpi_cam/dependencies.py b/backend/app/api/plugins/rpi_cam/dependencies.py index 39d05d71..6b832928 100644 --- a/backend/app/api/plugins/rpi_cam/dependencies.py +++ b/backend/app/api/plugins/rpi_cam/dependencies.py @@ -7,14 +7,28 @@ from pydantic import UUID4 from app.api.auth.dependencies import CurrentActiveUserDep +from app.api.auth.exceptions import UserHasNoOrgError, UserIsNotMemberError +from app.api.auth.models import User +from app.api.common.crud.utils import get_model_or_404 from app.api.common.routers.dependencies import AsyncSessionDep from app.api.common.utils import get_user_owned_object +from app.api.plugins.rpi_cam.exceptions import InvalidCameraOwnershipTransferError from app.api.plugins.rpi_cam.models import Camera -from app.api.plugins.rpi_cam.schemas import CameraFilter, CameraFilterWithOwner +from app.api.plugins.rpi_cam.schemas import CameraFilter, CameraFilterWithOwner, CameraUpdate ### FastAPI-Filters ### CameraFilterDep = Annotated[CameraFilter, FilterDepends(CameraFilter)] CameraFilterWithOwnerDep = Annotated[CameraFilterWithOwner, FilterDepends(CameraFilterWithOwner)] +OWNER_ID_FIELD = "owner_id" + + +### Camera Lookup Dependencies ### +async def get_camera_by_id(camera_id: UUID4, session: AsyncSessionDep) -> Camera: + """Retrieve a camera by ID.""" + return await get_model_or_404(session, Camera, camera_id) + + +CameraByIDDep = Annotated[Camera, Depends(get_camera_by_id)] ### Ownership Dependencies ### @@ -24,8 +38,42 @@ async def get_user_owned_camera( current_user: CurrentActiveUserDep, ) -> Camera: """Dependency function to retrieve a camera by ID and ensure it's owned by the current user.""" - db_camera = await get_user_owned_object(session, Camera, camera_id, current_user.id) - return db_camera + return await get_user_owned_object(session, Camera, camera_id, current_user.db_id) UserOwnedCameraDep = Annotated[Camera, Depends(get_user_owned_camera)] + + +async def get_camera_transfer_owner_id( + camera_in: CameraUpdate, + db_camera: UserOwnedCameraDep, + session: AsyncSessionDep, +) -> UUID4 | None: + """Validate ownership transfer requests and return the resolved owner ID.""" + if OWNER_ID_FIELD not in camera_in.model_fields_set: + return None + + new_owner_id = camera_in.owner_id + if new_owner_id is None: + raise InvalidCameraOwnershipTransferError + + current_owner = await get_model_or_404(session, User, db_camera.owner_id) + new_owner = await get_model_or_404(session, User, new_owner_id) + + if current_owner.id != new_owner.id: + if current_owner.organization_id is None: + raise UserHasNoOrgError( + user_id=current_owner.id, + details="Camera ownership can only be transferred within the same organization.", + ) + if new_owner.organization_id != current_owner.organization_id: + raise UserIsNotMemberError( + user_id=new_owner.id, + organization_id=current_owner.organization_id, + details="Camera ownership can only be transferred within the same organization.", + ) + + return new_owner_id + + +CameraTransferOwnerIDDep = Annotated[UUID4 | None, Depends(get_camera_transfer_owner_id)] diff --git a/backend/app/api/plugins/rpi_cam/exceptions.py b/backend/app/api/plugins/rpi_cam/exceptions.py new file mode 100644 index 00000000..ed82ac23 --- /dev/null +++ b/backend/app/api/plugins/rpi_cam/exceptions.py @@ -0,0 +1,70 @@ +"""Custom exceptions for the Raspberry Pi camera plugin.""" + +from app.api.common.exceptions import ( + BadRequestError, + ConflictError, + FailedDependencyError, + ForbiddenError, + ServiceUnavailableError, +) + + +class RecordingSessionStoreError(ServiceUnavailableError): + """Raised when a YouTube recording session cannot be persisted.""" + + def __init__(self) -> None: + super().__init__( + "Failed to store YouTube recording session in Redis.", + ) + + +class RecordingSessionNotFoundError(ConflictError): + """Raised when no cached YouTube recording session exists for a camera.""" + + def __init__(self) -> None: + super().__init__("No cached YouTube recording session found for this camera.") + + +class InvalidRecordingSessionDataError(BadRequestError): + """Raised when cached recording session data cannot be validated.""" + + def __init__(self, details: str) -> None: + super().__init__("Invalid recording session data.", details=details) + + +class GoogleOAuthAssociationRequiredError(ForbiddenError): + """Raised when a user tries to use YouTube features without linking Google OAuth first.""" + + def __init__(self) -> None: + super().__init__( + "Google OAuth account association required for YouTube streaming. " + "Use /api/auth/oauth/google/associate/authorize." + ) + + +class InvalidCameraResponseError(FailedDependencyError): + """Raised when the camera returns a payload that does not match the expected schema.""" + + def __init__(self, details: str) -> None: + super().__init__("Invalid response from camera.", details=details) + + +class NoActiveYouTubeRecordingError(ConflictError): + """Raised when monitor/stop actions require an active YouTube recording.""" + + def __init__(self) -> None: + super().__init__("No active YouTube recording found for this camera.") + + +class CameraProxyRequestError(ServiceUnavailableError): + """Raised when the backend cannot reach the camera over HTTP.""" + + def __init__(self, endpoint: str, details: str) -> None: + super().__init__(f"Network error contacting camera: {endpoint}", details=details) + + +class InvalidCameraOwnershipTransferError(BadRequestError): + """Raised when a camera ownership transfer payload is invalid.""" + + def __init__(self) -> None: + super().__init__("owner_id must reference an existing user in the same organization.") diff --git a/backend/app/api/plugins/rpi_cam/models.py b/backend/app/api/plugins/rpi_cam/models.py index 974fab6f..2c4dfb0f 100644 --- a/backend/app/api/plugins/rpi_cam/models.py +++ b/backend/app/api/plugins/rpi_cam/models.py @@ -1,29 +1,25 @@ """Database models for the Raspberry Pi Camera plugin.""" import uuid -from enum import Enum +from enum import StrEnum from functools import cached_property -from typing import TYPE_CHECKING from urllib.parse import urljoin import httpx -from asyncache import cached from cachetools import TTLCache -from pydantic import UUID4, BaseModel, HttpUrl, computed_field +from pydantic import UUID4, AnyUrl, BaseModel, SecretStr, computed_field from relab_rpi_cam_models.camera import CameraStatusView as CameraStatusDetails from sqlmodel import AutoString, Field, Relationship -from app.api.common.models.base import CustomBase, TimeStampMixinBare -from app.api.common.models.custom_fields import HttpUrlInDB +from app.api.auth.models import User +from app.api.common.models.base import CustomBase, TimeStampMixinBare, UUIDPrimaryKeyMixin from app.api.plugins.rpi_cam.config import settings from app.api.plugins.rpi_cam.utils.encryption import decrypt_dict, decrypt_str, encrypt_dict - -if TYPE_CHECKING: - from app.api.auth.models import User +from app.core.cache import async_ttl_cache ### Utility models ### -class CameraConnectionStatus(str, Enum): +class CameraConnectionStatus(StrEnum): """Camera connection status.""" ONLINE = "online" @@ -52,7 +48,6 @@ class CameraStatus(BaseModel): connection: CameraConnectionStatus = Field(description="Connection status of the camera") - # TODO: Publish the plugin as a separate package and import the status details schema from there details: CameraStatusDetails | None = Field( default=None, description="Additional status details from the Raspberry Pi camera API" ) @@ -65,34 +60,37 @@ class CameraBase(CustomBase): name: str = Field(index=True, min_length=2, max_length=100) description: str | None = Field(default=None, max_length=500) - # NOTE: Local addresses only work when they are on the local network of this API - # TODO: Add support for server communication to local network cameras for users via websocket or similar - - # NOTE: Database models will have url as string type. This is likely because of how sa_type=Autostring works - # This means HttpUrl methods are not available in database model instances. - # TODO: Only validate the URL format in Pydantic schemas and store as plain string in the database model. - url: HttpUrlInDB = Field(description="HTTP(S) URL where the camera API is hosted", sa_type=AutoString) + # NOTE: Camera URLs are validated in the Pydantic create/update schemas. + # The database stores the URL as a plain string so the API can make normal + # HTTP requests to locally hosted camera APIs or tunnel endpoints. + url: str = Field(description="HTTP(S) URL where the camera API is hosted", sa_type=AutoString) -class Camera(CameraBase, TimeStampMixinBare, table=True): +class Camera(CameraBase, UUIDPrimaryKeyMixin, TimeStampMixinBare, table=True): """Database model for Camera.""" - id: UUID4 = Field(default_factory=uuid.uuid4, primary_key=True) + id: UUID4 | None = Field(default_factory=uuid.uuid4, primary_key=True, nullable=False) + encrypted_api_key: str = Field(nullable=False) - # TODO: Consider merging encrypted_auth_headers and encrypted_api_key into a single encrypted_credentials field encrypted_auth_headers: str | None = Field(default=None) # Many-to-one relationship with User owner_id: UUID4 = Field(foreign_key="user.id") - owner: "User" = Relationship() # One-way relationship to maintain plugin isolation + owner: User = Relationship( # One-way relationship to maintain plugin isolation + sa_relationship_kwargs={ + "primaryjoin": "Camera.owner_id == User.id", + "foreign_keys": "[Camera.owner_id]", + } + ) @computed_field @cached_property - def auth_headers(self) -> dict[str, str]: + def auth_headers(self) -> dict[str, SecretStr]: """Get all authentication headers including server-generated x-api-key.""" - headers = {settings.api_key_header_name: decrypt_str(self.encrypted_api_key)} + headers = {settings.api_key_header_name: SecretStr(decrypt_str(self.encrypted_api_key))} if self.encrypted_auth_headers: - headers.update(self._decrypt_auth_headers()) + decrypted = self._decrypt_auth_headers() + headers.update({k: SecretStr(v) for k, v in decrypted.items()}) return headers def _decrypt_auth_headers(self) -> dict[str, str]: @@ -107,19 +105,23 @@ def set_auth_headers(self, headers: dict[str, str]) -> None: @cached_property def verify_ssl(self) -> bool: """Whether to verify SSL certificates based on URL scheme.""" - return HttpUrl(self.url).scheme == "https" + return AnyUrl(self.url).scheme in {"https", "wss"} def __hash__(self) -> int: """Make Camera instances hashable using their id. Used for caching.""" return hash(self.id) async def get_status(self, *, force_refresh: bool = False) -> CameraStatus: + """Get the current connection status of the camera, using cache if not force_refresh. + + Status is cached for 15 seconds to avoid excessive requests to the camera API. + """ if force_refresh: return await self._fetch_status() return await self._get_cached_status() - @cached(cache=TTLCache(maxsize=1, ttl=15)) + @async_ttl_cache(TTLCache(maxsize=1, ttl=15)) async def _get_cached_status(self) -> CameraStatus: """Cached version of status fetch.""" return await self._fetch_status() @@ -129,7 +131,8 @@ async def _fetch_status(self) -> CameraStatus: async with httpx.AsyncClient(timeout=2.0, verify=self.verify_ssl) as client: try: - response = await client.get(status_url, headers=self.auth_headers) + headers = {k: v.get_secret_value() for k, v in self.auth_headers.items()} + response = await client.get(status_url, headers=headers) match response.status_code: case 200: return CameraStatus( diff --git a/backend/app/api/plugins/rpi_cam/routers/__init__.py b/backend/app/api/plugins/rpi_cam/routers/__init__.py index e69de29b..ee9a7848 100644 --- a/backend/app/api/plugins/rpi_cam/routers/__init__.py +++ b/backend/app/api/plugins/rpi_cam/routers/__init__.py @@ -0,0 +1 @@ +"""Routes for the RPi Cam plugin.""" diff --git a/backend/app/api/plugins/rpi_cam/routers/admin.py b/backend/app/api/plugins/rpi_cam/routers/admin.py index dc69d431..74c95371 100644 --- a/backend/app/api/plugins/rpi_cam/routers/admin.py +++ b/backend/app/api/plugins/rpi_cam/routers/admin.py @@ -1,22 +1,20 @@ """Routers for the Raspberry Pi Camera plugin.""" -from collections.abc import Sequence +from typing import Annotated -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Depends, Path +from fastapi_pagination import Page from pydantic import UUID4 from app.api.auth.dependencies import current_active_superuser -from app.api.common.crud.base import get_model_by_id, get_models +from app.api.common.crud.base import get_paginated_models from app.api.common.routers.dependencies import AsyncSessionDep -from app.api.plugins.rpi_cam import crud -from app.api.plugins.rpi_cam.dependencies import CameraFilterWithOwnerDep +from app.api.plugins.rpi_cam.dependencies import CameraByIDDep, CameraFilterWithOwnerDep from app.api.plugins.rpi_cam.models import Camera, CameraStatus from app.api.plugins.rpi_cam.schemas import CameraRead ### Camera admin router ### -# TODO: Also make file and data-collection routers user-dependent and add admin routers for superusers -# TODO: write and implement generic get user_owned model dependency classes router = APIRouter( prefix="/admin/plugins/rpi-cam/cameras", @@ -28,39 +26,39 @@ ## GET ## @router.get( "", - response_model=list[CameraRead], + response_model=Page[CameraRead], summary="Get all Raspberry Pi cameras", ) async def get_all_cameras( session: AsyncSessionDep, camera_filter: CameraFilterWithOwnerDep, -) -> Sequence[Camera]: +) -> Page[Camera]: """Get all Raspberry Pi cameras.""" - return await get_models(session, Camera, model_filter=camera_filter) + return await get_paginated_models(session, Camera, model_filter=camera_filter, read_schema=CameraRead) @router.get("/{camera_id}", summary="Get Raspberry Pi camera by ID", response_model=CameraRead) -async def get_camera(camera_id: UUID4, session: AsyncSessionDep) -> Camera: +async def get_camera(_camera_id: Annotated[UUID4, Path(alias="camera_id")], camera: CameraByIDDep) -> Camera: """Get single Raspberry Pi camera by ID.""" - db_camera = await get_model_by_id(session, Camera, camera_id) - # TODO: Can we deduplicate these standard translations of exceptions to HTTP exceptions across the codebase? - - return db_camera + return camera @router.get("/{camera_id}/status", summary="Get Raspberry Pi camera online status") -async def get_camera_status(camera_id: UUID4, session: AsyncSessionDep) -> CameraStatus: +async def get_camera_status( + _camera_id: Annotated[UUID4, Path(alias="camera_id")], + camera: CameraByIDDep, +) -> CameraStatus: """Get Raspberry Pi camera online status.""" - db_camera = await get_model_by_id(session, Camera, camera_id) - - return await db_camera.get_status() + return await camera.get_status() ## DELETE @router.delete("/{camera_id}", summary="Delete Raspberry Pi camera", status_code=204) async def delete_camera( - camera_id: UUID4, + _camera_id: Annotated[UUID4, Path(alias="camera_id")], session: AsyncSessionDep, + camera: CameraByIDDep, ) -> None: """Delete Raspberry Pi camera.""" - await crud.force_delete_camera(session, camera_id) + await session.delete(camera) + await session.commit() diff --git a/backend/app/api/plugins/rpi_cam/routers/camera_crud.py b/backend/app/api/plugins/rpi_cam/routers/camera_crud.py index bc42148f..15a75cfe 100644 --- a/backend/app/api/plugins/rpi_cam/routers/camera_crud.py +++ b/backend/app/api/plugins/rpi_cam/routers/camera_crud.py @@ -1,18 +1,20 @@ """Camera CRUD operations for Raspberry Pi Camera plugin.""" -from collections.abc import Sequence +from typing import TYPE_CHECKING from fastapi import Query -from pydantic import UUID4 from sqlmodel import select from app.api.auth.dependencies import CurrentActiveUserDep from app.api.common.crud.base import get_models from app.api.common.routers.dependencies import AsyncSessionDep from app.api.common.routers.openapi import PublicAPIRouter -from app.api.common.utils import get_user_owned_object from app.api.plugins.rpi_cam import crud -from app.api.plugins.rpi_cam.dependencies import CameraFilterDep, UserOwnedCameraDep +from app.api.plugins.rpi_cam.dependencies import ( + CameraFilterDep, + CameraTransferOwnerIDDep, + UserOwnedCameraDep, +) from app.api.plugins.rpi_cam.models import Camera, CameraStatus from app.api.plugins.rpi_cam.schemas import ( CameraCreate, @@ -22,16 +24,15 @@ CameraUpdate, ) -# TODO improve exception handling, add custom exceptions and return more granular HTTP codes -# (.e.g. 404 on missing camera, 403 on unauthorized access) +if TYPE_CHECKING: + from collections.abc import Sequence -# TODO: Decide on proper path for user-dependent operations (e.g. cameras, organizations, etc.) -router = PublicAPIRouter(prefix="/plugins/rpi-cam/cameras", tags=["rpi-cam-management"]) +camera_router = PublicAPIRouter(tags=["rpi-cam-management"]) +router = PublicAPIRouter() ## GET ## -# TODO: Consider expanding get routes to cameras owned by any members of the organization of the user -@router.get( +@camera_router.get( "", response_model=list[CameraRead] | list[CameraReadWithStatus], summary="Get Raspberry Pi cameras of the current user", @@ -44,7 +45,7 @@ async def get_user_cameras( include_status: bool = Query(default=False, description="Include camera online status"), ) -> Sequence[Camera | CameraReadWithStatus]: """Get all Raspberry Pi cameras of the current user.""" - statement = select(Camera).where(Camera.owner_id == current_user.id) + statement = select(Camera).where(Camera.owner_id == current_user.db_id) db_cameras = await get_models(session, Camera, model_filter=camera_filter, statement=statement) return [ @@ -53,39 +54,40 @@ async def get_user_cameras( ] -@router.get("/{camera_id}", response_model=CameraRead | CameraReadWithStatus, summary="Get Raspberry Pi camera by ID") +@camera_router.get( + "/{camera_id}", + response_model=CameraRead | CameraReadWithStatus, + summary="Get Raspberry Pi camera by ID", +) async def get_user_camera( - camera_id: UUID4, - session: AsyncSessionDep, - current_user: CurrentActiveUserDep, + db_camera: UserOwnedCameraDep, *, include_status: bool = Query(default=False, description="Include camera online status"), ) -> Camera | CameraReadWithStatus: """Get single Raspberry Pi camera by ID, if owned by the current user.""" - db_camera = await get_user_owned_object(session, Camera, camera_id, current_user.id) - return await CameraReadWithStatus.from_db_model_with_status(db_camera) if include_status else db_camera -@router.get( +@camera_router.get( "/{camera_id}/status", summary="Get Raspberry Pi camera online status", ) async def get_user_camera_status( - camera_id: UUID4, - session: AsyncSessionDep, - current_user: CurrentActiveUserDep, + db_camera: UserOwnedCameraDep, *, force_refresh: bool = Query(default=False, description="Force a refresh of the status by bypassing the cache"), ) -> CameraStatus: """Get Raspberry Pi camera online status.""" - db_camera = await get_user_owned_object(session, Camera, camera_id, current_user.id) - return await db_camera.get_status(force_refresh=force_refresh) ## POST -@router.post("", response_model=CameraReadWithCredentials, summary="Register new Raspberry Pi camera", status_code=201) +@camera_router.post( + "", + response_model=CameraReadWithCredentials, + summary="Register new Raspberry Pi camera", + status_code=201, +) async def register_user_camera( camera: CameraCreate, session: AsyncSessionDep, current_user: CurrentActiveUserDep ) -> CameraReadWithCredentials: @@ -93,43 +95,48 @@ async def register_user_camera( db_camera = await crud.create_camera( session, camera, - current_user.id, + current_user.db_id, ) return CameraReadWithCredentials.from_db_model_with_credentials(db_camera) -@router.post( +@camera_router.post( "/{camera_id}/regenerate-api-key", response_model=CameraReadWithCredentials, summary="Regenerate API key for the Raspberry Pi camera", status_code=201, ) async def regenerate_api_key( - camera_id: UUID4, session: AsyncSessionDep, - current_user: CurrentActiveUserDep, + db_camera: UserOwnedCameraDep, ) -> CameraReadWithCredentials: """Regenerate API key for Raspberry Pi camera.""" - db_camera = await crud.regenerate_camera_api_key(session, camera_id, current_user.id) + db_camera = await crud.regenerate_camera_api_key(session, db_camera) return CameraReadWithCredentials.from_db_model_with_credentials(db_camera) ## PATCH -@router.patch("/{camera_id}", response_model=CameraRead, summary="Update Raspberry Pi camera") +@camera_router.patch("/{camera_id}", response_model=CameraRead, summary="Update Raspberry Pi camera") async def update_user_camera( - *, session: AsyncSessionDep, db_camera: UserOwnedCameraDep, camera_in: CameraUpdate + *, + session: AsyncSessionDep, + db_camera: UserOwnedCameraDep, + camera_in: CameraUpdate, + transfer_owner_id: CameraTransferOwnerIDDep, ) -> Camera: """Update Raspberry Pi camera.""" - db_camera = await crud.update_camera(session, db_camera, camera_in) - - return db_camera + return await crud.update_camera(session, db_camera, camera_in, new_owner_id=transfer_owner_id) ## DELETE -@router.delete("/{camera_id}", summary="Delete Raspberry Pi camera", status_code=204) +@camera_router.delete("/{camera_id}", summary="Delete Raspberry Pi camera", status_code=204) async def delete_user_camera(db: AsyncSessionDep, camera: UserOwnedCameraDep) -> None: """Delete Raspberry Pi camera.""" await db.delete(camera) await db.commit() + + +router.include_router(camera_router, prefix="/plugins/rpi-cam/cameras") +router.include_router(camera_router, prefix="/users/me/cameras") diff --git a/backend/app/api/plugins/rpi_cam/routers/camera_interaction/__init__.py b/backend/app/api/plugins/rpi_cam/routers/camera_interaction/__init__.py index e69de29b..a0afb91e 100644 --- a/backend/app/api/plugins/rpi_cam/routers/camera_interaction/__init__.py +++ b/backend/app/api/plugins/rpi_cam/routers/camera_interaction/__init__.py @@ -0,0 +1 @@ +"""Routes for interacting with plugin cameras.""" diff --git a/backend/app/api/plugins/rpi_cam/routers/camera_interaction/images.py b/backend/app/api/plugins/rpi_cam/routers/camera_interaction/images.py index 5143c484..184c0524 100644 --- a/backend/app/api/plugins/rpi_cam/routers/camera_interaction/images.py +++ b/backend/app/api/plugins/rpi_cam/routers/camera_interaction/images.py @@ -6,17 +6,13 @@ from pydantic import UUID4, PositiveInt from app.api.auth.dependencies import CurrentActiveUserDep -from app.api.common.routers.dependencies import AsyncSessionDep +from app.api.common.routers.dependencies import AsyncSessionDep, ExternalHTTPClientDep from app.api.common.routers.openapi import PublicAPIRouter from app.api.file_storage.models.models import Image from app.api.file_storage.schemas import ImageRead -from app.api.plugins.rpi_cam.routers.camera_interaction.utils import get_user_owned_camera +from app.api.plugins.rpi_cam.routers.camera_interaction.utils import build_camera_request, get_user_owned_camera from app.api.plugins.rpi_cam.services import capture_and_store_image -# TODO improve exception handling, add custom exceptions and return more granular HTTP codes -# (.e.g. 404 on missing camera, 403 on unauthorized access) - - router = PublicAPIRouter() @@ -34,12 +30,20 @@ async def capture_image( camera_id: UUID4, session: AsyncSessionDep, + http_client: ExternalHTTPClientDep, current_user: CurrentActiveUserDep, *, product_id: Annotated[PositiveInt, Body(description="ID of product to associate the image with")], description: Annotated[str | None, Body(description="Custom description for the image", max_length=500)] = None, ) -> Image: """Capture a still image with a remote Raspberry Pi Camera.""" - camera = await get_user_owned_camera(session, camera_id, current_user.id) - - return await capture_and_store_image(session, camera, product_id=product_id, description=description) + camera = await get_user_owned_camera(session, camera_id, current_user.db_id) + camera_request = build_camera_request(camera, http_client) + + return await capture_and_store_image( + session, + camera, + camera_request=camera_request, + product_id=product_id, + description=description, + ) diff --git a/backend/app/api/plugins/rpi_cam/routers/camera_interaction/remote_management.py b/backend/app/api/plugins/rpi_cam/routers/camera_interaction/remote_management.py index 818a0953..20a83db2 100644 --- a/backend/app/api/plugins/rpi_cam/routers/camera_interaction/remote_management.py +++ b/backend/app/api/plugins/rpi_cam/routers/camera_interaction/remote_management.py @@ -1,26 +1,21 @@ """Routers for the Raspberry Pi Camera plugin.""" -import json - -from fastapi import HTTPException, Query +from fastapi import Query from httpx import QueryParams from pydantic import UUID4, ValidationError from relab_rpi_cam_models.camera import CameraMode from app.api.auth.dependencies import CurrentActiveUserDep -from app.api.common.routers.dependencies import AsyncSessionDep +from app.api.common.routers.dependencies import AsyncSessionDep, ExternalHTTPClientDep from app.api.common.routers.openapi import PublicAPIRouter +from app.api.plugins.rpi_cam.exceptions import InvalidCameraResponseError from app.api.plugins.rpi_cam.models import CameraConnectionStatus, CameraStatus, CameraStatusDetails from app.api.plugins.rpi_cam.routers.camera_interaction.utils import ( HttpMethod, - fetch_from_camera_url, + build_camera_request, get_user_owned_camera, ) -# TODO improve exception handling, add custom exceptions and return more granular HTTP codes -# (.e.g. 404 on missing camera, 403 on unauthorized access) - - router = PublicAPIRouter() @@ -29,13 +24,14 @@ async def init_camera( camera_id: UUID4, session: AsyncSessionDep, + http_client: ExternalHTTPClientDep, current_user: CurrentActiveUserDep, mode: CameraMode = Query(default=CameraMode.PHOTO, description="Camera mode (photo or video)"), ) -> CameraStatus: """Initialize camera for a given use mode (photo or video).""" - camera = await get_user_owned_camera(session, camera_id, current_user.id) - response = await fetch_from_camera_url( - camera=camera, + camera = await get_user_owned_camera(session, camera_id, current_user.db_id) + camera_request = build_camera_request(camera, http_client) + response = await camera_request( endpoint="/camera/open", method=HttpMethod.POST, error_msg="Failed to open camera", @@ -44,19 +40,20 @@ async def init_camera( try: return CameraStatus(connection=CameraConnectionStatus.ONLINE, details=CameraStatusDetails(**response.json())) except ValidationError as e: - raise HTTPException(status_code=424, detail=f"Invalid response from camera: {json.loads(e.json())}") from e + raise InvalidCameraResponseError(e.json()) from e @router.post("/{camera_id}/close", summary="Close camera") async def close_camera( camera_id: UUID4, session: AsyncSessionDep, + http_client: ExternalHTTPClientDep, current_user: CurrentActiveUserDep, ) -> CameraStatus: """Close camera and free resources.""" - camera = await get_user_owned_camera(session, camera_id, current_user.id) - response = await fetch_from_camera_url( - camera=camera, + camera = await get_user_owned_camera(session, camera_id, current_user.db_id) + camera_request = build_camera_request(camera, http_client) + response = await camera_request( endpoint="/camera/close", method=HttpMethod.POST, error_msg="Failed to close camera", @@ -64,4 +61,4 @@ async def close_camera( try: return CameraStatus(connection=CameraConnectionStatus.ONLINE, details=CameraStatusDetails(**response.json())) except ValidationError as e: - raise HTTPException(status_code=424, detail=f"Invalid response from camera: {json.loads(e.json())}") from e + raise InvalidCameraResponseError(e.json()) from e diff --git a/backend/app/api/plugins/rpi_cam/routers/camera_interaction/streams.py b/backend/app/api/plugins/rpi_cam/routers/camera_interaction/streams.py index 810a4779..08953c36 100644 --- a/backend/app/api/plugins/rpi_cam/routers/camera_interaction/streams.py +++ b/backend/app/api/plugins/rpi_cam/routers/camera_interaction/streams.py @@ -1,61 +1,76 @@ """Camera stream interaction routes.""" -import json -from datetime import UTC, datetime +import logging from typing import Annotated from fastapi import Body, HTTPException, Request, Response from fastapi.responses import HTMLResponse from fastapi.templating import Jinja2Templates from httpx import QueryParams -from pydantic import UUID4, AnyUrl, HttpUrl, PositiveInt, ValidationError +from pydantic import UUID4, PositiveInt, ValidationError from relab_rpi_cam_models.stream import StreamMode, StreamView from sqlmodel import select from app.api.auth.dependencies import CurrentActiveUserDep from app.api.auth.models import OAuthAccount -from app.api.auth.services.oauth import google_youtube_oauth_client -from app.api.common.crud.utils import db_get_model_with_id_if_it_exists -from app.api.common.routers.dependencies import AsyncSessionDep +from app.api.auth.services.oauth_clients import google_youtube_oauth_client +from app.api.common.crud.utils import get_model_or_404 +from app.api.common.exceptions import APIError +from app.api.common.routers.dependencies import AsyncSessionDep, ExternalHTTPClientDep from app.api.common.routers.openapi import PublicAPIRouter -from app.api.common.schemas.base import serialize_datetime_with_z from app.api.data_collection.models import Product -from app.api.file_storage.crud import create_video -from app.api.file_storage.models.models import Video from app.api.file_storage.schemas import VideoCreate, VideoRead +from app.api.file_storage.video_crud import create_video +from app.api.plugins.rpi_cam.exceptions import ( + GoogleOAuthAssociationRequiredError, + InvalidCameraResponseError, + NoActiveYouTubeRecordingError, +) from app.api.plugins.rpi_cam.routers.camera_interaction.utils import ( HttpMethod, - fetch_from_camera_url, + build_camera_request, get_user_owned_camera, + stream_from_camera_url, +) +from app.api.plugins.rpi_cam.services import ( + YouTubePrivacyStatus, + YouTubeRecordingSession, + YouTubeService, + build_recording_text, + clear_recording_session, + load_recording_session, + serialize_stream_metadata, + store_recording_session, ) -from app.api.plugins.rpi_cam.services import YouTubePrivacyStatus, YouTubeService +from app.api.plugins.rpi_cam.youtube_schemas import YouTubeMonitorStreamResponse from app.core.config import settings +from app.core.logging import sanitize_log_value +from app.core.redis import OptionalRedisDep, require_redis # Initialize templates templates = Jinja2Templates(directory=settings.templates_path) # Initialize router router = PublicAPIRouter() +logger = logging.getLogger(__name__) ### Constants ### -# TODO: dynamically fetch the manifest file name from the camera HLS_MANIFEST_FILENAME = "master.m3u8" MAX_PREVIEW_STREAM_LENGTH_SECONDS = 7200 # 2 hours -### Common endpoints ### -# TODO: Move the CRUD like functionalities to services.py - +### Common endpoints ### @router.get("/{camera_id}/stream/status") async def get_camera_stream_status( camera_id: UUID4, session: AsyncSessionDep, + http_client: ExternalHTTPClientDep, current_user: CurrentActiveUserDep, ) -> StreamView: """Get current stream status.""" - camera = await get_user_owned_camera(session, camera_id, current_user.id) - response = await fetch_from_camera_url( - camera=camera, + camera = await get_user_owned_camera(session, camera_id, current_user.db_id) + camera_request = build_camera_request(camera, http_client) + response = await camera_request( endpoint="/stream/status", method=HttpMethod.GET, error_msg="Failed to get stream status", @@ -63,19 +78,20 @@ async def get_camera_stream_status( try: return StreamView(**response.json()) except ValidationError as e: - raise HTTPException(status_code=424, detail=f"Invalid response from camera: {json.loads(e.json())}") from e + raise InvalidCameraResponseError(e.json()) from e @router.delete("/{camera_id}/stream/stop", status_code=204, summary="Stop the active stream") async def stop_all_streams( camera_id: UUID4, session: AsyncSessionDep, + http_client: ExternalHTTPClientDep, current_user: CurrentActiveUserDep, ) -> None: """Stop the active stream (either youtube recording or preview stream).""" - camera = await get_user_owned_camera(session, camera_id, current_user.id) - await fetch_from_camera_url( - camera=camera, + camera = await get_user_owned_camera(session, camera_id, current_user.db_id) + camera_request = build_camera_request(camera, http_client) + await camera_request( endpoint="/stream/stop", method=HttpMethod.DELETE, error_msg="Failed to stop the active streams", @@ -83,18 +99,17 @@ async def stop_all_streams( ### Recording to Youtube ### -# TODO: Refine flow of video creation and product association in database. -# Currently, videos are creation in DB and associated with products on recording start. -# We should investigate whether it's better to save this on recording ending only. -# But how do we store the product id and description from recording start in the app state? Some smart caching? +# We cache the recording session in Redis on start and finalize the Video row on stop. @router.post( - "/{camera_id}/stream/record/start", response_model=VideoRead, status_code=201, summary="Start recording to YouTube" + "/{camera_id}/stream/record/start", response_model=StreamView, status_code=201, summary="Start recording to YouTube" ) async def start_recording( camera_id: UUID4, session: AsyncSessionDep, + http_client: ExternalHTTPClientDep, + redis: OptionalRedisDep, current_user: CurrentActiveUserDep, product_id: Annotated[PositiveInt, Body(description="ID of product to associate the video with")], title: Annotated[str | None, Body(description="Custom video title")] = None, @@ -102,50 +117,42 @@ async def start_recording( privacy_status: Annotated[ YouTubePrivacyStatus, Body(description="Privacy status for the YouTube video") ] = YouTubePrivacyStatus.PRIVATE, -) -> Video: - """Start recording to YouTube. Video will be stored and can be associated with a product.""" - # TODO: Break down this function into smaller parts for better maintainability - +) -> StreamView: + """Start recording to YouTube and cache the recording session in Redis.""" # Validate video data before starting stream - if product_id is not None: - await db_get_model_with_id_if_it_exists(session, Product, product_id) - video = VideoCreate( - url=HttpUrl("http://placeholder.com"), # Will be updated with actual stream URL + await get_model_or_404(session, Product, product_id) + redis_client = require_redis(redis) + resolved_title, resolved_description = build_recording_text( + product_id=product_id, title=title, description=description, - product_id=product_id, ) # Get Google OAuth account oauth_account = await session.scalar( select(OAuthAccount).where( - OAuthAccount.user_id == current_user.id, OAuthAccount.oauth_name == google_youtube_oauth_client.name + OAuthAccount.user_id == current_user.db_id, OAuthAccount.oauth_name == google_youtube_oauth_client.name ) ) if not oauth_account: - raise HTTPException( - 403, - "Google Oauth account association required for YouTube streaming. Use /api/auth/associate/google/authorize", - ) + raise GoogleOAuthAssociationRequiredError # Initialize YouTube service - youtube_service = YouTubeService(oauth_account, google_youtube_oauth_client) + youtube_service = YouTubeService(oauth_account, google_youtube_oauth_client, session, http_client) # Create livestream - now_str = serialize_datetime_with_z(datetime.now(UTC)) - title = title or f"Product {product_id} recording at {now_str}" if product_id else f"Recording at {now_str}" - description = description or f"Recording {f'of product {product_id}' if product_id else ''} at {now_str}" - youtube_config = await youtube_service.setup_livestream( - title, privacy_status=privacy_status, description=description + resolved_title, + privacy_status=privacy_status, + description=resolved_description, ) # Fetch user camera - camera = await get_user_owned_camera(session, camera_id, current_user.id) + camera = await get_user_owned_camera(session, camera_id, current_user.db_id) + camera_request = build_camera_request(camera, http_client) # Start Youtube stream - response = await fetch_from_camera_url( - camera=camera, + response = await camera_request( endpoint="/stream/start", method=HttpMethod.POST, error_msg="Failed to start stream", @@ -155,64 +162,156 @@ async def start_recording( try: stream_info = StreamView(**response.json()) except ValidationError as e: - raise HTTPException(status_code=424, detail=f"Invalid response from camera: {json.loads(e.json())}") from e + raise InvalidCameraResponseError(e.json()) from e # Validate stream is active await youtube_service.validate_stream_status(youtube_config.stream_id) - # Update video with actual stream URL and store in database - video.url = stream_info.url - video.video_metadata = stream_info.metadata.model_dump() - return await create_video(session, video) - - -@router.delete("/{camera_id}/stream/record/stop", summary="Stop recording to YouTube") + try: + await store_recording_session( + redis_client, + camera_id, + YouTubeRecordingSession( + product_id=product_id, + title=resolved_title, + description=resolved_description, + stream_url=stream_info.url, + broadcast_key=youtube_config.broadcast_key, + video_metadata=serialize_stream_metadata(stream_info.metadata), + ), + ) + except (HTTPException, APIError): + try: + await camera_request( + endpoint="/stream/stop", + method=HttpMethod.DELETE, + error_msg="Failed to roll back stream after recording session storage failure", + query_params=QueryParams({"mode": StreamMode.YOUTUBE.value}), + ) + except HTTPException as cleanup_error: + logger.warning( + "Failed to roll back camera stream for camera %s: %s", + sanitize_log_value(camera_id), + sanitize_log_value(cleanup_error), + ) + try: + await youtube_service.end_livestream(youtube_config.broadcast_key) + except APIError as cleanup_error: + logger.warning( + "Failed to roll back YouTube livestream for camera %s: %s", + sanitize_log_value(camera_id), + sanitize_log_value(cleanup_error), + ) + raise + + return stream_info + + +@router.delete( + "/{camera_id}/stream/record/stop", + response_model=VideoRead, + summary="Stop recording to YouTube", +) async def stop_recording( camera_id: UUID4, session: AsyncSessionDep, + http_client: ExternalHTTPClientDep, + redis: OptionalRedisDep, current_user: CurrentActiveUserDep, -) -> dict[str, AnyUrl]: - """Stop recording and save video to database.""" - camera = await get_user_owned_camera(session, camera_id, current_user.id) +) -> VideoRead: + """Stop recording, end the livestream, and create the video record.""" + redis_client = require_redis(redis) + recording_session = await load_recording_session(redis_client, camera_id) - # Get current stream info before stopping - stream_status_response = await fetch_from_camera_url( - camera=camera, - endpoint="/stream/status", - method=HttpMethod.GET, - error_msg="Failed to get stream status", + camera = await get_user_owned_camera(session, camera_id, current_user.db_id) + + oauth_account = await session.scalar( + select(OAuthAccount).where( + OAuthAccount.user_id == current_user.db_id, OAuthAccount.oauth_name == google_youtube_oauth_client.name + ) ) + if not oauth_account: + raise GoogleOAuthAssociationRequiredError - await fetch_from_camera_url( - camera=camera, + youtube_service = YouTubeService(oauth_account, google_youtube_oauth_client, session, http_client) + camera_request = build_camera_request(camera, http_client) + + await camera_request( endpoint="/stream/stop", method=HttpMethod.DELETE, error_msg="Failed to stop stream", query_params=QueryParams({"mode": StreamMode.YOUTUBE.value}), ) - # TODO: Stop YouTube stream on YouTube API + await youtube_service.end_livestream(recording_session.broadcast_key) + + video = VideoCreate( + url=recording_session.stream_url, + title=recording_session.title, + description=recording_session.description, + product_id=recording_session.product_id, + video_metadata=recording_session.video_metadata, + ) + created_video = await create_video(session, video) + await clear_recording_session(redis_client, camera_id) + + return VideoRead.model_validate(created_video) + +@router.get( + "/{camera_id}/stream/record/monitor", + response_model=YouTubeMonitorStreamResponse, + summary="Get YouTube livestream monitor stream", +) +async def get_recording_monitor_stream( + camera_id: UUID4, + session: AsyncSessionDep, + http_client: ExternalHTTPClientDep, + current_user: CurrentActiveUserDep, +) -> YouTubeMonitorStreamResponse: + """Get the YouTube monitor stream configuration for the active recording.""" + camera = await get_user_owned_camera(session, camera_id, current_user.db_id) + camera_request = build_camera_request(camera, http_client) + + stream_status_response = await camera_request( + endpoint="/stream/status", + method=HttpMethod.GET, + error_msg="Failed to get stream status", + ) try: stream_info = StreamView(**stream_status_response.json()) except ValidationError as e: - raise HTTPException(status_code=424, detail=f"Invalid response from camera: {json.loads(e.json())}") from e - else: - return {"video_url": stream_info.url} + raise InvalidCameraResponseError(e.json()) from e + if stream_info.youtube_config is None: + raise NoActiveYouTubeRecordingError + + oauth_account = await session.scalar( + select(OAuthAccount).where( + OAuthAccount.user_id == current_user.db_id, OAuthAccount.oauth_name == google_youtube_oauth_client.name + ) + ) + if not oauth_account: + raise GoogleOAuthAssociationRequiredError -# TODO: Add Youtube livestream status monitoring endpoint using liveBroadcast.contentDetails.monitorStream + youtube_service = YouTubeService(oauth_account, google_youtube_oauth_client, session, http_client) + return await youtube_service.get_broadcast_monitor_stream(stream_info.youtube_config.broadcast_key) ### Local stream preview ### @router.post( "/{camera_id}/stream/preview/start", response_model=StreamView, status_code=201, summary="Start preview stream" ) -async def start_preview(camera_id: UUID4, session: AsyncSessionDep, current_user: CurrentActiveUserDep) -> StreamView: +async def start_preview( + camera_id: UUID4, + session: AsyncSessionDep, + http_client: ExternalHTTPClientDep, + current_user: CurrentActiveUserDep, +) -> StreamView: """Start local HLS preview stream. Stream will not be recorded.""" - camera = await get_user_owned_camera(session, camera_id, current_user.id) - response = await fetch_from_camera_url( - camera=camera, + camera = await get_user_owned_camera(session, camera_id, current_user.db_id) + camera_request = build_camera_request(camera, http_client) + response = await camera_request( endpoint="/stream/start", method=HttpMethod.POST, error_msg="Failed to start stream", @@ -221,16 +320,21 @@ async def start_preview(camera_id: UUID4, session: AsyncSessionDep, current_user try: return StreamView(**response.json()) except ValidationError as e: - raise HTTPException(status_code=424, detail=f"Invalid response from camera: {json.loads(e.json())}") from e + raise InvalidCameraResponseError(e.json()) from e @router.delete("/{camera_id}/stream/preview/stop", status_code=204, summary="Stop preview stream") -async def stop_preview(camera_id: UUID4, session: AsyncSessionDep, current_user: CurrentActiveUserDep) -> None: +async def stop_preview( + camera_id: UUID4, + session: AsyncSessionDep, + http_client: ExternalHTTPClientDep, + current_user: CurrentActiveUserDep, +) -> None: """Stop recording and save video to database.""" - camera = await get_user_owned_camera(session, camera_id, current_user.id) + camera = await get_user_owned_camera(session, camera_id, current_user.db_id) + camera_request = build_camera_request(camera, http_client) - await fetch_from_camera_url( - camera=camera, + await camera_request( endpoint="/stream/stop", method=HttpMethod.DELETE, error_msg="Failed to stop stream", @@ -244,29 +348,31 @@ async def stop_preview(camera_id: UUID4, session: AsyncSessionDep, current_user: description="Fetches and serves HLS stream files (.m3u8, .ts) from the camera", ) async def hls_file_proxy( - camera_id: UUID4, file_path: str, session: AsyncSessionDep, current_user: CurrentActiveUserDep + camera_id: UUID4, + file_path: str, + session: AsyncSessionDep, + http_client: ExternalHTTPClientDep, + current_user: CurrentActiveUserDep, ) -> Response: """Proxy HLS files from camera to client.""" - # TODO: Use StreamResponse here and in the RPI cam API instead of FileResponse - camera = await get_user_owned_camera(session, camera_id, current_user.id) - - response = await fetch_from_camera_url( + camera = await get_user_owned_camera(session, camera_id, current_user.db_id) + response = await stream_from_camera_url( camera=camera, endpoint=f"/stream/hls/{file_path}", method=HttpMethod.GET, + http_client=http_client, error_msg=f"Failed to get HLS file {file_path}", ) - return Response( - content=response.content, - media_type=response.headers["content-type"], - headers={ + response.headers.update( + { "Cache-Control": "no-cache, no-store, must-revalidate" if file_path.endswith(".m3u8") # Cache .ts segments but not playlists else f"max-age={MAX_PREVIEW_STREAM_LENGTH_SECONDS}", "Access-Control-Allow-Origin": "*", - }, + } ) + return response @router.get( @@ -283,11 +389,9 @@ async def watch_preview( Note: HTML viewer makes authenticated requests directly to camera's stream endpoint. """ # Validate camera ownership - await get_user_owned_camera(session, camera_id, current_user.id) + await get_user_owned_camera(session, camera_id, current_user.db_id) - response = templates.TemplateResponse( + return templates.TemplateResponse( "plugins/rpi_cam/remote_stream_viewer.html", {"request": request, "camera_id": camera_id, "hls_manifest_file": HLS_MANIFEST_FILENAME}, ) - - return response diff --git a/backend/app/api/plugins/rpi_cam/routers/camera_interaction/utils.py b/backend/app/api/plugins/rpi_cam/routers/camera_interaction/utils.py index 178851eb..07aedfcf 100644 --- a/backend/app/api/plugins/rpi_cam/routers/camera_interaction/utils.py +++ b/backend/app/api/plugins/rpi_cam/routers/camera_interaction/utils.py @@ -1,18 +1,35 @@ """Utilities for the camera interaction endpoints.""" -from enum import Enum +from __future__ import annotations + +import json +import logging +from enum import StrEnum +from typing import TYPE_CHECKING from urllib.parse import urljoin from fastapi import HTTPException -from httpx import AsyncClient, Headers, HTTPStatusError, QueryParams, Response +from fastapi.responses import StreamingResponse +from httpx import AsyncClient, Headers, HTTPStatusError, QueryParams, RequestError +from httpx import Response as HTTPXResponse from pydantic import UUID4 from sqlmodel.ext.asyncio.session import AsyncSession +from starlette.background import BackgroundTask from app.api.common.utils import get_user_owned_object +from app.api.plugins.rpi_cam.exceptions import CameraProxyRequestError from app.api.plugins.rpi_cam.models import Camera, CameraConnectionStatus +from app.core.http import create_http_client +from app.core.logging import sanitize_log_value + +if TYPE_CHECKING: + from collections.abc import Awaitable, Callable + + from pydantic import UUID4 + from sqlmodel.ext.asyncio.session import AsyncSession -class HttpMethod(str, Enum): +class HttpMethod(StrEnum): """HTTP method type.""" GET = "GET" @@ -42,31 +59,227 @@ async def fetch_from_camera_url( endpoint: str, method: HttpMethod, headers: Headers | None = None, + http_client: AsyncClient | None = None, error_msg: str | None = None, query_params: QueryParams | None = None, body: dict | None = None, *, follow_redirects: bool = True, -) -> Response: +) -> HTTPXResponse: """Utility function to send HTTP requests to the camera API.""" # Add camera auth header to request if headers is None: headers = Headers() - headers.update(camera.auth_headers) - - async with AsyncClient( - headers=headers, timeout=5.0, verify=camera.verify_ssl, follow_redirects=follow_redirects - ) as client: - try: - url = urljoin(str(camera.url), endpoint) - response = await client.request(method.value, url, params=query_params, json=body) - response.raise_for_status() - except HTTPStatusError as e: - if error_msg is None: - error_msg = f"Failed to {method.value} {endpoint}" - raise HTTPException( - status_code=e.response.status_code, - detail={"main API": error_msg, "Camera API": e.response.json().get("detail")}, - ) from e - else: - return response + headers.update({key: value.get_secret_value() for key, value in camera.auth_headers.items()}) + + if http_client is None: + async with create_http_client() as client: + return await _fetch_from_camera_via_http( + client, + camera, + endpoint, + method, + headers, + error_msg, + query_params, + body, + follow_redirects=follow_redirects, + ) + + return await _fetch_from_camera_via_http( + http_client, + camera, + endpoint, + method, + headers, + error_msg, + query_params, + body, + follow_redirects=follow_redirects, + ) + + +async def _fetch_from_camera_via_http( + client: AsyncClient, + camera: Camera, + endpoint: str, + method: HttpMethod, + headers: Headers, + error_msg: str | None, + query_params: QueryParams | None, + body: dict | None, + *, + follow_redirects: bool, +) -> HTTPXResponse: + """Send an HTTP request to the camera API using the shared client.""" + try: + url = urljoin(str(camera.url), endpoint) + request_headers = Headers(client.headers) + request_headers.update(headers) + response = await client.request( + method.value, + url, + params=query_params, + json=body, + headers=request_headers, + follow_redirects=follow_redirects, + ) + response.raise_for_status() + except HTTPStatusError as e: + if error_msg is None: + error_msg = f"Failed to {method.value} {endpoint}" + raise HTTPException( + status_code=e.response.status_code, + detail={"main API": error_msg, "Camera API": _extract_camera_error_detail(e.response)}, + ) from e + except RequestError as e: + # Network-level errors (DNS, connection refused, timeouts). + logger = logging.getLogger(__name__) + logger.warning( + "Network error contacting camera %s%s: %s", + sanitize_log_value(camera.url), + sanitize_log_value(endpoint), + sanitize_log_value(e), + ) + raise CameraProxyRequestError(endpoint, str(e)) from e + else: + return response + + +async def stream_from_camera_url( + camera: Camera, + endpoint: str, + method: HttpMethod, + headers: Headers | None = None, + http_client: AsyncClient | None = None, + error_msg: str | None = None, + query_params: QueryParams | None = None, + body: dict | None = None, + *, + follow_redirects: bool = True, +) -> StreamingResponse: + """Stream camera bytes without buffering the full payload in memory.""" + if headers is None: + headers = Headers() + headers.update({key: value.get_secret_value() for key, value in camera.auth_headers.items()}) + + if http_client is None: + async with create_http_client() as client: + return await _stream_from_camera_via_http( + client, + camera, + endpoint, + method, + headers, + error_msg, + query_params, + body, + follow_redirects=follow_redirects, + ) + + return await _stream_from_camera_via_http( + http_client, + camera, + endpoint, + method, + headers, + error_msg, + query_params, + body, + follow_redirects=follow_redirects, + ) + + +async def _stream_from_camera_via_http( + client: AsyncClient, + camera: Camera, + endpoint: str, + method: HttpMethod, + headers: Headers, + error_msg: str | None, + query_params: QueryParams | None, + body: dict | None, + *, + follow_redirects: bool, +) -> StreamingResponse: + """Create a streaming response for an HTTP camera request.""" + try: + url = urljoin(str(camera.url), endpoint) + request_headers = Headers(client.headers) + request_headers.update(headers) + request = client.build_request( + method.value, + url, + params=query_params, + json=body, + headers=request_headers, + ) + response = await client.send(request, stream=True, follow_redirects=follow_redirects) + response.raise_for_status() + except HTTPStatusError as e: + if error_msg is None: + error_msg = f"Failed to {method.value} {endpoint}" + raise HTTPException( + status_code=e.response.status_code, + detail={"main API": error_msg, "Camera API": _extract_camera_error_detail(e.response)}, + ) from e + except RequestError as e: + logger = logging.getLogger(__name__) + logger.warning( + "Network error contacting camera %s%s: %s", + sanitize_log_value(camera.url), + sanitize_log_value(endpoint), + sanitize_log_value(e), + ) + raise CameraProxyRequestError(endpoint, str(e)) from e + else: + return StreamingResponse( + response.aiter_bytes(), + status_code=response.status_code, + headers=dict(response.headers), + background=BackgroundTask(response.aclose), + ) + + +def _extract_camera_error_detail(response: HTTPXResponse) -> str | dict | list | None: + """Extract a useful error payload from a camera response without assuming JSON.""" + try: + payload = response.json() + except json.JSONDecodeError: + return response.text or None + + if isinstance(payload, dict): + detail = payload.get("detail") + return payload if detail is None else detail + return payload + + +def build_camera_request( + camera: Camera, + http_client: AsyncClient | None = None, +) -> Callable[..., Awaitable[HTTPXResponse]]: + """Build a reusable request callable bound to one camera and shared client.""" + + async def request( + endpoint: str, + method: HttpMethod, + headers: Headers | None = None, + error_msg: str | None = None, + query_params: QueryParams | None = None, + body: dict | None = None, + *, + follow_redirects: bool = True, + ) -> HTTPXResponse: + return await fetch_from_camera_url( + camera=camera, + endpoint=endpoint, + method=method, + headers=headers, + http_client=http_client, + error_msg=error_msg, + query_params=query_params, + body=body, + follow_redirects=follow_redirects, + ) + + return request diff --git a/backend/app/api/plugins/rpi_cam/schemas.py b/backend/app/api/plugins/rpi_cam/schemas.py index 18de973b..b9cb7868 100644 --- a/backend/app/api/plugins/rpi_cam/schemas.py +++ b/backend/app/api/plugins/rpi_cam/schemas.py @@ -7,9 +7,9 @@ from pydantic import ( UUID4, AfterValidator, + AnyUrl, BaseModel, Field, - HttpUrl, PlainSerializer, SecretStr, ) @@ -20,9 +20,10 @@ BaseReadSchemaWithTimeStamp, BaseUpdateSchema, ) +from app.api.common.schemas.custom_fields import AnyUrlToDB from app.api.plugins.rpi_cam.config import settings from app.api.plugins.rpi_cam.models import Camera, CameraBase, CameraStatus -from app.api.plugins.rpi_cam.utils.encryption import decrypt_str +from app.api.plugins.rpi_cam.utils.encryption import decrypt_dict, decrypt_str ### Filters ### @@ -35,6 +36,8 @@ class CameraFilter(Filter): search: str | None = None + order_by: list[str] | None = None + class Constants(Filter.Constants): # Standard FastAPI-filter class """FilterAPI class configuration.""" @@ -72,7 +75,6 @@ class HeaderCreate(BaseModel): Field(description="Header key", min_length=1, max_length=100, pattern=r"^[a-zA-Z][-.a-zA-Z0-9]*$"), AfterValidator(validate_auth_header_key), ] - # TODO: Consider adding SecretStr for any secret values in all schemas. Requires custom (de-)serialization logic value: SecretStr = Field(description="Header value", min_length=1, max_length=500) @@ -94,6 +96,21 @@ def validate_auth_headers_size(headers: list[HeaderCreate] | None) -> list[Heade return headers +def validate_camera_url_scheme(url: AnyUrl) -> AnyUrl: + """Validate that camera URLs use plain HTTP(S).""" + if url.scheme not in {"http", "https"}: + err_msg = "Camera URLs must use HTTP or HTTPS." + raise ValueError(err_msg) + return url + + +def validate_optional_camera_url_scheme(url: AnyUrl | None) -> AnyUrl | None: + """Validate that optional camera URLs use plain HTTP(S).""" + if url is None: + return None + return validate_camera_url_scheme(url) + + OptionalAuthHeaderCreateList = Annotated[ list[HeaderCreate] | None, Field(default=None, description="List of additional authentication headers for the camera API"), @@ -107,6 +124,11 @@ def validate_auth_headers_size(headers: list[HeaderCreate] | None) -> list[Heade class CameraCreate(BaseCreateSchema, CameraBase): """Schema for creating a camera.""" + # Override url field to add validation + url: Annotated[ + AnyUrlToDB, + AfterValidator(validate_camera_url_scheme), + ] = Field(description="HTTP(S) URL where the camera API is hosted") auth_headers: OptionalAuthHeaderCreateList @@ -116,12 +138,6 @@ class CameraRead(BaseReadSchemaWithTimeStamp, CameraBase): owner_id: UUID4 - @classmethod - def _get_base_fields(cls, db_model: Camera) -> dict: - return { - **db_model.model_dump(exclude={"encrypted_api_key", "encrypted_auth_headers", "auth_headers", "status"}), - } - class CameraReadWithStatus(CameraRead): """Schema for camera read with online status.""" @@ -130,23 +146,29 @@ class CameraReadWithStatus(CameraRead): @classmethod async def from_db_model_with_status(cls, db_model: Camera) -> Self: - return cls(**CameraRead._get_base_fields(db_model), status=await db_model.get_status()) + """Create CameraReadWithStatus instance from Camera database model, fetching the online status.""" + return cls( + **db_model.model_dump(exclude={"encrypted_api_key", "encrypted_auth_headers", "auth_headers", "status"}), + status=await db_model.get_status(), + ) class CameraReadWithCredentials(CameraRead): """Schema for camera read with credentials.""" - api_key: str - auth_headers: dict[str, str] | None + api_key: SecretStr + auth_headers: dict[str, SecretStr] | None @classmethod def from_db_model_with_credentials(cls, db_model: Camera) -> Self: - decrypted_headers = db_model._decrypt_auth_headers() if db_model.encrypted_auth_headers else None + """Create CameraReadWithCredentials instance from Camera database model, decrypting the auth headers.""" + decrypted_headers = decrypt_dict(db_model.encrypted_auth_headers) if db_model.encrypted_auth_headers else None + auth_headers = {k: SecretStr(v) for k, v in decrypted_headers.items()} if decrypted_headers else None return cls( - **CameraRead._get_base_fields(db_model), - api_key=decrypt_str(db_model.encrypted_api_key), - auth_headers=decrypted_headers, + **db_model.model_dump(exclude={"encrypted_api_key", "encrypted_auth_headers", "auth_headers", "status"}), + api_key=SecretStr(decrypt_str(db_model.encrypted_api_key)), + auth_headers=auth_headers, ) @@ -156,8 +178,13 @@ class CameraUpdate(BaseUpdateSchema): name: str | None = Field(default=None, min_length=2, max_length=100) description: str | None = Field(default=None, max_length=500) - url: HttpUrl | None = Field(default=None, description="HTTP(S) URL where the camera API is hosted") + url: Annotated[ + AnyUrlToDB | None, + AfterValidator(validate_optional_camera_url_scheme), + ] = Field(default=None, description="HTTP(S) URL where the camera API is hosted") auth_headers: OptionalAuthHeaderCreateList - # TODO: Make it only possible to change ownership to existing users within the same organization - owner_id: UUID4 | None = None + owner_id: UUID4 | None = Field( + default=None, + description="Transfer ownership to an existing user in the same organization as the current owner.", + ) diff --git a/backend/app/api/plugins/rpi_cam/services.py b/backend/app/api/plugins/rpi_cam/services.py index ca56db49..266a7f4b 100644 --- a/backend/app/api/plugins/rpi_cam/services.py +++ b/backend/app/api/plugins/rpi_cam/services.py @@ -1,37 +1,146 @@ """Camera interaction services.""" +import logging +from collections.abc import Awaitable, Callable from datetime import UTC, datetime -from enum import Enum +from enum import StrEnum from io import BytesIO +from typing import TYPE_CHECKING, Any, cast from fastapi import UploadFile from fastapi.datastructures import Headers -from google.oauth2.credentials import Credentials -from googleapiclient.discovery import Resource, build -from googleapiclient.errors import HttpError -from httpx_oauth.clients.google import GoogleOAuth2 -from pydantic import Field, PositiveInt +from httpx import AsyncClient, HTTPStatusError, RequestError, Response +from pydantic import UUID4, AnyUrl, BaseModel, Field, PositiveInt, ValidationError from relab_rpi_cam_models.stream import YoutubeStreamConfig from sqlmodel.ext.asyncio.session import AsyncSession -from app.api.auth.config import settings from app.api.auth.models import OAuthAccount -from app.api.auth.services.oauth import GOOGLE_YOUTUBE_SCOPES -from app.api.common.crud.utils import db_get_model_with_id_if_it_exists +from app.api.common.crud.utils import get_model_or_404 from app.api.common.exceptions import APIError from app.api.common.schemas.base import serialize_datetime_with_z from app.api.data_collection.models import Product from app.api.file_storage.crud import create_image -from app.api.file_storage.models.models import Image, ImageParentType +from app.api.file_storage.models.models import Image, MediaParentType from app.api.file_storage.schemas import ImageCreateInternal +from app.api.plugins.rpi_cam.exceptions import ( + GoogleOAuthAssociationRequiredError, + InvalidRecordingSessionDataError, + RecordingSessionNotFoundError, + RecordingSessionStoreError, +) from app.api.plugins.rpi_cam.models import Camera -from app.api.plugins.rpi_cam.routers.camera_interaction.utils import HttpMethod, fetch_from_camera_url +from app.api.plugins.rpi_cam.routers.camera_interaction.utils import HttpMethod, build_camera_request +from app.api.plugins.rpi_cam.youtube_schemas import ( + YouTubeAPIErrorResponse, + YouTubeBroadcastContentDetailsCreate, + YouTubeBroadcastCreateRequest, + YouTubeBroadcastListResponse, + YouTubeBroadcastResponse, + YouTubeBroadcastStatusCreate, + YouTubeMonitorStreamResponse, + YouTubeSnippetCreate, + YouTubeStreamCDNCreate, + YouTubeStreamCreateRequest, + YouTubeStreamListResponse, + YouTubeStreamResponse, +) +from app.core.logging import sanitize_log_value +from app.core.redis import delete_redis_key, get_redis_value, set_redis_value + +if TYPE_CHECKING: + from collections.abc import Awaitable, Callable + + from httpx_oauth.clients.google import GoogleOAuth2 + from redis.asyncio import Redis + + +logger = logging.getLogger(__name__) +YOUTUBE_API_BASE_URL = "https://www.googleapis.com/youtube/v3" +YOUTUBE_RECORDING_SESSION_CACHE_PREFIX = "rpi_cam:youtube_recording" +YOUTUBE_RECORDING_SESSION_TTL_SECONDS = 60 * 60 * 12 + + +class YouTubeRecordingSession(BaseModel): + """Cached state for an in-progress YouTube recording.""" + + product_id: PositiveInt + title: str + description: str + stream_url: AnyUrl + broadcast_key: str + video_metadata: dict[str, Any] | None = None + + +def get_recording_session_cache_key(camera_id: UUID4) -> str: + """Build the Redis key for a camera's active YouTube recording.""" + return f"{YOUTUBE_RECORDING_SESSION_CACHE_PREFIX}:{camera_id}" + + +def build_recording_text( + *, + product_id: PositiveInt, + title: str | None, + description: str | None, +) -> tuple[str, str]: + """Build the final title and description for a YouTube recording.""" + now_str = serialize_datetime_with_z(datetime.now(UTC)) + resolved_title = title or f"Product {product_id} recording at {now_str}" + resolved_description = description or f"Recording of product {product_id} at {now_str}" + return resolved_title, resolved_description + + +def serialize_stream_metadata(metadata: object | None) -> dict[str, object] | None: + """Convert camera stream metadata into JSON-compatible data.""" + if metadata is None: + return None + if isinstance(metadata, BaseModel): + return cast("dict[str, object]", metadata.model_dump(mode="json")) + if isinstance(metadata, dict): + return cast("dict[str, object]", metadata) + msg = "Unsupported stream metadata type." + raise TypeError(msg) + + +async def store_recording_session( + redis_client: Redis, + camera_id: UUID4, + session: YouTubeRecordingSession, +) -> None: + """Persist in-progress recording state in Redis.""" + stored = await set_redis_value( + redis_client, + get_recording_session_cache_key(camera_id), + session.model_dump_json(), + ex=YOUTUBE_RECORDING_SESSION_TTL_SECONDS, + ) + if not stored: + raise RecordingSessionStoreError + + +async def load_recording_session(redis_client: Redis, camera_id: UUID4) -> YouTubeRecordingSession: + """Load in-progress recording state from Redis.""" + payload = await get_redis_value(redis_client, get_recording_session_cache_key(camera_id)) + if payload is None: + raise RecordingSessionNotFoundError + + try: + return YouTubeRecordingSession.model_validate_json(payload) + except ValidationError as e: + raise InvalidRecordingSessionDataError(str(e.errors())) from e + + +async def clear_recording_session(redis_client: Redis, camera_id: UUID4) -> None: + """Remove in-progress recording state from Redis.""" + cleared = await delete_redis_key(redis_client, get_recording_session_cache_key(camera_id)) + if not cleared: + logger.warning("Failed to clear YouTube recording session for camera %s", sanitize_log_value(camera_id)) async def capture_and_store_image( session: AsyncSession, camera: Camera, *, + camera_request: Callable[..., Awaitable[Response]] | None = None, product_id: PositiveInt, filename: str | None = None, description: str | None = None, @@ -39,11 +148,13 @@ async def capture_and_store_image( """Capture image from camera and store in database. Optionally associate with a parent product.""" # Validate the product_id if product_id: - await db_get_model_with_id_if_it_exists(session, Product, product_id) + await get_model_or_404(session, Product, product_id) + + if camera_request is None: + camera_request = build_camera_request(camera) # Capture image - capture_response = await fetch_from_camera_url( - camera=camera, + capture_response = await camera_request( endpoint="/images", method=HttpMethod.POST, error_msg="Failed to capture image", @@ -51,8 +162,7 @@ async def capture_and_store_image( capture_data = capture_response.json() # Download image - image_response = await fetch_from_camera_url( - camera=camera, + image_response = await camera_request( endpoint=capture_data["image_url"], method=HttpMethod.GET, error_msg="Failed to download image", @@ -63,13 +173,13 @@ async def capture_and_store_image( image_data = ImageCreateInternal( file=UploadFile( file=BytesIO(image_response.content), - filename=filename if filename else f"{camera.name}_{serialize_datetime_with_z(datetime.now(UTC))}.jpg", + filename=filename or f"{camera.name}_{serialize_datetime_with_z(datetime.now(UTC))}.jpg", size=len(image_response.content), headers=Headers({"content-type": "image/jpeg"}), ), - description=(description if description else f"Captured from camera {camera.name} at {timestamp_str}."), + description=(description or f"Captured from camera {camera.name} at {timestamp_str}."), image_metadata=capture_data.get("metadata"), - parent_type=ImageParentType.PRODUCT, + parent_type=MediaParentType.PRODUCT, parent_id=product_id, ) @@ -85,7 +195,7 @@ def __init__(self, http_status_code: int = 500, details: str | None = None): super().__init__("YouTube API error.", details) -class YouTubePrivacyStatus(str, Enum): +class YouTubePrivacyStatus(StrEnum): """Enumeration of YouTube privacy statuses.""" PUBLIC = "public" @@ -102,30 +212,81 @@ class YoutubeStreamConfigWithID(YoutubeStreamConfig): class YouTubeService: """YouTube API service for creating and managing live streams.""" - def __init__(self, oauth_account: OAuthAccount, google_oauth_client: GoogleOAuth2): + def __init__( + self, + oauth_account: OAuthAccount, + google_oauth_client: GoogleOAuth2, + session: AsyncSession, + http_client: AsyncClient, + ) -> None: self.oauth_account = oauth_account self.google_oauth_client = google_oauth_client + self.session = session + self.http_client = http_client async def refresh_token_if_needed(self) -> None: - """Refresh OAuth token if expired.""" + """Refresh OAuth token if expired and persist to database.""" if self.oauth_account.expires_at and self.oauth_account.expires_at < datetime.now(UTC).timestamp(): - # TODO: if Refresh token is None, what to do? https://medium.com/starthinker/google-oauth-2-0-access-token-and-refresh-token-explained-cccf2fc0a6d9 + # Check if refresh token exists + if not self.oauth_account.refresh_token: + raise GoogleOAuthAssociationRequiredError from None + + # Refresh the token new_token = await self.google_oauth_client.refresh_token(self.oauth_account.refresh_token) + + # Update the OAuth account self.oauth_account.access_token = new_token["access_token"] self.oauth_account.expires_at = datetime.now(UTC).timestamp() + new_token["expires_in"] - def get_youtube_client(self) -> Resource: - """Get authenticated YouTube API client.""" - # TODO: Make Google API client thread safe and async if possible (using asyncio/asyncer): https://github.com/googleapis/google-api-python-client/blob/main/docs/thread_safety.md - credentials = Credentials( - token=self.oauth_account.access_token, - refresh_token=self.oauth_account.refresh_token, - token_uri="https://oauth2.googleapis.com/token", # noqa: S106 # No sensitive data in URL - client_id=settings.google_oauth_client_id, - client_secret=settings.google_oauth_client_secret, - scopes=GOOGLE_YOUTUBE_SCOPES, - ) - return build("youtube", "v3", credentials=credentials) + # Persist to database + self.session.add(self.oauth_account) + await self.session.commit() + await self.session.refresh(self.oauth_account) + + async def request_youtube_api( + self, + method: str, + endpoint: str, + *, + params: dict[str, str] | None = None, + body: dict[str, Any] | None = None, + ) -> dict[str, Any]: + """Send an authenticated request to the YouTube Data API.""" + try: + response = await self.http_client.request( + method, + f"{YOUTUBE_API_BASE_URL}/{endpoint}", + params=params, + json=body, + headers={"Authorization": f"Bearer {self.oauth_account.access_token}"}, + ) + response.raise_for_status() + except HTTPStatusError as e: + raise YouTubeAPIError( + http_status_code=e.response.status_code, + details=self._build_error_detail(endpoint, e.response), + ) from e + except RequestError as e: + raise YouTubeAPIError(http_status_code=503, details=f"Network error contacting YouTube API: {e}") from e + + if response.status_code == 204: + return {} + return response.json() + + @staticmethod + def _build_error_detail(endpoint: str, response: Response) -> str: + """Build a useful error message from a failed YouTube API response.""" + try: + error_payload = YouTubeAPIErrorResponse.model_validate(response.json()) + error_message = error_payload.error.message if error_payload.error else None + except ValueError: + error_message = response.text + except ValidationError: + error_message = response.text + + if error_message: + return f"Failed calling {endpoint}: {error_message}" + return f"Failed calling {endpoint}: HTTP {response.status_code}" async def setup_livestream( self, @@ -135,81 +296,104 @@ async def setup_livestream( ) -> YoutubeStreamConfigWithID: """Create a YouTube livestream and return stream configuration.""" await self.refresh_token_if_needed() - youtube = self.get_youtube_client() - + # Create broadcast + broadcast_payload = YouTubeBroadcastCreateRequest( + snippet=YouTubeSnippetCreate( + title=title, + scheduledStartTime=serialize_datetime_with_z(datetime.now(UTC)), + description=description or "", + ), + status=YouTubeBroadcastStatusCreate(privacyStatus=privacy_status.value), + contentDetails=YouTubeBroadcastContentDetailsCreate(), + ) + broadcast = await self.request_youtube_api( + "POST", + "liveBroadcasts", + params={"part": "snippet,status,contentDetails"}, + body=broadcast_payload.model_dump(mode="json"), + ) try: - # Create broadcast - broadcast = ( - youtube.liveBroadcasts() - .insert( - part="snippet,status,contentDetails", - body={ - "snippet": { - "title": title, - "scheduledStartTime": serialize_datetime_with_z(datetime.now(UTC)), - "description": description or "", - }, - "status": {"privacyStatus": privacy_status.value, "selfDeclaredMadeForKids": False}, - "contentDetails": { # Enable auto start and stop of broadcast on stream start and stop - # TODO: Investigate potential pause function, which would require manual start/stop - "enableAutoStart": True, - "enableAutoStop": True, - }, - }, - ) - .execute() - ) - - # Create stream - # TODO: Create one stream per camera and store key and id in camera model - stream = ( - youtube.liveStreams() - .insert( - part="snippet,cdn", - body={ - "snippet": {"title": title}, - "cdn": {"frameRate": "30fps", "ingestionType": "hls", "resolution": "720p"}, - "description": description or "", - }, - ) - .execute() - ) - - # Bind them together - broadcast = ( - youtube.liveBroadcasts() - .bind(id=broadcast["id"], part="id,contentDetails", streamId=stream["id"]) - .execute() - ) - - return YoutubeStreamConfigWithID( - stream_key=stream["cdn"]["ingestionInfo"]["streamName"], - broadcast_key=broadcast["id"], - stream_id=stream["id"], - ) - - except HttpError as e: - raise YouTubeAPIError(http_status_code=e.status_code, details=f"Failed to create livestream: {e}") from e + broadcast_response = YouTubeBroadcastResponse.model_validate(broadcast) + except ValidationError as e: + raise YouTubeAPIError(details=f"Invalid YouTube broadcast response: {e}") from e + + # Create stream + # NOTE: This currently creates a stream per livestream request. + stream_payload = YouTubeStreamCreateRequest( + snippet=YouTubeSnippetCreate(title=title, description=description or ""), + cdn=YouTubeStreamCDNCreate(), + description=description or "", + ) + stream = await self.request_youtube_api( + "POST", + "liveStreams", + params={"part": "snippet,cdn"}, + body=stream_payload.model_dump(mode="json"), + ) + try: + stream_response = YouTubeStreamResponse.model_validate(stream) + except ValidationError as e: + raise YouTubeAPIError(details=f"Invalid YouTube stream response: {e}") from e + + # Bind them together + broadcast = await self.request_youtube_api( + "POST", + "liveBroadcasts/bind", + params={"id": broadcast_response.id, "part": "id,contentDetails", "streamId": stream_response.id}, + ) + try: + bound_broadcast_response = YouTubeBroadcastResponse.model_validate(broadcast) + except ValidationError as e: + raise YouTubeAPIError(details=f"Invalid YouTube bind response: {e}") from e + + return YoutubeStreamConfigWithID( + stream_key=stream_response.cdn.ingestionInfo.streamName, + broadcast_key=bound_broadcast_response.id, + stream_id=stream_response.id, + ) async def validate_stream_status(self, stream_id: str) -> bool: """Check if a YouTube livestream is live.""" await self.refresh_token_if_needed() - youtube = self.get_youtube_client() try: - response = youtube.liveStreams().list(part="status", id=stream_id).execute() - return response["items"][0]["status"]["streamStatus"] in ("active", "ready") - except HttpError as e: - raise YouTubeAPIError(http_status_code=e.status_code, details=f"Failed to validate livestream: {e}") from e - except KeyError as e: - raise YouTubeAPIError(details=f"Failed to validate livestream: {e}") from e + response = await self.request_youtube_api( + "GET", + "liveStreams", + params={"part": "status", "id": stream_id}, + ) + stream_list_response = YouTubeStreamListResponse.model_validate(response) + if not stream_list_response.items: + raise YouTubeAPIError(details="Failed to validate livestream: stream not found.") + return stream_list_response.items[0].status.streamStatus in ("active", "ready") + except ValidationError as e: + raise YouTubeAPIError(details=f"Invalid YouTube stream status response: {e}") from e async def end_livestream(self, broadcast_key: str) -> None: """End a YouTube livestream.""" await self.refresh_token_if_needed() - youtube = self.get_youtube_client() + await self.request_youtube_api("DELETE", "liveBroadcasts", params={"id": broadcast_key}) + + async def get_broadcast_monitor_stream(self, broadcast_key: str) -> YouTubeMonitorStreamResponse: + """Get the monitor stream configuration for a YouTube livestream.""" + await self.refresh_token_if_needed() try: - youtube.liveBroadcasts().delete(id=broadcast_key).execute() - except HttpError as e: - raise YouTubeAPIError(http_status_code=e.status_code, details=f"Failed to end livestream: {e}") from e + response = await self.request_youtube_api( + "GET", + "liveBroadcasts", + params={"part": "contentDetails", "id": broadcast_key}, + ) + broadcast_list_response = YouTubeBroadcastListResponse.model_validate(response) + if not broadcast_list_response.items: + raise YouTubeAPIError(details="Failed to fetch livestream monitor stream: broadcast not found.") + + content_details = broadcast_list_response.items[0].contentDetails + if content_details is None or content_details.monitorStream is None: + raise YouTubeAPIError( + details="Failed to fetch livestream monitor stream: monitor stream configuration missing." + ) + except ValidationError as e: + raise YouTubeAPIError(details=f"Invalid YouTube broadcast response: {e}") from e + else: + return content_details.monitorStream diff --git a/backend/app/api/plugins/rpi_cam/utils/encryption.py b/backend/app/api/plugins/rpi_cam/utils/encryption.py index 0cfd2f29..798dc36e 100644 --- a/backend/app/api/plugins/rpi_cam/utils/encryption.py +++ b/backend/app/api/plugins/rpi_cam/utils/encryption.py @@ -2,12 +2,15 @@ import json import secrets -from typing import Any +from typing import TYPE_CHECKING from cryptography.fernet import Fernet, InvalidToken from app.api.plugins.rpi_cam.config import settings +if TYPE_CHECKING: + from typing import Any + # Initialize the Fernet cipher CIPHER = Fernet(settings.rpi_cam_plugin_secret) diff --git a/backend/app/api/plugins/rpi_cam/youtube_schemas.py b/backend/app/api/plugins/rpi_cam/youtube_schemas.py new file mode 100644 index 00000000..d42f21d4 --- /dev/null +++ b/backend/app/api/plugins/rpi_cam/youtube_schemas.py @@ -0,0 +1,134 @@ +"""Pydantic models for YouTube API requests and responses.""" + +from pydantic import BaseModel, ConfigDict, Field + + +# ruff: noqa: N815 # PascalCase field names match YouTube API conventions; ignore snake_case naming violation +class YouTubeSnippetCreate(BaseModel): + """Common YouTube snippet payload.""" + + title: str + description: str = "" + scheduledStartTime: str | None = None + + +class YouTubeBroadcastStatusCreate(BaseModel): + """Broadcast status payload.""" + + privacyStatus: str + selfDeclaredMadeForKids: bool = False + + +class YouTubeBroadcastContentDetailsCreate(BaseModel): + """Broadcast content details payload.""" + + enableAutoStart: bool = True + enableAutoStop: bool = True + + +class YouTubeBroadcastCreateRequest(BaseModel): + """Create-live-broadcast request payload.""" + + snippet: YouTubeSnippetCreate + status: YouTubeBroadcastStatusCreate + contentDetails: YouTubeBroadcastContentDetailsCreate + + +class YouTubeStreamCDNCreate(BaseModel): + """Stream CDN configuration payload.""" + + frameRate: str = "30fps" + ingestionType: str = "hls" + resolution: str = "720p" + + +class YouTubeStreamCreateRequest(BaseModel): + """Create-live-stream request payload.""" + + snippet: YouTubeSnippetCreate + cdn: YouTubeStreamCDNCreate + description: str = "" + + +class YouTubeBroadcastResponse(BaseModel): + """Subset of broadcast response fields used by the app.""" + + id: str + + +class YouTubeMonitorStreamResponse(BaseModel): + """Subset of monitor stream fields used by the app.""" + + enableMonitorStream: bool + broadcastStreamDelayMs: int | None = None + embedHtml: str | None = None + + +class YouTubeBroadcastContentDetailsResponse(BaseModel): + """Subset of broadcast content details fields used by the app.""" + + monitorStream: YouTubeMonitorStreamResponse | None = None + + +class YouTubeBroadcastItemResponse(BaseModel): + """Single broadcast item from list response.""" + + id: str + contentDetails: YouTubeBroadcastContentDetailsResponse | None = None + + +class YouTubeBroadcastListResponse(BaseModel): + """List-broadcasts response payload.""" + + items: list[YouTubeBroadcastItemResponse] = Field(default_factory=list) + + +class YouTubeIngestionInfoResponse(BaseModel): + """Subset of ingestion info fields used by the app.""" + + streamName: str + + +class YouTubeCDNResponse(BaseModel): + """Subset of CDN response fields used by the app.""" + + ingestionInfo: YouTubeIngestionInfoResponse + + +class YouTubeStreamResponse(BaseModel): + """Subset of stream response fields used by the app.""" + + id: str + cdn: YouTubeCDNResponse + + +class YouTubeStreamStatusResponse(BaseModel): + """Subset of stream status response fields used by the app.""" + + streamStatus: str + + +class YouTubeStreamItemResponse(BaseModel): + """Single stream item from list response.""" + + status: YouTubeStreamStatusResponse + + +class YouTubeStreamListResponse(BaseModel): + """List-streams response payload.""" + + items: list[YouTubeStreamItemResponse] = Field(default_factory=list) + + +class YouTubeAPIErrorResponseDetail(BaseModel): + """Error detail object from YouTube API.""" + + message: str | None = None + + +class YouTubeAPIErrorResponse(BaseModel): + """Error response payload from YouTube API.""" + + model_config = ConfigDict(extra="ignore") + + error: YouTubeAPIErrorResponseDetail | None = None diff --git a/backend/app/core/background_tasks.py b/backend/app/core/background_tasks.py new file mode 100644 index 00000000..c675c079 --- /dev/null +++ b/backend/app/core/background_tasks.py @@ -0,0 +1,55 @@ +"""Base class for periodic async background tasks.""" + +import asyncio +import contextlib +import logging + +logger = logging.getLogger(__name__) + + +class PeriodicBackgroundTask: + """Base class for asyncio periodic background tasks. + + Subclasses must implement ``run_once``, which is called every + ``interval_seconds``. The first execution is delayed by one full interval + so that application startup is never blocked by background work. + + Lifecycle:: + + task = MyTask(interval_seconds=3600) + await task.initialize() # starts the background loop + ... + await task.close() # cancels the loop and waits for it + """ + + def __init__(self, interval_seconds: int) -> None: + self.interval_seconds = interval_seconds + self._task: asyncio.Task[None] | None = None + + async def run_once(self) -> None: + """Override with the work to perform each interval.""" + raise NotImplementedError + + async def initialize(self) -> None: + """Start the periodic background loop.""" + self._task = asyncio.create_task(self._loop()) + + async def _loop(self) -> None: + try: + while True: + await asyncio.sleep(self.interval_seconds) + try: + await self.run_once() + except Exception: + logger.exception("Error in periodic task %s:", self.__class__.__name__) + except asyncio.CancelledError: + logger.info("Periodic task %s cancelled.", self.__class__.__name__) + raise + + async def close(self) -> None: + """Cancel the background loop and wait for it to finish.""" + if self._task is not None: + self._task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await self._task + self._task = None diff --git a/backend/app/core/cache.py b/backend/app/core/cache.py new file mode 100644 index 00000000..c63b543b --- /dev/null +++ b/backend/app/core/cache.py @@ -0,0 +1,236 @@ +"""Cache utilities for FastAPI endpoints and async methods. + +This module provides: +- Optimized cache key builders for fastapi-cache that handle dependency injection +- Async cache decorators for instance methods using cachetools +""" + +import hashlib +import json +import logging +from functools import wraps +from typing import TYPE_CHECKING, Any, ParamSpec, TypeVar, overload + +from fastapi.responses import HTMLResponse +from fastapi_cache import FastAPICache +from fastapi_cache.backends.inmemory import InMemoryBackend +from fastapi_cache.backends.redis import RedisBackend +from fastapi_cache.coder import Coder +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.config import settings +from app.core.logging import sanitize_log_value + +if TYPE_CHECKING: + from collections.abc import Awaitable, Callable + + from cachetools import TTLCache + from redis.asyncio import Redis + from starlette.requests import Request + from starlette.responses import Response + +logger = logging.getLogger(__name__) + +# Type variables for generic decorator +P = ParamSpec("P") +T = TypeVar("T") + +# HTML coder constants +_HTML_RESPONSE_TYPE = "HTMLResponse" + +# JSON-compatible types for encoding/decoding +JSONValue = HTMLResponse | dict[str, Any] | list[Any] | str | float | bool | None + + +class HTMLCoder(Coder): + """Custom coder for caching HTMLResponse objects. + + This coder handles serialization and deserialization of HTMLResponse objects + by extracting the HTML body content and storing it with metadata for reconstruction. + """ + + @classmethod + def encode(cls, value: JSONValue) -> bytes: + """Encode value to bytes, handling HTMLResponse objects specially.""" + if isinstance(value, HTMLResponse): + # Extract body from HTMLResponse and encode with metadata + data: dict[str, Any] = { + "type": _HTML_RESPONSE_TYPE, + "body": value.body.decode("utf-8") if isinstance(value.body, bytes) else value.body, + "status_code": value.status_code, + "media_type": value.media_type, + "headers": dict(value.headers), + } + return json.dumps(data).encode("utf-8") + # For non-HTMLResponse objects, use default JSON encoding + return json.dumps(value).encode("utf-8") + + @classmethod + def decode(cls, value: bytes | str) -> JSONValue: + """Decode bytes to Python object, reconstructing HTMLResponse objects.""" + # Handle both bytes and string inputs (string occurs on cache retrieval) + if isinstance(value, bytes): + value = value.decode("utf-8") + + data = json.loads(value) + + # Reconstruct HTMLResponse if that's what was cached + if isinstance(data, dict) and data.get("type") == _HTML_RESPONSE_TYPE: + return HTMLResponse( + content=data["body"], + status_code=data.get("status_code", 200), + media_type=data.get("media_type", "text/html"), + headers=data.get("headers"), + ) + + return data + + @overload + @classmethod + def decode_as_type(cls, value: bytes | str, type_: type[T]) -> T: ... + + @overload + @classmethod + def decode_as_type(cls, value: bytes | str, type_: None = None) -> JSONValue: ... + + @classmethod + def decode_as_type(cls, value: bytes | str, type_: type[T] | None = None) -> T | JSONValue: # noqa: ARG003 # Argument is unused but expected by parent class + """Decode bytes to the specified type, handling HTMLResponse reconstruction. + + Note: type_ parameter is currently unused but kept for interface compatibility with Coder base class. + """ + return cls.decode(value) + + +# Pre-compile the set of types to exclude from cache key generation +# These are dependency injection instances that vary per request +_EXCLUDED_TYPES = (AsyncSession,) + + +def key_builder_excluding_dependencies( + func: Callable[..., Any], + namespace: str = "", + *, + request: Request | None = None, # noqa: ARG001 # request is expected by fastapi-cache but not used in key generation + response: Response | None = None, # noqa: ARG001 # response is expected by fastapi-cache but not used in key generation + args: tuple[Any, ...] = (), + kwargs: dict[str, Any] | None = None, +) -> str: + """Build cache key excluding dependency injection objects. + + This key builder filters out database sessions and other injected + dependencies that should not affect the cache key, preventing + different instances from creating different keys for identical requests. + + Args: + func: The cached function + namespace: Cache namespace prefix + request: HTTP request object (optional) + response: HTTP response object (optional) + args: Positional arguments to the function + kwargs: Keyword arguments to the function + + Returns: + Cache key string in format: {namespace}:{hash} + """ + if kwargs is None: + kwargs = {} + + # Filter out dependency injection instances + # This is more efficient than checking isinstance for each value + filtered_kwargs = {k: v for k, v in kwargs.items() if not isinstance(v, _EXCLUDED_TYPES)} + + # Build cache key from function identity and filtered parameters + # Using sha1 is faster than sha256 and sufficient for cache keys + module_name = getattr(func, "__module__", "") + function_name = getattr(func, "__name__", func.__class__.__name__) + cache_key_source = f"{module_name}:{function_name}:{args}:{filtered_kwargs}" + cache_key = hashlib.sha1(cache_key_source.encode(), usedforsecurity=False).hexdigest() + + return f"{namespace}:{cache_key}" + + +def async_ttl_cache(cache: TTLCache) -> Callable[[Callable[P, Awaitable[T]]], Callable[P, Awaitable[T]]]: + """Simple async cache decorator using cachetools.TTLCache. + + This decorator caches the results of async methods/functions with automatic + expiration based on the TTL (time-to-live) configured in the cache. + + Perfect for per-instance caching where Redis would be overkill, such as: + - Short-lived status checks + - External API calls with brief validity + - Computed properties that change infrequently + + Args: + cache: A TTLCache instance to use for caching results + + Returns: + Decorator function for async methods/functions + + Example: + ```python + from cachetools import TTLCache + from app.core.cache import async_ttl_cache + + + class Service: + @async_ttl_cache(TTLCache(maxsize=1, ttl=15)) + async def get_status(self) -> dict: + # Expensive operation cached for 15 seconds + return await self._fetch_status() + ``` + """ + + def decorator(func: Callable[P, Awaitable[T]]) -> Callable[P, Awaitable[T]]: + @wraps(func) + async def wrapper(*args: P.args, **kwargs: P.kwargs) -> T: + # Create cache key from function args + key = (args, tuple(sorted(kwargs.items()))) + + # Check if result is in cache + if key in cache: + return cache[key] + + # Call function and cache result + result = await func(*args, **kwargs) + cache[key] = result + return result + + return wrapper + + return decorator + + +def init_fastapi_cache(redis_client: Redis | None) -> None: + """Initialize FastAPI Cache with Redis backend and optimized key builder. + + This function sets up the FastAPI Cache to use Redis for caching and + configures it to use the custom key builder that excludes dependency + injection objects from cache keys. + + Args: + redis_client: An instance of a Redis client (e.g., aioredis.Redis) + """ + prefix = settings.cache.prefix + + if not settings.enable_caching: + logger.info("Caching disabled in '%s' environment. Using InMemoryBackend.", settings.environment) + FastAPICache.init(InMemoryBackend(), prefix=prefix, key_builder=key_builder_excluding_dependencies) + return + + if redis_client: + FastAPICache.init(RedisBackend(redis_client), prefix=prefix, key_builder=key_builder_excluding_dependencies) + logger.info("FastAPI Cache initialized with Redis backend") + else: + FastAPICache.init(InMemoryBackend(), prefix=prefix, key_builder=key_builder_excluding_dependencies) + logger.warning("FastAPI Cache initialized with in-memory backend - Redis unavailable") + + +async def clear_cache_namespace(namespace: str) -> None: + """Clear all cache entries for a specific namespace. + + Args: + namespace: Cache namespace to clear (e.g., "background-data", "docs") + """ + await FastAPICache.clear(namespace=namespace) + logger.info("Cleared cache namespace: %s", sanitize_log_value(namespace)) diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 7bf27406..e1d13330 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -1,83 +1,213 @@ """Configuration settings for the FastAPI app.""" +from __future__ import annotations + +from enum import StrEnum from functools import cached_property -from pathlib import Path +from pathlib import Path # noqa: TC003 # Runtime use is needed for Pydantic validation of settings +from typing import TYPE_CHECKING +from urllib.parse import urlsplit + +from pydantic import BaseModel, EmailStr, Field, HttpUrl, PostgresDsn, SecretStr, model_validator + +from app.core.constants import DAY, HOUR +from app.core.env import BACKEND_DIR, RelabBaseSettings + +if TYPE_CHECKING: + from typing import Self + +# Default superuser credentials (must be overridden in production) +DEFAULT_SUPERUSER_EMAIL = "your-email@example.com" + + +class CacheNamespace(StrEnum): + """Cache namespace identifiers for different application areas.""" + + BACKGROUND_DATA = "background-data" + DOCS = "docs" + -from pydantic import EmailStr, HttpUrl, PostgresDsn, computed_field -from pydantic_settings import BaseSettings, SettingsConfigDict +class CacheSettings(BaseModel): + """Centralized cache configuration for the application.""" -# Set the project base directory and .env file -BASE_DIR: Path = (Path(__file__).parents[2]).resolve() + # FastAPI Cache settings + prefix: str = "fastapi-cache" + # Namespace-specific TTL settings (in seconds) + ttls: dict[CacheNamespace, int] = Field( + default_factory=lambda: { + CacheNamespace.BACKGROUND_DATA: DAY, # 24 hours + CacheNamespace.DOCS: HOUR, # 1 hour + } + ) -class CoreSettings(BaseSettings): + +class Environment(StrEnum): + """Application execution environment.""" + + DEV = "dev" + STAGING = "staging" + PROD = "prod" + TESTING = "testing" + + +class CoreSettings(RelabBaseSettings): """Settings class to store all the configurations for the app.""" + # Application Environment + environment: Environment = Environment.DEV + # Database settings from .env file database_host: str = "localhost" database_port: int = 5432 postgres_user: str = "postgres" - postgres_password: str = "" + postgres_password: SecretStr = SecretStr("") postgres_db: str = "relab_db" - postgres_test_db: str = "relab_test_db" - # Debug settings - debug: bool = False + # Redis settings for caching + redis_host: str = "localhost" + redis_port: int = 6379 + redis_db: int = 0 + redis_password: SecretStr = SecretStr("") # Superuser settings - superuser_email: EmailStr = "your-email@example.com" - superuser_password: str = "" + superuser_email: EmailStr = DEFAULT_SUPERUSER_EMAIL + superuser_password: SecretStr = SecretStr("") # Network settings + backend_api_url: HttpUrl = HttpUrl("http://127.0.0.1:8001") frontend_web_url: HttpUrl = HttpUrl("http://127.0.0.1:8000") - frontend_app_url: HttpUrl = HttpUrl("http://127.0.0.1:8004") - allowed_origins: list[str] = [str(frontend_web_url), str(frontend_app_url)] + frontend_app_url: HttpUrl = HttpUrl("http://127.0.0.1:8003") + # Regex pattern matched against the Origin header β€” useful in dev to allow a whole subnet without listing every IP. + # Default covers localhost, 127.0.0.1, and any 192.168.x.x origin with any port. + # When set, origins matching this pattern are echoed back (credentials still work, unlike allow_origins=["*"]). + cors_origin_regex: str | None = Field(default=r"https?://(localhost|127\.0\.0\.1|192\.168\.\d+\.\d+)(:\d+)?") + + @staticmethod + def _normalize_origin(url: HttpUrl) -> str: + """Normalize URL-like values to browser Origin format.""" + parsed = urlsplit(str(url)) + return f"{parsed.scheme}://{parsed.netloc}" + + @cached_property + def allowed_origins(self) -> list[str]: + """Get CORS Origin allowlist (scheme + host + optional port).""" + return [ + self._normalize_origin(self.frontend_web_url), + self._normalize_origin(self.frontend_app_url), + ] + + @cached_property + def allowed_hosts(self) -> list[str]: + """Get trusted Host header values for backend requests.""" + if self.environment in (Environment.DEV, Environment.TESTING): + return ["*"] + + backend_host = urlsplit(str(self.backend_api_url)).hostname + if backend_host: + return [backend_host, "127.0.0.1", "localhost"] + return ["127.0.0.1", "localhost"] + + # Cache settings + cache: CacheSettings = Field(default_factory=CacheSettings) - # Initialize the settings configuration from the environment (Docker) or .env file (local) - model_config = SettingsConfigDict(env_file=BASE_DIR / ".env", extra="ignore") + # File cleanup settings + file_cleanup_enabled: bool = True + file_cleanup_interval_hours: int = 24 + file_cleanup_min_file_age_minutes: int = 30 + file_cleanup_dry_run: bool = False # Construct directory paths - uploads_path: Path = BASE_DIR / "data" / "uploads" + uploads_path: Path = BACKEND_DIR / "data" / "uploads" file_storage_path: Path = uploads_path / "files" image_storage_path: Path = uploads_path / "images" - static_files_path: Path = BASE_DIR / "app" / "static" - templates_path: Path = BASE_DIR / "app" / "templates" - log_path: Path = BASE_DIR / "logs" - docs_path: Path = BASE_DIR / "docs" / "site" # Mkdocs site directory + static_files_path: Path = BACKEND_DIR / "app" / "static" + templates_path: Path = BACKEND_DIR / "app" / "templates" + log_path: Path = BACKEND_DIR / "logs" + docs_path: Path = BACKEND_DIR / "docs" / "site" # Zensical site directory # Construct database URLs - def _build_database_url(self, driver: str, database: str) -> str: + def build_database_url(self, driver: str, database: str) -> str: """Build and validate PostgreSQL database URL.""" url = ( - f"postgresql+{driver}://{self.postgres_user}:{self.postgres_password}" + f"postgresql+{driver}://{self.postgres_user}:{self.postgres_password.get_secret_value()}" f"@{self.database_host}:{self.database_port}/{database}" ) PostgresDsn(url) # Validate URL format return url - @computed_field @cached_property def async_database_url(self) -> str: """Get async database URL.""" - return self._build_database_url("asyncpg", self.postgres_db) + return self.build_database_url("asyncpg", self.postgres_db) - @computed_field @cached_property def sync_database_url(self) -> str: """Get sync database URL.""" - return self._build_database_url("psycopg", self.postgres_db) + return self.build_database_url("psycopg", self.postgres_db) - @computed_field @cached_property - def async_test_database_url(self) -> str: - """Get test database URL.""" - return self._build_database_url("asyncpg", self.postgres_test_db) + def cache_url(self) -> str: + """Get Redis cache URL.""" + return ( + f"redis://:{self.redis_password.get_secret_value() or ''}" + f"@{self.redis_host}:{self.redis_port}/{self.redis_db}" + ) + + @property + def debug(self) -> bool: + """Enable debug mode (SQL echo, DEBUG log level) only in development.""" + return self.environment == Environment.DEV - @computed_field @cached_property - def sync_test_database_url(self) -> str: - """Get test database URL.""" - return self._build_database_url("psycopg", self.postgres_test_db) + def enable_caching(self) -> bool: + """Disable caching logic if we are running in development or testing.""" + return self.environment not in (Environment.DEV, Environment.TESTING) + + @property + def secure_cookies(self) -> bool: + """Set cookie 'Secure' flag to False in DEV so HTTP works on localhost.""" + return self.environment in (Environment.PROD, Environment.STAGING) + + @property + def mock_emails(self) -> bool: + """Set email sending to False in DEV and TESTING.""" + return self.environment in (Environment.DEV, Environment.TESTING) + + @property + def enable_rate_limit(self) -> bool: + """Disable rate limiting in DEV and TESTING.""" + return self.environment not in (Environment.DEV, Environment.TESTING) + + @model_validator(mode="after") + def validate_security_settings(self) -> Self: + """Validate environment-specific security settings.""" + if self.environment not in (Environment.PROD, Environment.STAGING): + return self + + errors: list[str] = [] + + if self.cors_origin_regex: + errors.append("CORS_ORIGIN_REGEX must not be set in production/staging") + + if not self.postgres_password.get_secret_value(): + errors.append("POSTGRES_PASSWORD must not be empty in production") + + if not self.redis_password.get_secret_value(): + errors.append("REDIS_PASSWORD must not be empty in production") + + if not self.superuser_password.get_secret_value(): + errors.append("SUPERUSER_PASSWORD must not be empty in production") + + if self.superuser_email == DEFAULT_SUPERUSER_EMAIL: + errors.append("SUPERUSER_EMAIL must not be the default placeholder in production") + + if errors: + formatted = "\n - ".join(errors) + msg = f"Production security check failed:\n - {formatted}" + raise ValueError(msg) + + return self # Create a settings instance that can be imported throughout the app diff --git a/backend/app/core/constants.py b/backend/app/core/constants.py new file mode 100644 index 00000000..94e361e1 --- /dev/null +++ b/backend/app/core/constants.py @@ -0,0 +1,8 @@ +"""Shared constants for the application.""" + +# Time constants in seconds +MINUTE = 60 +HOUR = 60 * MINUTE +DAY = 24 * HOUR +WEEK = 7 * DAY +MONTH = 30 * DAY diff --git a/backend/app/core/database.py b/backend/app/core/database.py index 0faa87dd..b8ffaeca 100644 --- a/backend/app/core/database.py +++ b/backend/app/core/database.py @@ -1,7 +1,7 @@ """Database initialization and session management.""" -from collections.abc import AsyncGenerator, Generator from contextlib import asynccontextmanager, contextmanager +from typing import TYPE_CHECKING from sqlalchemy import create_engine from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine @@ -10,18 +10,31 @@ from sqlmodel.ext.asyncio.session import AsyncSession from app.core.config import settings +from app.core.model_registry import load_sqlmodel_models + +if TYPE_CHECKING: + from collections.abc import AsyncGenerator, Generator + + +# Ensure ORM class registry is populated before sessions are created. +load_sqlmodel_models() ### Async database connection async_engine: AsyncEngine = create_async_engine(settings.async_database_url, future=True, echo=settings.debug) +async_sessionmaker_factory = async_sessionmaker(bind=async_engine, class_=AsyncSession, expire_on_commit=False) async def get_async_session() -> AsyncGenerator[AsyncSession]: """Get a new asynchronous database session. Can be used in FastAPI dependencies.""" - async_session = async_sessionmaker(bind=async_engine, class_=AsyncSession, expire_on_commit=False) - async with async_session() as session: + async with async_sessionmaker_factory() as session: yield session +async def close_async_engine() -> None: + """Dispose the shared async engine and close pooled DB connections.""" + await async_engine.dispose() + + # Async session context manager for 'async with' statements async_session_context = asynccontextmanager(get_async_session) diff --git a/backend/app/core/env.py b/backend/app/core/env.py new file mode 100644 index 00000000..03eecd26 --- /dev/null +++ b/backend/app/core/env.py @@ -0,0 +1,41 @@ +"""Shared helpers for environment-based settings loading.""" + +import os +from pathlib import Path +from typing import TYPE_CHECKING + +from pydantic_settings import BaseSettings, SettingsConfigDict + +if TYPE_CHECKING: + from pathlib import Path as PathType + +# Maps the ENVIRONMENT variable (matching app.core.config.Environment) to a .env filename. +# Mirrors the naming convention used by the frontend apps. +_ENV_FILE_MAP: dict[str, str] = { + "dev": ".env.dev", + "staging": ".env.staging", + "prod": ".env.prod", + "testing": ".env.test", +} + + +# Backend repo root. This file lives at ``backend/app/core/env.py``. +BACKEND_DIR = Path(__file__).parents[2].resolve() + + +def get_env_file(base_dir: PathType) -> Path: + """Return the .env file path for the current ENVIRONMENT. + + Falls back to ``dev`` (i.e. ``.env.dev``) when the variable is + absent. pydantic-settings silently ignores a missing file, so there is no + error if the file does not exist yet. + """ + env = os.environ.get("ENVIRONMENT", "dev") + filename = _ENV_FILE_MAP.get(env, f".env.{env}") + return base_dir / filename + + +class RelabBaseSettings(BaseSettings): + """Shared settings base class for backend modules.""" + + model_config = SettingsConfigDict(env_file=get_env_file(BACKEND_DIR), extra="ignore") diff --git a/backend/app/core/http.py b/backend/app/core/http.py new file mode 100644 index 00000000..c82ced86 --- /dev/null +++ b/backend/app/core/http.py @@ -0,0 +1,13 @@ +"""Shared HTTP client utilities for outbound network calls.""" + +from httpx import AsyncClient, Limits, Timeout + + +def create_http_client() -> AsyncClient: + """Create the shared outbound HTTP client.""" + return AsyncClient( + http2=True, + limits=Limits(max_connections=100, max_keepalive_connections=20), + timeout=Timeout(connect=5.0, read=30.0, write=10.0, pool=5.0), + headers={"User-Agent": "relab-backend/0.1"}, + ) diff --git a/backend/app/core/images.py b/backend/app/core/images.py new file mode 100644 index 00000000..8bd3c108 --- /dev/null +++ b/backend/app/core/images.py @@ -0,0 +1,293 @@ +"""Image processing utilities using Pillow.""" +# spell-checker: ignore getexif, LANCZOS + +from __future__ import annotations + +import contextlib +import io +import logging +from typing import TYPE_CHECKING + +import piexif +from PIL import Image as PILImage +from PIL import ImageOps, UnidentifiedImageError + +if TYPE_CHECKING: + from pathlib import Path + from typing import Any, BinaryIO + + from fastapi import UploadFile + +try: + from PIL.Image import Resampling + + RESAMPLE_FILTER = Resampling.LANCZOS +except ImportError, AttributeError: + # Fallback for older versions of Pillow + RESAMPLE_FILTER = getattr(PILImage, "LANCZOS", getattr(PILImage, "ANTIALIAS", 1)) + +logger = logging.getLogger(__name__) + +FORMAT_JPEG = "JPEG" +FORMAT_WEBP = "WEBP" +MAX_IMAGE_DIMENSION = 8000 +ALLOWED_IMAGE_MIME_TYPES: frozenset[str] = frozenset( + { + "image/bmp", + "image/gif", + "image/jpeg", + "image/png", + "image/tiff", + "image/webp", + } +) + +# EXIF tag IDs that are privacy-sensitive and should be stripped on upload. +# Technical metadata (Make, Model, exposure settings) is intentionally preserved. +_SENSITIVE_EXIF_TAGS: frozenset[int] = frozenset( + { + 0x8825, # GPSInfo β€” GPS IFD pointer and sub-IFD are fully removed + 0x927C, # MakerNote (device-specific, can contain serial numbers) + 0xA430, # CameraOwnerName + 0xA431, # BodySerialNumber + 0xA435, # LensSerialNumber + 0x013B, # Artist + 0xA420, # ImageUniqueID + } +) + +_EXIF_ORIENTATION_TAG = 0x0112 + + +def _clean_exif_bytes(exif_bytes: bytes) -> bytes | None: + """Return cleaned EXIF bytes with sensitive tags removed, or None on failure. + + This extracts the piexif logic so callers can reuse it and keep + `process_image_for_storage` simpler and easier to lint. + """ + try: + exif_dict = piexif.load(exif_bytes) + except ValueError, OSError, TypeError: + return None + + for tag_id in _SENSITIVE_EXIF_TAGS | {_EXIF_ORIENTATION_TAG}: + for ifd in ("0th", "Exif", "GPS", "1st"): + exif_dict.get(ifd, {}).pop(tag_id, None) + + exif_dict.pop("GPS", None) + + try: + return piexif.dump(exif_dict) + except ValueError, OSError: + return None + + +def _get_exif_orientation(exif_bytes: bytes) -> int | None: + """Return the EXIF orientation tag value, or None if absent or unreadable.""" + try: + exif_dict = piexif.load(exif_bytes) + return exif_dict.get("0th", {}).get(_EXIF_ORIENTATION_TAG) + except ValueError, OSError, TypeError: + return None + + +def validate_image_dimensions(img: PILImage.Image, max_dimension: int = MAX_IMAGE_DIMENSION) -> None: + """Raise ValueError if either image dimension exceeds the maximum allowed. + + Args: + img: Pillow image object to validate. + max_dimension: Maximum allowed width or height in pixels. + + Raises: + ValueError: If width or height exceeds max_dimension. + """ + width, height = img.size + if width > max_dimension or height > max_dimension: + msg = f"Image dimensions {width}x{height} exceed the maximum allowed {max_dimension}px per side." + raise ValueError(msg) + + +def validate_image_mime_type(file: UploadFile | None) -> UploadFile | None: + """Validate the uploaded image MIME type.""" + if file is None: + return file + if file.content_type not in ALLOWED_IMAGE_MIME_TYPES: + allowed_types = ", ".join(sorted(ALLOWED_IMAGE_MIME_TYPES)) + msg = f"Invalid file type: {file.content_type}. Allowed types: {allowed_types}" + raise ValueError(msg) + return file + + +def validate_image_file(file: BinaryIO) -> None: + """Validate that a binary file contains a supported image.""" + file.seek(0) + try: + with PILImage.open(file) as image_file: + image_file.verify() + except (AttributeError, OSError, TypeError, UnidentifiedImageError) as e: + msg = "Invalid image file" + raise ValueError(msg) from e + finally: + file.seek(0) + + +def apply_exif_orientation(img: PILImage.Image) -> PILImage.Image: + """Rotate/flip image pixels to match EXIF orientation, returning the corrected image. + + After calling this function, the orientation is baked into the pixel data. + The EXIF orientation tag (0x0112) should be stripped before saving to avoid + double rotation by other software. + + Args: + img: Pillow image object to correct. + + Returns: + Corrected image (may be the same object if orientation was 1 or absent). + """ + # Use Pillow's built-in EXIF-aware transpose helper which is robust + # and maintained upstream. Falls back to returning the original image + # if anything goes wrong. + try: + return ImageOps.exif_transpose(img) + except AttributeError, ValueError, OSError, TypeError: + return img + + +def strip_sensitive_exif(img: PILImage.Image) -> None: + """Remove privacy-sensitive EXIF tags in-place from a Pillow image object. + + Strips GPS coordinates, camera/lens serial numbers, and owner identifiers. + Preserves technical metadata: Make, Model, exposure settings, focal length, etc. + Also removes the orientation tag since callers should apply orientation before stripping. + + Args: + img: Pillow image object to mutate. + """ + # Keep this function focused and deterministic: operate on EXIF bytes + # using piexif when available. If no EXIF bytes are present or piexif + # fails, do nothing. This reduces brittle in-memory mutations. + exif_bytes = img.info.get("exif") + if not exif_bytes: + try: + exif_bytes = img.getexif().tobytes() + except AttributeError, ValueError, OSError, TypeError: + exif_bytes = None + + if not exif_bytes: + return + + cleaned = _clean_exif_bytes(exif_bytes) + if not cleaned: + return + + img.info["exif"] = cleaned + + # Also mutate Pillow's in-memory Exif mapping so callers that inspect + # `img.getexif()` observe the removed tags immediately (useful for + # tests and in-memory flows). This is best-effort and should not raise. + with contextlib.suppress(AttributeError, KeyError, ValueError, OSError): + exif = img.getexif() + for tag_id in _SENSITIVE_EXIF_TAGS | {_EXIF_ORIENTATION_TAG}: + exif.pop(tag_id, None) + + +def process_image_for_storage(image_path: Path) -> None: + """Process an uploaded image in-place: validate dimensions, apply EXIF orientation, strip sensitive metadata. + + This is CPU-bound and must be called via anyio.to_thread.run_sync in async contexts. + + Processing steps: + 1. Validate dimensions against MAX_IMAGE_DIMENSION to guard against memory exhaustion. + 2. Extract and clean EXIF: fully remove GPS IFD, sensitive identifiers, and orientation tag. + 3. JPEG fast path: if no rotation is needed, losslessly splice cleaned EXIF bytes using + piexif.insert() β€” no pixel re-encoding, no quality loss. + Slow path: rotation needed or non-JPEG format β€” decode pixels, apply orientation via + ImageOps.exif_transpose(), then re-encode with cleaned EXIF attached. + + Args: + image_path: Path to the image file to process in-place. + + Raises: + FileNotFoundError: If the image file does not exist. + ValueError: If image dimensions exceed MAX_IMAGE_DIMENSION. + """ + with PILImage.open(image_path) as img: + original_format = img.format or FORMAT_JPEG + validate_image_dimensions(img) + + exif_bytes: bytes | None = img.info.get("exif") or None + if not exif_bytes: + with contextlib.suppress(AttributeError, ValueError, OSError, TypeError): + raw = img.getexif().tobytes() + exif_bytes = raw or None + + cleaned_exif_bytes = _clean_exif_bytes(exif_bytes) if exif_bytes else None + orientation = _get_exif_orientation(exif_bytes) if exif_bytes else None + + needs_rotation = orientation not in (None, 1) + if needs_rotation or original_format != FORMAT_JPEG: + try: + processed: PILImage.Image | None = ImageOps.exif_transpose(img) + except AttributeError, ValueError, OSError, TypeError: + processed = img + processed = processed.copy() + else: + processed = None + # File handle is now closed. + + # JPEG fast path: losslessly splice cleaned EXIF without pixel re-encoding + if processed is None: + if not exif_bytes: + # No EXIF at all β€” file is already clean. + return + if cleaned_exif_bytes is not None: + piexif.insert(cleaned_exif_bytes, str(image_path)) + return + # EXIF parsing failed β€” fall through to re-encode, which saves without EXIF. + with PILImage.open(image_path) as img: + processed = img.copy() + + # Slow path: save re-encoded pixels with cleaned EXIF + save_kwargs: dict[str, Any] = {"format": original_format} + if original_format == FORMAT_JPEG: + save_kwargs.update({"quality": 95, "optimize": True}) + if cleaned_exif_bytes: + save_kwargs["exif"] = cleaned_exif_bytes + + processed.save(image_path, **save_kwargs) + + +def resize_image(image_path: Path, width: int | None = None, height: int | None = None) -> bytes: + """Resize an image while maintaining aspect ratio, returning WebP bytes. + + WebP provides better compression than JPEG/PNG at equivalent visual quality, + making it well-suited for network-served thumbnails. + + Args: + image_path: Path to the source image file. + width: Target width in pixels. + height: Target height in pixels. + + Returns: + WebP-encoded bytes of the resized image. + + Raises: + FileNotFoundError: If the image path does not exist. + """ + with PILImage.open(image_path) as img: + current_width, current_height = img.size + if width and not height: + height = int((width / current_width) * current_height) + elif height and not width: + width = int((height / current_height) * current_width) + elif not width and not height: + width, height = current_width, current_height + + final_width: int = width or current_width + final_height: int = height or current_height + + resized = img.resize((final_width, final_height), RESAMPLE_FILTER) + + buf = io.BytesIO() + resized.save(buf, format=FORMAT_WEBP, quality=85, method=6) + return buf.getvalue() diff --git a/backend/app/core/logging.py b/backend/app/core/logging.py new file mode 100644 index 00000000..1bb02dc2 --- /dev/null +++ b/backend/app/core/logging.py @@ -0,0 +1,195 @@ +"""Main logger setup.""" + +import logging +import sys +from dataclasses import dataclass +from typing import TYPE_CHECKING + +import loguru + +from app.core.config import Environment, settings + +if TYPE_CHECKING: + from pathlib import Path + +### Logging formats +LOG_FORMAT = ( + "{time:YYYY-MM-DD HH:mm:ss!UTC} | " + "{level: <8} | " + "{name}:{function}:{line} - " + "{message}" +) +LOG_DIR = settings.log_path + +BASE_LOG_LEVEL = "DEBUG" if settings.debug else "INFO" + + +@dataclass +class OriginalLogInfo: + """Original log info used when intercepting standard logging.""" + + original_name: str + original_func: str + original_line: int + + +def sanitize_log_value(value: object) -> str: + """Normalize a value before logging it.""" + return str(value).replace("\r", " ").replace("\n", " ") + + +class InterceptHandler(logging.Handler): + """Intercept standard logging messages and route them to loguru.""" + + def emit(self, record: logging.LogRecord) -> None: + """Override emit to route standard logging to loguru.""" + try: + level = loguru.logger.level(record.levelname).name + except ValueError: + level = record.levelno + + frame, depth = logging.currentframe(), 0 + while frame and ( + depth < 2 + or frame.f_code.co_filename == logging.__file__ + or frame.f_code.co_filename.endswith("logging/__init__.py") + ): + frame = frame.f_back + depth += 1 + + # Preserve the original log record info + loguru.logger.bind( + original_info=OriginalLogInfo( + original_name=record.name, + original_func=record.funcName, + original_line=record.lineno, + ) + ).opt(depth=depth, exception=record.exc_info).log(level, record.getMessage()) + + +def patch_log_record(record: loguru.Record) -> None: + """Patch loguru record to use the original standard logger name/function/line if intercepted.""" + if original_info := record["extra"].get("original_info"): + record["name"] = original_info.original_name + record["function"] = original_info.original_func + record["line"] = original_info.original_line + + +def configure_loguru_handlers(log_dir: Path | None, base_log_level: str) -> None: + """Setup loguru sinks.""" + is_enqueued = settings.environment in (Environment.PROD, Environment.STAGING) + + # Console handler + loguru.logger.add( + sys.stderr, + level=base_log_level, + format=LOG_FORMAT, + colorize=True, + backtrace=True, + diagnose=True, + enqueue=is_enqueued, + ) + + if log_dir is None: + return + + # Debug file sync - keep 3 days + loguru.logger.add( + log_dir / "debug.log", + level="DEBUG", + rotation="00:00", + retention="3 days", + format=LOG_FORMAT, + backtrace=True, + diagnose=True, + enqueue=is_enqueued, + encoding="utf-8", + ) + + # Info file sync - keep 14 days + loguru.logger.add( + log_dir / "info.log", + level="INFO", + rotation="00:00", + retention="14 days", + format=LOG_FORMAT, + backtrace=True, + diagnose=True, + enqueue=is_enqueued, + encoding="utf-8", + ) + + # Error file sync - keep 12 weeks + loguru.logger.add( + log_dir / "error.log", + level="ERROR", + rotation="1 week", + retention="12 weeks", + format=LOG_FORMAT, + backtrace=True, + diagnose=True, + enqueue=is_enqueued, + encoding="utf-8", + ) + + +def setup_logging( + log_dir: Path | None = LOG_DIR, + base_log_level: str = BASE_LOG_LEVEL, + *, + stdout_only: bool = settings.environment not in (Environment.PROD, Environment.STAGING), +) -> None: + """Setup loguru logging configuration and intercept standard logging.""" + if not stdout_only and log_dir is not None: + log_dir.mkdir(exist_ok=True) + else: + log_dir = None + + # Remove standard loguru stdout handler to avoid duplicates + loguru.logger.remove() + + loguru.logger.configure(patcher=patch_log_record) + configure_loguru_handlers(log_dir, base_log_level) + + # Clear any existing root handlers + for handler in logging.root.handlers[:]: + logging.root.removeHandler(handler) + + # Intercept everything at the root logger + logging.basicConfig(handlers=[InterceptHandler()], level=0, force=True) + + # Ensure uvicorn and other noisy loggers propagate correctly so that they are not duplicated in the logs + watchfiles_logger = "watchfiles.main" + + noisy_loggers = [ + watchfiles_logger, + "faker", + "faker.factory", + "uvicorn", + "uvicorn.error", + "uvicorn.access", + "watchfiles.main", + "sqlalchemy", + "sqlalchemy.engine", + "sqlalchemy.engine.Engine", + "sqlalchemy.pool", + "sqlalchemy.dialects", + "sqlalchemy.orm", + "fastapi", + "asyncio", + "starlette", + ] + for logger_name in noisy_loggers: + logging_logger = logging.getLogger(logger_name) + logging_logger.handlers = [] # Clear existing handlers + logging_logger.propagate = True # Propagate to InterceptHandler at the root + + # Keep known-noisy library loggers from spamming test and app output. + if logger_name in {watchfiles_logger, "faker", "faker.factory"}: + logging_logger.setLevel(logging.WARNING) + + +async def cleanup_logging() -> None: + """Cleanup loguru queues on shutdown.""" + loguru.logger.remove() + await loguru.logger.complete() diff --git a/backend/app/core/model_registry.py b/backend/app/core/model_registry.py new file mode 100644 index 00000000..74fecc0f --- /dev/null +++ b/backend/app/core/model_registry.py @@ -0,0 +1,22 @@ +"""Utilities to ensure all SQLModel models are registered before ORM use.""" + +from functools import lru_cache + + +# ruff: noqa: F401, PLC0415 # We want to import all model modules here to ensure they're registered with SQLModel before any ORM use. +@lru_cache(maxsize=1) +def load_sqlmodel_models() -> None: + """Import all model modules once so SQLAlchemy can resolve string relationships. + + SQLModel relationships that point to classes in other modules rely on those + classes being imported into SQLAlchemy's declarative registry before mapper + configuration runs. + """ + # data_collection is the hub: importing it pulls in auth, background_data, + # and file_storage transitively, registering all cross-module models. + from app.api.data_collection import models as _data_collection_models + + # rpi_cam and newsletter are self-contained; they only import auth and + # common.models which are already loaded via data_collection above. + from app.api.newsletter import models as _newsletter_models + from app.api.plugins.rpi_cam import models as _rpi_cam_models diff --git a/backend/app/core/redis.py b/backend/app/core/redis.py new file mode 100644 index 00000000..97aa7f0b --- /dev/null +++ b/backend/app/core/redis.py @@ -0,0 +1,183 @@ +"""Redis connection management.""" + +import logging +from typing import TYPE_CHECKING, Annotated + +from fastapi import Depends, HTTPException, Request +from redis.asyncio import Redis +from redis.exceptions import RedisError + +from app.core.config import settings +from app.core.logging import sanitize_log_value + +if TYPE_CHECKING: + from redis.typing import EncodableT + +logger = logging.getLogger(__name__) + + +async def init_redis() -> Redis | None: + """Initialize Redis client instance with connection pooling. + + Returns: + Redis: Async Redis client with connection pooling, or None if connection fails + + This should be called once during application startup. + Gracefully handles connection failures and returns None if Redis is unavailable. + """ + try: + redis_client = Redis( + host=settings.redis_host, + port=settings.redis_port, + db=settings.redis_db, + password=settings.redis_password.get_secret_value() if settings.redis_password else None, + decode_responses=True, + socket_connect_timeout=5, + socket_timeout=5, + ) + + # Verify connection on startup + await redis_client.pubsub().ping() + logger.info("Redis client initialized and connected: %s:%s", settings.redis_host, settings.redis_port) + + except (TimeoutError, RedisError, OSError, ConnectionError) as e: + logger.warning( + "Failed to connect to Redis during initialization: %s. Application will continue without Redis.", e + ) + return None + else: + return redis_client + + +async def close_redis(redis_client: Redis) -> None: + """Close Redis connection and connection pool. + + Args: + redis_client: Redis client to close + + This properly closes all connections in the pool. + """ + if redis_client: + await redis_client.aclose() + logger.info("Redis connection pool closed") + + +async def ping_redis(redis_client: Redis) -> bool: + """Check if Redis is available (health check). + + Args: + redis_client: Redis client to ping + + Returns: + bool: True if Redis is responding, False otherwise + + This is useful for health check endpoints. + """ + try: + await redis_client.pubsub().ping() + except (TimeoutError, RedisError, OSError) as e: + logger.warning("Redis ping failed: %s", e) + return False + else: + return True + + +async def get_redis_value(redis_client: Redis, key: str) -> str | None: + """Get value from Redis. + + Args: + redis_client: Redis client + key: Redis key + + Returns: + Value as string, or None if not found + """ + try: + return await redis_client.get(key) + except (TimeoutError, RedisError, OSError): + logger.exception("Failed to get Redis value for key %s.", sanitize_log_value(key)) + return None + + +async def set_redis_value(redis_client: Redis, key: str, value: EncodableT, ex: int | None = None) -> bool: + """Set value in Redis. + + Args: + redis_client: Redis client + key: Redis key + value: Value to store + ex: Expiration time in seconds (optional) + + Returns: + bool: True if successful, False otherwise + """ + try: + await redis_client.set(key, value, ex=ex) + except (TimeoutError, RedisError, OSError): + logger.exception("Failed to set Redis value for key %s.", sanitize_log_value(key)) + return False + else: + return True + + +async def delete_redis_key(redis_client: Redis, key: str) -> bool: + """Delete a key from Redis. + + Args: + redis_client: Redis client + key: Redis key + + Returns: + bool: True if successful, False otherwise + """ + try: + await redis_client.delete(key) + except (TimeoutError, RedisError, OSError): + logger.exception("Failed to delete Redis key %s.", sanitize_log_value(key)) + return False + else: + return True + + +def get_redis(request: Request) -> Redis: + """FastAPI dependency to get Redis client from application state (raises error if unavailable). + + Args: + request: FastAPI request object with app.state.redis + + Returns: + Redis client from app state + + Raises: + RuntimeError: If Redis not initialized or unavailable + """ + redis_client = request.app.state.redis if hasattr(request.app.state, "redis") else None + + if redis_client is None: + msg = "Redis not available. Check Redis connection settings." + raise RuntimeError(msg) + + return redis_client + + +# Type annotation for Redis dependency injection +RedisDep = Annotated[Redis, Depends(get_redis)] + + +def get_redis_optional(request: Request) -> Redis | None: + """FastAPI dependency that returns Redis client or None without raising. + + Use this where Redis is optional (e.g. in development where Redis may be unavailable). + """ + return request.app.state.redis if hasattr(request.app.state, "redis") else None + + +# Optional Redis dependency annotation +OptionalRedisDep = Annotated[Redis | None, Depends(get_redis_optional)] + + +def require_redis(redis_client: Redis | None) -> Redis: + """Raise an HTTP-style error if Redis is unavailable.""" + if redis_client is None: + raise HTTPException(status_code=503, detail="Redis is required for this operation.") + return redis_client diff --git a/backend/app/core/utils/__init__.py b/backend/app/core/utils/__init__.py deleted file mode 100644 index 9b957b1c..00000000 --- a/backend/app/core/utils/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Cross-package utility functions.""" diff --git a/backend/app/core/utils/custom_logging.py b/backend/app/core/utils/custom_logging.py deleted file mode 100644 index 4b7476ab..00000000 --- a/backend/app/core/utils/custom_logging.py +++ /dev/null @@ -1,86 +0,0 @@ -"""Main logger setup.""" - -import logging -import time -from logging.handlers import TimedRotatingFileHandler -from pathlib import Path - -import coloredlogs - -from app.core.config import settings - -### Logging formats - -LOG_FORMAT = "%(asctime)s - %(name)s - %(levelname)s - %(message)s" -DATE_FORMAT = "%Y-%m-%d %H:%M:%S" -LOG_DIR = settings.log_path - -LOG_CONFIG = { - # (level, rotation interval, backup count) - "debug": (logging.DEBUG, "midnight", 3), # All logs, 3 days - "info": (logging.INFO, "midnight", 14), # INFO and above, 14 days - "error": (logging.ERROR, "W0", 12), # ERROR and above, 12 weeks -} - -BASE_LOG_LEVEL = logging.DEBUG if settings.debug else logging.INFO - - -### Logging utils ### -# TODO: Move from coloredlogs to loguru for simpler logging configuration -def set_utc_logging() -> None: - """Configure logging to use UTC timestamps.""" - logging.Formatter.converter = time.gmtime - - -def create_file_handlers(log_dir: Path, fmt: str, datefmt: str) -> dict[str, logging.Handler]: - """Create file handlers for each log level.""" - handler_dict: dict[str, logging.Handler] = {} - for name, (level, interval, count) in LOG_CONFIG.items(): - handler = TimedRotatingFileHandler( - filename=log_dir / f"{name}.log", - when=interval, - backupCount=count, - encoding="utf-8", - utc=True, - ) - handler.setFormatter(logging.Formatter(fmt=fmt, datefmt=datefmt)) - handler.setLevel(level) - handler_dict[name] = handler - return handler_dict - - -def setup_logging( - *, - fmt: str = LOG_FORMAT, - datefmt: str = DATE_FORMAT, - log_dir: Path = LOG_DIR, - base_log_level: int = BASE_LOG_LEVEL, -) -> None: - """Setup logging configuration with consistent handlers.""" - # Set UTC timezone for all logging - set_utc_logging() - - # Create log directory if it doesn't exist - log_dir.mkdir(exist_ok=True) - - # Configure root logger - root_logger: logging.Logger = logging.getLogger() - root_logger.setLevel(base_log_level) - - # Install colored console logging - coloredlogs.install(level=base_log_level, fmt=fmt, datefmt=datefmt, logger=root_logger) - - # Add file handlers to root logger - file_handlers: dict[str, logging.Handler] = create_file_handlers(log_dir, fmt, datefmt) - for handler in file_handlers.values(): - root_logger.addHandler(handler) - - # Ensure uvicorn loggers propagate to root and have no handlers of their own - for logger_name in ["uvicorn", "uvicorn.error", "uvicorn.access"]: - logger = logging.getLogger(logger_name) - logger.handlers.clear() - logger.propagate = True - - # Optionally, quiet noisy loggers - for logger_name in ["watchfiles.main"]: - logging.getLogger(logger_name).setLevel(logging.WARNING) diff --git a/backend/app/main.py b/backend/app/main.py index 8623cfea..5affba73 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,54 +1,166 @@ """Main application module for the Reverse Engineering Lab - Data collection API. This module initializes the FastAPI application, sets up the API routes, -mounts static and upload directories, and initializes the admin interface. +and mounts static and upload directories. """ +import asyncio +import logging +from contextlib import asynccontextmanager +from typing import TYPE_CHECKING + +import anyio from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware -from fastapi.staticfiles import StaticFiles from fastapi_pagination import add_pagination +from httpx import CloseError +from starlette.middleware.trustedhost import TrustedHostMiddleware -from app.api.admin.main import init_admin +from app.api.auth.utils.email_validation import init_email_checker +from app.api.auth.utils.rate_limit import limiter from app.api.common.routers.exceptions import register_exception_handlers +from app.api.common.routers.file_mounts import mount_static_directories, register_favicon_route +from app.api.common.routers.health import router as health_router from app.api.common.routers.main import router from app.api.common.routers.openapi import init_openapi_docs +from app.api.file_storage.manager import FileCleanupManager +from app.core.cache import init_fastapi_cache from app.core.config import settings -from app.core.database import async_engine -from app.core.utils.custom_logging import setup_logging +from app.core.database import async_sessionmaker_factory +from app.core.http import create_http_client +from app.core.logging import cleanup_logging, setup_logging +from app.core.redis import close_redis, init_redis + +if TYPE_CHECKING: + from collections.abc import AsyncGenerator # Initialize logging setup_logging() +logger = logging.getLogger(__name__) + + +def ensure_storage_directories() -> None: + """Create configured storage directories before mounting them.""" + for path in [settings.file_storage_path, settings.image_storage_path]: + path.mkdir(parents=True, exist_ok=True) + + +@asynccontextmanager +async def lifespan(app: FastAPI) -> AsyncGenerator: + """Manage application lifespan: startup and shutdown events.""" + # Startup + logger.info("Starting up application...") + logger.info( + "Security config: allowed_hosts=%s allowed_origins=%s cors_origin_regex=%s", + settings.allowed_hosts, + settings.allowed_origins, + settings.cors_origin_regex, + ) + + # Initialize Redis connection and store in app.state + app.state.redis = await init_redis() + + # Initialize disposable email checker and store in app.state + app.state.email_checker = await init_email_checker(app.state.redis) + + # Initialize FastAPI Cache + init_fastapi_cache(app.state.redis) + + # Initialize File Cleanup Manager and store in app.state + app.state.file_cleanup_manager = FileCleanupManager(async_sessionmaker_factory) + await app.state.file_cleanup_manager.initialize() + + # Ensure storage directories exist and mark as ready + ensure_storage_directories() + app.state.storage_ready = True + + # Mount static file directories and register favicon after storage is ready + mount_static_directories(app) + register_favicon_route(app) + + # Shared outbound HTTP client for external APIs. + app.state.http_client = create_http_client() + + # Limit concurrent image resize workers to avoid thread pool exhaustion + app.state.image_resize_limiter = anyio.CapacityLimiter(10) + + logger.info("Application startup complete") + + yield -# Initialize FastAPI application + # Shutdown + logger.info("Shutting down application...") + + # Close email checker (this will cancel background tasks) + if app.state.email_checker is not None: + try: + await app.state.email_checker.close() + except (RuntimeError, OSError) as e: + logger.warning("Error closing email checker: %s", e) + + # Close Redis connection + if app.state.redis is not None: + try: + await close_redis(app.state.redis) + except (ConnectionError, OSError) as e: + logger.warning("Error closing Redis: %s", e) + + # Close File Cleanup Manager + if app.state.file_cleanup_manager is not None: + try: + await app.state.file_cleanup_manager.close() + except asyncio.CancelledError as e: + logger.warning("Error closing file cleanup manager: %s", e) + + # Close outbound HTTP client + if getattr(app.state, "http_client", None) is not None: + try: + await app.state.http_client.aclose() + except CloseError as e: + logger.warning("Error closing outbound HTTP client: %s", e) + + logger.info("Application shutdown complete") + + # Clean up logging queues + await cleanup_logging() + + +# Initialize FastAPI application with lifespan app = FastAPI( openapi_url=None, docs_url=None, redoc_url=None, + lifespan=lifespan, +) + +# Add SlowAPI rate limiter state +app.state.limiter = limiter + +# Add host header validation middleware +app.add_middleware( + TrustedHostMiddleware, # ty: ignore[invalid-argument-type] # Known false positive https://github.com/astral-sh/ty/issues/1635 + allowed_hosts=settings.allowed_hosts, ) # Add CORS middleware app.add_middleware( - CORSMiddleware, + CORSMiddleware, # ty: ignore[invalid-argument-type] # Known false positive https://github.com/astral-sh/ty/issues/1635 allow_origins=settings.allowed_origins, + allow_origin_regex=settings.cors_origin_regex, allow_credentials=True, allow_methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"], - allow_headers=["*"], + allow_headers=["Authorization", "Content-Type", "Accept", "X-Request-ID"], ) +# Include health check routes (liveness and readiness probes) +app.include_router(health_router) + # Include main API routes app.include_router(router) # Initialize OpenAPI documentation init_openapi_docs(app) -# Initialize admin interface -admin = init_admin(app, async_engine) - -# Mount local file storage -app.mount("/uploads", StaticFiles(directory=settings.uploads_path), name="uploads") -app.mount("/static", StaticFiles(directory=settings.static_files_path), name="static") - # Initialize exception handling register_exception_handlers(app) diff --git a/backend/app/static/css/styles.css b/backend/app/static/css/styles.css index 29528d3f..6776b319 100644 --- a/backend/app/static/css/styles.css +++ b/backend/app/static/css/styles.css @@ -1,63 +1,113 @@ :root { - --primary: #4755b6; - --on-primary: #fff; - --secondary: #485082; - --on-secondary: #fff; - --background: #fff; - --on-background: #1b1b1f; - --surface: #fffBff; - --border: #e3e1ec; - --shadow: rgba(71, 85, 182, 0.08); - --radius: 12px; - --font-family: 'Inter', sans-serif, system-ui; + --color-primary: #006783; + --color-primary-strong: #004d63; + --color-primary-light: #bce9ff; + --color-surface: #fbfcfe; + --color-surface-soft: #eef5f8; + --color-on-surface: #191c1e; + --color-muted: #40484c; + --color-border: #dce4e9; + --color-error: #ba1a1a; + --color-ring: rgba(0, 103, 131, 0.25); + --shadow-soft: 0 10px 30px rgba(25, 28, 30, 0.08); + --radius-md: 14px; + --radius-lg: 22px; +} + +@media (prefers-color-scheme: dark) { + :root { + --color-primary: #63d3ff; + --color-primary-strong: #3db5e5; + --color-primary-light: #004d63; + --color-surface: #191c1e; + --color-surface-soft: #1d2529; + --color-on-surface: #e1e2e4; + --color-muted: #c0c8cd; + --color-border: #40484c; + --color-ring: rgba(99, 211, 255, 0.28); + --shadow-soft: 0 14px 36px rgba(0, 0, 0, 0.35); + } +} + +* { + box-sizing: border-box; } body { - font-family: var(--font-family); - background: #fff; - color: var(--on-background); + font-family: "IBM Plex Sans", "Segoe UI", sans-serif; + background: + linear-gradient(180deg, rgba(251, 252, 254, 0.6) 0%, rgba(251, 252, 254, 0.68) 100%), + url("/static/images/bg-light.jpg") center / cover no-repeat; + color: var(--color-on-surface); margin: 0; - padding: 2rem; + padding: 2rem 1rem; + min-height: 100vh; + -webkit-font-smoothing: antialiased; + display: flex; + align-items: flex-start; + justify-content: center; } -.container { - max-width: 800px; - margin: 0 auto; - padding: 1.5rem; - background: var(--background); - border-radius: var(--radius); - box-shadow: 0 2px 2px var(--shadow); - border: 1px solid var(--border); - box-sizing: border-box; +@media (prefers-color-scheme: dark) { + body { + background: + linear-gradient(180deg, rgba(25, 28, 30, 0.72) 0%, rgba(25, 28, 30, 0.8) 100%), + url("/static/images/bg-dark.jpg") center / cover no-repeat; + } } -.section { - padding: 1rem; - border-radius: 6px; - margin-bottom: 1.5rem; +h1, h2, h3, h4 { + font-family: "Space Grotesk", "IBM Plex Sans", sans-serif; + letter-spacing: -0.02em; +} + +.container { + width: 100%; + max-width: 640px; + margin-top: 2rem; + padding: 2rem; + background: var(--color-surface); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-soft); + border: 1px solid var(--color-border); } h1 { - color: var(--primary); - font-size: 2.5rem; - margin: 0 0 1.5rem 0; + color: var(--color-on-surface); + font-size: 2rem; + margin: 0 0 0.5rem 0; text-align: center; - border-bottom: 2px solid var(--primary); - padding-bottom: 0.5rem; - font-family: 'Source Serif 4', Georgia, 'Times New Roman', Times, serif; - font-weight: 400; } -h2, h3, h4, h5, h6 { - color: var(--secondary); - font-family: 'Source Serif 4', Georgia, 'Times New Roman', Times, serif; - font-weight: 400; - margin: 0 0 1rem 0; +p.description { + color: var(--color-muted); + margin: 0 0 1.75rem 0; + font-size: 0.9375rem; + text-align: center; +} + +.section { + padding: 1.25rem; + border-radius: var(--radius-md); + background: var(--color-surface-soft); + border: 1px solid var(--color-border); + margin-bottom: 1.25rem; +} + +.section .primary-btn, +.section .secondary-btn, +form .primary-btn, +form .secondary-btn, +form button { + width: 100%; + margin: 0.5rem 0; } -.description { - color: #4a5568; - margin-bottom: 2rem; +.section h2 { + color: var(--color-on-surface); + font-size: 0.9375rem; + font-weight: 600; + margin: 0 0 0.875rem 0; } .form-group { @@ -66,76 +116,100 @@ h2, h3, h4, h5, h6 { label { display: block; - margin-bottom: 0.5rem; - color: var(--secondary); + margin-bottom: 0.375rem; + color: var(--color-muted); + font-size: 0.875rem; font-weight: 500; } -input, select { +input { width: 100%; - box-sizing: border-box; - padding: 0.75rem; - border: 1px solid var(--border); - border-radius: 6px; + padding: 0.625rem 0.875rem; + border: 1px solid var(--color-border); + border-radius: var(--radius-md); font-size: 1rem; - background: #f6f7fa; - color: var(--on-background); - font-family: var(--font-family); - transition: border 0.2s; + background: var(--color-surface); + color: var(--color-on-surface); + font-family: "IBM Plex Sans", sans-serif; + transition: border-color 160ms ease, box-shadow 160ms ease; } -input:focus, select:focus { - border-color: var(--primary); +input:focus { + border-color: var(--color-primary); outline: none; + box-shadow: 0 0 0 3px var(--color-ring); } -button, .primary-btn, .secondary-btn { +button, +.primary-btn, +.secondary-btn { display: block; - margin: 1rem auto; - padding: 1rem 2.5rem; + width: 100%; + margin: 0.5rem auto; + padding: 0.625rem 1.5rem; border: none; - border-radius: var(--radius); - font-size: 1rem; - font-weight: 400; + border-radius: var(--radius-md); + font-size: 0.9375rem; + font-weight: 500; cursor: pointer; text-align: center; text-decoration: none; - transition: background 0.2s, color 0.2s, box-shadow 0.2s; - box-shadow: 0 1px 3px var(--shadow); - min-width: 180px; - max-width: 100%; - width: auto; + transition: background 160ms ease, color 160ms ease, box-shadow 160ms ease; + font-family: "IBM Plex Sans", sans-serif; } -/* Primary button style */ .primary-btn { - background: var(--primary); - color: var(--on-primary); + background: var(--color-primary); + width: 100%; + color: #fff; + box-shadow: 0 1px 4px rgba(0, 103, 131, 0.2); + margin-bottom: 1rem; } .primary-btn:hover { - background: var(--secondary); - color: var(--on-secondary); + background: var(--color-primary-strong); + box-shadow: 0 2px 8px rgba(0, 103, 131, 0.3); + color: #fff; } -/* Secondary button style */ .secondary-btn { - background: var(--on-primary); - color: var(--primary); - border: 1px solid var(--primary); + background: transparent; + color: var(--color-primary); + border: 1px solid var(--color-border); } .secondary-btn:hover { - background: var(--primary); - color: var(--on-primary); + background: var(--color-primary-light); + border-color: var(--color-primary); + color: var(--color-primary-strong); } .error { - color: #ba1a1a; - font-size: 0.95rem; + color: var(--color-error); + font-size: 0.875rem; margin-top: 0.5rem; + padding: 0.5rem 0.75rem; + background: rgba(186, 26, 26, 0.08); + border-radius: 8px; + border: 1px solid rgba(186, 26, 26, 0.2); + display: none; +} + +.back-link { + display: block; + text-align: center; + margin-top: 1rem; } .back-link::before { content: '← '; } + +:focus-visible { + outline: 3px solid var(--color-ring); + outline-offset: 2px; +} + +::selection { + background: rgba(0, 103, 131, 0.2); +} diff --git a/backend/app/static/favicon.ico b/backend/app/static/favicon.ico new file mode 100644 index 00000000..19fb65a0 Binary files /dev/null and b/backend/app/static/favicon.ico differ diff --git a/backend/app/static/favicon.png b/backend/app/static/favicon_500.ico old mode 100644 new mode 100755 similarity index 100% rename from backend/app/static/favicon.png rename to backend/app/static/favicon_500.ico diff --git a/frontend-app/src/assets/images/bg-2.jpg b/backend/app/static/images/bg-dark.jpg similarity index 100% rename from frontend-app/src/assets/images/bg-2.jpg rename to backend/app/static/images/bg-dark.jpg diff --git a/frontend-app/src/assets/images/bg-1.jpg b/backend/app/static/images/bg-light.jpg similarity index 100% rename from frontend-app/src/assets/images/bg-1.jpg rename to backend/app/static/images/bg-light.jpg diff --git a/backend/app/templates/emails/build/newsletter.html b/backend/app/templates/emails/build/newsletter.html new file mode 100644 index 00000000..67e01ccc --- /dev/null +++ b/backend/app/templates/emails/build/newsletter.html @@ -0,0 +1,167 @@ + + + + + {{subject}} + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + + +
+ +
+ + + + + + +
+
{{content}}
+
+
+ +
+ +
+ +
+ + + + + + + +
+ +
+ + + + + + +
+
+ +
+ +
+
+ + diff --git a/backend/app/templates/emails/build/newsletter_subscription.html b/backend/app/templates/emails/build/newsletter_subscription.html new file mode 100644 index 00000000..d159f1d0 --- /dev/null +++ b/backend/app/templates/emails/build/newsletter_subscription.html @@ -0,0 +1,149 @@ + + + + + Reverse Engineering Lab: Confirm Your Newsletter Subscription + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + + +
+ +
+ + + + + + + + + + + + + + + + + + +
+
Hello,
+
+
Thank you for subscribing to the Reverse Engineering Lab newsletter!
+
+
Please confirm your subscription by clicking the button below:
+
+ + + + + +
+ + Confirm Subscription + +
+ +
+
Or copy and paste this link in your browser:
+{{confirmation_link}}
+
+
This link will expire in 24 hours.
+
+
We'll keep you updated with our progress and let you know when the full application is launched.
+
+
+ +
+ +
+
+ + diff --git a/backend/app/templates/emails/build/newsletter_unsubscribe.html b/backend/app/templates/emails/build/newsletter_unsubscribe.html new file mode 100644 index 00000000..bea9a4d9 --- /dev/null +++ b/backend/app/templates/emails/build/newsletter_unsubscribe.html @@ -0,0 +1,146 @@ + + + + + Reverse Engineering Lab: Unsubscribe Request + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + + +
+ +
+ + + + + + + + + + + + + + + + +
+
Hello,
+
+
We received a request to unsubscribe this email address from the Reverse Engineering Lab newsletter.
+
+
If you made this request, please click the button below to unsubscribe:
+
+ + + + + +
+ + Unsubscribe + +
+ +
+
Or copy and paste this link in your browser:
+{{unsubscribe_link}}
+
+
If you did not request to unsubscribe, you can safely ignore this email.
+
+
+ +
+ +
+
+ + diff --git a/backend/app/templates/emails/build/password_reset.html b/backend/app/templates/emails/build/password_reset.html new file mode 100644 index 00000000..4c692f74 --- /dev/null +++ b/backend/app/templates/emails/build/password_reset.html @@ -0,0 +1,145 @@ + + + + + Password Reset + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + + +
+ +
+ + + + + + + + + + + + + + + + +
+
Hello {{username}},
+
+
Please reset your password by clicking the button below:
+
+ + + + + +
+ + Reset Password + +
+ +
+
Or copy and paste this link in your browser:
+{{reset_link}}
+
+
This link will expire in 1 hour.
+
+
If you did not request a password reset, please ignore this email.
+
+
+ +
+ +
+
+ + diff --git a/backend/app/templates/emails/build/post_verification.html b/backend/app/templates/emails/build/post_verification.html new file mode 100644 index 00000000..1bdb7eba --- /dev/null +++ b/backend/app/templates/emails/build/post_verification.html @@ -0,0 +1,122 @@ + + + + + Email Verified + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + + +
+ +
+ + + + + + + + + + +
+
Hello {{username}},
+
+
Your email has been verified!
+
+
Thank you for verifying your email address. You can now enjoy full access to all features.
+
+
+ +
+ +
+
+ + diff --git a/backend/app/templates/emails/build/registration.html b/backend/app/templates/emails/build/registration.html new file mode 100644 index 00000000..4e9d2c65 --- /dev/null +++ b/backend/app/templates/emails/build/registration.html @@ -0,0 +1,145 @@ + + + + + Welcome to Reverse Engineering Lab - Verify Your Email + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + + +
+ +
+ + + + + + + + + + + + + + + + +
+
Hello {{ username }},
+
+
Thank you for registering! Please verify your email by clicking the button below:
+
+ + + + + +
+ + Verify Email Address + +
+ +
+
Or copy and paste this link in your browser:
+{{ verification_link }}
+
+
This link will expire in 1 hour.
+
+
If you did not register for this service, please ignore this email.
+
+
+ +
+ +
+
+ + diff --git a/backend/app/templates/emails/build/verification.html b/backend/app/templates/emails/build/verification.html new file mode 100644 index 00000000..222b7de7 --- /dev/null +++ b/backend/app/templates/emails/build/verification.html @@ -0,0 +1,145 @@ + + + + + Email Verification + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + + +
+ +
+ + + + + + + + + + + + + + + + +
+
Hello {{username}},
+
+
Please verify your email by clicking the button below:
+
+ + + + + +
+ + Verify Email Address + +
+ +
+
Or copy and paste this link in your browser:
+{{verification_link}}
+
+
This link will expire in 1 hour.
+
+
If you did not request verification, please ignore this email.
+
+
+ +
+ +
+
+ + diff --git a/backend/app/templates/emails/src/components/footer.mjml b/backend/app/templates/emails/src/components/footer.mjml new file mode 100644 index 00000000..5ab4bdaf --- /dev/null +++ b/backend/app/templates/emails/src/components/footer.mjml @@ -0,0 +1,18 @@ + + + + + + + + + Best regards,
+ The Reverse Engineering Lab Team +
+
+
+ + + This email was sent from Reverse Engineering Lab + + diff --git a/backend/app/templates/emails/src/components/header.mjml b/backend/app/templates/emails/src/components/header.mjml new file mode 100644 index 00000000..4f30e345 --- /dev/null +++ b/backend/app/templates/emails/src/components/header.mjml @@ -0,0 +1,15 @@ + + + + + + + + Reverse Engineering Lab + + + + + + + diff --git a/backend/app/templates/emails/src/components/styles.mjml b/backend/app/templates/emails/src/components/styles.mjml new file mode 100644 index 00000000..5e624a23 --- /dev/null +++ b/backend/app/templates/emails/src/components/styles.mjml @@ -0,0 +1,41 @@ + + + + + + + + + + .header-title { + font-family: 'Space Grotesk', 'IBM Plex Sans', Arial, sans-serif; + font-size: 22px; + font-weight: 600; + color: #006783; + letter-spacing: -0.02em; + } + .footer-text { + font-size: 12px; + color: #40484c; + } + .muted { + color: #40484c; + } + .link { + color: #006783; + word-break: break-all; + } + diff --git a/backend/app/templates/emails/src/newsletter.mjml b/backend/app/templates/emails/src/newsletter.mjml new file mode 100644 index 00000000..0be45e03 --- /dev/null +++ b/backend/app/templates/emails/src/newsletter.mjml @@ -0,0 +1,26 @@ + + + {{ subject }} + {{include:styles}} + + + {{include:header}} + + + + {{ content }} + + + + {{include:footer}} + + + + + You're receiving this email because you subscribed to the Reverse Engineering Lab newsletter.
+ Unsubscribe +
+
+
+
+
diff --git a/backend/app/templates/emails/src/newsletter_subscription.mjml b/backend/app/templates/emails/src/newsletter_subscription.mjml new file mode 100644 index 00000000..c8658c06 --- /dev/null +++ b/backend/app/templates/emails/src/newsletter_subscription.mjml @@ -0,0 +1,28 @@ + + + Reverse Engineering Lab: Confirm Your Newsletter Subscription + {{include:styles}} + + + {{include:header}} + + + + Hello, + Thank you for subscribing to the Reverse Engineering Lab newsletter! + Please confirm your subscription by clicking the button below: + Confirm Subscription + + Or copy and paste this link in your browser:
+ {{ confirmation_link }} +
+ This link will expire in 24 hours. + + We'll keep you updated with our progress and let you know when the full application is launched. + +
+
+ + {{include:footer}} +
+
diff --git a/backend/app/templates/emails/src/newsletter_unsubscribe.mjml b/backend/app/templates/emails/src/newsletter_unsubscribe.mjml new file mode 100644 index 00000000..566c787e --- /dev/null +++ b/backend/app/templates/emails/src/newsletter_unsubscribe.mjml @@ -0,0 +1,37 @@ + + + Reverse Engineering Lab: Unsubscribe Request + {{include:styles}} + + + + + + {{include:header}} + + + + Hello, + + We received a request to unsubscribe this email address from the Reverse Engineering Lab newsletter. + + If you made this request, please click the button below to unsubscribe: + Unsubscribe + + Or copy and paste this link in your browser:
+ {{ unsubscribe_link }} +
+ If you did not request to unsubscribe, you can safely ignore this email. +
+
+ + {{include:footer}} +
+
diff --git a/backend/app/templates/emails/src/password_reset.mjml b/backend/app/templates/emails/src/password_reset.mjml new file mode 100644 index 00000000..da8d0c55 --- /dev/null +++ b/backend/app/templates/emails/src/password_reset.mjml @@ -0,0 +1,25 @@ + + + Password Reset + {{include:styles}} + + + {{include:header}} + + + + Hello {{ username }}, + Please reset your password by clicking the button below: + Reset Password + + Or copy and paste this link in your browser:
+ {{ reset_link }} +
+ This link will expire in 1 hour. + If you did not request a password reset, please ignore this email. +
+
+ + {{include:footer}} +
+
diff --git a/backend/app/templates/emails/src/post_verification.mjml b/backend/app/templates/emails/src/post_verification.mjml new file mode 100644 index 00000000..9e52873b --- /dev/null +++ b/backend/app/templates/emails/src/post_verification.mjml @@ -0,0 +1,19 @@ + + + Email Verified + {{include:styles}} + + + {{include:header}} + + + + Hello {{ username }}, + Your email has been verified! + Thank you for verifying your email address. You can now enjoy full access to all features. + + + + {{include:footer}} + + diff --git a/backend/app/templates/emails/src/registration.mjml b/backend/app/templates/emails/src/registration.mjml new file mode 100644 index 00000000..bd6056da --- /dev/null +++ b/backend/app/templates/emails/src/registration.mjml @@ -0,0 +1,25 @@ + + + Welcome to Reverse Engineering Lab - Verify Your Email + {{include:styles}} + + + {{include:header}} + + + + Hello {{ username }}, + Thank you for registering! Please verify your email by clicking the button below: + Verify Email Address + + Or copy and paste this link in your browser:
+ {{ verification_link }} +
+ This link will expire in 1 hour. + If you did not register for this service, please ignore this email. +
+
+ + {{include:footer}} +
+
diff --git a/backend/app/templates/emails/src/verification.mjml b/backend/app/templates/emails/src/verification.mjml new file mode 100644 index 00000000..9134774b --- /dev/null +++ b/backend/app/templates/emails/src/verification.mjml @@ -0,0 +1,25 @@ + + + Email Verification + {{include:styles}} + + + {{include:header}} + + + + Hello {{ username }}, + Please verify your email by clicking the button below: + Verify Email Address + + Or copy and paste this link in your browser:
+ {{ verification_link }} +
+ This link will expire in 1 hour. + If you did not request verification, please ignore this email. +
+
+ + {{include:footer}} +
+
diff --git a/backend/app/templates/index.html b/backend/app/templates/index.html index 7ddf4a8d..65403b3d 100644 --- a/backend/app/templates/index.html +++ b/backend/app/templates/index.html @@ -4,13 +4,16 @@ Reverse Engineering Labs API + + + - - + +
-

ReLab API

+

RELab API

This is the backend API for the Reverse Engineering Lab.

Go to the main website @@ -25,15 +28,8 @@

API Documentation

{% endif %}
- {% if show_full_docs %} -
-

Administration

- Admin Dashboard -
- {% endif %} -
-

API Login

+

User Access

{% if user %} Logout {% else %} @@ -45,7 +41,7 @@

API Login

+ + diff --git a/frontend-web/src/pages/newsletter/confirm.astro b/frontend-web/src/pages/newsletter/confirm.astro new file mode 100644 index 00000000..99aba1ff --- /dev/null +++ b/frontend-web/src/pages/newsletter/confirm.astro @@ -0,0 +1,93 @@ +--- +import Layout from '@/layouts/Layout.astro'; +import { joinApiUrl } from '@/utils/url'; + +const apiUrl = import.meta.env.PUBLIC_API_URL; +const newsletterConfirmUrl = joinApiUrl(apiUrl, '/newsletter/confirm'); +--- + + +
+

Newsletter Subscription

+
+ +

Confirming your newsletter subscription…

+
+
+
+ + + + diff --git a/frontend-web/src/pages/newsletter/unsubscribe-form.astro b/frontend-web/src/pages/newsletter/unsubscribe-form.astro new file mode 100644 index 00000000..52095a1a --- /dev/null +++ b/frontend-web/src/pages/newsletter/unsubscribe-form.astro @@ -0,0 +1,86 @@ +--- +import Layout from '@/layouts/Layout.astro'; +import { joinApiUrl } from '@/utils/url'; + +const apiUrl = import.meta.env.PUBLIC_API_URL; +const newsletterRequestUnsubscribeUrl = joinApiUrl(apiUrl, '/newsletter/request-unsubscribe'); +--- + + +
+

Unsubscribe from Newsletter

+

Please enter your email address to unsubscribe from our newsletter.

+ +
+ + +
+ +

+ You will receive a confirmation email with a link to complete the unsubscription. This step ensures only the + account owner can unsubscribe. +

+ + +
+
+ + + + diff --git a/frontend-web/src/pages/newsletter/unsubscribe.astro b/frontend-web/src/pages/newsletter/unsubscribe.astro new file mode 100644 index 00000000..3806abba --- /dev/null +++ b/frontend-web/src/pages/newsletter/unsubscribe.astro @@ -0,0 +1,93 @@ +--- +import Layout from '@/layouts/Layout.astro'; +import { joinApiUrl } from '@/utils/url'; + +const apiUrl = import.meta.env.PUBLIC_API_URL; +const newsletterUnsubscribeUrl = joinApiUrl(apiUrl, '/newsletter/unsubscribe'); +--- + + +
+

Unsubscribe from Newsletter

+
+ +

Unsubscribing from the newsletter…

+
+
+
+ + + + diff --git a/frontend-web/src/pages/privacy.astro b/frontend-web/src/pages/privacy.astro new file mode 100644 index 00000000..47212cdf --- /dev/null +++ b/frontend-web/src/pages/privacy.astro @@ -0,0 +1,95 @@ +--- +import Layout from '@/layouts/Layout.astro'; +--- + + +

Privacy Policy

+

Last updated: March 11, 2026

+

This Privacy Policy explains what we collect, how we use it, and your choices.

+ +
+

User Information

+

+ When you register we collect a username and email for your account, and a password used for authentication. + Passwords are stored only in hashed form. We use your email for authentication and important service notifications. +

+
+ +
+

Uploads & Media

+

+ Files and images you upload are stored on our servers and included in regular backups. We use uploads to display + your contributions in the app and for research purposes when you choose to contribute. Retention is managed for + service operation and backups. You can delete your products and uploaded images yourself in the app; if you need + assistance we will remove uploads and any linked metadata on request. +

+
+ +
+

AI & Research Use

+

+ We may use de-identified research contributions for research purposes only. We do not use personal account + information (email, username, password) to train models. Contact us for details or to request restrictions on your + contributed data. +

+
+ +
+

Your Rights

+

+ Newsletter: You can + unsubscribe at any time; your email will be removed when you do. +

+

+ Account holders: You may access and update your account details, and request deletion of your account + and associated data. +

+
+

+ Contact us at + relab@cml.leidenuniv.nl for questions or data requests. +

+
+
+ + diff --git a/frontend-web/src/styles/global.css b/frontend-web/src/styles/global.css new file mode 100644 index 00000000..6edae5f4 --- /dev/null +++ b/frontend-web/src/styles/global.css @@ -0,0 +1,207 @@ +@import url("https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600&family=Space+Grotesk:wght@500;600;700&display=swap"); +@import "tailwindcss"; + +/* Light theme β€” Material Design 3 tokens (matches frontend-app/src/assets/themes/light.ts) */ +:root { + --color-primary: #006783; + --color-primary-strong: #004d63; + --color-primary-light: #bce9ff; + --color-accent: #5c5b7d; + --color-surface: #fbfcfe; + --color-surface-soft: #eef5f8; + --color-on-surface: #191c1e; + --color-muted: #40484c; + --color-border: #dce4e9; + --color-ring: rgba(0, 103, 131, 0.25); + --shadow-soft: 0 10px 30px rgba(25, 28, 30, 0.08); + --radius-md: 14px; + --radius-lg: 22px; +} + +/* Dark theme β€” Material Design 3 tokens (matches frontend-app/src/assets/themes/dark.ts) */ +@media (prefers-color-scheme: dark) { + :root { + --color-primary: #63d3ff; + --color-primary-strong: #3db5e5; + --color-primary-light: #004d63; + --color-accent: #c5c2ea; + --color-surface: #191c1e; + --color-surface-soft: #1d2529; + --color-on-surface: #e1e2e4; + --color-muted: #c0c8cd; + --color-border: #40484c; + --color-ring: rgba(99, 211, 255, 0.28); + --shadow-soft: 0 14px 36px rgba(0, 0, 0, 0.35); + } +} + +* { + box-sizing: border-box; +} + +body { + font-family: "IBM Plex Sans", "Segoe UI", sans-serif; + background: + linear-gradient(180deg, rgba(251, 252, 254, 0.6) 0%, rgba(251, 252, 254, 0.68) 100%), + url("/bg-light.jpg") center / cover no-repeat; + color: var(--color-on-surface); + margin: 0; + padding: 0; + min-height: 100vh; + display: flex; + flex-direction: column; + -webkit-font-smoothing: antialiased; +} + +@media (prefers-color-scheme: dark) { + body { + background: + linear-gradient(180deg, rgba(25, 28, 30, 0.72) 0%, rgba(25, 28, 30, 0.8) 100%), + url("/bg-dark.jpg") center / cover no-repeat; + } +} + +h1, +h2, +h3, +h4 { + font-family: "Space Grotesk", "IBM Plex Sans", sans-serif; + letter-spacing: -0.02em; +} + +a { + color: var(--color-primary); + transition: color 160ms ease; +} + +a:hover { + color: var(--color-primary-strong); +} + +/* Restore underlines for links within body text so they're distinguishable without colour alone */ +p a, +li a { + text-decoration: underline; +} + +:focus-visible { + outline: 3px solid var(--color-ring); + outline-offset: 2px; +} + +::selection { + background: rgba(0, 103, 131, 0.2); +} + +@media (prefers-color-scheme: dark) { + ::selection { + background: rgba(99, 211, 255, 0.25); + } +} + +/* Button utility styles shared across pages */ +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0.62rem 1.1rem; + border-radius: var(--radius-md); + font-size: 0.92rem; + font-weight: 600; + text-decoration: none; + cursor: pointer; + border: 1px solid transparent; + transition: + transform 130ms ease, + box-shadow 160ms ease, + background 160ms ease; +} + +.btn:hover { + transform: translateY(-1px); +} + +/* Larger primary button for the hero CTA */ +.btn-large { + padding: 0.9rem 1.4rem; + font-size: 1.05rem; +} + +/* Primary variant (used for main CTAs) */ +.btn-primary { + background: var(--color-primary); + color: #fff; + white-space: nowrap; + box-shadow: 0 8px 22px rgba(0, 103, 131, 0.25); +} + +.btn-primary:hover { + background: var(--color-primary-strong); +} + +/* Subtle / muted variant for less-prominent actions */ +.btn-muted { + background: transparent; + color: var(--color-on-surface); + border-color: color-mix(in srgb, var(--color-border) 60%, transparent); + box-shadow: none; +} + +@media (max-width: 760px) { + .btn { + width: 100%; + } +} + +/* Shared UI primitives moved here for consistency */ +.card { + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + padding: 1.5rem; +} + +.input-row { + display: flex; + gap: 0.75rem; + margin-bottom: 0.75rem; +} + +input[type="email"] { + flex: 1; + padding: 0.58rem 0.85rem; + border: 1.5px solid var(--color-border); + border-radius: var(--radius-md); + font-size: 0.95rem; + background: var(--color-surface); + color: var(--color-on-surface); + min-width: 0; +} + +input[type="email"]::placeholder { + color: var(--color-muted); +} + +input[type="email"]:focus { + border-color: var(--color-primary); + box-shadow: 0 0 0 4px var(--color-ring); + outline: none; +} + +.muted-text { + font-size: 0.85rem; + color: var(--color-muted); +} + +.message { + font-size: 0.9rem; + min-height: 1.5rem; +} + +.message-success { + color: #2e7d32; +} + +.message-error { + color: #c62828; +} diff --git a/frontend-web/src/utils/url.test.ts b/frontend-web/src/utils/url.test.ts new file mode 100644 index 00000000..1f8c6635 --- /dev/null +++ b/frontend-web/src/utils/url.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest'; + +import { joinApiUrl } from './url'; + +describe('joinApiUrl', () => { + it('joins a base URL and absolute path', () => { + expect(joinApiUrl('https://api.example.com', '/newsletter/subscribe')).toBe( + 'https://api.example.com/newsletter/subscribe', + ); + }); + + it('removes duplicate trailing slashes from base URL', () => { + expect(joinApiUrl('https://api.example.com///', '/newsletter/subscribe')).toBe( + 'https://api.example.com/newsletter/subscribe', + ); + }); + + it('adds a leading slash when path is relative', () => { + expect(joinApiUrl('https://api.example.com', 'newsletter/subscribe')).toBe( + 'https://api.example.com/newsletter/subscribe', + ); + }); + + it('handles a single trailing slash on base URL', () => { + expect(joinApiUrl('https://api.example.com/', '/health')).toBe( + 'https://api.example.com/health', + ); + }); + + it('works with a path that is just a slash', () => { + expect(joinApiUrl('https://api.example.com', '/')).toBe('https://api.example.com/'); + }); + + it('throws a descriptive error when baseUrl is undefined (missing PUBLIC_API_URL)', () => { + expect(() => joinApiUrl(undefined as unknown as string, '/path')).toThrow( + 'joinApiUrl: baseUrl is undefined β€” is PUBLIC_API_URL set?', + ); + }); + + it('throws a descriptive error when baseUrl is empty string', () => { + expect(() => joinApiUrl('', '/path')).toThrow('joinApiUrl: baseUrl is'); + }); + + it('preserves existing path segments on the base URL', () => { + expect(joinApiUrl('https://api.example.com/v1', '/users')).toBe( + 'https://api.example.com/v1/users', + ); + }); +}); diff --git a/frontend-web/src/utils/url.ts b/frontend-web/src/utils/url.ts new file mode 100644 index 00000000..e1faf52a --- /dev/null +++ b/frontend-web/src/utils/url.ts @@ -0,0 +1,6 @@ +export function joinApiUrl(baseUrl: string, path: string): string { + if (!baseUrl) throw new Error(`joinApiUrl: baseUrl is ${baseUrl} β€” is PUBLIC_API_URL set?`); + const base = baseUrl.replace(/\/+$/, ''); + const suffix = path.startsWith('/') ? path : `/${path}`; + return `${base}${suffix}`; +} diff --git a/frontend-web/tsconfig.json b/frontend-web/tsconfig.json index 8ee088f4..19688588 100644 --- a/frontend-web/tsconfig.json +++ b/frontend-web/tsconfig.json @@ -1,12 +1,11 @@ { - "extends": "expo/tsconfig.base", - "baseUrl": ".", + "extends": "astro/tsconfigs/strict", + "include": [".astro/types.d.ts", "**/*"], + "exclude": ["dist", "node_modules", "coverage", "test-results"], "compilerOptions": { - "jsx": "react-jsx", + "baseUrl": ".", "paths": { - "@/*": ["./src/*"] - }, - "strict": true - }, - "include": ["**/*.ts", "**/*.tsx", ".expo/types/**/*.ts", "expo-env.d.ts"] + "@/*": ["src/*"] + } + } } diff --git a/frontend-web/vitest.config.ts b/frontend-web/vitest.config.ts new file mode 100644 index 00000000..b9e307ba --- /dev/null +++ b/frontend-web/vitest.config.ts @@ -0,0 +1,16 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + exclude: ['e2e/**', 'node_modules/**'], + coverage: { + provider: 'v8', + include: ['src/utils/**'], + exclude: ['node_modules/**', 'e2e/**'], + reporter: ['text', 'lcov', 'json'], + thresholds: { + statements: 80, + }, + }, + }, +}); diff --git a/justfile b/justfile new file mode 100644 index 00000000..8e997b65 --- /dev/null +++ b/justfile @@ -0,0 +1,290 @@ +# RELab Monorepo Task Runner +# Run `just --list` to see all available commands + +# Show available recipes +default: + @just --list + +# ============================================================================ +# Setup +# ============================================================================ + +# Install all workspace dependencies (root + all subrepos) +install: + uv sync + just backend/install + just frontend-web/install + just frontend-app/install + just docs/install + @echo "βœ“ All dependencies installed" + +# Update all workspace dependencies +update: + uv lock --upgrade + @echo "βœ“ Dependencies updated (run 'just install' to sync)" + +# Install pre-commit hooks (run once after clone) +pre-commit-install: + uv run pre-commit install + @echo "βœ“ Pre-commit hooks installed" + +# Create a conventional commit message interactively +commit: + uv run cz commit + +# Bootstrap a full local development environment +setup: install pre-commit-install + @echo "βœ“ Development environment ready" + +# ============================================================================ +# Quality Checks +# ============================================================================ + +# Run repository-wide policy checks +check-root: + uv run pre-commit run --all-files + @echo "βœ“ Repository policy checks passed" + +# Run all quality checks across every subrepo +check: + @just check-root + @just backend/check + @just docs/check + @just frontend-web/check + @just frontend-app/check + @echo "βœ“ All quality checks passed" + +# Auto-fix code issues where supported +fix: + @just backend/fix + @just frontend-web/fix + @just frontend-app/fix + @just docs/fix + @echo "βœ“ Code fixed" + +# Run all pre-commit hooks on all files (useful before big commits) +pre-commit: + @just check-root + +# Run shellcheck on all shell scripts in the repo +shellcheck: + git ls-files '*.sh' | xargs shellcheck -x --source-path=SCRIPTDIR:. + @echo "βœ“ Shell scripts linted" + +# Run spell check on all files in the repo +spellcheck: + npx cspell lint --dot --gitignore . + + +# ============================================================================ +# Testing +# ============================================================================ + +# Full local test suite across all subrepos +test: + @just backend/test + @just frontend-web/test + @just frontend-app/test + @just docs/test + @echo "βœ… All tests passed" + +# CI-oriented test suite across all subrepos +test-ci: + @just backend/test-ci + @just frontend-web/test-ci + @just frontend-app/test-ci + @just docs/test-ci + @echo "βœ… All CI test suites passed" + +# Full local CI pipeline +ci: check test-ci + @echo "βœ… Local CI pipeline passed" + +# Full-stack E2E: spin up Docker backend, build Expo web, run Playwright, tear down +# Requires Docker to be running. +test-e2e-full-stack: + #!/usr/bin/env bash + set -euo pipefail + trap 'docker compose -p relab_e2e -f compose.e2e.yml down -v --remove-orphans || true' EXIT + echo "β†’ Starting backend infrastructure..." + docker compose -p relab_e2e -f compose.e2e.yml up --build -d --wait --wait-timeout 120 + echo "β†’ Running database migrations..." + docker compose -p relab_e2e -f compose.e2e.yml run --rm --profile migrations backend-migrations + echo "β†’ Creating E2E superuser..." + docker compose -p relab_e2e -f compose.e2e.yml exec -T backend python scripts/create_superuser.py + echo "β†’ Building Expo web app..." + just frontend-app/build-web + echo "β†’ Running Playwright E2E tests..." + just frontend-app/test-e2e + echo "βœ… Full-stack E2E tests passed" + +# ============================================================================ +# Security +# ============================================================================ + +# Run dependency vulnerability audit across all subrepos +audit: + @just backend/audit + @just frontend-app/audit + @just frontend-web/audit + @just docs/audit + @echo "βœ… All dependency audits complete" + + +# ============================================================================ +# Docker β€” Targeted Development (subset of services with hot reload) +# ============================================================================ + +# Start backend + its infrastructure (database, cache) with hot reload +dev-backend: + docker compose up --watch backend + +# Start frontend-app + backend with hot reload +dev-frontend-app: + docker compose up --watch backend frontend-app + +# Start frontend-web + backend with hot reload +dev-frontend-web: + docker compose up --watch backend frontend-web + +# Start docs server with hot reload +dev-docs: + docker compose up --watch docs + +# ============================================================================ +# Docker β€” Development +# ============================================================================ + +# Start full dev stack with hot reload (syncs source changes, auto-rebuilds on lockfile changes) +dev: + docker compose up --watch + +# Start full dev stack without hot reload (uses source snapshot baked into image) +dev-up: + docker compose up + +# Build (or rebuild) dev images +dev-build: + docker compose build + +# Stop and remove dev containers +dev-down: + docker compose down + +# Tail dev logs (all services) +dev-logs: + docker compose logs -f + +# Run database migrations (dev) β€” required on first start and after schema changes +dev-migrate: + docker compose --profile migrations up backend-migrations + +# Wipe all dev volumes and containers (full clean slate β€” re-run dev-migrate after this) +dev-reset: + docker compose down -v + +# ============================================================================ +# Docker β€” Production +# ============================================================================ + +prod_compose := "docker compose -p relab_prod -f compose.yml -f compose.prod.yml" + +# Start production stack in the background +prod-up: + {{ prod_compose }} up -d + +# Stop production stack +prod-down: + {{ prod_compose }} down + +# Tail production logs +prod-logs: + {{ prod_compose }} logs -f + +# Run database migrations (prod) β€” required on first deploy and after schema changes +prod-migrate: + {{ prod_compose }} --profile migrations up backend-migrations + +# Enable automated database + upload backups (prod) +prod-backups-up: + {{ prod_compose }} --profile backups up -d + +# ============================================================================ +# Docker β€” Staging +# ============================================================================ + +staging_compose := "docker compose -p relab_staging -f compose.yml -f compose.staging.yml" + +# Start staging stack in the background +staging-up: + {{ staging_compose }} up -d + +# Stop staging stack +staging-down: + {{ staging_compose }} down + +# Tail staging logs +staging-logs: + {{ staging_compose }} logs -f + +# Run database migrations and seed dummy data (staging) +staging-migrate: + {{ staging_compose }} --profile migrations up backend-migrations + +# ============================================================================ +# Docker β€” CI +# ============================================================================ + +docker_compose := "docker compose -p relab_ci -f compose.yml -f compose.ci.yml" + +# Smoke test: backend + its infrastructure (database, cache) +docker-smoke-backend: + #!/usr/bin/env bash + set -euo pipefail + trap '{{ docker_compose }} down -v --remove-orphans || true' EXIT + {{ docker_compose }} up --build -d --wait --wait-timeout 120 database cache backend + echo "βœ… Backend smoke test passed" + +# Smoke test: frontend-web static server +docker-smoke-frontend-web: + #!/usr/bin/env bash + set -euo pipefail + trap '{{ docker_compose }} down -v --remove-orphans || true' EXIT + {{ docker_compose }} up --build -d --wait --wait-timeout 60 frontend-web + echo "βœ… Frontend-web smoke test passed" + +# Smoke test: frontend-app static server (slow: expo export runs during build) +docker-smoke-frontend-app: + #!/usr/bin/env bash + set -euo pipefail + trap '{{ docker_compose }} down -v --remove-orphans || true' EXIT + {{ docker_compose }} up --build -d --wait --wait-timeout 300 frontend-app + echo "βœ… Frontend-app smoke test passed" + +# Smoke test: docs static server +docker-smoke-docs: + #!/usr/bin/env bash + set -euo pipefail + trap '{{ docker_compose }} down -v --remove-orphans || true' EXIT + {{ docker_compose }} up --build -d --wait --wait-timeout 60 docs + echo "βœ… Docs smoke test passed" + +# Run all smoke tests sequentially (CI runs them in parallel per-service) +docker-smoke-all: + @just docker-smoke-backend + @just docker-smoke-frontend-web + @just docker-smoke-frontend-app + @just docker-smoke-docs + +# ============================================================================ +# Maintenance +# ============================================================================ + +# Clean build artifacts and caches across all subrepos +clean: + @just backend/clean + @just frontend-web/clean + @just frontend-app/clean + @just docs/clean + rm -rf .ruff_cache + @echo "βœ“ Cleaned caches and build artifacts" diff --git a/pyproject.toml b/pyproject.toml index e90d9275..507ccd5f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,34 +1,33 @@ [project] - authors = [ - { name = "Franco Donati", email = "f.donati@cml.leidenuniv.nl" }, - { name = "Simon van Lierde", email = "s.n.van.lierde@cml.leidenuniv.nl" }, + ## Project metadata + authors = [{ name = "Simon van Lierde", email = "s.n.van.lierde@cml.leidenuniv.nl" }] + classifiers = [ + "Development Status :: 4 - Beta", + "Framework :: FastAPI", + "License-Expression :: AGPL-3.0-or-later", + "Natural Language :: English", + "Programming Language :: Python :: 3", + "Topic :: Scientific/Engineering :: Artificial Intelligence", + "Topic :: Scientific/Engineering :: Image Recognition", ] description = "Reverse Engineering Lab monorepo" + keywords = ["automated-lca", "circular-economy", "computer-vision"] license = { text = "AGPL-3.0-or-later" } + maintainers = [{ name = "Simon van Lierde", email = "s.n.van.lierde@cml.leidenuniv.nl" }] name = "relab" - requires-python = ">=3.13" - # NOTE: package versioning across the repo is managed by commitizen + readme = "README.md" + + ## Dependencies and version constraints + requires-python = ">=3.14" + # NOTE: package versioning across the repo is managed by release-please. version = "0.1.0" [dependency-groups] dev = ["commitizen>=4.8.3", "pre-commit>=4.2.0"] [tool.commitizen] - annotated_tag = true + annotated_tag = true major_version_zero = true - name = "cz_conventional_commits" - tag_format = "v$version" - update_changelog_on_bump = true - version_files = [ - "CITATION.cff", - "backend/app/__version__.py", - "backend/pyproject.toml", - "codemeta.json", - "docs/pyproject.toml", - "frontend-app/app.json", - "frontend-app/package.json", - "frontend-web/app.config.ts", - "frontend-web/package.json", - ] - version_provider = "uv" - version_scheme = "semver2" + name = "cz_conventional_commits" + tag_format = "v$version" + version_scheme = "semver2" diff --git a/renovate.json b/renovate.json index a024b677..eabf2167 100644 --- a/renovate.json +++ b/renovate.json @@ -4,34 +4,56 @@ "config:best-practices", ":approveMajorUpdates", ":maintainLockFilesWeekly", + ":preserveSemverRanges", "schedule:weekly" ], + "semanticCommits": "enabled", + "semanticCommitType": "chore", + "semanticCommitScope": "deps", + "platformAutomerge": true, + "pep621": { + "enabled": true + }, + "lockFileMaintenance": { + "enabled": true, + "automerge": true + }, "packageRules": [ + { + "matchUpdateTypes": ["minor", "patch"], + "matchPackagePatterns": ["*"], + "automerge": true, + "labels": ["automerge"] + }, + { + "groupName": "github-actions", + "matchManagers": ["github-actions"], + "matchUpdateTypes": ["digest", "minor", "patch"], + "pinDigests": true, + "automerge": true, + "labels": ["automerge", "github-actions"] + }, { "groupName": "backend", - "matchFileNames": [ - "backend/pyproject.toml", - "backend/.python-version", - "backend/Dockerfile*" - ] + "matchFileNames": ["backend/.python-version", "backend/pyproject.toml", "backend/Dockerfile*"] + }, + { + "groupName": "docs", + "matchFileNames": ["docs/pyproject.toml", "docs/Dockerfile*"] + }, + { + "groupName": "frontend-web", + "matchFileNames": ["frontend-web/package.json", "frontend-web/Dockerfile*"] }, { - "groupName": "frontend", - "matchFileNames": [ - "frontend/package.json", - "frontend/Dockerfile*" - ] + "groupName": "frontend-app", + "matchFileNames": ["frontend-app/package.json", "frontend-app/Dockerfile*"] }, { "groupName": "infrastructure", - "matchFileNames": [ - "**/compose.*.yml", - "**/compose.yml" - ] + "matchFileNames": ["**/compose.*.yml", "**/compose.yml"], + "pinDigests": true } ], - "labels": [ - "dependencies", - "renovate" - ] + "labels": ["dependencies", "renovate"] } diff --git a/uv.lock b/uv.lock index a14c571c..4fb3f01e 100644 --- a/uv.lock +++ b/uv.lock @@ -1,64 +1,64 @@ version = 1 revision = 3 -requires-python = ">=3.13" +requires-python = ">=3.14" [[package]] name = "argcomplete" -version = "3.6.2" +version = "3.6.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/16/0f/861e168fc813c56a78b35f3c30d91c6757d1fd185af1110f1aec784b35d0/argcomplete-3.6.2.tar.gz", hash = "sha256:d0519b1bc867f5f4f4713c41ad0aba73a4a5f007449716b16f385f2166dc6adf", size = 73403, upload-time = "2025-04-03T04:57:03.52Z" } +sdist = { url = "https://files.pythonhosted.org/packages/38/61/0b9ae6399dd4a58d8c1b1dc5a27d6f2808023d0b5dd3104bb99f45a33ff6/argcomplete-3.6.3.tar.gz", hash = "sha256:62e8ed4fd6a45864acc8235409461b72c9a28ee785a2011cc5eb78318786c89c", size = 73754, upload-time = "2025-10-20T03:33:34.741Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/31/da/e42d7a9d8dd33fa775f467e4028a47936da2f01e4b0e561f9ba0d74cb0ca/argcomplete-3.6.2-py3-none-any.whl", hash = "sha256:65b3133a29ad53fb42c48cf5114752c7ab66c1c38544fdf6460f450c09b42591", size = 43708, upload-time = "2025-04-03T04:57:01.591Z" }, + { url = "https://files.pythonhosted.org/packages/74/f5/9373290775639cb67a2fce7f629a1c240dce9f12fe927bc32b2736e16dfc/argcomplete-3.6.3-py3-none-any.whl", hash = "sha256:f5007b3a600ccac5d25bbce33089211dfd49eab4a7718da3f10e3082525a92ce", size = 43846, upload-time = "2025-10-20T03:33:33.021Z" }, ] [[package]] name = "cfgv" -version = "3.4.0" +version = "3.5.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/11/74/539e56497d9bd1d484fd863dd69cbbfa653cd2aa27abfe35653494d85e94/cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560", size = 7114, upload-time = "2023-08-12T20:38:17.776Z" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/b5/721b8799b04bf9afe054a3899c6cf4e880fcf8563cc71c15610242490a0c/cfgv-3.5.0.tar.gz", hash = "sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132", size = 7334, upload-time = "2025-11-19T20:55:51.612Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c5/55/51844dd50c4fc7a33b653bfaba4c2456f06955289ca770a5dbd5fd267374/cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9", size = 7249, upload-time = "2023-08-12T20:38:16.269Z" }, + { url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0", size = 7445, upload-time = "2025-11-19T20:55:50.744Z" }, ] [[package]] name = "charset-normalizer" -version = "3.4.4" +version = "3.4.6" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7b/60/e3bec1881450851b087e301bedc3daa9377a4d45f1c26aa90b0b235e38aa/charset_normalizer-3.4.6.tar.gz", hash = "sha256:1ae6b62897110aa7c79ea2f5dd38d1abca6db663687c0b1ad9aed6f6bae3d9d6", size = 143363, upload-time = "2026-03-15T18:53:25.478Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/97/45/4b3a1239bbacd321068ea6e7ac28875b03ab8bc0aa0966452db17cd36714/charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794", size = 208091, upload-time = "2025-10-14T04:41:13.346Z" }, - { url = "https://files.pythonhosted.org/packages/7d/62/73a6d7450829655a35bb88a88fca7d736f9882a27eacdca2c6d505b57e2e/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed", size = 147936, upload-time = "2025-10-14T04:41:14.461Z" }, - { url = "https://files.pythonhosted.org/packages/89/c5/adb8c8b3d6625bef6d88b251bbb0d95f8205831b987631ab0c8bb5d937c2/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72", size = 144180, upload-time = "2025-10-14T04:41:15.588Z" }, - { url = "https://files.pythonhosted.org/packages/91/ed/9706e4070682d1cc219050b6048bfd293ccf67b3d4f5a4f39207453d4b99/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328", size = 161346, upload-time = "2025-10-14T04:41:16.738Z" }, - { url = "https://files.pythonhosted.org/packages/d5/0d/031f0d95e4972901a2f6f09ef055751805ff541511dc1252ba3ca1f80cf5/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede", size = 158874, upload-time = "2025-10-14T04:41:17.923Z" }, - { url = "https://files.pythonhosted.org/packages/f5/83/6ab5883f57c9c801ce5e5677242328aa45592be8a00644310a008d04f922/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894", size = 153076, upload-time = "2025-10-14T04:41:19.106Z" }, - { url = "https://files.pythonhosted.org/packages/75/1e/5ff781ddf5260e387d6419959ee89ef13878229732732ee73cdae01800f2/charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1", size = 150601, upload-time = "2025-10-14T04:41:20.245Z" }, - { url = "https://files.pythonhosted.org/packages/d7/57/71be810965493d3510a6ca79b90c19e48696fb1ff964da319334b12677f0/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490", size = 150376, upload-time = "2025-10-14T04:41:21.398Z" }, - { url = "https://files.pythonhosted.org/packages/e5/d5/c3d057a78c181d007014feb7e9f2e65905a6c4ef182c0ddf0de2924edd65/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44", size = 144825, upload-time = "2025-10-14T04:41:22.583Z" }, - { url = "https://files.pythonhosted.org/packages/e6/8c/d0406294828d4976f275ffbe66f00266c4b3136b7506941d87c00cab5272/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133", size = 162583, upload-time = "2025-10-14T04:41:23.754Z" }, - { url = "https://files.pythonhosted.org/packages/d7/24/e2aa1f18c8f15c4c0e932d9287b8609dd30ad56dbe41d926bd846e22fb8d/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3", size = 150366, upload-time = "2025-10-14T04:41:25.27Z" }, - { url = "https://files.pythonhosted.org/packages/e4/5b/1e6160c7739aad1e2df054300cc618b06bf784a7a164b0f238360721ab86/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e", size = 160300, upload-time = "2025-10-14T04:41:26.725Z" }, - { url = "https://files.pythonhosted.org/packages/7a/10/f882167cd207fbdd743e55534d5d9620e095089d176d55cb22d5322f2afd/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc", size = 154465, upload-time = "2025-10-14T04:41:28.322Z" }, - { url = "https://files.pythonhosted.org/packages/89/66/c7a9e1b7429be72123441bfdbaf2bc13faab3f90b933f664db506dea5915/charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac", size = 99404, upload-time = "2025-10-14T04:41:29.95Z" }, - { url = "https://files.pythonhosted.org/packages/c4/26/b9924fa27db384bdcd97ab83b4f0a8058d96ad9626ead570674d5e737d90/charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14", size = 107092, upload-time = "2025-10-14T04:41:31.188Z" }, - { url = "https://files.pythonhosted.org/packages/af/8f/3ed4bfa0c0c72a7ca17f0380cd9e4dd842b09f664e780c13cff1dcf2ef1b/charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2", size = 100408, upload-time = "2025-10-14T04:41:32.624Z" }, - { url = "https://files.pythonhosted.org/packages/2a/35/7051599bd493e62411d6ede36fd5af83a38f37c4767b92884df7301db25d/charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd", size = 207746, upload-time = "2025-10-14T04:41:33.773Z" }, - { url = "https://files.pythonhosted.org/packages/10/9a/97c8d48ef10d6cd4fcead2415523221624bf58bcf68a802721a6bc807c8f/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb", size = 147889, upload-time = "2025-10-14T04:41:34.897Z" }, - { url = "https://files.pythonhosted.org/packages/10/bf/979224a919a1b606c82bd2c5fa49b5c6d5727aa47b4312bb27b1734f53cd/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e", size = 143641, upload-time = "2025-10-14T04:41:36.116Z" }, - { url = "https://files.pythonhosted.org/packages/ba/33/0ad65587441fc730dc7bd90e9716b30b4702dc7b617e6ba4997dc8651495/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14", size = 160779, upload-time = "2025-10-14T04:41:37.229Z" }, - { url = "https://files.pythonhosted.org/packages/67/ed/331d6b249259ee71ddea93f6f2f0a56cfebd46938bde6fcc6f7b9a3d0e09/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191", size = 159035, upload-time = "2025-10-14T04:41:38.368Z" }, - { url = "https://files.pythonhosted.org/packages/67/ff/f6b948ca32e4f2a4576aa129d8bed61f2e0543bf9f5f2b7fc3758ed005c9/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838", size = 152542, upload-time = "2025-10-14T04:41:39.862Z" }, - { url = "https://files.pythonhosted.org/packages/16/85/276033dcbcc369eb176594de22728541a925b2632f9716428c851b149e83/charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6", size = 149524, upload-time = "2025-10-14T04:41:41.319Z" }, - { url = "https://files.pythonhosted.org/packages/9e/f2/6a2a1f722b6aba37050e626530a46a68f74e63683947a8acff92569f979a/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e", size = 150395, upload-time = "2025-10-14T04:41:42.539Z" }, - { url = "https://files.pythonhosted.org/packages/60/bb/2186cb2f2bbaea6338cad15ce23a67f9b0672929744381e28b0592676824/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c", size = 143680, upload-time = "2025-10-14T04:41:43.661Z" }, - { url = "https://files.pythonhosted.org/packages/7d/a5/bf6f13b772fbb2a90360eb620d52ed8f796f3c5caee8398c3b2eb7b1c60d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090", size = 162045, upload-time = "2025-10-14T04:41:44.821Z" }, - { url = "https://files.pythonhosted.org/packages/df/c5/d1be898bf0dc3ef9030c3825e5d3b83f2c528d207d246cbabe245966808d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152", size = 149687, upload-time = "2025-10-14T04:41:46.442Z" }, - { url = "https://files.pythonhosted.org/packages/a5/42/90c1f7b9341eef50c8a1cb3f098ac43b0508413f33affd762855f67a410e/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828", size = 160014, upload-time = "2025-10-14T04:41:47.631Z" }, - { url = "https://files.pythonhosted.org/packages/76/be/4d3ee471e8145d12795ab655ece37baed0929462a86e72372fd25859047c/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec", size = 154044, upload-time = "2025-10-14T04:41:48.81Z" }, - { url = "https://files.pythonhosted.org/packages/b0/6f/8f7af07237c34a1defe7defc565a9bc1807762f672c0fde711a4b22bf9c0/charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9", size = 99940, upload-time = "2025-10-14T04:41:49.946Z" }, - { url = "https://files.pythonhosted.org/packages/4b/51/8ade005e5ca5b0d80fb4aff72a3775b325bdc3d27408c8113811a7cbe640/charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c", size = 107104, upload-time = "2025-10-14T04:41:51.051Z" }, - { url = "https://files.pythonhosted.org/packages/da/5f/6b8f83a55bb8278772c5ae54a577f3099025f9ade59d0136ac24a0df4bde/charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2", size = 100743, upload-time = "2025-10-14T04:41:52.122Z" }, - { url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" }, + { url = "https://files.pythonhosted.org/packages/25/6f/ffe1e1259f384594063ea1869bfb6be5cdb8bc81020fc36c3636bc8302a1/charset_normalizer-3.4.6-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:9cc6e6d9e571d2f863fa77700701dae73ed5f78881efc8b3f9a4398772ff53e8", size = 294458, upload-time = "2026-03-15T18:51:41.134Z" }, + { url = "https://files.pythonhosted.org/packages/56/60/09bb6c13a8c1016c2ed5c6a6488e4ffef506461aa5161662bd7636936fb1/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef5960d965e67165d75b7c7ffc60a83ec5abfc5c11b764ec13ea54fbef8b4421", size = 199277, upload-time = "2026-03-15T18:51:42.953Z" }, + { url = "https://files.pythonhosted.org/packages/00/50/dcfbb72a5138bbefdc3332e8d81a23494bf67998b4b100703fd15fa52d81/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b3694e3f87f8ac7ce279d4355645b3c878d24d1424581b46282f24b92f5a4ae2", size = 218758, upload-time = "2026-03-15T18:51:44.339Z" }, + { url = "https://files.pythonhosted.org/packages/03/b3/d79a9a191bb75f5aa81f3aaaa387ef29ce7cb7a9e5074ba8ea095cc073c2/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5d11595abf8dd942a77883a39d81433739b287b6aa71620f15164f8096221b30", size = 215299, upload-time = "2026-03-15T18:51:45.871Z" }, + { url = "https://files.pythonhosted.org/packages/76/7e/bc8911719f7084f72fd545f647601ea3532363927f807d296a8c88a62c0d/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7bda6eebafd42133efdca535b04ccb338ab29467b3f7bf79569883676fc628db", size = 206811, upload-time = "2026-03-15T18:51:47.308Z" }, + { url = "https://files.pythonhosted.org/packages/e2/40/c430b969d41dda0c465aa36cc7c2c068afb67177bef50905ac371b28ccc7/charset_normalizer-3.4.6-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:bbc8c8650c6e51041ad1be191742b8b421d05bbd3410f43fa2a00c8db87678e8", size = 193706, upload-time = "2026-03-15T18:51:48.849Z" }, + { url = "https://files.pythonhosted.org/packages/48/15/e35e0590af254f7df984de1323640ef375df5761f615b6225ba8deb9799a/charset_normalizer-3.4.6-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:22c6f0c2fbc31e76c3b8a86fba1a56eda6166e238c29cdd3d14befdb4a4e4815", size = 202706, upload-time = "2026-03-15T18:51:50.257Z" }, + { url = "https://files.pythonhosted.org/packages/5e/bd/f736f7b9cc5e93a18b794a50346bb16fbfd6b37f99e8f306f7951d27c17c/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7edbed096e4a4798710ed6bc75dcaa2a21b68b6c356553ac4823c3658d53743a", size = 202497, upload-time = "2026-03-15T18:51:52.012Z" }, + { url = "https://files.pythonhosted.org/packages/9d/ba/2cc9e3e7dfdf7760a6ed8da7446d22536f3d0ce114ac63dee2a5a3599e62/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:7f9019c9cb613f084481bd6a100b12e1547cf2efe362d873c2e31e4035a6fa43", size = 193511, upload-time = "2026-03-15T18:51:53.723Z" }, + { url = "https://files.pythonhosted.org/packages/9e/cb/5be49b5f776e5613be07298c80e1b02a2d900f7a7de807230595c85a8b2e/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:58c948d0d086229efc484fe2f30c2d382c86720f55cd9bc33591774348ad44e0", size = 220133, upload-time = "2026-03-15T18:51:55.333Z" }, + { url = "https://files.pythonhosted.org/packages/83/43/99f1b5dad345accb322c80c7821071554f791a95ee50c1c90041c157ae99/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:419a9d91bd238052642a51938af8ac05da5b3343becde08d5cdeab9046df9ee1", size = 203035, upload-time = "2026-03-15T18:51:56.736Z" }, + { url = "https://files.pythonhosted.org/packages/87/9a/62c2cb6a531483b55dddff1a68b3d891a8b498f3ca555fbcf2978e804d9d/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:5273b9f0b5835ff0350c0828faea623c68bfa65b792720c453e22b25cc72930f", size = 216321, upload-time = "2026-03-15T18:51:58.17Z" }, + { url = "https://files.pythonhosted.org/packages/6e/79/94a010ff81e3aec7c293eb82c28f930918e517bc144c9906a060844462eb/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:0e901eb1049fdb80f5bd11ed5ea1e498ec423102f7a9b9e4645d5b8204ff2815", size = 208973, upload-time = "2026-03-15T18:51:59.998Z" }, + { url = "https://files.pythonhosted.org/packages/2a/57/4ecff6d4ec8585342f0c71bc03efaa99cb7468f7c91a57b105bcd561cea8/charset_normalizer-3.4.6-cp314-cp314-win32.whl", hash = "sha256:b4ff1d35e8c5bd078be89349b6f3a845128e685e751b6ea1169cf2160b344c4d", size = 144610, upload-time = "2026-03-15T18:52:02.213Z" }, + { url = "https://files.pythonhosted.org/packages/80/94/8434a02d9d7f168c25767c64671fead8d599744a05d6a6c877144c754246/charset_normalizer-3.4.6-cp314-cp314-win_amd64.whl", hash = "sha256:74119174722c4349af9708993118581686f343adc1c8c9c007d59be90d077f3f", size = 154962, upload-time = "2026-03-15T18:52:03.658Z" }, + { url = "https://files.pythonhosted.org/packages/46/4c/48f2cdbfd923026503dfd67ccea45c94fd8fe988d9056b468579c66ed62b/charset_normalizer-3.4.6-cp314-cp314-win_arm64.whl", hash = "sha256:e5bcc1a1ae744e0bb59641171ae53743760130600da8db48cbb6e4918e186e4e", size = 143595, upload-time = "2026-03-15T18:52:05.123Z" }, + { url = "https://files.pythonhosted.org/packages/31/93/8878be7569f87b14f1d52032946131bcb6ebbd8af3e20446bc04053dc3f1/charset_normalizer-3.4.6-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:ad8faf8df23f0378c6d527d8b0b15ea4a2e23c89376877c598c4870d1b2c7866", size = 314828, upload-time = "2026-03-15T18:52:06.831Z" }, + { url = "https://files.pythonhosted.org/packages/06/b6/fae511ca98aac69ecc35cde828b0a3d146325dd03d99655ad38fc2cc3293/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f5ea69428fa1b49573eef0cc44a1d43bebd45ad0c611eb7d7eac760c7ae771bc", size = 208138, upload-time = "2026-03-15T18:52:08.239Z" }, + { url = "https://files.pythonhosted.org/packages/54/57/64caf6e1bf07274a1e0b7c160a55ee9e8c9ec32c46846ce59b9c333f7008/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:06a7e86163334edfc5d20fe104db92fcd666e5a5df0977cb5680a506fe26cc8e", size = 224679, upload-time = "2026-03-15T18:52:10.043Z" }, + { url = "https://files.pythonhosted.org/packages/aa/cb/9ff5a25b9273ef160861b41f6937f86fae18b0792fe0a8e75e06acb08f1d/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e1f6e2f00a6b8edb562826e4632e26d063ac10307e80f7461f7de3ad8ef3f077", size = 223475, upload-time = "2026-03-15T18:52:11.854Z" }, + { url = "https://files.pythonhosted.org/packages/fc/97/440635fc093b8d7347502a377031f9605a1039c958f3cd18dcacffb37743/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:95b52c68d64c1878818687a473a10547b3292e82b6f6fe483808fb1468e2f52f", size = 215230, upload-time = "2026-03-15T18:52:13.325Z" }, + { url = "https://files.pythonhosted.org/packages/cd/24/afff630feb571a13f07c8539fbb502d2ab494019492aaffc78ef41f1d1d0/charset_normalizer-3.4.6-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:7504e9b7dc05f99a9bbb4525c67a2c155073b44d720470a148b34166a69c054e", size = 199045, upload-time = "2026-03-15T18:52:14.752Z" }, + { url = "https://files.pythonhosted.org/packages/e5/17/d1399ecdaf7e0498c327433e7eefdd862b41236a7e484355b8e0e5ebd64b/charset_normalizer-3.4.6-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:172985e4ff804a7ad08eebec0a1640ece87ba5041d565fff23c8f99c1f389484", size = 211658, upload-time = "2026-03-15T18:52:16.278Z" }, + { url = "https://files.pythonhosted.org/packages/b5/38/16baa0affb957b3d880e5ac2144caf3f9d7de7bc4a91842e447fbb5e8b67/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:4be9f4830ba8741527693848403e2c457c16e499100963ec711b1c6f2049b7c7", size = 210769, upload-time = "2026-03-15T18:52:17.782Z" }, + { url = "https://files.pythonhosted.org/packages/05/34/c531bc6ac4c21da9ddfddb3107be2287188b3ea4b53b70fc58f2a77ac8d8/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:79090741d842f564b1b2827c0b82d846405b744d31e84f18d7a7b41c20e473ff", size = 201328, upload-time = "2026-03-15T18:52:19.553Z" }, + { url = "https://files.pythonhosted.org/packages/fa/73/a5a1e9ca5f234519c1953608a03fe109c306b97fdfb25f09182babad51a7/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:87725cfb1a4f1f8c2fc9890ae2f42094120f4b44db9360be5d99a4c6b0e03a9e", size = 225302, upload-time = "2026-03-15T18:52:21.043Z" }, + { url = "https://files.pythonhosted.org/packages/ba/f6/cd782923d112d296294dea4bcc7af5a7ae0f86ab79f8fefbda5526b6cfc0/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:fcce033e4021347d80ed9c66dcf1e7b1546319834b74445f561d2e2221de5659", size = 211127, upload-time = "2026-03-15T18:52:22.491Z" }, + { url = "https://files.pythonhosted.org/packages/0e/c5/0b6898950627af7d6103a449b22320372c24c6feda91aa24e201a478d161/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:ca0276464d148c72defa8bb4390cce01b4a0e425f3b50d1435aa6d7a18107602", size = 222840, upload-time = "2026-03-15T18:52:24.113Z" }, + { url = "https://files.pythonhosted.org/packages/7d/25/c4bba773bef442cbdc06111d40daa3de5050a676fa26e85090fc54dd12f0/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:197c1a244a274bb016dd8b79204850144ef77fe81c5b797dc389327adb552407", size = 216890, upload-time = "2026-03-15T18:52:25.541Z" }, + { url = "https://files.pythonhosted.org/packages/35/1a/05dacadb0978da72ee287b0143097db12f2e7e8d3ffc4647da07a383b0b7/charset_normalizer-3.4.6-cp314-cp314t-win32.whl", hash = "sha256:2a24157fa36980478dd1770b585c0f30d19e18f4fb0c47c13aa568f871718579", size = 155379, upload-time = "2026-03-15T18:52:27.05Z" }, + { url = "https://files.pythonhosted.org/packages/5d/7a/d269d834cb3a76291651256f3b9a5945e81d0a49ab9f4a498964e83c0416/charset_normalizer-3.4.6-cp314-cp314t-win_amd64.whl", hash = "sha256:cd5e2801c89992ed8c0a3f0293ae83c159a60d9a5d685005383ef4caca77f2c4", size = 169043, upload-time = "2026-03-15T18:52:28.502Z" }, + { url = "https://files.pythonhosted.org/packages/23/06/28b29fba521a37a8932c6a84192175c34d49f84a6d4773fa63d05f9aff22/charset_normalizer-3.4.6-cp314-cp314t-win_arm64.whl", hash = "sha256:47955475ac79cc504ef2704b192364e51d0d473ad452caedd0002605f780101c", size = 148523, upload-time = "2026-03-15T18:52:29.956Z" }, + { url = "https://files.pythonhosted.org/packages/2a/68/687187c7e26cb24ccbd88e5069f5ef00eba804d36dde11d99aad0838ab45/charset_normalizer-3.4.6-py3-none-any.whl", hash = "sha256:947cf925bc916d90adba35a64c82aace04fa39b46b52d4630ece166655905a69", size = 61455, upload-time = "2026-03-15T18:53:23.833Z" }, ] [[package]] @@ -72,7 +72,7 @@ wheels = [ [[package]] name = "commitizen" -version = "4.9.1" +version = "4.13.9" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "argcomplete" }, @@ -88,9 +88,9 @@ dependencies = [ { name = "termcolor" }, { name = "tomlkit" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/77/19/927ac5b0eabb9451e2d5bb45b30813915c9a1260713b5b68eeb31358ea23/commitizen-4.9.1.tar.gz", hash = "sha256:b076b24657718f7a35b1068f2083bd39b4065d250164a1398d1dac235c51753b", size = 56610, upload-time = "2025-09-10T14:19:33.746Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a6/44/10f95e8178ab5a584298726a4a94ceb83a7f77e00741fec4680df05fedd5/commitizen-4.13.9.tar.gz", hash = "sha256:2b4567ed50555e10920e5bd804a6a4e2c42ec70bb74f14a83f2680fe9eaf9727", size = 64145, upload-time = "2026-02-25T02:40:05.326Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/cf/49/577035b841442fe031b017027c3d99278b46104d227f0353c69dbbe55148/commitizen-4.9.1-py3-none-any.whl", hash = "sha256:4241b2ecae97b8109af8e587c36bc3b805a09b9a311084d159098e12d6ead497", size = 80624, upload-time = "2025-09-10T14:19:32.102Z" }, + { url = "https://files.pythonhosted.org/packages/28/22/9b14ee0f17f0aad219a2fb37a293a57b8324d9d195c6ef6807bcd0bf2055/commitizen-4.13.9-py3-none-any.whl", hash = "sha256:d2af3d6a83cacec9d5200e17768942c5de6266f93d932c955986c60c4285f2db", size = 85373, upload-time = "2026-02-25T02:40:03.83Z" }, ] [[package]] @@ -104,14 +104,14 @@ wheels = [ [[package]] name = "deprecated" -version = "1.2.18" +version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "wrapt" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/98/97/06afe62762c9a8a86af0cfb7bfdab22a43ad17138b07af5b1a58442690a2/deprecated-1.2.18.tar.gz", hash = "sha256:422b6f6d859da6f2ef57857761bfb392480502a64c3028ca9bbe86085d72115d", size = 2928744, upload-time = "2025-01-27T10:46:25.7Z" } +sdist = { url = "https://files.pythonhosted.org/packages/49/85/12f0a49a7c4ffb70572b6c2ef13c90c88fd190debda93b23f026b25f9634/deprecated-1.3.1.tar.gz", hash = "sha256:b1b50e0ff0c1fddaa5708a2c6b0a6588bb09b892825ab2b214ac9ea9d92a5223", size = 2932523, upload-time = "2025-10-30T08:19:02.757Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6e/c6/ac0b6c1e2d138f1002bcf799d330bd6d85084fece321e662a14223794041/Deprecated-1.2.18-py2.py3-none-any.whl", hash = "sha256:bd5011788200372a32418f888e326a09ff80d0214bd961147cfed01b5c018eec", size = 9998, upload-time = "2025-01-27T10:46:09.186Z" }, + { url = "https://files.pythonhosted.org/packages/84/d0/205d54408c08b13550c733c4b85429e7ead111c7f0014309637425520a9a/deprecated-1.3.1-py2.py3-none-any.whl", hash = "sha256:597bfef186b6f60181535a29fbe44865ce137a5079f295b479886c82729d5f3f", size = 11298, upload-time = "2025-10-30T08:19:00.758Z" }, ] [[package]] @@ -125,20 +125,20 @@ wheels = [ [[package]] name = "filelock" -version = "3.20.0" +version = "3.25.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/58/46/0028a82567109b5ef6e4d2a1f04a583fb513e6cf9527fcdd09afd817deeb/filelock-3.20.0.tar.gz", hash = "sha256:711e943b4ec6be42e1d4e6690b48dc175c822967466bb31c0c293f34334c13f4", size = 18922, upload-time = "2025-10-08T18:03:50.056Z" } +sdist = { url = "https://files.pythonhosted.org/packages/94/b8/00651a0f559862f3bb7d6f7477b192afe3f583cc5e26403b44e59a55ab34/filelock-3.25.2.tar.gz", hash = "sha256:b64ece2b38f4ca29dd3e810287aa8c48182bbecd1ae6e9ae126c9b35f1382694", size = 40480, upload-time = "2026-03-11T20:45:38.487Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/76/91/7216b27286936c16f5b4d0c530087e4a54eead683e6b0b73dd0c64844af6/filelock-3.20.0-py3-none-any.whl", hash = "sha256:339b4732ffda5cd79b13f4e2711a31b0365ce445d95d243bb996273d072546a2", size = 16054, upload-time = "2025-10-08T18:03:48.35Z" }, + { url = "https://files.pythonhosted.org/packages/a4/a5/842ae8f0c08b61d6484b52f99a03510a3a72d23141942d216ebe81fefbce/filelock-3.25.2-py3-none-any.whl", hash = "sha256:ca8afb0da15f229774c9ad1b455ed96e85a81373065fb10446672f64444ddf70", size = 26759, upload-time = "2026-03-11T20:45:37.437Z" }, ] [[package]] name = "identify" -version = "2.6.15" +version = "2.6.18" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ff/e7/685de97986c916a6d93b3876139e00eef26ad5bbbd61925d670ae8013449/identify-2.6.15.tar.gz", hash = "sha256:e4f4864b96c6557ef2a1e1c951771838f4edc9df3a72ec7118b338801b11c7bf", size = 99311, upload-time = "2025-10-02T17:43:40.631Z" } +sdist = { url = "https://files.pythonhosted.org/packages/46/c4/7fb4db12296cdb11893d61c92048fe617ee853f8523b9b296ac03b43757e/identify-2.6.18.tar.gz", hash = "sha256:873ac56a5e3fd63e7438a7ecbc4d91aca692eb3fefa4534db2b7913f3fc352fd", size = 99580, upload-time = "2026-03-15T18:39:50.319Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0f/1c/e5fd8f973d4f375adb21565739498e2e9a1e54c858a97b9a8ccfdc81da9b/identify-2.6.15-py2.py3-none-any.whl", hash = "sha256:1181ef7608e00704db228516541eb83a88a9f94433a8c80bb9b5bd54b1d81757", size = 99183, upload-time = "2025-10-02T17:43:39.137Z" }, + { url = "https://files.pythonhosted.org/packages/46/33/92ef41c6fad0233e41d3d84ba8e8ad18d1780f1e5d99b3c683e6d7f98b63/identify-2.6.18-py2.py3-none-any.whl", hash = "sha256:8db9d3c8ea9079db92cafb0ebf97abdc09d52e97f4dcf773a2e694048b7cd737", size = 99394, upload-time = "2026-03-15T18:39:48.915Z" }, ] [[package]] @@ -159,28 +159,6 @@ version = "3.0.3" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, - { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, - { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, - { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, - { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, - { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, - { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, - { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, - { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, - { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, - { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, - { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, - { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, - { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, - { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, - { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, - { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, - { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, - { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, - { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, - { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, - { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, @@ -207,34 +185,34 @@ wheels = [ [[package]] name = "nodeenv" -version = "1.9.1" +version = "1.10.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/43/16/fc88b08840de0e0a72a2f9d8c6bae36be573e475a6326ae854bcc549fc45/nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f", size = 47437, upload-time = "2024-06-04T18:44:11.171Z" } +sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d2/1d/1b658dbd2b9fa9c4c9f32accbfc0205d532c8c6194dc0f2a4c0428e7128a/nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9", size = 22314, upload-time = "2024-06-04T18:44:08.352Z" }, + { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" }, ] [[package]] name = "packaging" -version = "25.0" +version = "26.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" } +sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, + { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, ] [[package]] name = "platformdirs" -version = "4.5.0" +version = "4.9.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/61/33/9611380c2bdb1225fdef633e2a9610622310fed35ab11dac9620972ee088/platformdirs-4.5.0.tar.gz", hash = "sha256:70ddccdd7c99fc5942e9fc25636a8b34d04c24b335100223152c2803e4063312", size = 21632, upload-time = "2025-10-08T17:44:48.791Z" } +sdist = { url = "https://files.pythonhosted.org/packages/19/56/8d4c30c8a1d07013911a8fdbd8f89440ef9f08d07a1b50ab8ca8be5a20f9/platformdirs-4.9.4.tar.gz", hash = "sha256:1ec356301b7dc906d83f371c8f487070e99d3ccf9e501686456394622a01a934", size = 28737, upload-time = "2026-03-05T18:34:13.271Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/73/cb/ac7874b3e5d58441674fb70742e6c374b28b0c7cb988d37d991cde47166c/platformdirs-4.5.0-py3-none-any.whl", hash = "sha256:e578a81bb873cbb89a41fcc904c7ef523cc18284b7e3b3ccf06aca1403b7ebd3", size = 18651, upload-time = "2025-10-08T17:44:47.223Z" }, + { url = "https://files.pythonhosted.org/packages/63/d7/97f7e3a6abb67d8080dd406fd4df842c2be0efaf712d1c899c32a075027c/platformdirs-4.9.4-py3-none-any.whl", hash = "sha256:68a9a4619a666ea6439f2ff250c12a853cd1cbd5158d258bd824a7df6be2f868", size = 21216, upload-time = "2026-03-05T18:34:12.172Z" }, ] [[package]] name = "pre-commit" -version = "4.3.0" +version = "4.5.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cfgv" }, @@ -243,9 +221,9 @@ dependencies = [ { name = "pyyaml" }, { name = "virtualenv" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ff/29/7cf5bbc236333876e4b41f56e06857a87937ce4bf91e117a6991a2dbb02a/pre_commit-4.3.0.tar.gz", hash = "sha256:499fe450cc9d42e9d58e606262795ecb64dd05438943c62b66f6a8673da30b16", size = 193792, upload-time = "2025-08-09T18:56:14.651Z" } +sdist = { url = "https://files.pythonhosted.org/packages/40/f1/6d86a29246dfd2e9b6237f0b5823717f60cad94d47ddc26afa916d21f525/pre_commit-4.5.1.tar.gz", hash = "sha256:eb545fcff725875197837263e977ea257a402056661f09dae08e4b149b030a61", size = 198232, upload-time = "2025-12-16T21:14:33.552Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5b/a5/987a405322d78a73b66e39e4a90e4ef156fd7141bf71df987e50717c321b/pre_commit-4.3.0-py2.py3-none-any.whl", hash = "sha256:2b0747ad7e6e967169136edffee14c16e148a778a54e4f967921aa1ebf2308d8", size = 220965, upload-time = "2025-08-09T18:56:13.192Z" }, + { url = "https://files.pythonhosted.org/packages/5d/19/fd3ef348460c80af7bb4669ea7926651d1f95c23ff2df18b9d24bab4f3fa/pre_commit-4.5.1-py2.py3-none-any.whl", hash = "sha256:3b3afd891e97337708c1674210f8eba659b52a38ea5f822ff142d10786221f77", size = 226437, upload-time = "2025-12-16T21:14:32.409Z" }, ] [[package]] @@ -260,22 +238,25 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ce/4f/5249960887b1fbe561d9ff265496d170b55a735b76724f10ef19f9e40716/prompt_toolkit-3.0.51-py3-none-any.whl", hash = "sha256:52742911fde84e2d423e2f9a4cf1de7d7ac4e51958f648d9540e0fb8db077b07", size = 387810, upload-time = "2025-04-15T09:18:44.753Z" }, ] +[[package]] +name = "python-discovery" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "filelock" }, + { name = "platformdirs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9c/90/bcce6b46823c9bec1757c964dc37ed332579be512e17a30e9698095dcae4/python_discovery-1.2.0.tar.gz", hash = "sha256:7d33e350704818b09e3da2bd419d37e21e7c30db6e0977bb438916e06b41b5b1", size = 58055, upload-time = "2026-03-19T01:43:08.248Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/3c/2005227cb951df502412de2fa781f800663cccbef8d90ec6f1b371ac2c0d/python_discovery-1.2.0-py3-none-any.whl", hash = "sha256:1e108f1bbe2ed0ef089823d28805d5ad32be8e734b86a5f212bf89b71c266e4a", size = 31524, upload-time = "2026-03-19T01:43:07.045Z" }, +] + [[package]] name = "pyyaml" version = "6.0.3" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, - { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, - { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, - { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, - { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, - { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, - { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, - { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, - { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, - { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, @@ -329,80 +310,73 @@ dev = [ [[package]] name = "termcolor" -version = "3.1.0" +version = "3.3.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ca/6c/3d75c196ac07ac8749600b60b03f4f6094d54e132c4d94ebac6ee0e0add0/termcolor-3.1.0.tar.gz", hash = "sha256:6a6dd7fbee581909eeec6a756cff1d7f7c376063b14e4a298dc4980309e55970", size = 14324, upload-time = "2025-04-30T11:37:53.791Z" } +sdist = { url = "https://files.pythonhosted.org/packages/46/79/cf31d7a93a8fdc6aa0fbb665be84426a8c5a557d9240b6239e9e11e35fc5/termcolor-3.3.0.tar.gz", hash = "sha256:348871ca648ec6a9a983a13ab626c0acce02f515b9e1983332b17af7979521c5", size = 14434, upload-time = "2025-12-29T12:55:21.882Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/4f/bd/de8d508070629b6d84a30d01d57e4a65c69aa7f5abe7560b8fad3b50ea59/termcolor-3.1.0-py3-none-any.whl", hash = "sha256:591dd26b5c2ce03b9e43f391264626557873ce1d379019786f99b0c2bee140aa", size = 7684, upload-time = "2025-04-30T11:37:52.382Z" }, + { url = "https://files.pythonhosted.org/packages/33/d1/8bb87d21e9aeb323cc03034f5eaf2c8f69841e40e4853c2627edf8111ed3/termcolor-3.3.0-py3-none-any.whl", hash = "sha256:cf642efadaf0a8ebbbf4bc7a31cec2f9b5f21a9f726f4ccbb08192c9c26f43a5", size = 7734, upload-time = "2025-12-29T12:55:20.718Z" }, ] [[package]] name = "tomlkit" -version = "0.13.3" +version = "0.14.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/cc/18/0bbf3884e9eaa38819ebe46a7bd25dcd56b67434402b66a58c4b8e552575/tomlkit-0.13.3.tar.gz", hash = "sha256:430cf247ee57df2b94ee3fbe588e71d362a941ebb545dec29b53961d61add2a1", size = 185207, upload-time = "2025-06-05T07:13:44.947Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/af/14b24e41977adb296d6bd1fb59402cf7d60ce364f90c890bd2ec65c43b5a/tomlkit-0.14.0.tar.gz", hash = "sha256:cf00efca415dbd57575befb1f6634c4f42d2d87dbba376128adb42c121b87064", size = 187167, upload-time = "2026-01-13T01:14:53.304Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/bd/75/8539d011f6be8e29f339c42e633aae3cb73bffa95dd0f9adec09b9c58e85/tomlkit-0.13.3-py3-none-any.whl", hash = "sha256:c89c649d79ee40629a9fda55f8ace8c6a1b42deb912b2a8fd8d942ddadb606b0", size = 38901, upload-time = "2025-06-05T07:13:43.546Z" }, + { url = "https://files.pythonhosted.org/packages/b5/11/87d6d29fb5d237229d67973a6c9e06e048f01cf4994dee194ab0ea841814/tomlkit-0.14.0-py3-none-any.whl", hash = "sha256:592064ed85b40fa213469f81ac584f67a4f2992509a7c3ea2d632208623a3680", size = 39310, upload-time = "2026-01-13T01:14:51.965Z" }, ] [[package]] name = "virtualenv" -version = "20.35.3" +version = "21.2.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "distlib" }, { name = "filelock" }, { name = "platformdirs" }, + { name = "python-discovery" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a4/d5/b0ccd381d55c8f45d46f77df6ae59fbc23d19e901e2d523395598e5f4c93/virtualenv-20.35.3.tar.gz", hash = "sha256:4f1a845d131133bdff10590489610c98c168ff99dc75d6c96853801f7f67af44", size = 6002907, upload-time = "2025-10-10T21:23:33.178Z" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/92/58199fe10049f9703c2666e809c4f686c54ef0a68b0f6afccf518c0b1eb9/virtualenv-21.2.0.tar.gz", hash = "sha256:1720dc3a62ef5b443092e3f499228599045d7fea4c79199770499df8becf9098", size = 5840618, upload-time = "2026-03-09T17:24:38.013Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/27/73/d9a94da0e9d470a543c1b9d3ccbceb0f59455983088e727b8a1824ed90fb/virtualenv-20.35.3-py3-none-any.whl", hash = "sha256:63d106565078d8c8d0b206d48080f938a8b25361e19432d2c9db40d2899c810a", size = 5981061, upload-time = "2025-10-10T21:23:30.433Z" }, + { url = "https://files.pythonhosted.org/packages/c6/59/7d02447a55b2e55755011a647479041bc92a82e143f96a8195cb33bd0a1c/virtualenv-21.2.0-py3-none-any.whl", hash = "sha256:1bd755b504931164a5a496d217c014d098426cddc79363ad66ac78125f9d908f", size = 5825084, upload-time = "2026-03-09T17:24:35.378Z" }, ] [[package]] name = "wcwidth" -version = "0.2.14" +version = "0.6.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/24/30/6b0809f4510673dc723187aeaf24c7f5459922d01e2f794277a3dfb90345/wcwidth-0.2.14.tar.gz", hash = "sha256:4d478375d31bc5395a3c55c40ccdf3354688364cd61c4f6adacaa9215d0b3605", size = 102293, upload-time = "2025-09-22T16:29:53.023Z" } +sdist = { url = "https://files.pythonhosted.org/packages/35/a2/8e3becb46433538a38726c948d3399905a4c7cabd0df578ede5dc51f0ec2/wcwidth-0.6.0.tar.gz", hash = "sha256:cdc4e4262d6ef9a1a57e018384cbeb1208d8abbc64176027e2c2455c81313159", size = 159684, upload-time = "2026-02-06T19:19:40.919Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/af/b5/123f13c975e9f27ab9c0770f514345bd406d0e8d3b7a0723af9d43f710af/wcwidth-0.2.14-py2.py3-none-any.whl", hash = "sha256:a7bb560c8aee30f9957e5f9895805edd20602f2d7f720186dfd906e82b4982e1", size = 37286, upload-time = "2025-09-22T16:29:51.641Z" }, + { url = "https://files.pythonhosted.org/packages/68/5a/199c59e0a824a3db2b89c5d2dade7ab5f9624dbf6448dc291b46d5ec94d3/wcwidth-0.6.0-py3-none-any.whl", hash = "sha256:1a3a1e510b553315f8e146c54764f4fb6264ffad731b3d78088cdb1478ffbdad", size = 94189, upload-time = "2026-02-06T19:19:39.646Z" }, ] [[package]] name = "wrapt" -version = "1.17.3" +version = "2.1.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/95/8f/aeb76c5b46e273670962298c23e7ddde79916cb74db802131d49a85e4b7d/wrapt-1.17.3.tar.gz", hash = "sha256:f66eb08feaa410fe4eebd17f2a2c8e2e46d3476e9f8c783daa8e09e0faa666d0", size = 55547, upload-time = "2025-08-12T05:53:21.714Z" } +sdist = { url = "https://files.pythonhosted.org/packages/2e/64/925f213fdcbb9baeb1530449ac71a4d57fc361c053d06bf78d0c5c7cd80c/wrapt-2.1.2.tar.gz", hash = "sha256:3996a67eecc2c68fd47b4e3c564405a5777367adfd9b8abb58387b63ee83b21e", size = 81678, upload-time = "2026-03-06T02:53:25.134Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fc/f6/759ece88472157acb55fc195e5b116e06730f1b651b5b314c66291729193/wrapt-1.17.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a47681378a0439215912ef542c45a783484d4dd82bac412b71e59cf9c0e1cea0", size = 54003, upload-time = "2025-08-12T05:51:48.627Z" }, - { url = "https://files.pythonhosted.org/packages/4f/a9/49940b9dc6d47027dc850c116d79b4155f15c08547d04db0f07121499347/wrapt-1.17.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:54a30837587c6ee3cd1a4d1c2ec5d24e77984d44e2f34547e2323ddb4e22eb77", size = 39025, upload-time = "2025-08-12T05:51:37.156Z" }, - { url = "https://files.pythonhosted.org/packages/45/35/6a08de0f2c96dcdd7fe464d7420ddb9a7655a6561150e5fc4da9356aeaab/wrapt-1.17.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:16ecf15d6af39246fe33e507105d67e4b81d8f8d2c6598ff7e3ca1b8a37213f7", size = 39108, upload-time = "2025-08-12T05:51:58.425Z" }, - { url = "https://files.pythonhosted.org/packages/0c/37/6faf15cfa41bf1f3dba80cd3f5ccc6622dfccb660ab26ed79f0178c7497f/wrapt-1.17.3-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6fd1ad24dc235e4ab88cda009e19bf347aabb975e44fd5c2fb22a3f6e4141277", size = 88072, upload-time = "2025-08-12T05:52:37.53Z" }, - { url = "https://files.pythonhosted.org/packages/78/f2/efe19ada4a38e4e15b6dff39c3e3f3f73f5decf901f66e6f72fe79623a06/wrapt-1.17.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ed61b7c2d49cee3c027372df5809a59d60cf1b6c2f81ee980a091f3afed6a2d", size = 88214, upload-time = "2025-08-12T05:52:15.886Z" }, - { url = "https://files.pythonhosted.org/packages/40/90/ca86701e9de1622b16e09689fc24b76f69b06bb0150990f6f4e8b0eeb576/wrapt-1.17.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:423ed5420ad5f5529db9ce89eac09c8a2f97da18eb1c870237e84c5a5c2d60aa", size = 87105, upload-time = "2025-08-12T05:52:17.914Z" }, - { url = "https://files.pythonhosted.org/packages/fd/e0/d10bd257c9a3e15cbf5523025252cc14d77468e8ed644aafb2d6f54cb95d/wrapt-1.17.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e01375f275f010fcbf7f643b4279896d04e571889b8a5b3f848423d91bf07050", size = 87766, upload-time = "2025-08-12T05:52:39.243Z" }, - { url = "https://files.pythonhosted.org/packages/e8/cf/7d848740203c7b4b27eb55dbfede11aca974a51c3d894f6cc4b865f42f58/wrapt-1.17.3-cp313-cp313-win32.whl", hash = "sha256:53e5e39ff71b3fc484df8a522c933ea2b7cdd0d5d15ae82e5b23fde87d44cbd8", size = 36711, upload-time = "2025-08-12T05:53:10.074Z" }, - { url = "https://files.pythonhosted.org/packages/57/54/35a84d0a4d23ea675994104e667ceff49227ce473ba6a59ba2c84f250b74/wrapt-1.17.3-cp313-cp313-win_amd64.whl", hash = "sha256:1f0b2f40cf341ee8cc1a97d51ff50dddb9fcc73241b9143ec74b30fc4f44f6cb", size = 38885, upload-time = "2025-08-12T05:53:08.695Z" }, - { url = "https://files.pythonhosted.org/packages/01/77/66e54407c59d7b02a3c4e0af3783168fff8e5d61def52cda8728439d86bc/wrapt-1.17.3-cp313-cp313-win_arm64.whl", hash = "sha256:7425ac3c54430f5fc5e7b6f41d41e704db073309acfc09305816bc6a0b26bb16", size = 36896, upload-time = "2025-08-12T05:52:55.34Z" }, - { url = "https://files.pythonhosted.org/packages/02/a2/cd864b2a14f20d14f4c496fab97802001560f9f41554eef6df201cd7f76c/wrapt-1.17.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cf30f6e3c077c8e6a9a7809c94551203c8843e74ba0c960f4a98cd80d4665d39", size = 54132, upload-time = "2025-08-12T05:51:49.864Z" }, - { url = "https://files.pythonhosted.org/packages/d5/46/d011725b0c89e853dc44cceb738a307cde5d240d023d6d40a82d1b4e1182/wrapt-1.17.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e228514a06843cae89621384cfe3a80418f3c04aadf8a3b14e46a7be704e4235", size = 39091, upload-time = "2025-08-12T05:51:38.935Z" }, - { url = "https://files.pythonhosted.org/packages/2e/9e/3ad852d77c35aae7ddebdbc3b6d35ec8013af7d7dddad0ad911f3d891dae/wrapt-1.17.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:5ea5eb3c0c071862997d6f3e02af1d055f381b1d25b286b9d6644b79db77657c", size = 39172, upload-time = "2025-08-12T05:51:59.365Z" }, - { url = "https://files.pythonhosted.org/packages/c3/f7/c983d2762bcce2326c317c26a6a1e7016f7eb039c27cdf5c4e30f4160f31/wrapt-1.17.3-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:281262213373b6d5e4bb4353bc36d1ba4084e6d6b5d242863721ef2bf2c2930b", size = 87163, upload-time = "2025-08-12T05:52:40.965Z" }, - { url = "https://files.pythonhosted.org/packages/e4/0f/f673f75d489c7f22d17fe0193e84b41540d962f75fce579cf6873167c29b/wrapt-1.17.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dc4a8d2b25efb6681ecacad42fca8859f88092d8732b170de6a5dddd80a1c8fa", size = 87963, upload-time = "2025-08-12T05:52:20.326Z" }, - { url = "https://files.pythonhosted.org/packages/df/61/515ad6caca68995da2fac7a6af97faab8f78ebe3bf4f761e1b77efbc47b5/wrapt-1.17.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:373342dd05b1d07d752cecbec0c41817231f29f3a89aa8b8843f7b95992ed0c7", size = 86945, upload-time = "2025-08-12T05:52:21.581Z" }, - { url = "https://files.pythonhosted.org/packages/d3/bd/4e70162ce398462a467bc09e768bee112f1412e563620adc353de9055d33/wrapt-1.17.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d40770d7c0fd5cbed9d84b2c3f2e156431a12c9a37dc6284060fb4bec0b7ffd4", size = 86857, upload-time = "2025-08-12T05:52:43.043Z" }, - { url = "https://files.pythonhosted.org/packages/2b/b8/da8560695e9284810b8d3df8a19396a6e40e7518059584a1a394a2b35e0a/wrapt-1.17.3-cp314-cp314-win32.whl", hash = "sha256:fbd3c8319de8e1dc79d346929cd71d523622da527cca14e0c1d257e31c2b8b10", size = 37178, upload-time = "2025-08-12T05:53:12.605Z" }, - { url = "https://files.pythonhosted.org/packages/db/c8/b71eeb192c440d67a5a0449aaee2310a1a1e8eca41676046f99ed2487e9f/wrapt-1.17.3-cp314-cp314-win_amd64.whl", hash = "sha256:e1a4120ae5705f673727d3253de3ed0e016f7cd78dc463db1b31e2463e1f3cf6", size = 39310, upload-time = "2025-08-12T05:53:11.106Z" }, - { url = "https://files.pythonhosted.org/packages/45/20/2cda20fd4865fa40f86f6c46ed37a2a8356a7a2fde0773269311f2af56c7/wrapt-1.17.3-cp314-cp314-win_arm64.whl", hash = "sha256:507553480670cab08a800b9463bdb881b2edeed77dc677b0a5915e6106e91a58", size = 37266, upload-time = "2025-08-12T05:52:56.531Z" }, - { url = "https://files.pythonhosted.org/packages/77/ed/dd5cf21aec36c80443c6f900449260b80e2a65cf963668eaef3b9accce36/wrapt-1.17.3-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:ed7c635ae45cfbc1a7371f708727bf74690daedc49b4dba310590ca0bd28aa8a", size = 56544, upload-time = "2025-08-12T05:51:51.109Z" }, - { url = "https://files.pythonhosted.org/packages/8d/96/450c651cc753877ad100c7949ab4d2e2ecc4d97157e00fa8f45df682456a/wrapt-1.17.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:249f88ed15503f6492a71f01442abddd73856a0032ae860de6d75ca62eed8067", size = 40283, upload-time = "2025-08-12T05:51:39.912Z" }, - { url = "https://files.pythonhosted.org/packages/d1/86/2fcad95994d9b572db57632acb6f900695a648c3e063f2cd344b3f5c5a37/wrapt-1.17.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a03a38adec8066d5a37bea22f2ba6bbf39fcdefbe2d91419ab864c3fb515454", size = 40366, upload-time = "2025-08-12T05:52:00.693Z" }, - { url = "https://files.pythonhosted.org/packages/64/0e/f4472f2fdde2d4617975144311f8800ef73677a159be7fe61fa50997d6c0/wrapt-1.17.3-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5d4478d72eb61c36e5b446e375bbc49ed002430d17cdec3cecb36993398e1a9e", size = 108571, upload-time = "2025-08-12T05:52:44.521Z" }, - { url = "https://files.pythonhosted.org/packages/cc/01/9b85a99996b0a97c8a17484684f206cbb6ba73c1ce6890ac668bcf3838fb/wrapt-1.17.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:223db574bb38637e8230eb14b185565023ab624474df94d2af18f1cdb625216f", size = 113094, upload-time = "2025-08-12T05:52:22.618Z" }, - { url = "https://files.pythonhosted.org/packages/25/02/78926c1efddcc7b3aa0bc3d6b33a822f7d898059f7cd9ace8c8318e559ef/wrapt-1.17.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e405adefb53a435f01efa7ccdec012c016b5a1d3f35459990afc39b6be4d5056", size = 110659, upload-time = "2025-08-12T05:52:24.057Z" }, - { url = "https://files.pythonhosted.org/packages/dc/ee/c414501ad518ac3e6fe184753632fe5e5ecacdcf0effc23f31c1e4f7bfcf/wrapt-1.17.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:88547535b787a6c9ce4086917b6e1d291aa8ed914fdd3a838b3539dc95c12804", size = 106946, upload-time = "2025-08-12T05:52:45.976Z" }, - { url = "https://files.pythonhosted.org/packages/be/44/a1bd64b723d13bb151d6cc91b986146a1952385e0392a78567e12149c7b4/wrapt-1.17.3-cp314-cp314t-win32.whl", hash = "sha256:41b1d2bc74c2cac6f9074df52b2efbef2b30bdfe5f40cb78f8ca22963bc62977", size = 38717, upload-time = "2025-08-12T05:53:15.214Z" }, - { url = "https://files.pythonhosted.org/packages/79/d9/7cfd5a312760ac4dd8bf0184a6ee9e43c33e47f3dadc303032ce012b8fa3/wrapt-1.17.3-cp314-cp314t-win_amd64.whl", hash = "sha256:73d496de46cd2cdbdbcce4ae4bcdb4afb6a11234a1df9c085249d55166b95116", size = 41334, upload-time = "2025-08-12T05:53:14.178Z" }, - { url = "https://files.pythonhosted.org/packages/46/78/10ad9781128ed2f99dbc474f43283b13fea8ba58723e98844367531c18e9/wrapt-1.17.3-cp314-cp314t-win_arm64.whl", hash = "sha256:f38e60678850c42461d4202739f9bf1e3a737c7ad283638251e79cc49effb6b6", size = 38471, upload-time = "2025-08-12T05:52:57.784Z" }, - { url = "https://files.pythonhosted.org/packages/1f/f6/a933bd70f98e9cf3e08167fc5cd7aaaca49147e48411c0bd5ae701bb2194/wrapt-1.17.3-py3-none-any.whl", hash = "sha256:7171ae35d2c33d326ac19dd8facb1e82e5fd04ef8c6c0e394d7af55a55051c22", size = 23591, upload-time = "2025-08-12T05:53:20.674Z" }, + { url = "https://files.pythonhosted.org/packages/39/25/e7ea0b417db02bb796182a5316398a75792cd9a22528783d868755e1f669/wrapt-2.1.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:1370e516598854e5b4366e09ce81e08bfe94d42b0fd569b88ec46cc56d9164a9", size = 61418, upload-time = "2026-03-06T02:53:55.706Z" }, + { url = "https://files.pythonhosted.org/packages/ec/0f/fa539e2f6a770249907757eaeb9a5ff4deb41c026f8466c1c6d799088a9b/wrapt-2.1.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6de1a3851c27e0bd6a04ca993ea6f80fc53e6c742ee1601f486c08e9f9b900a9", size = 61914, upload-time = "2026-03-06T02:52:53.37Z" }, + { url = "https://files.pythonhosted.org/packages/53/37/02af1867f5b1441aaeda9c82deed061b7cd1372572ddcd717f6df90b5e93/wrapt-2.1.2-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:de9f1a2bbc5ac7f6012ec24525bdd444765a2ff64b5985ac6e0692144838542e", size = 120417, upload-time = "2026-03-06T02:54:30.74Z" }, + { url = "https://files.pythonhosted.org/packages/c3/b7/0138a6238c8ba7476c77cf786a807f871672b37f37a422970342308276e7/wrapt-2.1.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:970d57ed83fa040d8b20c52fe74a6ae7e3775ae8cff5efd6a81e06b19078484c", size = 122797, upload-time = "2026-03-06T02:54:51.539Z" }, + { url = "https://files.pythonhosted.org/packages/e1/ad/819ae558036d6a15b7ed290d5b14e209ca795dd4da9c58e50c067d5927b0/wrapt-2.1.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3969c56e4563c375861c8df14fa55146e81ac11c8db49ea6fb7f2ba58bc1ff9a", size = 117350, upload-time = "2026-03-06T02:54:37.651Z" }, + { url = "https://files.pythonhosted.org/packages/8b/2d/afc18dc57a4600a6e594f77a9ae09db54f55ba455440a54886694a84c71b/wrapt-2.1.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:57d7c0c980abdc5f1d98b11a2aa3bb159790add80258c717fa49a99921456d90", size = 121223, upload-time = "2026-03-06T02:54:35.221Z" }, + { url = "https://files.pythonhosted.org/packages/b9/5b/5ec189b22205697bc56eb3b62aed87a1e0423e9c8285d0781c7a83170d15/wrapt-2.1.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:776867878e83130c7a04237010463372e877c1c994d449ca6aaafeab6aab2586", size = 116287, upload-time = "2026-03-06T02:54:19.654Z" }, + { url = "https://files.pythonhosted.org/packages/f7/2d/f84939a7c9b5e6cdd8a8d0f6a26cabf36a0f7e468b967720e8b0cd2bdf69/wrapt-2.1.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:fab036efe5464ec3291411fabb80a7a39e2dd80bae9bcbeeca5087fdfa891e19", size = 119593, upload-time = "2026-03-06T02:54:16.697Z" }, + { url = "https://files.pythonhosted.org/packages/0b/fe/ccd22a1263159c4ac811ab9374c061bcb4a702773f6e06e38de5f81a1bdc/wrapt-2.1.2-cp314-cp314-win32.whl", hash = "sha256:e6ed62c82ddf58d001096ae84ce7f833db97ae2263bff31c9b336ba8cfe3f508", size = 58631, upload-time = "2026-03-06T02:53:06.498Z" }, + { url = "https://files.pythonhosted.org/packages/65/0a/6bd83be7bff2e7efaac7b4ac9748da9d75a34634bbbbc8ad077d527146df/wrapt-2.1.2-cp314-cp314-win_amd64.whl", hash = "sha256:467e7c76315390331c67073073d00662015bb730c566820c9ca9b54e4d67fd04", size = 60875, upload-time = "2026-03-06T02:53:50.252Z" }, + { url = "https://files.pythonhosted.org/packages/6c/c0/0b3056397fe02ff80e5a5d72d627c11eb885d1ca78e71b1a5c1e8c7d45de/wrapt-2.1.2-cp314-cp314-win_arm64.whl", hash = "sha256:da1f00a557c66225d53b095a97eace0fc5349e3bfda28fa34ffae238978ee575", size = 59164, upload-time = "2026-03-06T02:53:59.128Z" }, + { url = "https://files.pythonhosted.org/packages/71/ed/5d89c798741993b2371396eb9d4634f009ff1ad8a6c78d366fe2883ea7a6/wrapt-2.1.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:62503ffbc2d3a69891cf29beeaccdb4d5e0a126e2b6a851688d4777e01428dbb", size = 63163, upload-time = "2026-03-06T02:52:54.873Z" }, + { url = "https://files.pythonhosted.org/packages/c6/8c/05d277d182bf36b0a13d6bd393ed1dec3468a25b59d01fba2dd70fe4d6ae/wrapt-2.1.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c7e6cd120ef837d5b6f860a6ea3745f8763805c418bb2f12eeb1fa6e25f22d22", size = 63723, upload-time = "2026-03-06T02:52:56.374Z" }, + { url = "https://files.pythonhosted.org/packages/f4/27/6c51ec1eff4413c57e72d6106bb8dec6f0c7cdba6503d78f0fa98767bcc9/wrapt-2.1.2-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3769a77df8e756d65fbc050333f423c01ae012b4f6731aaf70cf2bef61b34596", size = 152652, upload-time = "2026-03-06T02:53:23.79Z" }, + { url = "https://files.pythonhosted.org/packages/db/4c/d7dd662d6963fc7335bfe29d512b02b71cdfa23eeca7ab3ac74a67505deb/wrapt-2.1.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a76d61a2e851996150ba0f80582dd92a870643fa481f3b3846f229de88caf044", size = 158807, upload-time = "2026-03-06T02:53:35.742Z" }, + { url = "https://files.pythonhosted.org/packages/b4/4d/1e5eea1a78d539d346765727422976676615814029522c76b87a95f6bcdd/wrapt-2.1.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6f97edc9842cf215312b75fe737ee7c8adda75a89979f8e11558dfff6343cc4b", size = 146061, upload-time = "2026-03-06T02:52:57.574Z" }, + { url = "https://files.pythonhosted.org/packages/89/bc/62cabea7695cd12a288023251eeefdcb8465056ddaab6227cb78a2de005b/wrapt-2.1.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:4006c351de6d5007aa33a551f600404ba44228a89e833d2fadc5caa5de8edfbf", size = 155667, upload-time = "2026-03-06T02:53:39.422Z" }, + { url = "https://files.pythonhosted.org/packages/e9/99/6f2888cd68588f24df3a76572c69c2de28287acb9e1972bf0c83ce97dbc1/wrapt-2.1.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:a9372fc3639a878c8e7d87e1556fa209091b0a66e912c611e3f833e2c4202be2", size = 144392, upload-time = "2026-03-06T02:54:22.41Z" }, + { url = "https://files.pythonhosted.org/packages/40/51/1dfc783a6c57971614c48e361a82ca3b6da9055879952587bc99fe1a7171/wrapt-2.1.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3144b027ff30cbd2fca07c0a87e67011adb717eb5f5bd8496325c17e454257a3", size = 150296, upload-time = "2026-03-06T02:54:07.848Z" }, + { url = "https://files.pythonhosted.org/packages/6c/38/cbb8b933a0201076c1f64fc42883b0023002bdc14a4964219154e6ff3350/wrapt-2.1.2-cp314-cp314t-win32.whl", hash = "sha256:3b8d15e52e195813efe5db8cec156eebe339aaf84222f4f4f051a6c01f237ed7", size = 60539, upload-time = "2026-03-06T02:54:00.594Z" }, + { url = "https://files.pythonhosted.org/packages/82/dd/e5176e4b241c9f528402cebb238a36785a628179d7d8b71091154b3e4c9e/wrapt-2.1.2-cp314-cp314t-win_amd64.whl", hash = "sha256:08ffa54146a7559f5b8df4b289b46d963a8e74ed16ba3687f99896101a3990c5", size = 63969, upload-time = "2026-03-06T02:54:39Z" }, + { url = "https://files.pythonhosted.org/packages/5c/99/79f17046cf67e4a95b9987ea129632ba8bcec0bc81f3fb3d19bdb0bd60cd/wrapt-2.1.2-cp314-cp314t-win_arm64.whl", hash = "sha256:72aaa9d0d8e4ed0e2e98019cea47a21f823c9dd4b43c7b77bba6679ffcca6a00", size = 60554, upload-time = "2026-03-06T02:53:14.132Z" }, + { url = "https://files.pythonhosted.org/packages/1a/c7/8528ac2dfa2c1e6708f647df7ae144ead13f0a31146f43c7264b4942bf12/wrapt-2.1.2-py3-none-any.whl", hash = "sha256:b8fd6fa2b2c4e7621808f8c62e8317f4aae56e59721ad933bac5239d913cf0e8", size = 43993, upload-time = "2026-03-06T02:53:12.905Z" }, ]