Skip to content

Commit 347b67a

Browse files
committed
first functional
1 parent 89da29e commit 347b67a

82 files changed

Lines changed: 15120 additions & 8327 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.dockerignore

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
__pycache__/
2+
venv/
3+
node_modules/
4+
.pytest_cache/
5+
*.pyc
6+
*.pyo
7+
*.pyd
8+
dist/
9+
build/
10+
*.sqlite3
11+
*.db
12+
env/
13+
*.env
14+
backend/.env

.github/workflows/ci.yml

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
branches: [ main ]
6+
pull_request:
7+
branches: [ main ]
8+
9+
jobs:
10+
backend-tests:
11+
runs-on: ubuntu-latest
12+
steps:
13+
- uses: actions/checkout@v4
14+
- name: Set up Python
15+
uses: actions/setup-python@v4
16+
with:
17+
python-version: '3.12'
18+
- name: Install backend deps
19+
run: |
20+
python -m pip install --upgrade pip
21+
pip install -r backend/requirements.txt
22+
- name: Apply alembic migrations (SQLite CI DB)
23+
env:
24+
DATABASE_URL: sqlite:///./ci_test.db
25+
run: |
26+
cd backend
27+
alembic -c alembic.ini upgrade head
28+
- name: Run backend tests
29+
run: |
30+
cd backend
31+
pytest -q
32+
33+
frontend-check:
34+
runs-on: ubuntu-latest
35+
steps:
36+
- uses: actions/checkout@v4
37+
- name: Use Node.js
38+
uses: actions/setup-node@v4
39+
with:
40+
node-version: '18'
41+
- name: Install frontend deps
42+
run: |
43+
cd frontend
44+
npm ci
45+
- name: Typecheck frontend
46+
run: |
47+
cd frontend
48+
npx tsc -p tsconfig.json --noEmit

README_DEPLOY.md

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
# CineMatch — Local dev and deploy notes
2+
3+
This file explains how to run the backend locally with Docker and how to prepare for production.
4+
5+
Local dev with Docker Compose
6+
7+
1. Start services:
8+
9+
```bash
10+
docker-compose up --build
11+
```
12+
13+
This will start:
14+
- Postgres on `localhost:5432` (user: `cine`, pass: `cinepass`, db: `cinematch`)
15+
- MailHog on `http://localhost:8025` (SMTP port: 1025)
16+
- Backend on `http://localhost:8000`
17+
18+
2. Apply migrations (inside container or locally):
19+
20+
```bash
21+
# if running locally with venv
22+
cd backend
23+
source ../venv/bin/activate
24+
alembic -c alembic.ini upgrade head
25+
26+
# or inside running container
27+
docker-compose exec backend alembic -c alembic.ini upgrade head
28+
```
29+
30+
Production checklist (high level)
31+
- Provision PostgreSQL managed service and set `DATABASE_URL`.
32+
- Set secrets in your hosting provider (JWT_SECRET_KEY, TMDB_API_KEY, SMTP credentials, PUBLIC_BACKEND_URL).
33+
- Ensure `alembic -c alembic.ini upgrade head` runs during deployment before starting the app.
34+
- Use Redis for distributed rate limiting.
35+
- Configure HTTPS and health checks.

