Compare commits

...
17 Commits
20 changed files with 339 additions and 157 deletions
+2 -1
View File
@@ -9,7 +9,8 @@ wheels/
# Virtual environments # Virtual environments
.venv .venv
.env .env*
.ruff_cache
*.sqlite *.sqlite
*.duckdb *.duckdb
+1
View File
@@ -5,6 +5,7 @@ WORKDIR /app
ENV UV_COMPILE_BYTECODE=1 ENV UV_COMPILE_BYTECODE=1
ADD pyproject.toml uv.lock . ADD pyproject.toml uv.lock .
RUN mkdir -p /app/data
RUN uv sync --frozen --no-dev RUN uv sync --frozen --no-dev
ADD /src . ADD /src .
+14 -2
View File
@@ -1,10 +1,22 @@
services: services:
pjl: pjl-backend:
# Build the image from the Dockerfile in the current directory # Build the image from the Dockerfile in the current directory
build: build:
context: . context: .
dockerfile: Dockerfile dockerfile: Dockerfile
image: pjl:0.9 image: pjl-backend:1.0rc1
env_file:
- .env.prod
Environment:
DB_URL: /app/data/pjl.duckdb
DEFAULT_AVATAR_URL: https://api.dicebear.com/9.x/adventurer-neutral/svg?seed=Kingston
OMDB_API_KEY: ${OMDB_API_KEY}
STAGE: prod
volumes:
- ./data:/app/data
# Host the FastAPI application on port 8000 # Host the FastAPI application on port 8000
ports: ports:
+13 -3
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "pjl" name = "pjl-backend"
version = "0.9" version = "1.0rc1"
description = "Add your description here" description = "Add your description here"
readme = "README.md" readme = "README.md"
requires-python = ">=3.12" requires-python = ">=3.12"
@@ -11,8 +11,18 @@ dependencies = [
"loguru>=0.7.2", "loguru>=0.7.2",
"pydantic-settings>=2.6.1", "pydantic-settings>=2.6.1",
"pydantic>=2.9.2", "pydantic>=2.9.2",
"requests>=2.32.3",
"rich>=13.9.4", "rich>=13.9.4",
"uvicorn>=0.32.0", "uvicorn>=0.32.0",
"pandas>=2.2.3", "pandas>=2.2.3",
"requests>=2.32.3",
] ]
[dependency-groups]
dev = [
"ruff>=0.8.0",
]
[tool.ruff]
lint.extend-select = ["C4", "SIM", "TCH", "ERA", "FAST", "DOC"]
target-version = "py312"
+55 -18
View File
@@ -1,10 +1,14 @@
from datetime import datetime
import bcrypt
import requests
from lib.database.database import db_run from lib.database.database import db_run
from models import Message from models import Message
from models.user import UserCredentials from models.user import UserCredentials
from models.movie import Movie from models.movie import MovieIn, Movie
from models.card import Card from models.card import Card
from lib.settings import settings
import bcrypt from lib.logger import logger
def add_user(user: UserCredentials): def add_user(user: UserCredentials):
@@ -14,40 +18,71 @@ def add_user(user: UserCredentials):
sql = f"INSERT INTO users (username, password) VALUES ('{user.username}', '{hashed_password}')" sql = f"INSERT INTO users (username, password) VALUES ('{user.username}', '{hashed_password}')"
try: try:
db_run(sql) db_run(sql)
except: except Exception as e:
return Message(message="User already exists", message_type="error") return Message(message=f"User already exists. Error: {e}", message_type="error")
return Message(message="User added successfully", message_type="success") return Message(message="User added successfully", message_type="success")
def remove_user(user_id: str): def remove_user(user_id: str):
logger.debug(f"Removing user {user_id}")
sql = f"DELETE FROM users WHERE id = '{user_id}'" sql = f"DELETE FROM users WHERE id = '{user_id}'"
try: try:
db_run(sql) db_run(sql)
except: except Exception as e:
return Message(message="User not found", message_type="error") return Message(message=f"User not found. Error: {e}", message_type="error")
return Message(message="User removed successfully", message_type="success") return Message(message="User removed successfully", message_type="success")
def add_movie(movie: Movie): def add_movie(movie: MovieIn):
sql = "INSERT INTO movies (name, imdb_id, actors, release_date, plot, showtime) VALUES (?, ?, ?, ?, ?, ?)" logger.info(f"Adding movie {movie.imdb_id}")
logger.debug(f"Requesting movie data from OMDB: {movie.imdb_id}")
request_url = "https://omdbapi.com/"
request_params = {
"apikey": settings.OMDB_API_KEY,
"i": movie.imdb_id,
}
response = requests.get(request_url, params=request_params)
if response.status_code != 200:
logger.error(f"Failed to add movie {movie.imdb_id}")
return Message(message="Failed to add movie", message_type="error")
movie_data = response.json()
logger.debug(f"Movie data received from OMDB: {movie_data}")
if "Released" in movie_data:
movie_data["Released"] = datetime.strptime(movie_data["Released"], "%d %b %Y")
new_movie = Movie(
name=movie_data["Title"],
imdb_id=movie_data["imdbID"],
actors=[actor.strip() for actor in movie_data["Actors"].split(",")],
release_date=movie_data["Released"],
plot=movie.plot,
poster_url=movie_data["Poster"],
showtime=movie.showtime,
)
logger.debug(f"Movie data: {new_movie.model_dump()}")
sql = "INSERT INTO movies (name, imdb_id, actors, release_date, plot, showtime, poster_url) VALUES (?, ?, ?, ?, ?, ?, ?)"
try: try:
db_run( db_run(
sql, sql,
( (
movie.name, new_movie.name,
movie.imdb_id, new_movie.imdb_id,
movie.actors, new_movie.actors,
movie.release_date, new_movie.release_date,
movie.plot, new_movie.plot,
movie.showtime, new_movie.showtime,
new_movie.poster_url,
), ),
) )
except: except Exception as e:
logger.error(f"Failed to add movie {movie.imdb_id}. Error: {e}")
return Message(message="Failed to add movie", message_type="error") return Message(message="Failed to add movie", message_type="error")
logger.info(f"Movie {movie.imdb_id} added successfully")
return Message(message="Movie added successfully", message_type="success") return Message(message="Movie added successfully", message_type="success")
@@ -55,8 +90,10 @@ def set_movie_watched(movie_id: str):
sql = f"UPDATE movies SET is_watched = TRUE WHERE id = '{movie_id}'" sql = f"UPDATE movies SET is_watched = TRUE WHERE id = '{movie_id}'"
try: try:
db_run(sql) db_run(sql)
except: except Exception as e:
return Message(message="Failed to set movie as watched", message_type="error") return Message(
message=f"Failed to set movie as watched. Error: {e}", message_type="error"
)
return Message(message="Movie set as watched successfully", message_type="success") return Message(message="Movie set as watched successfully", message_type="success")
+7 -9
View File
@@ -1,15 +1,16 @@
from contextlib import contextmanager from contextlib import contextmanager
import duckdb import duckdb
import bcrypt import bcrypt
from rich import print
from lib import settings from lib import settings
from lib.logger import logger
@contextmanager @contextmanager
def get_db(): def get_db():
try: try:
conn = duckdb.connect(database=settings.db_url) logger.debug(f"Connecting to database {settings.db_url.absolute()}")
conn = duckdb.connect(database=settings.db_url.absolute())
yield conn yield conn
finally: finally:
conn.close() conn.close()
@@ -23,11 +24,11 @@ def db_run(sql, values: tuple = None):
def init_db(): def init_db():
if settings.db_url.exists(): if settings.db_url.exists():
pass
# TODO: Development feature. Remove this in production # TODO: Development feature. Remove this in production
from os import unlink # from os import unlink
unlink(settings.db_url) # unlink(settings.db_url)
# return
sql = "" sql = ""
with open("lib/database/database.sql") as f: with open("lib/database/database.sql") as f:
sql = f.read() sql = f.read()
@@ -38,8 +39,5 @@ def init_db():
hashed_password = bcrypt.hashpw("password".encode("utf-8"), bcrypt.gensalt()) hashed_password = bcrypt.hashpw("password".encode("utf-8"), bcrypt.gensalt())
sql = f"INSERT INTO users (username, password, is_admin) VALUES ('admin', '{hashed_password.decode('utf-8')}', true)" sql = f"INSERT INTO users (username, password, is_admin) VALUES ('admin', '{hashed_password.decode('utf-8')}', true), ('kessinen', '{hashed_password.decode('utf-8')}', false), ('jensku', '{hashed_password.decode('utf-8')}', false), ('emppu', '{hashed_password.decode('utf-8')}', false)"
db_run(sql)
sql = f"INSERT INTO users (username, password, is_admin) VALUES ('test', '{hashed_password.decode('utf-8')}', false)"
db_run(sql) db_run(sql)
+38 -31
View File
@@ -53,48 +53,55 @@ CREATE TABLE log (
); );
-- Add placeholder data -- Add placeholder data
INSERT INTO movies (name, imdb_id, actors, release_date, plot, poster_url, showtime) INSERT INTO movies (name, imdb_id, actors, release_date, plot, poster_url, showtime)
VALUES ('A Maple Valley Christmas', VALUES ('Christmas Sail',
'tt21841642', 'tt15758364',
['Peyton List', 'Andrew W. Walker', 'Frances Flanagan'], ['Katee Sackhoff', 'Patrick Sabongui', 'Terry O''Quinn'],
'2022-11-05', '2022-11-05',
'Erica on karjatilallinen, joka on viettänyt koko elämänsä työskennellen perhetilalla äitinsä ja sisarensa kanssa. 'Lizin etääntynyt isä pyytää apua ja Liz palaa kotikaupunkiinsa. Hän päättää luoda täydellisen joulun tyttärelleen ja saada ihmissuhteensa kuntoon.',
Kun Aaron saapuu ja häiritsee hänen suunnitelmiaan, hän alkaa kyseenalaistaa, mitä hän oikeastaan haluaa.', 'https://m.media-amazon.com/images/M/MV5BZTA0MjA2MmMtNWZhNy00N2UyLWI5YjItZTA4ZDVkZmNmMWVjXkEyXkFqcGc@._V1_SX300.jpg',
'https://m.media-amazon.com/images/M/MV5BZjliMTRlMGItOGQzZC00NTI2LWJkODctZWZmMDIzOGRmYTQ3XkEyXkFqcGc@._V1_.jpg', '2024-12-02'),
'2024-12-01'); ('The Holiday Stocking',
'tt22308008',
['Nadine Ellis', 'Tamala Jones', 'B.J. Britt'],
'2022-12-03','Enkeli palaa maan päälle vieraana ja saa mahdollisuuden auttaa sisariaan sovintoon - asia mitä hän ei onnistunut tekemään elinaikanaan.',
'https://m.media-amazon.com/images/M/MV5BZTI5OTc5OTYtOWVlMC00YmRhLWFhMmQtMGIwYjNmZTI1Y2Y5XkEyXkFqcGc@._V1_SX300.jpg',
'2024-12-03'),;
INSERT INTO cards (title, description, point_value) INSERT INTO cards (title, description, point_value)
VALUES VALUES
('Punainen villakangastakki', 'Päähenkilöllä on aina kirkkaanpunainen, täydellisesti istuva talvitakki.', 1), ('Blondi tuli taloon', 'Naispääosalla pitkät blondit hiukset.', 1),
('Keskeytetty suudelma', 'Juuri, kun romanttinen hetki on käsillä, jokin keskeyttää ensisuudelman.', 1), ('Ensilumi', 'Juuri kun kaikki toivo on menetetty, ensilumi alkaa sataa.', 3),
('Miespääosa yh', 'Miespäähenkilö on yksinhuoltaja, joka tasapainoilee uran ja lapsenhoidon välillä.', 2),
('Leskimies', 'Päähenkilö on menettänyt puolisonsa ja yrittää nyt toipua elämässään.', 2),
('Jouluoksennus', 'Koti, työpaikka tai jokin muu koristeltu liioitellusti jouluteemalla, ilman mitään hillittyä tyylitajua.', 1),
('Evergreen', 'Tarinan kylä tai kaupunki on idyllinen paikka, nimeltään jotain jouluista, kuten ''Evergreen'' tai ''Snowville''.', 3), ('Evergreen', 'Tarinan kylä tai kaupunki on idyllinen paikka, nimeltään jotain jouluista, kuten ''Evergreen'' tai ''Snowville''.', 3),
('Yritys pulassa', 'Päähenkilön yritys on konkurssin partaalla tai vaarassa tulla ostetuksi.', 2), ('Hengailua kirjastossa', 'Pieni kirjakauppa tai kirjasto on keskeinen kohtauspaikka ja keskustelujen paikka.', 3),
('Naispääosa pitkä blondi', 'Naispääosalla pitkät blondit hiukset.', 1), ('Himo jouluttaja', 'Yksi hahmoista on ylitsepursuava jouluihminen, jolla on täydellisesti koristeltu koti.', 1),
('Jouluenkeli', 'Jouluenkeli joko koristeena tai todellisena ilmestyksenä ohjaa hahmoja oikealle tielle.', 3),
('Joulukuusten Invaasio', 'Elokuvassa on koti jossa on vähintään 5 joulukuusta. Kuuset voivat olla eri kokoisia.', 2),
('Joulumörkö', 'Hahmo, joka vihaa joulua, mutta päätyy lopulta rakastamaan sitä.', 1), ('Joulumörkö', 'Hahmo, joka vihaa joulua, mutta päätyy lopulta rakastamaan sitä.', 1),
('Pääosa jumissa', 'Päähenkilö jää jumiin esimerkiksi kaukaiseen kylään tai pikkukaupunkiin.', 1), ('Jouluoksennus', 'Koti, työpaikka tai jokin muu koristeltu liioitellusti jouluteemalla, ilman mitään hillittyä tyylitajua.', 1),
('Kantakahvila', 'Pieni, kodikas kahvila toimii kaikkien tapaamispaikkana ja keskustelujen keskuksena.', 2), ('Kantakahvila', 'Pieni, kodikas kahvila toimii kaikkien tapaamispaikkana ja keskustelujen keskuksena.', 2),
('Keskeytetty suudelma', 'Juuri, kun romanttinen hetki on käsillä, jokin keskeyttää ensisuudelman.', 1),
('Kuuma kaakao', 'Kuumaa kaakaota juodaan lukemattomia kertoja, aina kermavaahdon kera.', 2),
('Kuusen valinta', 'Kohtaus, jossa kuusen valinnasta tehdään koko tarinan käännekohta.', 2),
('Kylän joulutapahtuma', 'Joulukulkue, jouluvalotapahtuma tai kuusen valaiseminen, jossa kaikki kokoontuvat yhteen.', 1),
('Lapsen joulutoive', 'Pieni lapsi toivoo joululta jotain suurta, yleensä perheeseen liittyvää.', 3),
('Leskimies', 'Päähenkilö on menettänyt puolisonsa ja yrittää nyt toipua elämässään.', 2),
('Lumipyry', 'Suuri lumipyry eristää päähenkilöt mökkiin tai kylään.', 3),
('Lumiukko', 'Lumiukon rakentaminen on täydellisen joulupäivän kohokohta.', 3),
('Majatalo', 'Tarinaan kuuluu aina viehättävä majatalo, jossa on perinteinen joulukoristelu.', 2), ('Majatalo', 'Tarinaan kuuluu aina viehättävä majatalo, jossa on perinteinen joulukoristelu.', 2),
('Miespääosa eräjorma', 'Miespäähenkilö on karskin komea eränkävijä, jolla on piilotettu herkkä puoli.', 2), ('Miespääosa eräjorma', 'Miespäähenkilö on karskin komea eränkävijä, jolla on piilotettu herkkä puoli.', 2),
('Työkriisi', 'Päähenkilön työelämässä on kriisi, joka vaatii kaiken huomion joulun alla.', 1),
('Kylän joulutapahtuma', 'Joulukulkue, jouluvalotapahtuma tai kuusen valaiseminen, jossa kaikki kokoontuvat yhteen.', 1),
('Naispääosa kaupungista', 'Uraohjautunut nainen suurkaupungista, jolla on asenneongelma pientä kylää kohtaan.', 1), ('Naispääosa kaupungista', 'Uraohjautunut nainen suurkaupungista, jolla on asenneongelma pientä kylää kohtaan.', 1),
('Naispääosan tuore ero', 'Päähenkilö on juuri eronnut ja toipuu kipeästä ihmissuhteesta.', 2), ('Naispääosan tuore ero', 'Päähenkilö on juuri eronnut ja toipuu kipeästä ihmissuhteesta.', 2),
('Suuri salaisuus', 'Joku hahmo kantaa suurta salaisuutta, joka paljastuu juuri oikealla hetkellä.', 2), ('Pääosa jumissa', 'Päähenkilö jää jumiin esimerkiksi kaukaiseen kylään tai pikkukaupunkiin.', 1),
('Himo jouluttaja', 'Yksi hahmoista on ylitsepursuava jouluihminen, jolla on täydellisesti koristeltu koti.', 1),
('Kuusen valinta', 'Kohtaus, jossa kuusen valinnasta tehdään koko tarinan käännekohta.', 2),
('Täysikuu', 'Jouluyön täysikuu valaisee maiseman ja tuo taianomaisen tunnelman.', 3),
('Kuuma kaakao', 'Kuumaa kaakaota juodaan lukemattomia kertoja, aina kermavaahdon kera.', 2),
('Punainen lava-auto', 'Joku hahmo ajaa punaisella lava-autolla, usein ilman mitään käytännön syytä.', 1),
('Lumiukko', 'Lumiukon rakentaminen on täydellisen joulupäivän kohokohta.', 3),
('Hengailua kirjastossa', 'Pieni kirjakauppa tai kirjasto on keskeinen kohtauspaikka ja keskustelujen paikka.', 3),
('Jouluenkeli', 'Jouluenkeli joko koristeena tai todellisena ilmestyksenä ohjaa hahmoja oikealle tielle.', 3),
('Lumipyry', 'Suuri lumipyry eristää päähenkilöt mökkiin tai kylään.', 3),
('Yllättävä vieras', 'Joulun alla joku saapuu yllättäen perhejuhliin tai kylään.', 3),
('Vanha kirje', 'Päähenkilö löytää vanhan kirjeen, joka muuttaa hänen elämänsä.', 3),
('Pakollinen luistelukohtaus', 'Pari luisteluhetkellä, jonka aikana tunteet alkavat syttyä.', 2), ('Pakollinen luistelukohtaus', 'Pari luisteluhetkellä, jonka aikana tunteet alkavat syttyä.', 2),
('Punainen lava-auto', 'Joku hahmo ajaa punaisella lava-autolla, usein ilman mitään käytännön syytä.', 1),
('Punainen villakangastakki', 'Päähenkilöllä on aina kirkkaanpunainen, täydellisesti istuva talvitakki.', 1),
('Suuri salaisuus', 'Joku hahmo kantaa suurta salaisuutta, joka paljastuu juuri oikealla hetkellä.', 2),
('Täysikuu', 'Jouluyön täysikuu valaisee maiseman ja tuo taianomaisen tunnelman.', 3),
('The Huoltaja', 'Miespäähenkilö on yksinhuoltaja, joka tasapainoilee uran ja lapsenhoidon välillä.', 2),
('Työkriisi', 'Päähenkilön työelämässä on kriisi, joka vaatii kaiken huomion joulun alla.', 1),
('Vanha kirje', 'Päähenkilö löytää vanhan kirjeen, joka muuttaa hänen elämänsä.', 3),
('Vanhan parin rakkausneuvo', 'Iäkäs pariskunta antaa päähenkilölle neuvoja rakkaudesta.', 3), ('Vanhan parin rakkausneuvo', 'Iäkäs pariskunta antaa päähenkilölle neuvoja rakkaudesta.', 3),
('Ensilumi', 'Juuri kun kaikki toivo on menetetty, ensilumi alkaa sataa.', 3), ('Yllättävä vieras', 'Joulun alla joku saapuu yllättäen perhejuhliin tai kylään.', 3),
('Lapsen joulutoive', 'Pieni lapsi toivoo joululta jotain suurta, yleensä perheeseen liittyvää.', 3); ('Yritys pulassa', 'Päähenkilön yritys on konkurssin partaalla tai vaarassa tulla ostetuksi.', 2);
+12 -15
View File
@@ -1,25 +1,22 @@
from models.movie import Movie from models.movie import Movie
from lib.database.database import get_db from lib.database.database import get_db
from lib.logger import logger
@logger.catch
def get_all_movies() -> list[Movie]: def get_all_movies() -> list[Movie]:
logger.info("Getting all movies")
sql = "SELECT * FROM movies" sql = "SELECT * FROM movies"
with get_db() as conn: with get_db() as conn:
result = conn.execute(sql).fetchall() result = conn.execute(sql).df()
result_dict = result.to_dict(orient="records")
movies = [] movies = []
for row in result: for row in result_dict:
movie = Movie( row["id"] = str(row["id"])
id=str(row[0]), logger.debug(f"Found movie: {row}")
name=row[1], print("Type of name:", type(row["name"]))
imdb_id=row[2], movie = Movie(**row)
actors=row[3],
release_date=row[4],
plot=row[5],
showtime=row[6],
is_watched=row[7],
created_at=row[8],
updated_at=row[9],
)
movies.append(movie) movies.append(movie)
return movies return movies
@@ -28,7 +25,7 @@ def get_all_movies() -> list[Movie]:
def get_next_movie() -> Movie | None: def get_next_movie() -> Movie | None:
sql = "SELECT * FROM movies WHERE is_watched = FALSE AND showtime > CURRENT_TIMESTAMP ORDER BY showtime ASC" sql = "SELECT * FROM movies WHERE is_watched = FALSE AND showtime = CURRENT_DATE ORDER BY showtime ASC"
with get_db() as conn: with get_db() as conn:
result = conn.execute(sql).fetchdf() result = conn.execute(sql).fetchdf()
if result.empty: if result.empty:
+11
View File
@@ -0,0 +1,11 @@
from models.score import ScoreBase
from lib.database.database import get_db
from models.general import Message
def set_score(score: ScoreBase) -> Message:
sql = "INSERT INTO scores (movie_id, user_id, score) VALUES (?, ?, ?)"
with get_db() as conn:
conn.execute(sql, (score.movie_id, score.user_id, score.score))
return Message(message="Score added successfully", message_type="success")
+18 -13
View File
@@ -1,24 +1,29 @@
from datetime import datetime
from models.user import UserIn, UserOut from models.user import UserIn, UserOut
from lib.database.database import get_db from lib.database.database import get_db
from lib.logger import logger
def set_last_login(user_id: str) -> datetime:
logger.debug(f"Setting last login for user {user_id}")
cur_time = datetime.now()
sql = f"UPDATE users SET last_login = '{cur_time}' WHERE id = '{user_id}'"
with get_db() as conn:
conn.execute(sql)
return cur_time
def get_user_by_username(username) -> UserIn | None: def get_user_by_username(username) -> UserIn | None:
sql = f"SELECT * FROM users WHERE username = '{username}'" sql = f"SELECT * FROM users WHERE username = '{username}'"
with get_db() as conn: with get_db() as conn:
result = conn.execute(sql).fetchone() result = conn.execute(sql).df().to_dict(orient="records")[0]
if result is not None: if result is not None:
return UserIn( last_login = set_last_login(result["id"])
id=str(result[0]), result["id"] = str(result["id"])
username=result[1], result["last_login"] = last_login
password=result[2], return UserIn(**result)
email=result[3],
avatar_url=result[4],
is_active=result[5],
is_admin=result[6],
last_login=result[7],
created_at=result[8],
updated_at=result[9],
)
return None return None
+2 -4
View File
@@ -4,14 +4,12 @@ from pathlib import Path
class Settings(BaseSettings): class Settings(BaseSettings):
db_url: Path = Field(Path("pjl.duckdb"), env="DB_URL") db_url: Path = Field(Path("pjl_default.duckdb"), env="DB_URL")
default_avatar_url: str = Field( default_avatar_url: str = Field(
"https://api.dicebear.com/9.x/adventurer-neutral/svg?seed=Kingston", "https://api.dicebear.com/9.x/adventurer-neutral/svg?seed=Kingston",
env="DEFAULT_AVATAR_URL", env="DEFAULT_AVATAR_URL",
) )
OMDB_API_KEY: str = Field(env="OMDB_API_KEY")
class Config:
env_file = ".env"
settings = Settings() settings = Settings()
-6
View File
@@ -1,11 +1,5 @@
# from .movie import Movie
# from .score import Score
# from .card import Card
from .general import Message from .general import Message
__all__ = [ __all__ = [
# "Movie",
# "Score",
# "Card",
"Message", "Message",
] ]
+24 -28
View File
@@ -1,44 +1,40 @@
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from datetime import datetime from datetime import datetime, date
from typing import Optional from typing import Optional
class Movie(BaseModel): class MovieBase(BaseModel):
id: Optional[str] = Field( name: Optional[str] | None = Field(
default=None, examples=["123e4567-e89b-12d3-a456-426655440000"] None, description="Name of the movie", examples=["A Maple Valley Christmas"]
)
name: str = Field(
description="Name of the movie", examples=["A Maple Valley Christmas"]
) )
imdb_id: str = Field(description="IMDB ID of the movie", examples=["tt1234567"]) imdb_id: str = Field(description="IMDB ID of the movie", examples=["tt1234567"])
actors: list[str] = Field( showtime: date = Field(description="Showtime of the movie", examples=["2024-12-01"])
description="Actors of the movie",
examples=[["Peyton List", "Andrew W. Walker"]],
)
plot: str = Field( plot: str = Field(
description="Plot of the movie", examples=["Erica is a rancher..."] description="Plot of the movie", examples=["Erica is a rancher..."]
) )
release_date: datetime = Field(
description="Release date of the movie", examples=["2022-11-05"]
class MovieIn(MovieBase):
actors: Optional[list[str]] | None = Field(
None,
description="Actors of the movie",
examples=[["Peyton List", "Andrew W. Walker"]],
) )
poster_url: str = Field( release_date: datetime | None = Field(
description="Poster URL of the movie", examples=["https://..."] None, description="Release date of the movie", examples=["2022-11-05"]
) )
showtime: datetime = Field( poster_url: str | None = Field(
description="Showtime of the movie", examples=["2024-12-01"] None, description="Poster URL of the movie", examples=["https://..."]
) )
is_watched: bool = Field(
default=False,
description="Whether the movie is watched or not", class Movie(MovieIn):
examples=[True], id: Optional[str] = Field(
default=None, examples=["123e4567-e89b-12d3-a456-426655440000"]
) )
created_at: datetime = Field( created_at: datetime = Field(
default_factory=datetime.utcnow, default_factory=datetime.utcnow, description="Creation date of the movie"
description="Creation date of the movie",
examples=[datetime.utcnow()],
) )
updated_at: datetime = Field( modified_at: datetime = Field(
default_factory=datetime.utcnow, default_factory=datetime.utcnow, description="Modification date of the movie"
description="Modification date of the movie",
examples=[datetime.utcnow()],
) )
+17 -8
View File
@@ -1,12 +1,21 @@
from sqlmodel import Field, SQLModel
from datetime import datetime from datetime import datetime
from typing import Optional from typing import Optional
from pydantic import BaseModel, Field
class Score(SQLModel, table=True): class ScoreBase(BaseModel):
id: Optional[int] = Field(default=None, primary_key=True) user_id: str = Field(
user_id: Optional[int] = Field(default=None, foreign_key="user.id") description="UUID representation of the user",
movie_id: Optional[int] = Field(default=None, foreign_key="movie.id") examples=["123e4567-e89b-12d3-a456-426655440000"],
score: int )
created_at: datetime = Field(default_factory=datetime.utcnow) movie_id: str = Field(
updated_at: datetime = Field(default_factory=datetime.utcnow) description="UUID representation of the movie",
examples=["123e4567-e89b-12d3-a456-426655440000"],
)
score: int = Field(description="Score of the movie", examples=[5], ge=-18, le=33)
created_at: Optional[datetime] = Field(default_factory=datetime.utcnow)
updated_at: Optional[datetime] = Field(default_factory=datetime.utcnow)
class ScoreDB(ScoreBase):
id: Optional[str]
+50 -8
View File
@@ -2,7 +2,7 @@ from fastapi import APIRouter
from models import Message from models import Message
from models.user import UserCredentials from models.user import UserCredentials
from models.movie import Movie from models.movie import MovieBase
from models.card import Card from models.card import Card
from lib.database.admin import ( from lib.database.admin import (
add_user, add_user,
@@ -22,35 +22,77 @@ router = APIRouter(
@router.post("/user", response_model=Message) @router.post("/user", response_model=Message)
async def add_user_endpoint(user: UserCredentials): async def add_user_endpoint(user: UserCredentials):
"""Add a user to the database""" """Add a user to the database
Args:
user (UserCredentials): The user to add
Returns:
Message: A message indicating the result of the operation
"""
return add_user(user) return add_user(user)
@router.delete("/user", response_model=Message) @router.delete("/user", response_model=Message)
async def remove_user_endpoint(user_id: str): async def remove_user_endpoint(user_id: str):
"""Remove a user from the database""" """Remove a user from the database
Args:
user_id (str): The id of the user to remove
Returns:
Message: A message indicating the result of the operation
"""
return remove_user(user_id) return remove_user(user_id)
@router.post("/movie", response_model=Message) @router.post("/movie", response_model=Message)
async def add_movie_endpoint(movie: Movie): async def add_movie_endpoint(movie: MovieBase):
"""Add a movie to the database""" """Add a movie to the database
Args:
movie (MovieBase): The movie to add
Returns:
Message: A message indicating the result of the operation
"""
return add_movie(movie) return add_movie(movie)
@router.patch("/movie", response_model=Message) @router.patch("/movie", response_model=Message)
async def set_movie_watched_endpoint(movie_id: str): async def set_movie_watched_endpoint(movie_id: str):
"""Set movie as watched""" """Set movie as watched
Args:
movie_id (str): The id of the movie to set as watched
Returns:
Message: A message indicating the result of the operation
"""
return set_movie_watched(movie_id) return set_movie_watched(movie_id)
@router.post("/card", response_model=Message) @router.post("/card", response_model=Message)
async def add_card_endpoint(card: Card): async def add_card_endpoint(card: Card):
"""Add a card to the database""" """Add a card to the database
Args:
card (Card): The card to add
Returns:
Message: A message indicating the result of the operation
"""
return add_card(card) return add_card(card)
@router.delete("/card", response_model=Message) @router.delete("/card", response_model=Message)
async def remove_card_endpoint(card_id: str): async def remove_card_endpoint(card_id: str):
"""Remove a card from the database""" """Remove a card from the database
Args:
card_id (str): The id of the card to remove
Returns:
Message: A message indicating the result of the operation
"""
return remove_card(card_id) return remove_card(card_id)
+6 -2
View File
@@ -10,7 +10,11 @@ router = APIRouter(
) )
@router.get("/", response_model=list[Card]) @router.get("/", responses={200: {"model": list[Card]}})
async def get_cards() -> list[Card]: async def get_cards() -> list[Card]:
"""Get all cards""" """Get all cards
Returns:
list[Card]: List of cards
"""
return get_all_cards() return get_all_cards()
+5 -1
View File
@@ -18,7 +18,11 @@ async def get_movies():
@router.get("/next", response_model=Movie) @router.get("/next", response_model=Movie)
async def get_next_movie_endpoint(): async def get_next_movie_endpoint():
"""Get the next movie to watch""" """Get the next movie to watch
Returns:
Movie: The next movie to watch
"""
next_movie = get_next_movie() next_movie = get_next_movie()
if next_movie is None: if next_movie is None:
return Response( return Response(
+8
View File
@@ -1,5 +1,8 @@
from fastapi import APIRouter from fastapi import APIRouter
from models.score import ScoreBase
from lib.database.score import set_score
router = APIRouter( router = APIRouter(
prefix="/score", prefix="/score",
tags=["score"], tags=["score"],
@@ -10,3 +13,8 @@ router = APIRouter(
@router.get("/leaderboard") @router.get("/leaderboard")
async def get_leaderboard(): async def get_leaderboard():
return {"leaderboard": ["user1", "user2", "user3"]} return {"leaderboard": ["user1", "user2", "user3"]}
@router.post("/")
async def add_score(score: ScoreBase):
return set_score(score)
+21 -6
View File
@@ -14,11 +14,14 @@ router = APIRouter(
@router.get( @router.get(
"/", "/",
response_model=list[UserOut], responses={200: {"model": list[UserOut]}, 404: {"model": Message}},
responses={200: {"model": UserOut}, 404: {"model": Message}},
) )
async def get_users() -> list[UserOut]: async def get_users() -> list[UserOut]:
"""Get all users""" """Get all users
Returns:
list[UserOut]: List of users
"""
return get_all_users() return get_all_users()
@@ -29,11 +32,20 @@ async def get_users() -> list[UserOut]:
400: {"model": Message}, 400: {"model": Message},
404: {"model": Message}, 404: {"model": Message},
}, },
summary="Login a user",
description="Login a user by providing their username and password",
) )
async def login(user: UserCredentials): async def login(user: UserCredentials):
"""Login a user""" """Login a user by providing their username and password
user_from_db: UserIn = get_user_by_username(user.username)
Returns:
UserOut: User information
Response: Error message
"""
# Get the user from the database
user_from_db: UserIn | None = get_user_by_username(user.username)
if user_from_db is None: if user_from_db is None:
# User not found
return Response( return Response(
status_code=404, status_code=404,
media_type="application/json", media_type="application/json",
@@ -41,11 +53,14 @@ async def login(user: UserCredentials):
message="User not found", message_type="error" message="User not found", message_type="error"
).model_dump_json(), ).model_dump_json(),
) )
password = user.password.encode("utf-8")
# Check the password
password = user.password.encode("utf-8")
if bcrypt.checkpw(password, user_from_db.password.encode("utf-8")): if bcrypt.checkpw(password, user_from_db.password.encode("utf-8")):
# Login successful
return UserOut(**user_from_db.model_dump()) return UserOut(**user_from_db.model_dump())
# Invalid password
return Response( return Response(
status_code=400, status_code=400,
media_type="application/json", media_type="application/json",
Generated
+35 -2
View File
@@ -289,8 +289,8 @@ wheels = [
] ]
[[package]] [[package]]
name = "pjl" name = "pjl-backend"
version = "0.9" version = "1.0rc1"
source = { virtual = "." } source = { virtual = "." }
dependencies = [ dependencies = [
{ name = "bcrypt" }, { name = "bcrypt" },
@@ -305,6 +305,11 @@ dependencies = [
{ name = "uvicorn" }, { name = "uvicorn" },
] ]
[package.dev-dependencies]
dev = [
{ name = "ruff" },
]
[package.metadata] [package.metadata]
requires-dist = [ requires-dist = [
{ name = "bcrypt", specifier = ">=4.2.0" }, { name = "bcrypt", specifier = ">=4.2.0" },
@@ -319,6 +324,9 @@ requires-dist = [
{ name = "uvicorn", specifier = ">=0.32.0" }, { name = "uvicorn", specifier = ">=0.32.0" },
] ]
[package.metadata.requires-dev]
dev = [{ name = "ruff", specifier = ">=0.8.0" }]
[[package]] [[package]]
name = "pydantic" name = "pydantic"
version = "2.9.2" version = "2.9.2"
@@ -448,6 +456,31 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/19/71/39c7c0d87f8d4e6c020a393182060eaefeeae6c01dab6a84ec346f2567df/rich-13.9.4-py3-none-any.whl", hash = "sha256:6049d5e6ec054bf2779ab3358186963bac2ea89175919d699e378b99738c2a90", size = 242424 }, { url = "https://files.pythonhosted.org/packages/19/71/39c7c0d87f8d4e6c020a393182060eaefeeae6c01dab6a84ec346f2567df/rich-13.9.4-py3-none-any.whl", hash = "sha256:6049d5e6ec054bf2779ab3358186963bac2ea89175919d699e378b99738c2a90", size = 242424 },
] ]
[[package]]
name = "ruff"
version = "0.8.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b2/d6/a2373f3ba7180ddb44420d2a9d1f1510e1a4d162b3d27282bedcb09c8da9/ruff-0.8.0.tar.gz", hash = "sha256:a7ccfe6331bf8c8dad715753e157457faf7351c2b69f62f32c165c2dbcbacd44", size = 3276537 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ec/77/e889ee3ce7fd8baa3ed1b77a03b9fb8ec1be68be1418261522fd6a5405e0/ruff-0.8.0-py3-none-linux_armv6l.whl", hash = "sha256:fcb1bf2cc6706adae9d79c8d86478677e3bbd4ced796ccad106fd4776d395fea", size = 10518283 },
{ url = "https://files.pythonhosted.org/packages/da/c8/0a47de01edf19fb22f5f9b7964f46a68d0bdff20144d134556ffd1ba9154/ruff-0.8.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:295bb4c02d58ff2ef4378a1870c20af30723013f441c9d1637a008baaf928c8b", size = 10317691 },
{ url = "https://files.pythonhosted.org/packages/41/17/9885e4a0eeae07abd2a4ebabc3246f556719f24efa477ba2739146c4635a/ruff-0.8.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7b1f1c76b47c18fa92ee78b60d2d20d7e866c55ee603e7d19c1e991fad933a9a", size = 9940999 },
{ url = "https://files.pythonhosted.org/packages/3e/cd/46b6f7043597eb318b5f5482c8ae8f5491cccce771e85f59d23106f2d179/ruff-0.8.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eb0d4f250a7711b67ad513fde67e8870109e5ce590a801c3722580fe98c33a99", size = 10772437 },
{ url = "https://files.pythonhosted.org/packages/5d/87/afc95aeb8bc78b1d8a3461717a4419c05aa8aa943d4c9cbd441630f85584/ruff-0.8.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0e55cce9aa93c5d0d4e3937e47b169035c7e91c8655b0974e61bb79cf398d49c", size = 10299156 },
{ url = "https://files.pythonhosted.org/packages/65/fa/04c647bb809c4d65e8eae1ed1c654d9481b21dd942e743cd33511687b9f9/ruff-0.8.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3f4cd64916d8e732ce6b87f3f5296a8942d285bbbc161acee7fe561134af64f9", size = 11325819 },
{ url = "https://files.pythonhosted.org/packages/90/26/7dad6e7d833d391a8a1afe4ee70ca6f36c4a297d3cca83ef10e83e9aacf3/ruff-0.8.0-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:c5c1466be2a2ebdf7c5450dd5d980cc87c8ba6976fb82582fea18823da6fa362", size = 12023927 },
{ url = "https://files.pythonhosted.org/packages/24/a0/be5296dda6428ba8a13bda8d09fbc0e14c810b485478733886e61597ae2b/ruff-0.8.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2dabfd05b96b7b8f2da00d53c514eea842bff83e41e1cceb08ae1966254a51df", size = 11589702 },
{ url = "https://files.pythonhosted.org/packages/26/3f/7602eb11d2886db545834182a9dbe500b8211fcbc9b4064bf9d358bbbbb4/ruff-0.8.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:facebdfe5a5af6b1588a1d26d170635ead6892d0e314477e80256ef4a8470cf3", size = 12782936 },
{ url = "https://files.pythonhosted.org/packages/4c/5d/083181bdec4ec92a431c1291d3fff65eef3ded630a4b55eb735000ef5f3b/ruff-0.8.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:87a8e86bae0dbd749c815211ca11e3a7bd559b9710746c559ed63106d382bd9c", size = 11138488 },
{ url = "https://files.pythonhosted.org/packages/b7/23/c12cdef58413cee2436d6a177aa06f7a366ebbca916cf10820706f632459/ruff-0.8.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:85e654f0ded7befe2d61eeaf3d3b1e4ef3894469cd664ffa85006c7720f1e4a2", size = 10744474 },
{ url = "https://files.pythonhosted.org/packages/29/61/a12f3b81520083cd7c5caa24ba61bb99fd1060256482eff0ef04cc5ccd1b/ruff-0.8.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:83a55679c4cb449fa527b8497cadf54f076603cc36779b2170b24f704171ce70", size = 10369029 },
{ url = "https://files.pythonhosted.org/packages/08/2a/c013f4f3e4a54596c369cee74c24870ed1d534f31a35504908b1fc97017a/ruff-0.8.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:812e2052121634cf13cd6fddf0c1871d0ead1aad40a1a258753c04c18bb71bbd", size = 10867481 },
{ url = "https://files.pythonhosted.org/packages/d5/f7/685b1e1d42a3e94ceb25eab23c70bdd8c0ab66a43121ef83fe6db5a58756/ruff-0.8.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:780d5d8523c04202184405e60c98d7595bdb498c3c6abba3b6d4cdf2ca2af426", size = 11237117 },
{ url = "https://files.pythonhosted.org/packages/03/20/401132c0908e8837625e3b7e32df9962e7cd681a4df1e16a10e2a5b4ecda/ruff-0.8.0-py3-none-win32.whl", hash = "sha256:5fdb6efecc3eb60bba5819679466471fd7d13c53487df7248d6e27146e985468", size = 8783511 },
{ url = "https://files.pythonhosted.org/packages/1d/5c/4d800fca7854f62ad77f2c0d99b4b585f03e2d87a6ec1ecea85543a14a3c/ruff-0.8.0-py3-none-win_amd64.whl", hash = "sha256:582891c57b96228d146725975fbb942e1f30a0c4ba19722e692ca3eb25cc9b4f", size = 9559876 },
{ url = "https://files.pythonhosted.org/packages/5b/bc/cc8a6a5ca4960b226dc15dd8fb511dd11f2014ff89d325c0b9b9faa9871f/ruff-0.8.0-py3-none-win_arm64.whl", hash = "sha256:ba93e6294e9a737cd726b74b09a6972e36bb511f9a102f1d9a7e1ce94dd206a6", size = 8939733 },
]
[[package]] [[package]]
name = "six" name = "six"
version = "1.16.0" version = "1.16.0"