commit 85d261e9871c4422990dea18c2c47f6a92175221 Author: notifier <> Date: Wed Nov 11 09:56:43 2026 +0000 Initial commit diff --git a/.gitea/workflows/build-local.yaml b/.gitea/workflows/build-local.yaml new file mode 100644 index 0000000..47a0931 --- /dev/null +++ b/.gitea/workflows/build-local.yaml @@ -0,0 +1,63 @@ +name: Build & Publish to GHCR via local build + +on: + workflow_dispatch: + +env: + GHCR_ORG: gansejunge + IMAGE_NAME: app-notifications-service-GENERIC + IMAGE_PATH: ghcr.io/gansejunge/app-notifications-service-GENERIC + OPS_ORG: notifier + OPS_REPO: ops-deployment + OPS_BRANCH_BASE: main + OPS_PATH: service-GENERIC/deployment.yaml + +jobs: + build: + runs-on: local + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Log in to GHCR + run: echo "${{ secrets.GHCR_TOKEN }}" | docker login ghcr.io -u ${{ secrets.GHCR_USERNAME }} --password-stdin + + - name: Get Latest Tag + id: get_tag + run: | + BASE64_TOKEN=$(echo "${{ secrets.GHCR_TOKEN }}" | base64) + LATEST_TAG=$(curl -s -H "Authorization: Bearer $BASE64_TOKEN" \ + https://ghcr.io/v2/${{ env.GHCR_ORG }}/${{ env.IMAGE_NAME }}/tags/list \ + | jq -r '.tags | map(select(test("^[0-9]+$"))) | map(tonumber) | max // 0') + NEXT_TAG=$((LATEST_TAG + 1)) + echo "latest=$LATEST_TAG" >> $GITHUB_OUTPUT + echo "next=$NEXT_TAG" >> $GITHUB_OUTPUT + + - name: Build and Push Docker Image + run: | + docker build \ + --build-arg COMMIT_ID=$(echo "${{ github.sha }}" | cut -c1-7) \ + -t ghcr.io/gansejunge/${{ env.IMAGE_NAME }}:${{ steps.get_tag.outputs.next }} . + docker push ghcr.io/gansejunge/${{ env.IMAGE_NAME }}:${{ steps.get_tag.outputs.next }} + + - name: Clone ops-deployment repo + uses: actions/checkout@v4 + with: + repository: notifier/ops-deployment + path: ./ops-deployment + token: ${{ secrets.BOT_TOKEN }} + + - name: Update GENERIC deployment + working-directory: ./ops-deployment + run: | + NEW_TAG=${{ steps.get_tag.outputs.next }} + NEW_IMAGE="${{ env.IMAGE_PATH }}:$NEW_TAG" + git config user.name "automation-bot" + git config user.email "dev@gansejunge.com" + + sed -i "s|ghcr.io/$GHCR_ORG/$IMAGE_NAME:[0-9]\+|$NEW_IMAGE|g" "$OPS_PATH" + + COMMIT_URL="https://git.gansejunge.com/${GITHUB_REPOSITORY}/commit/${GITHUB_SHA}" + git add $OPS_PATH + git commit -m "Update GENERIC image to version $NEW_TAG" -m "Linked build commit: $COMMIT_URL" + git push origin $OPS_BRANCH_BASE diff --git a/.gitea/workflows/build.yaml b/.gitea/workflows/build.yaml new file mode 100644 index 0000000..81cc554 --- /dev/null +++ b/.gitea/workflows/build.yaml @@ -0,0 +1,63 @@ +name: Build & Publish to GHCR + +on: + workflow_dispatch: + +env: + GHCR_ORG: gansejunge + IMAGE_NAME: app-notifications-service-GENERIC + IMAGE_PATH: ghcr.io/gansejunge/app-notifications-service-GENERIC + OPS_ORG: notifier + OPS_REPO: ops-deployment + OPS_BRANCH_BASE: main + OPS_PATH: service-GENERIC/deployment.yaml + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Log in to GHCR + run: echo "${{ secrets.GHCR_TOKEN }}" | docker login ghcr.io -u ${{ secrets.GHCR_USERNAME }} --password-stdin + + - name: Get Latest Tag + id: get_tag + run: | + BASE64_TOKEN=$(echo "${{ secrets.GHCR_TOKEN }}" | base64) + LATEST_TAG=$(curl -s -H "Authorization: Bearer $BASE64_TOKEN" \ + https://ghcr.io/v2/${{ env.GHCR_ORG }}/${{ env.IMAGE_NAME }}/tags/list \ + | jq -r '.tags | map(select(test("^[0-9]+$"))) | map(tonumber) | max // 0') + NEXT_TAG=$((LATEST_TAG + 1)) + echo "latest=$LATEST_TAG" >> $GITHUB_OUTPUT + echo "next=$NEXT_TAG" >> $GITHUB_OUTPUT + + - name: Build and Push Docker Image + run: | + docker build \ + --build-arg COMMIT_ID=$(echo "${{ github.sha }}" | cut -c1-7) \ + -t ghcr.io/gansejunge/${{ env.IMAGE_NAME }}:${{ steps.get_tag.outputs.next }} . + docker push ghcr.io/gansejunge/${{ env.IMAGE_NAME }}:${{ steps.get_tag.outputs.next }} + + - name: Clone ops-deployment repo + uses: actions/checkout@v4 + with: + repository: notifier/ops-deployment + path: ./ops-deployment + token: ${{ secrets.BOT_TOKEN }} + + - name: Update GENERIC deployment + working-directory: ./ops-deployment + run: | + NEW_TAG=${{ steps.get_tag.outputs.next }} + NEW_IMAGE="${{ env.IMAGE_PATH }}:$NEW_TAG" + git config user.name "automation-bot" + git config user.email "dev@gansejunge.com" + + sed -i "s|ghcr.io/$GHCR_ORG/$IMAGE_NAME:[0-9]\+|$NEW_IMAGE|g" "$OPS_PATH" + + COMMIT_URL="https://git.gansejunge.com/${GITHUB_REPOSITORY}/commit/${GITHUB_SHA}" + git add $OPS_PATH + git commit -m "Update GENERIC image to version $NEW_TAG" -m "Linked build commit: $COMMIT_URL" + git push origin $OPS_BRANCH_BASE diff --git a/.gitea/workflows/pip-audit.yaml b/.gitea/workflows/pip-audit.yaml new file mode 100644 index 0000000..b131169 --- /dev/null +++ b/.gitea/workflows/pip-audit.yaml @@ -0,0 +1,78 @@ +name: pip-audit +on: + schedule: + - cron: "35 1 * * *" + push: + branches: [main] + paths: [requirements.txt, scripts/filter_requirements.py] + workflow_dispatch: + +jobs: + audit: + runs-on: ubuntu-latest + outputs: + report_b64: ${{ steps.audit.outputs.report_b64 }} + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Audit dependencies + id: audit + shell: bash + run: | + pip install pip-audit + python3 scripts/filter_requirements.py requirements.txt requirements.audit.txt + + # Don't let a non-zero exit abort the step, so the report is saved first + set +e + pip-audit -r requirements.audit.txt --no-deps --disable-pip > audit.txt 2>&1 + echo $? > audit.exit + set -e + + cat audit.txt + echo "report_b64=$(head -c 100000 audit.txt | base64 -w0)" >> "$GITHUB_OUTPUT" + + - name: Fail if the audit failed + shell: bash + run: exit "$(cat audit.exit)" + + notify: + needs: audit + if: ${{ always() && needs.audit.result == 'failure' }} + runs-on: host + steps: + - name: Send mail via local postfix + env: + MAIL_FROM: ${{ vars.MAIL_FROM }} + MAIL_TO: ${{ vars.MAIL_TO }} + REPO: ${{ gitea.repository }} + RUN_URL: ${{ gitea.server_url }}/${{ gitea.repository }}/actions/runs/${{ gitea.run_number }} + REPORT_B64: ${{ needs.audit.outputs.report_b64 }} + run: | + python3 - <<'EOF' + import base64, os, smtplib + from email.message import EmailMessage + + b64 = os.environ.get("REPORT_B64", "") + report = ( + base64.b64decode(b64).decode("utf-8", errors="replace") + if b64 else + "(no audit output, the job failed before pip-audit finished)" + ) + + msg = EmailMessage() + msg["Subject"] = f"[pip-audit] Problem in {os.environ['REPO']}" + msg["From"] = os.environ["MAIL_FROM"] + msg["To"] = os.environ["MAIL_TO"] + msg.set_content( + f"pip-audit result for {os.environ['REPO']}:\n\n" + f"{report}\n\n" + f"Run: {os.environ['RUN_URL']}\n" + ) + + with smtplib.SMTP("127.0.0.1", 25, timeout=30) as s: + s.send_message(msg) + EOF diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..36b13f1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,176 @@ +# ---> Python +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# 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 + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# UV +# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +#uv.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/latest/usage/project/#working-with-version-control +.pdm.toml +.pdm-python +.pdm-build/ + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ + +# Ruff stuff: +.ruff_cache/ + +# PyPI configuration file +.pypirc + diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..4b69624 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,19 @@ +# Builder stage +FROM python:3.12-slim AS builder +WORKDIR /install +COPY requirements.txt . +RUN pip install --prefix=/install --no-cache-dir -r requirements.txt +WORKDIR /app +COPY src/ /app/ + +# Runtime image +FROM nvcr.io/nvidia/distroless/python:3.12-v4.2.0 +COPY --from=builder /install /usr/local +COPY --from=builder /app /app +WORKDIR /app + +ARG COMMIT_ID=unknown +ENV COMMIT_ID=$COMMIT_ID + +ENTRYPOINT ["python", "main.py"] + diff --git a/README.md b/README.md new file mode 100644 index 0000000..164f6e0 --- /dev/null +++ b/README.md @@ -0,0 +1,2 @@ +# service-musicbrainz + diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..112ee1a --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,29 @@ +[tool.ruff] +line-length = 120 +target-version = "py312" +[tool.ruff.lint] +select = [ + "E", "F", # pycodestyle errors, pyflakes (unused imports/variables, undefined names) + "I", # import sorting (replaces isort) + "B", # bugbear: likely bugs + "ASYNC", # async mistakes + "RUF006", # un-referenced asyncio.create_task (tasks can be garbage-collected) +] +ignore = ["E501"] + +[tool.ruff.lint.isort] +known-first-party = ["db_module", "secret_manager", "service_auth", "shared_models", + "simple_logger_handler", "send_notifications", "send_notifications_webhook",] + + +[tool.ruff.format] +indent-style = "tab" + +[tool.ruff.lint.flake8-bugbear] +extend-immutable-calls = [ + "fastapi.Depends", + "fastapi.Query", + "fastapi.Header", + "fastapi.Body", + "fastapi.Security", +] \ No newline at end of file diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..af3ee57 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1 @@ +ruff diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..4c8023e --- /dev/null +++ b/requirements.txt @@ -0,0 +1,11 @@ +--extra-index-url https://git.gansejunge.com/api/packages/notifier/pypi/simple/ +fastapi +lib-db-module +lib-secret-manager +lib-send-notifications +lib-service-auth +lib-shared-models +prometheus_client +pydantic +simple-logger-handler +uvicorn \ No newline at end of file diff --git a/scripts/filter_requirements.py b/scripts/filter_requirements.py new file mode 100644 index 0000000..480db65 --- /dev/null +++ b/scripts/filter_requirements.py @@ -0,0 +1,39 @@ +#!/usr/bin/env python3 +"""Write a copy of requirements.txt without the self-hosted packages. + +Usage: filter_requirements.py +""" +import re +import sys + +SKIP = { + "lib-db-module", + "lib-secret-manager", + "lib-send-notifications", + "send_notifications_webhook", + "lib-service-auth", + "lib-shared-models", + "simple-logger-handler", +} + +NAME_RE = re.compile(r"^\s*([A-Za-z0-9][A-Za-z0-9._-]*)") + + +def main(src: str, dst: str) -> None: + skipped = [] + + with open(src) as fin, open(dst, "w") as fout: + for line in fin: + match = NAME_RE.match(line) + if match and match.group(1) in SKIP: + skipped.append(match.group(1)) + continue + fout.write(line) + + print(f"Skipped {len(skipped)} self-hosted package(s): {', '.join(skipped) or '-'}") + + +if __name__ == "__main__": + if len(sys.argv) != 3: + sys.exit(__doc__) + main(sys.argv[1], sys.argv[2]) \ No newline at end of file diff --git a/src/auth_config.py b/src/auth_config.py new file mode 100644 index 0000000..43c3784 --- /dev/null +++ b/src/auth_config.py @@ -0,0 +1,13 @@ +from enum import StrEnum + +from service_auth import KeySource, ServiceAuth + + +class Caller(StrEnum): + GATEWAY = "gateway" + SCHEDULER = "scheduler" + +auth = ServiceAuth({ + Caller.GATEWAY: KeySource("GATEWAY_KEY", "/etc/secrets/gateway_key"), + Caller.SCHEDULER: KeySource("SCHEDULER_KEY", "/etc/secrets/scheduler_key"), +}) \ No newline at end of file diff --git a/src/handlers.py b/src/handlers.py new file mode 100644 index 0000000..5ec7828 --- /dev/null +++ b/src/handlers.py @@ -0,0 +1,47 @@ +from fastapi import FastAPI, Request +from fastapi.responses import JSONResponse +from starlette.exceptions import HTTPException as StarletteHTTPException +from starlette.middleware.base import BaseHTTPMiddleware + +from metrics_server import REQUEST_COUNTER +from simple_logger_handler import setup_logger + +logger = setup_logger(__name__) + +async def custom_http_exception_handler(request,exc): + logger.debug(f"Handling HTTP exception: {exc}") + if exc.status_code == 404: + return JSONResponse( + status_code=403, + content={"detail": "Unauthorized"} + ) + return JSONResponse( + status_code=exc.status_code, + content={"detail": exc.detail} + ) + +async def prometheus_middleware(request: Request, call_next): + logger.debug( + "Incoming request: %s %s", + request.method, request.url.path, + ) + status = 500 + try: + response = await call_next(request) + status = response.status_code + logger.debug("Request processed with status: %s", status) + except Exception as e: + logger.exception("[FastAPI] Exception during request processing: %s", e) + raise + finally: + REQUEST_COUNTER.labels(request.method, request.url.path, status).inc() + logger.debug( + "[FastAPI] Prometheus counter incremented: %s %s %s", + request.method, request.url.path, status, + ) + return response + +def register_handlers(app: FastAPI) -> None: + """Attach the error handlers and the metrics middleware to the app.""" + app.add_exception_handler(StarletteHTTPException, custom_http_exception_handler) + app.add_middleware(BaseHTTPMiddleware, dispatch=prometheus_middleware) diff --git a/src/main.py b/src/main.py new file mode 100644 index 0000000..60ad736 --- /dev/null +++ b/src/main.py @@ -0,0 +1,78 @@ +import asyncio +import os +from contextlib import asynccontextmanager + +import uvicorn +from fastapi import Depends, FastAPI + +from auth_config import Caller, auth +from db_module import ( + close_connection_pool, + create_connection_pool, + start_healthcheck_thread, +) +from handlers import register_handlers +from metrics_server import metrics_api +from routers.scheduler import scheduler_router +from routers.user import user_router +from secret_manager import cleanup_secret_files +from send_notifications import NotificationClient +from simple_logger_handler import LOG_LEVEL, setup_logger + +logger = setup_logger(__name__) + +@asynccontextmanager +async def lifespan(app: FastAPI): + logger.info("[App] Starting application...") + app.state.notifier = NotificationClient() + logger.info("[DB] Creating MySQL connection pool...") + create_connection_pool("seadexPool") + + start_healthcheck_thread() + logger.info("[DB] MySQL healthcheck thread started.") + + SECRET_PATHS = frozenset({ + "/etc/secrets/api_key", + "/etc/secrets/db_username", + "/etc/secrets/db_password" + }) + cleanup_secret_files(SECRET_PATHS) + + yield + logger.info("[App] Closing MySQL connection pool...") + close_connection_pool() + logger.info("[App] Shutdown complete.") + + + +api = FastAPI( + title="Service GENERIC", + description="GENERICGENERICGENERICGENERICGENERIC", + version="1.0.0", + lifespan=lifespan, + dependencies=[Depends(auth.authenticate)], +) + +register_handlers(api) +api.include_router(user_router) +api.include_router(scheduler_router) + + +async def start_servers(): + logger.info("[Server] Starting main API (port 5000) and metrics server (port 9000)...") + logger.info("[Server] Build commit id=%s", os.getenv("COMMIT_ID", "unknown")) + + auth.load_keys(api, Caller.GATEWAY, Caller.SCHEDULER, Caller.ADMIN) + cleanup_secret_files(auth.key_paths(Caller.GATEWAY, Caller.SCHEDULER, Caller.ADMIN)) + + config_main = uvicorn.Config(api, host="0.0.0.0", port=5000, log_level=LOG_LEVEL.lower(),log_config=None) + config_metrics = uvicorn.Config(metrics_api, host="0.0.0.0", port=9000, log_level=LOG_LEVEL.lower(),log_config=None) + + server_main = uvicorn.Server(config_main) + server_metrics = uvicorn.Server(config_metrics) + + await asyncio.gather(server_main.serve(), server_metrics.serve()) + logger.info("[Server] Both servers have stopped.") + +if __name__ == "__main__": + asyncio.run(start_servers()) \ No newline at end of file diff --git a/src/metrics_server.py b/src/metrics_server.py new file mode 100644 index 0000000..15e7fd1 --- /dev/null +++ b/src/metrics_server.py @@ -0,0 +1,10 @@ +from fastapi import FastAPI, Response +from prometheus_client import CONTENT_TYPE_LATEST, Counter, generate_latest + +metrics_api = FastAPI(title="Metrics Server", description="Prometheus metrics endpoint") + +REQUEST_COUNTER = Counter("http_requests_total", "Total HTTP Requests", ["method", "endpoint", "status"]) + +@metrics_api.get("/metrics") +async def metrics(): + return Response(generate_latest(), media_type=CONTENT_TYPE_LATEST) diff --git a/src/models.py b/src/models.py new file mode 100644 index 0000000..97b72e9 --- /dev/null +++ b/src/models.py @@ -0,0 +1,22 @@ +from datetime import datetime + +from pydantic import BaseModel, Field, computed_field + + +class GenericEntry(BaseModel): + name: str + custom_name: str | None = None + published: datetime | None = None + +class Counts(BaseModel): + active: int + inactive: int + +class GenericList(BaseModel): + active: list[GenericEntry] = Field(default_factory=list) + inactive: list[GenericEntry] = Field(default_factory=list) + + @computed_field + @property + def counts(self) -> Counts: + return Counts(active=len(self.active),inactive=len(self.inactive)) \ No newline at end of file diff --git a/src/routers/__init__.py b/src/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/routers/scheduler.py b/src/routers/scheduler.py new file mode 100644 index 0000000..15fb95a --- /dev/null +++ b/src/routers/scheduler.py @@ -0,0 +1,16 @@ + +from fastapi import APIRouter, Depends, Request + +from auth_config import Caller, auth +from db_module import ( + get_db, +) +from simple_logger_handler import setup_logger + +logger = setup_logger(__name__) + +scheduler_router = APIRouter(dependencies=[Depends(auth.require(Caller.SCHEDULER))]) + +@scheduler_router.post("/update") +def check_for_updates(request:Request, db=Depends(get_db)): + return \ No newline at end of file diff --git a/src/routers/user.py b/src/routers/user.py new file mode 100644 index 0000000..74a833a --- /dev/null +++ b/src/routers/user.py @@ -0,0 +1,42 @@ + +from fastapi import APIRouter, Depends + +from auth_config import Caller, auth +from db_module import ( + get_db, +) +from models import GenericList +from service_auth import UserId +from simple_logger_handler import setup_logger + +logger = setup_logger(__name__) + +user_router = APIRouter(dependencies=[Depends(auth.require(Caller.GATEWAY))]) + +@user_router.post("/entry/add") +def add_entry( + user_id: UserId, + db=Depends(get_db), + ): + return + +@user_router.post("/entry/delete") +def remove_anime( + user_id: UserId, + db=Depends(get_db) +): + return + +@user_router.get("/entry/list", response_model=GenericList) +def list_active_and_inactive_anime( + user_id: UserId, + db=Depends(get_db), + ): + return + +@user_router.post("/entry/rename") +def rename_entry( + user_id: UserId, + db=Depends(get_db), + ): + return \ No newline at end of file