WUT_Computer_Science/Programming/ERSMS-project/backend/app.py
Krzysztof kuhy Rudnicki 2a59de6363 The six mypy errors are gone: four were fixable, two are scoped
The note in TODO-refactor-remaining.md said ERSMS's four were structural and
that a fix "would rewrite both models". Both halves were wrong.
flask-sqlalchemy 3.1.1 does ship py.typed; the real blocker was that
`class User(db.Model)` is attribute access, which mypy cannot resolve in a
base-class position.

backend/models.py now declares a named DeclarativeBase with SQLAlchemy 2.0
Mapped[] columns. The Model mixin goes away with the base, so the six legacy
Model.query call sites became db.session selects and the two table names are
spelled out rather than derived.

Verified by driving the real routes through Flask's test client with Firebase
stubbed out -- 16 scenarios covering every one of the six call sites, plus
both CREATE TABLE statements, byte-identical to the submitted version. That
harness caught the one thing review would not have: mapped_column(ForeignKey(
"user.uid")) silently infers VARCHAR(80) from the referenced column, which
had changed the submitted `user_id INTEGER`. An explicit Integer puts it back.

app.py went over the 250-line cap on the way, hence the split; models.py takes
the shebang + exec bit that NLP's nine import-only modules already use, since
__init__.py would make `from models import` unresolvable to mypy while it
still works at runtime. backend/Dockerfile copied only app.py, so it needed
the new file too.

PSD's two are pyflink's MapFunction and KeyedProcessFunction, which ship no
types and are not installed in that venv at all -- a hand-written .pyi would
assert an API nothing here can check. A [[tool.mypy.overrides]] scoped to that
one module turns off disallow_subclassing_any and nothing else.

ERSMS 4 -> 0, PSD 2 -> 0, under --strict. ruff and ruff format clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0172dj34FDpao6wsix8ZuCzE
2026-08-29 11:00:28 +02:00

228 lines
7.8 KiB
Python
Executable File

