diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..75d938f --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +_pycache_/ +*.pyc +.env +.venv/ +pgdata/ +.mypy_cache/ +.pytest_cache/ \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..bbbf3d6 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,13 @@ +# Define como se construye la imagen de la aplicación +FROM python:3.11-slim + +WORKDIR /app +# Copia el archivo de requerimientos y lo instala +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Copia el resto de los archivos de la aplicación +COPY . . + +# Expone el puerto 8000 +EXPOSE 8000 \ No newline at end of file diff --git a/README.md b/README.md index dc0925c..60f9064 100644 --- a/README.md +++ b/README.md @@ -1,112 +1,72 @@ -# Backend Project Plan — Jesus (Beginner Track) +# Setup — Reviewass -**Project:** "TicketBox" — Event Ticketing & Checkout API ---- +Reproducir el entorno con Docker (Python 3.11 en la imagen; no uses el Python del host). -## 1. Objective +## Requisitos -Build a REST API for a small event-ticketing platform where users can browse events, reserve seats, and "pay" for tickets through a fake payment gateway. The goal is to demonstrate solid fundamentals: clean project structure, correct HTTP semantics, relational data modeling, basic caching, and testable business logic. +- Docker + Docker Compose +- Una API key de TMDB (gratuita): https://www.themoviedb.org/settings/api ---- +## Config -## 2. Guard Rails (Non-Negotiable Constraints) +Crea `.env` en la raíz del repo: -1. **Pick exactly ONE framework** and stick with it for the entire project: - - Python → FastAPI (or Django REST Framework) - - Node.js → NestJS - - Go → Gin -2. **Database:** PostgreSQL only. No SQLite, no MongoDB, no "temporary" alternatives. -3. **Cache:** Redis only, and it must be used for at least the use cases listed in §6. -5. All infrastructure must run locally via **docker-compose** (app + Postgres + Redis). -6. **Migrations are mandatory** (Alembic / TypeORM migrations / golang-migrate). No `CREATE TABLE` by hand in psql. +```env +DATABASE_URL=postgresql://reviewass:reviewass@db:5432/reviewass +REDIS_URL=redis://redis:6379/0 +SECRET_KEY=IMEKTKQ94CqBWqWgOue00JRn +ALGORITHM=HS256 +ACCESS_TOKEN_EXPIRE_MINUTES=30 +TMDB_API_KEY=tu-api-key-de-tmdb +TMDB_BASE_URL=https://api.themoviedb.org/3 +``` ---- +## Arranque -## 3. Functional Requirements +Primera vez (o si cambias `Dockerfile` / `requirements.txt`): -### FR-1: Users -- Register (email + password, hashed with bcrypt/argon2). -- Login returning a JWT (access token only is fine at this level). -- Get own profile (`GET /me`) — authenticated. +```bash +docker compose up --build +``` -### FR-2: Events -- CRUD for events (create/update/delete restricted to an `admin` role; seed one admin user). -- Event fields: name, description, venue, starts_at, total_capacity, price_cents, currency, status (`draft`, `published`, `cancelled`). -- Public listing endpoint with pagination (`GET /events?page=&limit=`) and filtering by date range and status. +Día a día (la imagen ya existe): -### FR-3: Ticket Reservation -- Authenticated user reserves N tickets for an event (`POST /events/{id}/reservations`). -- A reservation holds tickets for **10 minutes**. If not paid within that window, the tickets return to the available pool. -- The system must never oversell: capacity checks must be race-safe (use a DB transaction with row locking — `SELECT ... FOR UPDATE` — and be prepared to explain why). +```bash +docker compose up +# o en segundo plano: +docker compose up -d +``` -### FR-4: Fake Payment Gateway -You will build a **separate mock payment module/service** inside the same codebase (a `payments` module with its own routes, simulating an external provider): -- `POST /mock-gateway/charges` accepts `{ amount_cents, currency, card_number, reservation_ref }`. -- Behavior rules (deterministic, so it can be tested): - - Card ending in `0000` → always **declined**. - - Card ending in `9999` → responds after a 5-second delay (simulates timeout handling). - - Any other card → **approved**, returns a fake `charge_id`. -- The main API consumes this gateway over HTTP (yes, HTTP call to itself/localhost — the point is to practice integrating an external provider: timeouts, error mapping, retries **are not** required at this level, but a timeout on the HTTP client is). +- API: http://localhost:8000 +- Health: http://localhost:8000/health +- Docs: http://localhost:8000/docs -### FR-5: Checkout -- `POST /reservations/{id}/pay` with card details → calls the mock gateway → on success, reservation becomes a confirmed `order` with generated ticket codes (UUIDs). -- On decline, reservation stays active until it expires. -- `GET /orders` and `GET /orders/{id}` for the authenticated user. +Servicios: app (`8000`), Postgres (`5432`), Redis (`6379`). ---- +## Estado -## 4. Non-Functional Requirements +```bash +docker compose ps +curl http://localhost:8000/health +``` -- **NFR-1:** All list endpoints paginated; default limit 20, max 100. -- **NFR-2:** Consistent JSON error format: `{ "error": { "code": "...", "message": "..." } }`. -- **NFR-3:** Correct HTTP status codes (201 on create, 409 on capacity conflict, 402 on payment declined, etc.). -- **NFR-4:** Minimum 10 automated tests, covering at least: registration/login, overselling prevention, payment decline path, reservation expiry. -- **NFR-5:** A `README.md` with setup instructions that work from a clean machine (`docker-compose up` + one migration command). -- **NFR-6:** Structured logging (JSON logs or at least consistent log lines) for every payment attempt. +## Migraciones ---- +Con los contenedores arriba, una vez que definas tus propios modelos en `app/models/`: -## 5. Data Model (Minimum Entities) +```bash +docker compose exec app alembic -c app/alembic.ini revision --autogenerate -m "mensaje" +docker compose exec app alembic -c app/alembic.ini upgrade head +``` -You must deliver an **ERD diagram** and, if using classes, a **class diagram** (Mermaid or draw.io, committed to the repo under `/docs`). +## Parar -Required entities (you may add fields, not remove): +```bash +docker compose down +``` -- `users` (id, email unique, password_hash, role, created_at) -- `events` (id, name, description, venue, starts_at, total_capacity, price_cents, currency, status, created_at) -- `reservations` (id, user_id, event_id, quantity, status [`pending`, `expired`, `paid`], expires_at, created_at) -- `orders` (id, reservation_id, user_id, amount_cents, currency, gateway_charge_id, created_at) -- `tickets` (id, order_id, event_id, code UUID, created_at) +Conserva los datos de Postgres. Para borrarlos también: `docker compose down -v`. -Referential integrity enforced with real foreign keys. Explain your indexing choices for at least 2 indexes beyond primary keys. - ---- - -## 6. Redis — Required Uses - -1. **Event listing cache:** cache the published-events list for 60 seconds; invalidate on event create/update/delete. Be ready to explain cache invalidation choice. -2. **Reservation expiry:** either (a) a Redis key with TTL + a background sweep, or (b) a scheduled job checking `expires_at`. Justify your choice in the README. -3. **Login rate limiting:** max 5 failed logins per email per 15 minutes, tracked in Redis. - ---- - -## 7. Deliverables Checklist - -- [ ] Git repository with meaningful commit history (no single "final commit"). -- [ ] `docker-compose.yml` (app, Postgres, Redis). -- [ ] Migrations folder. -- [ ] `/docs/erd.md` (ERD diagram) and `/docs/architecture.md` (module layout + request flow for the checkout: reservation → gateway → order, as a sequence diagram). -- [ ] OpenAPI/Swagger available at `/docs` endpoint (FastAPI/NestJS give this nearly free; Gin: use swaggo). -- [ ] Test suite runnable with a single command. -- [ ] Postman/Insomnia collection or `.http` file to exercise the full happy path. -- [ ] README: setup, design decisions, known limitations. - -### Rubric (100 pts) -- Correctness of requirements — 30 -- Data modeling & migrations — 15 -- Concurrency safety (no overselling) — 15 -- Code organization & readability — 15 -- Tests — 10 -- Redis usage & justification — 10 -- Docs & diagrams — 5 +## Qué construir +Ver `/docs/README.md` para el plan completo (usuarios/auth, búsqueda de películas vía TMDB, reviews y ratings). diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/alembic.ini b/app/alembic.ini new file mode 100644 index 0000000..ae4c240 --- /dev/null +++ b/app/alembic.ini @@ -0,0 +1,116 @@ +# A generic, single database configuration. + +[alembic] +# path to migration scripts +# Use forward slashes (/) also on windows to provide an os agnostic path +script_location = app/migrations + +# 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 migrations/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:migrations/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 = postgresql://ticketbox:ticketbox@db:5432/ticketbox + + +[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 + +# lint with attempts to fix using "ruff" - use the exec runner, execute a binary +# hooks = ruff +# ruff.type = exec +# ruff.executable = %(here)s/.venv/bin/ruff +# ruff.options = --fix 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/app/config.py b/app/config.py new file mode 100644 index 0000000..c972cd9 --- /dev/null +++ b/app/config.py @@ -0,0 +1,17 @@ +from pydantic_settings import BaseSettings + +# Configuración de la aplicación +class Settings(BaseSettings): + DATABASE_URL: str + REDIS_URL: str + SECRET_KEY: str + ALGORITHM: str = "HS256" + ACCESS_TOKEN_EXPIRE_MINUTES: int = 30 + TMDB_API_KEY: str + TMDB_BASE_URL: str = "https://api.themoviedb.org/3" + + class Config: + env_file = ".env" + +# Instancia de la configuración +settings = Settings() \ No newline at end of file diff --git a/app/database.py b/app/database.py new file mode 100644 index 0000000..1e50b5d --- /dev/null +++ b/app/database.py @@ -0,0 +1,29 @@ +# Este import permite crear un motor de base de datos para interactuar con la base de datos +from sqlalchemy import create_engine + +# Este import permite crear una sesión de base de datos para interactuar con la base de datos +from sqlalchemy.orm import sessionmaker, DeclarativeBase + +# Este import permite obtener la configuración de la base de datos +from app.config import settings + +# Este import permite crear un motor de base de datos para interactuar con la base de datos +engine = create_engine(settings.DATABASE_URL) + +# Este import permite crear una sesión de base de datos para interactuar con la base de datos +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + +# Esta clase permite crear una base de datos para interactuar con la base de datos +class Base(DeclarativeBase): + pass + +# Esta función permite obtener una sesión de base de datos para interactuar con la base de datos +def get_db(): + db = SessionLocal() + try: + # Yield entrega algo temporal abre sesion y se usa + yield db + finally: + db.close() + + diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..e2fb82e --- /dev/null +++ b/app/main.py @@ -0,0 +1,11 @@ +from fastapi import FastAPI + +app = FastAPI(title="Reviewass API", version="0.1.0") + +@app.get("/health") +def health_check(): + return {"status": "ok"} + +@app.get("/") +def read_root(): + return health_check() diff --git a/app/migrations/README b/app/migrations/README new file mode 100644 index 0000000..98e4f9c --- /dev/null +++ b/app/migrations/README @@ -0,0 +1 @@ +Generic single-database configuration. \ No newline at end of file diff --git a/app/migrations/env.py b/app/migrations/env.py new file mode 100644 index 0000000..4024bc7 --- /dev/null +++ b/app/migrations/env.py @@ -0,0 +1,85 @@ +# Este archivo permite ejecutar las migraciones de la base de datos +from logging.config import fileConfig + +from sqlalchemy import engine_from_config +from sqlalchemy import pool + +from alembic import context + +from app.config import settings +from app.database import Base +# importa todos los modelos aquí (agrega tus propios modelos a medida que los crees) +import app.models # noqa: F401 + + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config + +# URL desde .env (via config.py), no desde alembic.ini +config.set_main_option("sqlalchemy.url", settings.DATABASE_URL) + +# 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) + +# Metadata de tus modelos SQLAlchemy (autogenerate) +target_metadata = Base.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") +# ... etc. + + +def run_migrations_offline() -> None: + """Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + + """ + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + """Run migrations in 'online' mode. + + In this scenario we need to create an Engine + and associate a connection with the context. + + """ + connectable = engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + + with connectable.connect() as connection: + context.configure( + connection=connection, target_metadata=target_metadata + ) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/app/migrations/script.py.mako b/app/migrations/script.py.mako new file mode 100644 index 0000000..fbc4b07 --- /dev/null +++ b/app/migrations/script.py.mako @@ -0,0 +1,26 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision: str = ${repr(up_revision)} +down_revision: Union[str, None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} diff --git a/app/models/__init__.py b/app/models/__init__.py new file mode 100644 index 0000000..8b85071 --- /dev/null +++ b/app/models/__init__.py @@ -0,0 +1,3 @@ +# TODO: import and re-export your models here, e.g.: +# from app.models.user import User +# from app.models.review import Review diff --git a/app/routers/__init__.py b/app/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/schemas/__init__.py b/app/schemas/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/services/__init__.py b/app/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..a55fcc3 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,55 @@ +services: + app: + # Construir la imagen de la aplicación + build: . + # El contenedor de la aplicación debe estar expuesto en el puerto 8000 + ports: + - "8000:8000" + env_file: + - .env + depends_on: + # El contenedor de la base de datos debe estar saludable para que el contenedor de la aplicación pueda acceder a ella + db: + condition: service_healthy + # El contenedor de Redis debe estar iniciado para que el contenedor de la aplicación pueda acceder a él + redis: + condition: service_started + volumes: + # Volumen para que el contenedor pueda acceder a los archivos de la aplicación + - .:/app + # Comando para iniciar la aplicación + command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload + + db: + # Alpine es una imagen muy ligera de PostgreSQL + image: postgres:16-alpine + # Variables de entorno para la base de datos + environment: + POSTGRES_USER: ticketbox + POSTGRES_PASSWORD: ticketbox + POSTGRES_DB: ticketbox + # El contenedor de la base de datos debe estar expuesto en el puerto 5432 que es el puerto por defecto de PostgreSQL + ports: + - "5432:5432" + volumes: + - pgdata:/var/lib/postgresql/data + healthcheck: + # Comando para verificar si la base de datos está saludable + test: ["CMD-SHELL", "pg_isready -U ticketbox"] + # Intervalo para verificar si la base de datos está saludable + interval: 5s + # Tiempo de timeout para verificar si la base de datos está saludable + timeout: 5s + # Número de intentos para verificar si la base de datos está saludable + retries: 5 + + redis: + # Alpine es una imagen muy ligera de Redis + image: redis:7-alpine + ports: + # El contenedor de Redis debe estar expuesto en el puerto 6379 que es el puerto por defecto de Redis + - "6379:6379" + +volumes: + # Volumen para que el contenedor pueda acceder a los datos de la base de datos + pgdata: diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..efbf397 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,86 @@ +# Backend Project Plan — Jesus (Beginner Track) + +**Project:** "Reviewass" — Movie Review & Rating API +--- + +## 1. Objective + +Build a REST API where users can search for movies (via the TMDB API), write reviews, and rate movies with stars. The goal is to demonstrate solid backend fundamentals: authentication, authorization, clean project structure, correct HTTP semantics, relational data modeling, basic caching, and testable business logic. + +--- + +## 2. Guard Rails (Non-Negotiable Constraints) + +1. **FastAPI**, Python. Stick with it for the entire project. +2. **Database:** PostgreSQL only. No SQLite, no MongoDB, no "temporary" alternatives. +3. **Cache:** Redis only, and it must be used for at least the use cases listed in §6. +4. All infrastructure must run locally via **docker-compose** (app + Postgres + Redis) — already scaffolded for you. +5. **Migrations are mandatory** (Alembic). No `CREATE TABLE` by hand in psql. + + +--- + +## 3. Functional Requirements + +> Stuck on a requirement below? `docs/fr-guide.md` breaks down the concepts and search terms behind each one — no code, just what to go learn. + +### FR-1: Users & Authentication +- Register (email + password, hashed with bcrypt). +- Login returning a JWT (access token only is fine at this level). +- Get own profile (`GET /me`) — authenticated. +- Update own profile (`PATCH /me`) — authenticated. +- Roles: `user` and `admin` (seed one admin user via a script or migration data). + +### FR-2: Movie Search (TMDB Integration) +Get a free API key at https://www.themoviedb.org/settings/api and set `TMDB_API_KEY` in `.env` (already added to `app/config.py`'s `Settings`). + +- `GET /movies/search?query=&page=` — proxies TMDB's `GET /search/movie` and returns a simplified shape (`tmdb_id`, `title`, `poster_path`, `release_date`, `vote_average`). Public, no auth required. +- `GET /movies/{tmdb_id}` — proxies TMDB's `GET /movie/{movie_id}` for details (overview, genres, runtime, poster). Public. +- Use `httpx` with an explicit timeout for all outbound TMDB calls. Map TMDB errors (404, rate limit, timeout) to sensible API error responses — don't leak raw TMDB error bodies. +- There is no local `movies` table — TMDB is the source of truth for movie data. Your `reviews` table only stores the `tmdb_movie_id`. + +### FR-3: Reviews & Ratings +- `POST /movies/{tmdb_id}/reviews` — authenticated user creates a review: `rating` (1–5 stars, integer) + `body` (text). **One review per user per movie** — enforce with a unique constraint on `(user_id, tmdb_movie_id)`, return `409` on duplicate. +- `GET /movies/{tmdb_id}/reviews` — public, paginated list of reviews for a movie, plus the movie's average rating. +- `GET /reviews/{id}` — public, single review. +- `PATCH /reviews/{id}` / `DELETE /reviews/{id}` — only the review's owner (or an admin) may edit/delete it. Enforce this in code, not just in the UI. +- `GET /users/{id}/reviews` — public, paginated list of a given user's reviews. + +### FR-4: Authorization & User Control +- Role-based access: `admin` can delete **any** review (moderation) and deactivate (`is_active = false`) any user account. Deactivated users cannot log in. +- Ownership checks on every mutating review endpoint: confirm `review.user_id == current_user.id` or `current_user.role == "admin"` before allowing edit/delete. +- Dependency-injected `get_current_user` and `require_admin` (FastAPI `Depends`) — don't repeat auth logic per-route. + +--- + +## 4. Non-Functional Requirements + +- **NFR-1:** All list endpoints paginated; default limit 20, max 100. +- **NFR-2:** Consistent JSON error format: `{ "error": { "code": "...", "message": "..." } }`. +- **NFR-3:** Correct HTTP status codes (201 on create, 409 on duplicate review, 401 on missing/invalid auth, 403 on forbidden ownership, 404 on missing resource). +- **NFR-4:** Minimum 10 automated tests, covering at least: registration/login, duplicate-review rejection, ownership enforcement on edit/delete, TMDB search response shape (mock the TMDB call in tests — don't hit the real API). +- **NFR-5:** A `README.md` with setup instructions that work from a clean machine (`docker-compose up` + one migration command). +- **NFR-6:** Structured logging (JSON logs or at least consistent log lines) for every outbound TMDB call and every failed login attempt. + +--- + +## 5. Data Model (Minimum Entities) + +You must deliver an **ERD diagram** and, if using classes, a **class diagram** (Mermaid or draw.io, committed to the repo under `/docs`). + +Required entities (you may add fields, not remove): + +- `users` (id, email unique, password_hash, role, is_active, created_at) +- `reviews` (id, user_id FK → users, tmdb_movie_id int, rating int 1–5, body, created_at, updated_at, unique on `(user_id, tmdb_movie_id)`) + +Referential integrity enforced with real foreign keys. Explain your indexing choices for at least 2 indexes beyond primary keys (hint: you will query reviews by `tmdb_movie_id` a lot, and by `user_id` for the user's-reviews endpoint). + +--- + +## 6. Redis — Required Uses + +1. **Movie response cache:** cache TMDB search results and movie-details responses for 60 seconds per query/id, to avoid hammering TMDB and to survive their rate limits. Be ready to explain your cache key design (must vary by query string / page / movie id). +2. **Average rating cache:** cache a movie's average rating, invalidate on every new/edited/deleted review for that movie. Justify your invalidation strategy in the README. +3. **Login rate limiting:** max 5 failed logins per email per 15 minutes, tracked in Redis. + +--- diff --git a/docs/fr-guide.md b/docs/fr-guide.md new file mode 100644 index 0000000..ed5d242 --- /dev/null +++ b/docs/fr-guide.md @@ -0,0 +1,110 @@ +# Functional Requirements Guide — What to Research + +This is not a how-to. For every requirement in `docs/README.md` §3, it lists the **concepts** behind it and **search terms** to look up so you can implement it yourself. If you find yourself pasting these terms into an LLM for the finished code instead of reading docs/tutorials, you're skipping the part that teaches you something. + +--- + +## FR-1: Users & Authentication + +**Concepts:** password hashing vs. encryption, salts, JWT (structure: header/payload/signature), access tokens vs. refresh tokens, OAuth2 password flow, dependency injection, environment-based secrets. + +**Search terms:** +- "why you never store plaintext passwords" / "bcrypt vs argon2" +- "passlib CryptContext bcrypt python" +- "FastAPI OAuth2PasswordBearer tutorial" +- "JWT explained" (jwt.io is a good interactive reference) +- "python-jose encode decode JWT" +- "FastAPI Depends dependency injection" +- "SQLAlchemy Enum column type" +- "pydantic-settings BaseSettings env file" + +**Questions to be able to answer:** Why hash instead of encrypt a password? What's inside a JWT and can anyone read it without the secret key? What happens if your `SECRET_KEY` leaks? Why is a "get current user" dependency better than checking auth in every route by hand? + +**Resources:** +- [OAuth2 with Password (and hashing), Bearer with JWT tokens](https://fastapi.tiangolo.com/tutorial/security/oauth2-jwt/) — official FastAPI docs, start here +- [A Guide to Authentication in FastAPI with JWT](https://davidmuraya.com/blog/fastapi-jwt-authentication/) — full walkthrough, register/login/protected routes +- [Login & Registration System with JWT in FastAPI](https://www.geeksforgeeks.org/python/login-registration-system-with-jwt-in-fastapi/) — practical CRUD-style example + +--- + +## FR-2: Movie Search (TMDB Integration) + +**Concepts:** consuming a third-party REST API, API keys as secrets, server-to-server calls vs. exposing keys to a client, HTTP timeouts, response-shape translation (don't just forward TMDB's raw JSON), error mapping. + +**Search terms:** +- "why you should never expose an API key in frontend code" +- "httpx python client timeout example" +- "TMDB API search movie" (you already have the raw endpoint docs) +- "backend for frontend pattern" / "API proxy pattern" +- "mapping upstream API errors to your own error format" +- "pydantic response model FastAPI" + +**Questions to be able to answer:** Why does your API return a *simplified* shape instead of forwarding TMDB's response verbatim? What should happen if TMDB is down or slow — should your whole API hang? What's the difference between a client-side timeout and a server-side one? + +**Resources:** +- [TMDB API documentation](https://developer.themoviedb.org/reference/intro/getting-started) — the source you're proxying +- [httpx documentation — Timeouts](https://www.python-httpx.org/advanced/timeouts/) — official httpx docs on client/request timeouts +- [FastAPI and Redis Tutorial: Build a High-Performance Python API](https://redis.io/tutorials/develop/python/fastapi/) — official Redis tutorial, covers caching an external API's response + +--- + +## FR-3: Reviews & Ratings + +**Concepts:** foreign keys and relationships, composite unique constraints, one-to-many, pagination (offset/limit), SQL aggregate functions, request/response schema validation. + +**Search terms:** +- "SQLAlchemy ForeignKey relationship one to many" +- "SQLAlchemy UniqueConstraint multiple columns" +- "SQL AVG GROUP BY explained" +- "offset vs cursor pagination" +- "pydantic BaseModel request vs response schema" +- "SQLAlchemy Mapped mapped_column" + +**Questions to be able to answer:** Why does the unique constraint need *both* `user_id` and `tmdb_movie_id`, not just one? What SQL would you write to get a movie's average rating without loading every review into Python first? Why validate the incoming `rating` is between 1 and 5 at the schema level instead of in the route function? + +**Resources:** +- [SQL (Relational) Databases](https://fastapi.tiangolo.com/tutorial/sql-databases/) — official FastAPI + SQLAlchemy tutorial +- [The Ultimate FastAPI Tutorial Part 7 — Database Setup with SQLAlchemy and Alembic](https://christophergs.com/tutorials/ultimate-fastapi-tutorial-pt-7-sqlalchemy-database-setup/) — well-regarded series, this part covers exactly your migrations setup +- [Patterns and Practices for using SQLAlchemy 2.0 with FastAPI](https://chaoticengineer.hashnode.dev/fastapi-sqlalchemy) — modern `Mapped`/`mapped_column` style, matches what's already in this repo + +--- + +## FR-4: Authorization & User Control + +**Concepts:** authentication vs. authorization (they are not the same thing), role-based access control (RBAC), ownership-based authorization, HTTP 401 vs 403, soft delete. + +**Search terms:** +- "authentication vs authorization difference" +- "role based access control (RBAC) explained" +- "ownership-based authorization API" +- "HTTP status code 401 vs 403 when to use each" +- "soft delete vs hard delete database pattern" +- "FastAPI reusable Depends for role checks" + +**Questions to be able to answer:** A logged-in `user` tries to delete someone else's review — is that a 401 or a 403, and why? Why deactivate (`is_active=false`) a user instead of deleting their row? Where should the ownership check live so you don't copy-paste it into every route? + +**Resources:** +- [FastAPI RBAC - Full Implementation Tutorial](https://www.permit.io/blog/fastapi-rbac-full-implementation-tutorial) — thorough walkthrough of the `RoleChecker`-as-dependency pattern +- [Role-based access control using FastAPI](https://dev.to/moadennagi/role-based-access-control-using-fastapi-h59) — shorter, practical example +- [FastAPI/Python Code Sample: API Role-Based Access Control](https://developer.auth0.com/resources/code-samples/api/fastapi/basic-role-based-access-control) — Auth0's reference sample, good for comparing your own approach against + +--- + +## Cross-cutting concepts (show up in NFRs and Redis section too) + +- **Redis caching**: cache-aside pattern, TTL/expiry, cache invalidation ("there are only two hard things in computer science..."). +- **Rate limiting**: fixed window vs sliding window, why track failed logins by email in Redis instead of in Postgres. +- **Testing**: mocking an external HTTP call (don't let your tests hit real TMDB), FastAPI's `TestClient`, test database isolation. +- **Migrations**: why hand-written `CREATE TABLE` breaks reproducibility, what `alembic revision --autogenerate` actually detects vs. what it misses. +- **Structured logging**: why `print()` doesn't scale, what a log line needs to be useful in production (timestamp, level, context). + +**Resources for the cross-cutting stuff:** +- [Redis Cache Aside Pattern Explained](https://parottasalna.hashnode.dev/redis-cache-aside-pattern) — short, clear explainer +- [Rate Limiting for Your FastAPI App](https://upstash.com/docs/redis/tutorials/python_rate_limiting) — Upstash's tutorial, directly applicable to your login rate-limit requirement +- [How to rate limit FastAPI with Redis](https://dev.to/dpills/how-to-rate-limit-fastapi-with-redis-1dhf) — concrete Redis `INCR` + expiry example +- [Async Tests](https://fastapi.tiangolo.com/advanced/async-tests/) — official FastAPI docs on testing async endpoints +- [Developing and Testing an Asynchronous API with FastAPI and Pytest](https://testdriven.io/blog/fastapi-crud/) — TestDriven.io, a full CRUD + test suite example worth studying end to end +- [FastAPI Best Practices](https://github.com/zhanymkanov/fastapi-best-practices) — widely-cited GitHub repo on structuring a FastAPI project past toy-app size +- [Full Stack FastAPI Template](https://github.com/fastapi/full-stack-fastapi-template) — the official template; its `app/` folder is a good reference for how a "real" auth + Postgres + Docker FastAPI project is laid out + +Look these up, understand the *why*, then write the code yourself. diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..645cf19 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,13 @@ +# Versiones registradas como estables para el proyecto +fastapi==0.115.0 +uvicorn[standard]==0.30.6 +sqlalchemy==2.0.35 +psycopg2-binary==2.9.9 +alembic==1.13.2 +python-jose[cryptography]==3.3.0 +passlib[bcrypt]==1.7.4 +redis==5.1.0 +pydantic-settings==2.5.2 +python-multipart==0.0.12 +httpx==0.27.2 +pytest==8.3.3 \ No newline at end of file