backend/Dockerfile

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
FROM python:3.12-slim
2+
3+
WORKDIR /app
4+
5+
ENV PYTHONDONTWRITEBYTECODE=1
6+
ENV PYTHONUNBUFFERED=1
7+
8+
RUN apt-get update && apt-get install -y build-essential libpq-dev && rm -rf /var/lib/apt/lists/*
9+
10+
COPY requirements.txt ./
11+
RUN pip install --no-cache-dir -r requirements.txt
12+
13+
COPY . /app
14+
15+
EXPOSE 8000
16+
17+
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]

backend/alembic.ini

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
[alembic]
2+
script_location = alembic
3+
# sqlalchemy.url is set dynamically from app.core.config.settings in alembic/env.py
4+
5+
[loggers]
6+
keys = root,sqlalchemy,alembic
7+
8+
[handlers]
9+
keys = console
10+
11+
[formatters]
12+
keys = generic
13+
14+
[logger_root]
15+
level = WARN
16+
handlers = console
17+
18+
[logger_sqlalchemy]
19+
level = WARN
20+
handlers =
21+
qualname = sqlalchemy.engine
22+
23+
[logger_alembic]
24+
level = INFO
25+
handlers = console
26+
qualname = alembic
27+
28+
[handler_console]
29+
class = StreamHandler
30+
args = (sys.stderr,)
31+
level = NOTSET
32+
formatter = generic
33+
34+
[formatter_generic]
35+
format = %(levelname)-5.5s [%(name)s] %(message)s

backend/alembic/env.py

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
from __future__ import annotations
2+
3+
from logging.config import fileConfig
4+
from pathlib import Path
5+
import sys
6+
7+
from alembic import context
8+
from sqlalchemy import engine_from_config, pool
9+
10+
BASE_DIR = Path(__file__).resolve().parents[1]
11+
if str(BASE_DIR) not in sys.path:
12+
sys.path.insert(0, str(BASE_DIR))
13+
14+
from app.core.config import settings
15+
from app.db.models import Base # noqa: E402
16+
17+
config = context.config
18+
config.set_main_option("sqlalchemy.url", settings.database_url)
19+
20+
if config.config_file_name is not None:
21+
fileConfig(config.config_file_name)
22+
23+
target_metadata = Base.metadata
24+
25+
26+
def run_migrations_offline() -> None:
27+
url = config.get_main_option("sqlalchemy.url")
28+
context.configure(
29+
url=url,
30+
target_metadata=target_metadata,
31+
literal_binds=True,
32+
dialect_opts={"paramstyle": "named"},
33+
compare_type=True,
34+
compare_server_default=True,
35+
)
36+
37+
with context.begin_transaction():
38+
context.run_migrations()
39+
40+
41+
def run_migrations_online() -> None:
42+
connectable = engine_from_config(
43+
config.get_section(config.config_ini_section, {}),
44+
prefix="sqlalchemy.",
45+
poolclass=pool.NullPool,
46+
)
47+
48+
with connectable.connect() as connection:
49+
context.configure(
50+
connection=connection,
51+
target_metadata=target_metadata,
52+
compare_type=True,
53+
compare_server_default=True,
54+
)
55+
56+
with context.begin_transaction():
57+
context.run_migrations()
58+
59+
60+
if context.is_offline_mode():
61+
run_migrations_offline()
62+
else:
63+
run_migrations_online()

backend/alembic/script.py.mako

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
"""${message}
2+
3+
Revision ID: ${up_revision}
4+
Revises: ${down_revision | comma,n}
5+
Create Date: ${create_date}
6+
7+
"""
8+
from alembic import op
9+
import sqlalchemy as sa
10+
11+
12+
# revision identifiers, used by Alembic.
13+
revision = ${repr(up_revision)}
14+
down_revision = ${repr(down_revision)}
15+
branch_labels = ${repr(branch_labels)}
16+
depends_on = ${repr(depends_on)}
17+
18+
19+
def upgrade() -> None:
20+
${upgrades if upgrades else "pass"}
21+
22+
23+
def downgrade() -> None:
24+
${downgrades if downgrades else "pass"}
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
"""create core user tables
2+
3+
Revision ID: 0001_create_core_user_tables
4+
Revises:
5+
Create Date: 2026-06-01 00:00:00.000000
6+
"""
7+
8+
from alembic import op
9+
import sqlalchemy as sa
10+
11+
12+
# revision identifiers, used by Alembic.
13+
revision = "0001_create_core_user_tables"
14+
down_revision = None
15+
branch_labels = None
16+
depends_on = None
17+
18+
19+
def upgrade() -> None:
20+
op.create_table(
21+
"users",
22+
sa.Column("id", sa.Integer(), primary_key=True),
23+
sa.Column("email", sa.String(), nullable=False),
24+
sa.Column("password_hash", sa.String(), nullable=False),
25+
sa.Column("first_name", sa.String(), nullable=False),
26+
sa.Column("last_name", sa.String(), nullable=False),
27+
sa.Column("display_name", sa.String(), nullable=True),
28+
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.text("true")),
29+
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
30+
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
31+
)
32+
op.create_index(op.f("ix_users_email"), "users", ["email"], unique=True)
33+
op.create_index(op.f("ix_users_id"), "users", ["id"], unique=False)
34+
35+
op.create_table(
36+
"user_preferred_genres",
37+
sa.Column("id", sa.Integer(), primary_key=True),
38+
sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id"), nullable=False),
39+
sa.Column("genre_id", sa.Integer(), nullable=False),
40+
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
41+
sa.UniqueConstraint("user_id", "genre_id"),
42+
)
43+
op.create_index(op.f("ix_user_preferred_genres_id"), "user_preferred_genres", ["id"], unique=False)
44+
op.create_index(op.f("ix_user_preferred_genres_user_id"), "user_preferred_genres", ["user_id"], unique=False)
45+
op.create_index(op.f("ix_user_preferred_genres_genre_id"), "user_preferred_genres", ["genre_id"], unique=False)
46+
47+
op.create_table(
48+
"refresh_tokens",
49+
sa.Column("id", sa.Integer(), primary_key=True),
50+
sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id"), nullable=False),
51+
sa.Column("jti", sa.String(), nullable=False),
52+
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
53+
sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True),
54+
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
55+
)
56+
op.create_index(op.f("ix_refresh_tokens_id"), "refresh_tokens", ["id"], unique=False)
57+
op.create_index(op.f("ix_refresh_tokens_user_id"), "refresh_tokens", ["user_id"], unique=False)
58+
op.create_index(op.f("ix_refresh_tokens_jti"), "refresh_tokens", ["jti"], unique=True)
59+
60+
op.create_table(
61+
"user_saved_movies",
62+
sa.Column("id", sa.Integer(), primary_key=True),
63+
sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id"), nullable=False),
64+
sa.Column("movie_id", sa.Integer(), nullable=False),
65+
sa.Column("saved_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
66+
sa.UniqueConstraint("user_id", "movie_id"),
67+
)
68+
op.create_index(op.f("ix_user_saved_movies_id"), "user_saved_movies", ["id"], unique=False)
69+
op.create_index(op.f("ix_user_saved_movies_user_id"), "user_saved_movies", ["user_id"], unique=False)
70+
op.create_index(op.f("ix_user_saved_movies_movie_id"), "user_saved_movies", ["movie_id"], unique=False)
71+
72+
op.create_table(
73+
"user_watched_movies",
74+
sa.Column("id", sa.Integer(), primary_key=True),
75+
sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id"), nullable=False),
76+
sa.Column("movie_id", sa.Integer(), nullable=False),
77+
sa.Column("watched_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
78+
sa.UniqueConstraint("user_id", "movie_id"),
79+
)
80+
op.create_index(op.f("ix_user_watched_movies_id"), "user_watched_movies", ["id"], unique=False)
81+
op.create_index(op.f("ix_user_watched_movies_user_id"), "user_watched_movies", ["user_id"], unique=False)
82+
op.create_index(op.f("ix_user_watched_movies_movie_id"), "user_watched_movies", ["movie_id"], unique=False)
83+
84+
op.create_table(
85+
"user_movie_notes",
86+
sa.Column("id", sa.Integer(), primary_key=True),
87+
sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id"), nullable=False),
88+
sa.Column("movie_id", sa.Integer(), nullable=False),
89+
sa.Column("note_text", sa.Text(), nullable=False, server_default=""),
90+
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
91+
sa.UniqueConstraint("user_id", "movie_id"),
92+
)
93+
op.create_index(op.f("ix_user_movie_notes_id"), "user_movie_notes", ["id"], unique=False)
94+
op.create_index(op.f("ix_user_movie_notes_user_id"), "user_movie_notes", ["user_id"], unique=False)
95+
op.create_index(op.f("ix_user_movie_notes_movie_id"), "user_movie_notes", ["movie_id"], unique=False)
96+
97+
98+
def downgrade() -> None:
99+
op.drop_index(op.f("ix_user_movie_notes_movie_id"), table_name="user_movie_notes")
100+
op.drop_index(op.f("ix_user_movie_notes_user_id"), table_name="user_movie_notes")
101+
op.drop_index(op.f("ix_user_movie_notes_id"), table_name="user_movie_notes")
102+
op.drop_table("user_movie_notes")
103+
104+
op.drop_index(op.f("ix_user_watched_movies_movie_id"), table_name="user_watched_movies")
105+
op.drop_index(op.f("ix_user_watched_movies_user_id"), table_name="user_watched_movies")
106+
op.drop_index(op.f("ix_user_watched_movies_id"), table_name="user_watched_movies")
107+
op.drop_table("user_watched_movies")
108+
109+
op.drop_index(op.f("ix_user_saved_movies_movie_id"), table_name="user_saved_movies")
110+
op.drop_index(op.f("ix_user_saved_movies_user_id"), table_name="user_saved_movies")
111+
op.drop_index(op.f("ix_user_saved_movies_id"), table_name="user_saved_movies")
112+
op.drop_table("user_saved_movies")
113+
114+
op.drop_index(op.f("ix_refresh_tokens_jti"), table_name="refresh_tokens")
115+
op.drop_index(op.f("ix_refresh_tokens_user_id"), table_name="refresh_tokens")
116+
op.drop_index(op.f("ix_refresh_tokens_id"), table_name="refresh_tokens")
117+
op.drop_table("refresh_tokens")
118+
119+
op.drop_index(op.f("ix_user_preferred_genres_genre_id"), table_name="user_preferred_genres")
120+
op.drop_index(op.f("ix_user_preferred_genres_user_id"), table_name="user_preferred_genres")
121+
op.drop_index(op.f("ix_user_preferred_genres_id"), table_name="user_preferred_genres")
122+
op.drop_table("user_preferred_genres")
123+
124+
op.drop_index(op.f("ix_users_id"), table_name="users")
125+
op.drop_index(op.f("ix_users_email"), table_name="users")
126+
op.drop_table("users")
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
"""add is_verified column to users
2+
3+
Revision ID: 0002_add_is_verified_column
4+
Revises: 0001_create_core_user_tables
5+
Create Date: 2026-06-01 00:10:00.000000
6+
"""
7+
8+
from alembic import op
9+
import sqlalchemy as sa
10+
11+
12+
# revision identifiers, used by Alembic.
13+
revision = "0002_add_is_verified_column"
14+
down_revision = "0001_create_core_user_tables"
15+
branch_labels = None
16+
depends_on = None
17+
18+
19+
def upgrade() -> None:
20+
op.add_column("users", sa.Column("is_verified", sa.Boolean(), nullable=False, server_default=sa.text("false")))
21+
22+
23+
def downgrade() -> None:
24+
op.drop_column("users", "is_verified")

0 commit comments

Comments
 (0)