#!/usr/bin/env python3
"""The Firebase-authenticated API: log in, rate a movie, look films up on TMDB.
Every route takes a Firebase ID token in the request body, verifies it, and
uses the uid inside it as the identity. Ratings live in a local SQLite file;
film metadata is proxied from TMDB.
"""
from __future__ import annotations
import logging
import os
from typing import TYPE_CHECKING
import firebase_admin
import requests
from dotenv import load_dotenv
from firebase_admin import auth, credentials, exceptions
from flask import Flask, jsonify, request
from flask_cors import CORS
from flask_sqlalchemy import SQLAlchemy
from models import Base, Rating, User
from sqlalchemy.exc import SQLAlchemyError
if TYPE_CHECKING:
from flask.typing import ResponseReturnValue
logger = logging.getLogger(__name__)
load_dotenv()
TMDB_BEARER_TOKEN = os.getenv("TMDB_BEARER_TOKEN")
# TMDB is a third party over the internet; never wait on it indefinitely.
TMDB_TIMEOUT_SECONDS = 10
HTTP_OK = 200
HTTP_CREATED = 201
HTTP_UNAUTHORIZED = 401
HTTP_INTERNAL_SERVER_ERROR = 500
# A token that will not verify, and a request the token owner may not make.
AUTH_ERRORS = (exceptions.FirebaseError, ValueError, KeyError)
app = Flask(__name__)
app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///app.sqlite3"
app.config["SQLALCHEY_TRACK_MODIFICATIONS"] = False
db = SQLAlchemy(app, model_class=Base)
CORS(app)
cred = credentials.Certificate("movie-recommendation-firebase-adminsdk.json")
firebase_admin.initialize_app(cred)
with app.app_context():
db.create_all()
@app.route("/login", methods=["POST"])
def login() -> ResponseReturnValue:
"""Verify the token and create the user row if this is their first visit."""
token = request.json.get("token")
try:
decoded_token = auth.verify_id_token(token)
uid = decoded_token["uid"]
logger.info("login: %s", uid)
email = decoded_token.get("email")
user = db.session.scalars(db.select(User).filter_by(uid=uid)).first()
if user is None:
user = User(uid=uid, email=email)
db.session.add(user)
db.session.commit()
except (*AUTH_ERRORS, SQLAlchemyError):
logger.exception("Login failed")
return jsonify({"message": "Login failed"}), HTTP_UNAUTHORIZED
return jsonify(
{"message": "Login successful!", "email": email, "is_admin": user.is_admin}
), HTTP_OK
@app.route("/count_user_ratings", methods=["POST"])
def count_user_ratings() -> ResponseReturnValue:
"""Return how many films this user has rated."""
token = request.json.get("token")
try:
decoded_token = auth.verify_id_token(token)
user_id = decoded_token["uid"]
user = db.session.scalars(db.select(User).filter_by(uid=user_id)).first()
if user is None:
return jsonify({"message": "Error"}), HTTP_INTERNAL_SERVER_ERROR
rating_count = db.session.scalar(
db.select(db.func.count()).select_from(Rating).filter_by(user_id=user_id)
)
except (*AUTH_ERRORS, SQLAlchemyError) as exc:
logger.exception("Could not count ratings")
return jsonify({"message": str(exc)}), HTTP_INTERNAL_SERVER_ERROR
return jsonify(
{"message": "Ratings counted!", "rating_count": rating_count}
), HTTP_OK
@app.route("/movie/<int:movie_id>", methods=["GET"])
def get_tmdb_data_movie_id(movie_id: int) -> ResponseReturnValue:
"""Proxy one film's TMDB record."""
url = f"https://api.themoviedb.org/3/movie/{movie_id}"
headers = {"Authorization": f"Bearer {TMDB_BEARER_TOKEN}"}
try:
response = requests.get(url, headers=headers, timeout=TMDB_TIMEOUT_SECONDS)
response.raise_for_status()
except requests.RequestException as exc:
logger.exception("TMDB lookup failed for %s", movie_id)
return jsonify({"message": str(exc)}), HTTP_INTERNAL_SERVER_ERROR
return jsonify(response.json()), HTTP_OK
@app.route("/movie", methods=["GET"])
def get_tmdb_data_query() -> ResponseReturnValue:
"""Search TMDB, or return what is trending today when there is no query."""
query = request.args.get("query")
if query:
url = f"https://api.themoviedb.org/3/search/movie?query={query}"
else:
url = "https://api.themoviedb.org/3/trending/movie/day"
headers = {"Authorization": f"Bearer {TMDB_BEARER_TOKEN}"}
try:
response = requests.get(url, headers=headers, timeout=TMDB_TIMEOUT_SECONDS)
response.raise_for_status()
except requests.RequestException as exc:
logger.exception("TMDB search failed")
return jsonify({"message": str(exc)}), HTTP_INTERNAL_SERVER_ERROR
return jsonify(response.json()), HTTP_OK
@app.route("/rating", methods=["POST"])
def add_rating() -> ResponseReturnValue:
"""Record this user's score for a film, or update it if they had one."""
token = request.json.get("token")
movie = request.json.get("movie")
value = request.json.get("value")
try:
decoded_token = auth.verify_id_token(token)
user_id = decoded_token["uid"]
rating = db.session.scalars(
db.select(Rating).filter_by(user_id=user_id, movie_id=movie)
).first()
if rating is None:
rating = Rating(user_id=user_id, movie_id=movie, value=value)
db.session.add(rating)
db.session.commit()
return jsonify({"message": "Rating added successfully!"}), HTTP_CREATED
rating.value = value
db.session.commit()
except (*AUTH_ERRORS, SQLAlchemyError) as exc:
logger.exception("Could not add rating")
return jsonify({"message": str(exc)}), HTTP_INTERNAL_SERVER_ERROR
return jsonify({"message": "Rating updated successfully!"}), HTTP_OK
@app.route("/rating", methods=["DELETE"])
def remove_rating() -> ResponseReturnValue:
"""Delete this user's score for a film."""
token = request.json.get("token")
movie = request.json.get("movie")
try:
decoded_token = auth.verify_id_token(token)
user_id = decoded_token["uid"]
rating = db.session.scalars(
db.select(Rating).filter_by(user_id=user_id, movie_id=movie)
).first()
if rating is None:
return jsonify({"message": "Error"}), HTTP_INTERNAL_SERVER_ERROR
db.session.delete(rating)
db.session.commit()
except (*AUTH_ERRORS, SQLAlchemyError) as exc:
logger.exception("Could not remove rating")
return jsonify({"message": str(exc)}), HTTP_INTERNAL_SERVER_ERROR
return jsonify({"message": "Rating removed successfully!"}), HTTP_OK
@app.route("/get_rating", methods=["POST"])
def get_rating() -> ResponseReturnValue:
"""Return this user's score for a film, if they have given one."""
token = request.json.get("token")
movie = request.json.get("movie")
try:
decoded_token = auth.verify_id_token(token)
user_id = decoded_token["uid"]
rating = db.session.scalars(
db.select(Rating).filter_by(user_id=user_id, movie_id=movie)
).first()
if rating is None:
return jsonify({"message": "Rating not found!"}), HTTP_OK
except (*AUTH_ERRORS, SQLAlchemyError) as exc:
logger.exception("Could not read rating")
return jsonify({"message": str(exc)}), HTTP_INTERNAL_SERVER_ERROR
return jsonify(
{
"message": "Rating found!",
"movie": rating.movie_id,
"value": rating.value,
}
), HTTP_OK
if __name__ == "__main__":
logging.basicConfig(format="%(message)s", level=logging.INFO)
# Inside a container the service must bind every interface or the
# published port is unreachable, so docker-compose sets ERSMS_HOST. The
# default is loopback, which is the safe thing to do outside Docker.
app.run(host=os.environ.get("ERSMS_HOST", "127.0.0.1"), port=8084)