19 Commits
Author SHA1 Message Date
Esa Kataja c3b63a61cc Add user profile update functionality to planned features for 1.0 2025-05-16 08:16:04 +03:00
Esa Kataja b6a1d4aeaf Prepare 1.0rc3 release 2025-05-16 08:11:54 +03:00
Esa Kataja 6c512c78aa Add avatar images 2025-05-16 08:05:10 +03:00
Esa Kataja 0aee5ca021 Change result end point to output artist info 2025-05-15 20:53:23 +03:00
Esa Kataja e863a8a088 Fix data dir was copied from dev and errored on build 2025-05-15 20:52:37 +03:00
Esa Kataja 58632e29cb Fix when no reviews exist raises error 2025-05-15 19:16:54 +03:00
Esa Kataja e560809c25 Add result endpoints 2025-05-15 19:16:08 +03:00
Esa Kataja a0344903ec Add contest table and routes 2025-05-15 17:58:52 +03:00
Esa Kataja 9ecdc1b040 Fix container not starting if data dir did not exist 2025-05-13 21:10:32 +03:00
Esa Kataja 4a480a312e Create result views to database schema 2025-05-13 20:58:28 +03:00
Esa Kataja f00bff5f64 Fix review put failing when no previous review exist 2025-05-13 20:11:38 +03:00
Esa Kataja 25f528c86d Add gitignore entries 2025-05-13 19:51:41 +03:00
Esa Kataja 21e30b0fb2 Add more logging 2025-05-13 19:48:37 +03:00
Esa Kataja 07d8e15925 Update lock file to rc2 2025-05-10 15:19:17 +03:00
Esa Kataja 7609159f60 Add message model 2025-05-10 15:18:26 +03:00
Esa Kataja c2884ac9ce Add more logging 2025-05-10 15:17:57 +03:00
Esa Kataja 40010c9860 Change logger loglevel operation 2025-05-10 15:17:42 +03:00
Esa Kataja d97a665a9c Prepare 1.0rc2 release 2025-05-10 14:33:35 +03:00
Esa Kataja 39da976ef3 Add more comprehensive logging 2025-05-10 14:28:34 +03:00
47 changed files with 508 additions and 83 deletions
+3
View File
@@ -2,3 +2,6 @@
*.duckdb *.duckdb
.env .env
__pycache__ __pycache__
src/data/
+2
View File
@@ -16,4 +16,6 @@ wheels/
*.log *.log
*.gz *.gz
data
.vscode/* .vscode/*
+48
View File
@@ -0,0 +1,48 @@
# Eurovision 25 Backend Changelog
## 1.0rc3 (2025-05-16)
### Features
- Added contest table and routes
- Added result endpoints with artist information
- Created result views in database schema
- Added avatar images for users
### Fixes
- Fixed data directory copied from dev environment causing build errors
- Fixed error when no reviews exist
- Fixed container not starting if data directory did not exist
- Fixed review PUT failing when no previous review exists
### Changes
- Updated application version to 1.0rc3
- Merged changelog files for better version tracking
- Added more comprehensive logging
- Added gitignore entries
## 1.0rc2 (2025-05-10)
### Changes
- Updated application version to 1.0rc2
- Added more comprehensive logging throughout the application
## 1.0rc1 (2025-05-01)
### Features
- Initial release candidate
- Eurovision 25 Homereview API implementation
- Authentication and authorization system
- User management features
- Song management and reviews
- Review system
- Results calculation and display
- Admin functionality
- Static file serving for avatars and country flags
The application is feature complete, but may still contain bugs that will be addressed before the final 1.0.0 release.
## Planned for 1.0.0 (Final Release)
### Features
- Implementation of user profile update functionality to allow users to edit their personal information (name, email, password, avatar, etc.)
- Additional security improvements and bug fixes
+3
View File
@@ -6,10 +6,13 @@ WORKDIR /app
COPY pyproject.toml uv.lock /app/ COPY pyproject.toml uv.lock /app/
RUN uv sync --frozen --no-install-project --no-dev RUN uv sync --frozen --no-install-project --no-dev
COPY src /app COPY src /app
# Ensure data directory exists but don't copy from host
RUN uv sync --frozen --no-dev RUN uv sync --frozen --no-dev
FROM base FROM base
COPY --from=builder /app /app COPY --from=builder /app /app
ENV PATH="/app/.venv/bin:$PATH" ENV PATH="/app/.venv/bin:$PATH"
WORKDIR /app WORKDIR /app
# Create data directory
RUN mkdir -p /app/data && chmod 755 /app/data
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8000"] CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8000"]
+4
View File
@@ -220,6 +220,10 @@ Automated tests are not implemented at this time. Manual testing via the API doc
## TODOs ## TODOs
* [ ] Replace password hashing with passlib * [ ] Replace password hashing with passlib
* [ ] Switch to PostgreSQL from DuckDB
* [x] Implement more comprehensive logging
* [ ] Implement user disabling functionality
* [ ] Implement user profile update functionality (name, email, password, avatar, etc.)
## Contributing ## Contributing
+1 -1
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "Eurovision-25-backend" name = "Eurovision-25-backend"
version = "1.0rc1" version = "1.0rc2"
description = "Backend for Eurovision 25" description = "Backend for Eurovision 25"
readme = "README.md" readme = "README.md"
requires-python = ">=3.12" requires-python = ">=3.12"
+12 -2
View File
@@ -3,7 +3,7 @@ from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
# Import routers # Import routers
from routes import auth, admin, users, songs, reviews, results from routes import auth, admin, users, songs, reviews, results, contest
from lib.logger import logger from lib.logger import logger
from lib.db import init_db from lib.db import init_db
@@ -12,9 +12,18 @@ from lib.db import init_db
app = FastAPI( app = FastAPI(
title="Eurovision 25 Homereview API", title="Eurovision 25 Homereview API",
description="Backend API for Eurovision 25 Homereview application", description="Backend API for Eurovision 25 Homereview application",
version="1.0rc1", version="1.0rc3",
) )
@app.middleware("http")
async def log_requests(request, call_next):
body = await request.body()
logger.debug(f"Request body: {body.decode('utf-8')}")
response = await call_next(request)
return response
# Add CORS middleware # Add CORS middleware
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
@@ -51,3 +60,4 @@ app.include_router(users.router)
app.include_router(songs.router) app.include_router(songs.router)
app.include_router(reviews.router) app.include_router(reviews.router)
app.include_router(results.router) app.include_router(results.router)
app.include_router(contest.router)
+64
View File
@@ -9,12 +9,16 @@ DROP TABLE IF EXISTS Review;
DROP TABLE IF EXISTS Song; DROP TABLE IF EXISTS Song;
DROP TABLE IF EXISTS "User"; -- Quoted because USER is a reserved keyword DROP TABLE IF EXISTS "User"; -- Quoted because USER is a reserved keyword
DROP TABLE IF EXISTS Team; DROP TABLE IF EXISTS Team;
DROP TABLE IF EXISTS Contest;
DROP TABLE IF EXISTS CountryCodes; DROP TABLE IF EXISTS CountryCodes;
DROP VIEW IF EXISTS ReviewSummary;
DROP VIEW IF EXISTS ReviewSummaryByTeam;
DROP SEQUENCE IF EXISTS group_id_seq; DROP SEQUENCE IF EXISTS group_id_seq;
DROP SEQUENCE IF EXISTS user_id_seq; DROP SEQUENCE IF EXISTS user_id_seq;
DROP SEQUENCE IF EXISTS song_id_seq; DROP SEQUENCE IF EXISTS song_id_seq;
DROP SEQUENCE IF EXISTS review_id_seq; DROP SEQUENCE IF EXISTS review_id_seq;
DROP SEQUENCE IF EXISTS contest_id_seq;
-- ============================================================================= -- =============================================================================
-- Sequences for Primary Keys -- Sequences for Primary Keys
@@ -23,6 +27,23 @@ CREATE SEQUENCE group_id_seq START 1;
CREATE SEQUENCE user_id_seq START 1; CREATE SEQUENCE user_id_seq START 1;
CREATE SEQUENCE song_id_seq START 1; CREATE SEQUENCE song_id_seq START 1;
CREATE SEQUENCE review_id_seq START 1; CREATE SEQUENCE review_id_seq START 1;
CREATE SEQUENCE contest_id_seq START 1;
-- =============================================================================
-- Table: Contest
-- Stores information about Eurovision contests by year.
-- =============================================================================
CREATE TABLE Contest (
id INTEGER PRIMARY KEY DEFAULT nextval('contest_id_seq'), -- Use sequence for auto-increment
year INTEGER DEFAULT EXTRACT(YEAR FROM CURRENT_DATE) NOT NULL, -- The year of the Eurovision contest
is_active BOOLEAN DEFAULT TRUE NOT NULL, -- Flag indicating if this is the active contest
finals_date DATE NOT NULL, -- Date of the finals
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, -- Timestamp when the contest was created
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP -- Timestamp when the contest was last updated
);
-- Add index for faster lookups by year
CREATE INDEX idx_contest_year ON Contest (year);
-- ============================================================================= -- =============================================================================
-- Table: Group -- Table: Group
@@ -42,6 +63,8 @@ CREATE TABLE "Team" (
CREATE TABLE "User" ( CREATE TABLE "User" (
id INTEGER PRIMARY KEY DEFAULT nextval('user_id_seq'), -- Use sequence for auto-increment id INTEGER PRIMARY KEY DEFAULT nextval('user_id_seq'), -- Use sequence for auto-increment
username VARCHAR UNIQUE NOT NULL, -- The user's login name username VARCHAR UNIQUE NOT NULL, -- The user's login name
first_name VARCHAR DEFAULT '',
last_name VARCHAR DEFAULT '',
hashed_password VARCHAR NOT NULL, -- The securely hashed password hashed_password VARCHAR NOT NULL, -- The securely hashed password
email VARCHAR UNIQUE, -- User's email address (nullable) email VARCHAR UNIQUE, -- User's email address (nullable)
team_id INTEGER NOT NULL, -- Foreign Key -> Group.id team_id INTEGER NOT NULL, -- Foreign Key -> Group.id
@@ -115,6 +138,44 @@ CREATE TABLE CountryCodes (
name_sv VARCHAR NOT NULL name_sv VARCHAR NOT NULL
); );
CREATE VIEW ReviewSummaryGlobal AS
SELECT
song_id,
cc.name_fi AS country_fi,
cc.name_sv AS country_sv,
s.artist,
s.title,
COUNT(*) AS total_reviews,
AVG(score_song) AS avg_score_song,
AVG(score_show) AS avg_score_show,
AVG(score_costume) AS avg_score_costume,
AVG((score_song + score_show + score_costume)/3) AS avg_total_score
FROM Review r
JOIN Song s ON r.song_id = s.id
JOIN CountryCodes cc ON s.country = cc.code
GROUP BY song_id, cc.name_fi, cc.name_sv, s.artist, s.title
ORDER BY avg_total_score DESC;
CREATE VIEW ReviewSummaryByTeam AS
SELECT
r.song_id,
cc.name_fi AS country_fi,
cc.name_sv AS country_sv,
s.artist,
s.title,
u.team_id,
COUNT(*) AS total_reviews,
AVG(r.score_song) AS avg_score_song,
AVG(r.score_show) AS avg_score_show,
AVG(r.score_costume) AS avg_score_costume,
AVG((r.score_song + r.score_show + r.score_costume)/3) AS avg_total_score
FROM Review r
JOIN "User" u ON r.user_id = u.id
JOIN Song s ON r.song_id = s.id
JOIN CountryCodes cc ON s.country = cc.code
GROUP BY r.song_id, u.team_id, cc.name_fi, cc.name_sv, s.artist, s.title
ORDER BY avg_total_score DESC;
-- Insert country codes data -- Insert country codes data
INSERT INTO CountryCodes (code, name_en, name_fi, name_sv) VALUES ('ALB', 'Albania', 'Albania', 'Albanien'); INSERT INTO CountryCodes (code, name_en, name_fi, name_sv) VALUES ('ALB', 'Albania', 'Albania', 'Albanien');
INSERT INTO CountryCodes (code, name_en, name_fi, name_sv) VALUES ('ARM', 'Armenia', 'Armenia', 'Armenien'); INSERT INTO CountryCodes (code, name_en, name_fi, name_sv) VALUES ('ARM', 'Armenia', 'Armenia', 'Armenien');
@@ -154,6 +215,9 @@ INSERT INTO CountryCodes (code, name_en, name_fi, name_sv) VALUES ('CHE', 'Switz
INSERT INTO CountryCodes (code, name_en, name_fi, name_sv) VALUES ('UKR', 'Ukraine', 'Ukraina', 'Ukraina'); INSERT INTO CountryCodes (code, name_en, name_fi, name_sv) VALUES ('UKR', 'Ukraine', 'Ukraina', 'Ukraina');
INSERT INTO CountryCodes (code, name_en, name_fi, name_sv) VALUES ('GBR', 'United Kingdom', 'Englanti', 'Storbritannien'); INSERT INTO CountryCodes (code, name_en, name_fi, name_sv) VALUES ('GBR', 'United Kingdom', 'Englanti', 'Storbritannien');
-- TODO: Clean this next year. Its now here just to get the show running
INSERT INTO Contest (year, finals_date) VALUES (2025, '2025-05-16');
-- ============================================================================= -- =============================================================================
-- Views (Optional - Not Created Here Based on Documentation Decision) -- Views (Optional - Not Created Here Based on Documentation Decision)
-- ============================================================================= -- =============================================================================
+8
View File
@@ -6,6 +6,7 @@ from dotenv import load_dotenv
import os import os
from lib.helpers import hash_password from lib.helpers import hash_password
from lib.logger import logger
load_dotenv() load_dotenv()
@@ -61,6 +62,9 @@ def seed_db() -> None:
# Seed Admin users # Seed Admin users
admin_username = os.getenv("ADMIN_USERNAME") admin_username = os.getenv("ADMIN_USERNAME")
hashed_admin_password = hash_password(os.getenv("ADMIN_PASSWORD", "password")) hashed_admin_password = hash_password(os.getenv("ADMIN_PASSWORD", "password"))
logger.debug(
f"Admin user: {admin_username}, hashed password: {hashed_admin_password}"
)
with get_connection() as conn: with get_connection() as conn:
conn.execute( conn.execute(
'INSERT INTO "User" (username, hashed_password, team_id, is_active, is_admin) VALUES (?, ?, ?, ?, ?)', 'INSERT INTO "User" (username, hashed_password, team_id, is_active, is_admin) VALUES (?, ?, ?, ?, ?)',
@@ -75,8 +79,12 @@ def seed_db() -> None:
def init_db(): def init_db():
logger.info("Initializing database...")
if Path(DB_PATH).exists(): if Path(DB_PATH).exists():
return return
logger.info("Database does not exist, creating...")
if not Path(DB_PATH).parent.exists():
Path(DB_PATH).parent.mkdir(parents=True, exist_ok=True)
with open(SQL_PATH, "r") as f: with open(SQL_PATH, "r") as f:
sql = f.read() sql = f.read()
with get_connection() as conn: with get_connection() as conn:
+17 -7
View File
@@ -1,16 +1,26 @@
from loguru import logger from loguru import logger
from pathlib import Path from pathlib import Path
import sys import sys
from dotenv import load_dotenv
import os
LOG_PATH = Path("./data/logs/logs.log").absolute() load_dotenv()
ERROR_PATH = Path("./data/logs/errors.log").absolute()
LOG_PATH = Path(os.getenv("LOG_PATH", "./data/logs/logs.log")).absolute()
ERROR_PATH = Path(os.getenv("ERROR_PATH", "./data/logs/errors.log")).absolute()
logger.remove() logger.remove()
logger.add(LOG_PATH, rotation="10 MB", compression="gz", level="INFO") logger.add(
logger.add(sys.stdout, level="INFO") LOG_PATH, rotation="10 MB", compression="gz", level=os.getenv("LOG_LEVEL", "INFO")
)
logger.add(sys.stdout, level=os.getenv("LOG_LEVEL", "INFO"))
logger.add(ERROR_PATH, rotation="10 MB", compression="gz", level="ERROR") logger.add(
logger.add(sys.stderr, level="ERROR") ERROR_PATH,
logger.add(sys.stdout, level="DEBUG") rotation="10 MB",
compression="gz",
level=os.getenv("LOG_LEVEL_ERROR", "ERROR"),
)
logger.add(sys.stderr, level=os.getenv("LOG_LEVEL_ERROR", "ERROR"))
logger = logger logger = logger
+25
View File
@@ -0,0 +1,25 @@
from pydantic import BaseModel, Field
from datetime import date, datetime
class Contest(BaseModel):
id: int
year: int = Field(
default_factory=lambda: datetime.now().year,
description="The year of the contest",
examples=[2025],
)
is_active: bool = Field(
default=True, description="Is this the active contest?", examples=[True, False]
)
finals_date: date = Field(
description="The date of the finals", examples=["2025-05-15"]
)
created_at: datetime = Field(
description="The date and time when the contest was created",
examples=["2025-05-15T17:40:42.000Z"],
)
updated_at: datetime = Field(
description="The date and time when the contest was last updated",
examples=["2025-05-15T17:40:42.000Z"],
)
+8
View File
@@ -0,0 +1,8 @@
from pydantic import BaseModel, Field
from datetime import datetime
class Message(BaseModel):
type: str | None = None
message: str
timestamp: datetime = Field(default_factory=datetime.now)
+28
View File
@@ -0,0 +1,28 @@
from pydantic import BaseModel, Field
class Result(BaseModel):
song_id: int = Field(description="ID of the song", examples=[1])
country_fi: str = Field(
description="Country of the song in Finnish", examples=["Suomi"]
)
country_sv: str = Field(
description="Country of the song in Swedish", examples=["Sverige"]
)
artist: str = Field(description="Artist of the song", examples=["Käärijä"])
title: str = Field(description="Title of the song", examples=["Cha cha cha"])
total_reviews: int = Field(0, description="Total number of reviews", examples=[10])
avg_score_song: float = Field(
description="Average score for song quality", examples=[85.5]
)
avg_score_show: float = Field(
description="Average score for stage show", examples=[82.3]
)
avg_score_costume: float = Field(
description="Average score for wardrobe/costumes", examples=[88.1]
)
avg_total_score: float = Field(description="Average total score", examples=[85.5])
class TeamResult(Result):
team_id: int = Field(description="ID of the team", examples=[1])
+44 -6
View File
@@ -1,9 +1,10 @@
from fastapi import APIRouter, Body, HTTPException from fastapi import APIRouter, Body, HTTPException, Request
from models.user import CreateUser from models.user import CreateUser
from models.team import TeamBase, Team from models.team import TeamBase, Team
from lib.db import get_connection from lib.db import get_connection
from lib.helpers import hash_password from lib.helpers import hash_password
from lib.logger import logger
router = APIRouter(prefix="/admin", tags=["Admin"]) router = APIRouter(prefix="/admin", tags=["Admin"])
@@ -19,22 +20,46 @@ router = APIRouter(prefix="/admin", tags=["Admin"])
@router.post("/teams") @router.post("/teams")
async def create_team(team: TeamBase): async def create_team(team: TeamBase, request: Request):
logger.info(
f"Admin action: Creating new team '{team.name}' from IP: {request.client.host}"
)
try:
with get_connection() as conn: with get_connection() as conn:
conn.execute("INSERT INTO Team (name) VALUES (?)", (team.name,)) conn.execute("INSERT INTO Team (name) VALUES (?)", (team.name,))
logger.info(f"Team '{team.name}' created successfully")
return {"message": "Team created successfully"} return {"message": "Team created successfully"}
except Exception as e:
logger.error(f"Failed to create team '{team.name}': {str(e)}")
raise HTTPException(status_code=500, detail="Failed to create team")
@router.get("/teams", response_model=list[Team]) @router.get("/teams", response_model=list[Team])
async def list_teams(): async def list_teams(request: Request):
logger.debug(f"Admin action: Listing all teams from IP: {request.client.host}")
with get_connection() as conn: with get_connection() as conn:
teams = conn.execute("SELECT * FROM Team").fetchdf() teams = conn.execute("SELECT * FROM Team").fetchdf()
return teams.to_dict(orient="records") return teams.to_dict(orient="records")
@router.post("/users") @router.post("/users")
async def create_user(user: CreateUser): async def create_user(user: CreateUser, request: Request):
logger.info(
f"Admin action: Creating new user '{user.username}' from IP: {request.client.host}"
)
try:
with get_connection() as conn: with get_connection() as conn:
# Check if username already exists
existing = conn.execute(
"SELECT COUNT(*) as count FROM User WHERE username = ?",
(user.username,),
).fetchdf()
if existing["count"][0] > 0:
logger.warning(
f"Failed to create user: Username '{user.username}' already exists"
)
raise HTTPException(status_code=400, detail="Username already exists")
conn.execute( conn.execute(
"INSERT INTO User (username, hashed_password, team_id, is_admin) VALUES (?, ?, ?, ?)", "INSERT INTO User (username, hashed_password, team_id, is_admin) VALUES (?, ?, ?, ?)",
( (
@@ -44,11 +69,24 @@ async def create_user(user: CreateUser):
user.is_admin, user.is_admin,
), ),
) )
logger.info(
f"User '{user.username}' created successfully with team_id: {user.team_id}, admin status: {user.is_admin}"
)
return {"message": "User created successfully"} return {"message": "User created successfully"}
except HTTPException:
# Re-raise HTTP exceptions
raise
except Exception as e:
logger.error(f"Failed to create user '{user.username}': {str(e)}")
raise HTTPException(status_code=500, detail="Failed to create user")
@router.post("/users/") @router.post("/users/disable")
async def disable_user(user_id: int = Body(..., embed=True)): async def disable_user(user_id: int = Body(..., embed=True), request: Request = None):
logger.info(
f"Admin action: Attempting to disable user with ID: {user_id} from IP: {request.client.host}"
)
logger.warning(f"Disable user functionality not implemented for user_id: {user_id}")
raise HTTPException(status_code=405, detail="Method not yet implemented") raise HTTPException(status_code=405, detail="Method not yet implemented")
+21 -6
View File
@@ -1,24 +1,34 @@
from fastapi import APIRouter, HTTPException from fastapi import APIRouter, HTTPException, Request
from lib.db import get_connection from lib.db import get_connection
from models.user import User, UserLogin from models.user import User, UserLogin
from lib.helpers import verify_password from lib.helpers import verify_password
from lib.logger import logger
router = APIRouter(prefix="/auth", tags=["auth"]) router = APIRouter(prefix="/auth", tags=["auth"])
@router.post("/token", response_model=User) @router.post("/token", response_model=User)
async def login(user_login: UserLogin): async def login(user_login: UserLogin, request: Request):
logger.debug(
f"Login attempt for user: {user_login.username} from IP: {request.client.host}"
)
with get_connection() as conn: with get_connection() as conn:
user_df = conn.execute( user_df = conn.execute(
'SELECT * FROM "User" WHERE username = ?', (user_login.username,) 'SELECT * FROM "User" WHERE username = ?', (user_login.username,)
).fetchdf() ).fetchdf()
if user_df.empty: if user_df.empty:
logger.warning(
f"Failed login: Username {user_login.username} not found - IP: {request.client.host}"
)
raise HTTPException(status_code=401, detail="Invalid username or password") raise HTTPException(status_code=401, detail="Invalid username or password")
user_data = user_df.to_dict(orient="records")[0] user_data = user_df.to_dict(orient="records")[0]
if not verify_password(user_login.password, user_data["hashed_password"]): if not verify_password(user_login.password, user_data["hashed_password"]):
logger.warning(
f"Failed login: Incorrect password for user {user_login.username} - IP: {request.client.host}"
)
raise HTTPException(status_code=401, detail="Invalid username or password") raise HTTPException(status_code=401, detail="Invalid username or password")
# Update the login timestamp in the database # Update the login timestamp in the database
@@ -29,9 +39,14 @@ async def login(user_login: UserLogin):
) )
# Fetch the updated user data with the new last_login timestamp # Fetch the updated user data with the new last_login timestamp
updated_user = conn.execute( updated_user = (
'SELECT * FROM "User" WHERE id = ?', conn.execute('SELECT * FROM "User" WHERE id = ?', (user_data["id"],))
(user_data["id"],) .fetchdf()
).fetchdf().to_dict(orient="records")[0] .to_dict(orient="records")[0]
)
logger.info(
f"Successful login: User {user_login.username} (ID: {user_data['id']}) logged in from {request.client.host}"
)
return User(**updated_user) return User(**updated_user)
+39
View File
@@ -0,0 +1,39 @@
from fastapi import APIRouter, HTTPException
from lib.db import get_connection
from lib.logger import logger
from models.contest import Contest
from models.msg import Message
router = APIRouter(prefix="/contest", tags=["contest"])
@router.get("/", response_model=list[Contest])
async def list_contests():
with get_connection() as conn:
logger.debug("Listing all contests")
df = conn.execute("SELECT * FROM Contest").fetchdf()
logger.debug(f"Contests: {df}")
contests = [Contest(**row) for row in df.to_dict(orient="records")]
return contests
@router.get("/{year}", response_model=Contest)
async def get_contest(year: int):
with get_connection() as conn:
df = conn.execute("SELECT * FROM Contest WHERE year = ?", (year,)).fetchdf()
if df.empty:
raise HTTPException(status_code=404, detail="Contest not found")
contest = Contest(**df.to_dict(orient="records")[0])
return contest
@router.put("/", response_model=Message)
async def update_contest(year: int, is_active: bool):
with get_connection() as conn:
logger.debug(f"Updating contest {year} to active: {is_active}")
conn.execute(
"UPDATE Contest SET is_active = ? WHERE year = ?",
(is_active, year),
)
return Message(message="Contest updated successfully")
+25
View File
@@ -1,3 +1,28 @@
from fastapi import APIRouter from fastapi import APIRouter
from models.result import Result, TeamResult
from lib.db import get_connection
router = APIRouter(prefix="/results", tags=["results"]) router = APIRouter(prefix="/results", tags=["results"])
@router.get("/", response_model=list[Result])
async def list_results():
with get_connection() as conn:
df = conn.execute("SELECT * FROM ReviewSummaryGlobal").fetchdf()
if df.empty:
return []
results = [Result(**row) for row in df.to_dict(orient="records")]
return results
@router.get("/team/{team_id}", response_model=list[TeamResult])
async def list_team_results(team_id: int):
with get_connection() as conn:
df = conn.execute(
"SELECT * FROM ReviewSummaryByTeam WHERE team_id = ?", (team_id,)
).fetchdf()
if df.empty:
return []
results = [TeamResult(**row) for row in df.to_dict(orient="records")]
return results
+100 -16
View File
@@ -1,7 +1,9 @@
from fastapi import APIRouter, HTTPException from fastapi import APIRouter, HTTPException
from models.review import ReviewOut, ReviewSearch, ReviewIn from models.review import ReviewOut, ReviewIn
from models.msg import Message
from lib.db import get_connection from lib.db import get_connection
from lib.logger import logger
router = APIRouter(prefix="/reviews", tags=["reviews"]) router = APIRouter(prefix="/reviews", tags=["reviews"])
@@ -11,26 +13,36 @@ async def list_reviews():
with get_connection() as conn: with get_connection() as conn:
reviews = conn.execute("SELECT * FROM Review").fetchdf() reviews = conn.execute("SELECT * FROM Review").fetchdf()
if reviews.empty: if reviews.empty:
raise HTTPException(status_code=404, detail="Reviews not found") return []
return reviews.to_dict(orient="records") return reviews.to_dict(orient="records")
@router.get("/", response_model=ReviewOut) @router.get("/", response_model=ReviewOut)
async def get_review(review_id: ReviewSearch): async def get_review(song_id: int, user_id: int):
with get_connection() as conn: with get_connection() as conn:
review = conn.execute( review = conn.execute(
"SELECT * FROM Review WHERE song_id = ? AND user_id = ?", "SELECT * FROM Review WHERE song_id = ? AND user_id = ?",
(review_id.song_id, review_id.user_id), (song_id, user_id),
).fetchdf() ).fetchdf()
if review.empty: if review.empty:
raise HTTPException(status_code=404, detail="Review not found") return []
return review.to_dict(orient="records")[0] return review.to_dict(orient="records")[0]
@router.post("/", response_model=ReviewOut) @router.post("/", response_model=Message)
async def create_review(review: ReviewIn): async def create_review(review: ReviewIn):
logger.debug(f"Received create review request with data: {review.dict()}")
try:
with get_connection() as conn: with get_connection() as conn:
review = conn.execute( # Log the SQL query and parameters
logger.debug(
f"Executing INSERT INTO Review (user_id, song_id, score_song, score_show, score_costume, text_review) "
f"VALUES ({review.user_id}, {review.song_id}, {review.score_song}, "
f"{review.score_show}, {review.score_costume}, '{review.text_review}')"
)
# Insert the review
conn.execute(
"INSERT INTO Review (user_id, song_id, score_song, score_show, score_costume, text_review) VALUES (?, ?, ?, ?, ?, ?)", "INSERT INTO Review (user_id, song_id, score_song, score_show, score_costume, text_review) VALUES (?, ?, ?, ?, ?, ?)",
( (
review.user_id, review.user_id,
@@ -40,20 +52,58 @@ async def create_review(review: ReviewIn):
review.score_costume, review.score_costume,
review.text_review, review.text_review,
), ),
)
# Fetch the newly created review
logger.debug(
f"Checking if review was created for song_id={review.song_id}, user_id={review.user_id}"
)
result = conn.execute(
"SELECT * FROM Review WHERE song_id = ? AND user_id = ?",
(review.song_id, review.user_id),
).fetchdf() ).fetchdf()
if review.empty: logger.debug(f"Database result for newly created review: {result}")
if result.empty:
logger.warning(
f"Review not found after insert attempt: song_id={review.song_id}, user_id={review.user_id}"
)
raise HTTPException(status_code=404, detail="Review not found") raise HTTPException(status_code=404, detail="Review not found")
return review.to_dict(orient="records")[0]
logger.info(
f"Review created successfully for song_id={review.song_id}, user_id={review.user_id}"
)
return Message(type="success", message="Review created successfully")
except Exception as e:
logger.error(f"Error creating review: {str(e)}")
raise
@router.put("/", response_model=ReviewOut) @router.put("/", response_model=Message)
async def update_review(review: ReviewIn): async def update_review(review: ReviewIn):
print("update_review")
logger.debug(f"Received update review request with data: {review.dict()}")
try:
with get_connection() as conn: with get_connection() as conn:
review = conn.execute( # Log the SQL query and parameters
"UPDATE Review SET user_id = ?, song_id = ?, score_song = ?, score_show = ?, score_costume = ?, text_review = ? WHERE song_id = ? AND user_id = ?", logger.debug(
f"Executing UPDATE Review SET score_song = {review.score_song}, "
f"score_show = {review.score_show}, score_costume = {review.score_costume}, "
f"text_review = '{review.text_review}' WHERE song_id = {review.song_id} "
f"AND user_id = {review.user_id}"
)
# Check if the review exists
review_df = conn.execute(
"SELECT * FROM Review WHERE song_id = ? AND user_id = ?",
(review.song_id, review.user_id),
).fetchdf()
if not review_df.empty:
# Update the review
conn.execute(
"UPDATE Review SET score_song = ?, score_show = ?, score_costume = ?, text_review = ? WHERE song_id = ? AND user_id = ?",
( (
review.user_id,
review.song_id,
review.score_song, review.score_song,
review.score_show, review.score_show,
review.score_costume, review.score_costume,
@@ -61,7 +111,41 @@ async def update_review(review: ReviewIn):
review.song_id, review.song_id,
review.user_id, review.user_id,
), ),
)
else:
# Insert the review
conn.execute(
"INSERT INTO Review (user_id, song_id, score_song, score_show, score_costume, text_review) VALUES (?, ?, ?, ?, ?, ?)",
(
review.user_id,
review.song_id,
review.score_song,
review.score_show,
review.score_costume,
review.text_review,
),
)
# Fetch the updated review
logger.debug(
f"Checking if review was updated for song_id={review.song_id}, user_id={review.user_id}"
)
result = conn.execute(
"SELECT * FROM Review WHERE song_id = ? AND user_id = ?",
(review.song_id, review.user_id),
).fetchdf() ).fetchdf()
if review.empty: logger.debug(f"Database result: {result}")
if result.empty:
logger.warning(
f"Review not found after update attempt: song_id={review.song_id}, user_id={review.user_id}"
)
raise HTTPException(status_code=404, detail="Review not found") raise HTTPException(status_code=404, detail="Review not found")
return review.to_dict(orient="records")[0]
logger.info(
f"Review updated successfully for song_id={review.song_id}, user_id={review.user_id}"
)
return Message(type="success", message="Review updated successfully")
except Exception as e:
logger.error(f"Error updating review: {str(e)}")
raise
+16 -5
View File
@@ -1,14 +1,15 @@
from fastapi import APIRouter from fastapi import APIRouter, HTTPException, Request
from fastapi import HTTPException
from models.user import User, UserChangePassword from models.user import User, UserChangePassword
from lib.db import get_connection from lib.db import get_connection
from lib.logger import logger
router = APIRouter(prefix="/users", tags=["users"]) router = APIRouter(prefix="/users", tags=["users"])
@router.get("/", response_model=list[User]) @router.get("/", response_model=list[User])
async def list_users(): async def list_users(request: Request):
logger.info(f"User list requested from {request.client.host}")
with get_connection() as conn: with get_connection() as conn:
users = conn.execute('SELECT * FROM "User"').fetchdf() users = conn.execute('SELECT * FROM "User"').fetchdf()
@@ -18,19 +19,29 @@ async def list_users():
@router.get("/{user_id}", response_model=User) @router.get("/{user_id}", response_model=User)
async def get_user(user_id: int): async def get_user(user_id: int, request: Request):
logger.debug(
f"User details requested for user_id: {user_id} from {request.client.host}"
)
with get_connection() as conn: with get_connection() as conn:
user_df = conn.execute( user_df = conn.execute(
'SELECT * FROM "User" WHERE id = ?', (user_id,) 'SELECT * FROM "User" WHERE id = ?', (user_id,)
).fetchdf() ).fetchdf()
if user_df.empty: if user_df.empty:
logger.warning(f"Failed user lookup: user_id {user_id} not found")
raise HTTPException(status_code=404, detail="User not found") raise HTTPException(status_code=404, detail="User not found")
user_data = user_df.to_dict(orient="records")[0] user_data = user_df.to_dict(orient="records")[0]
logger.debug(f"User details retrieved: {user_data}")
return User(**user_data) return User(**user_data)
@router.patch("/{user_id}") @router.patch("/{user_id}")
async def change_password(user: UserChangePassword): async def change_password(user_id: int, user: UserChangePassword, request: Request):
logger.info(
f"Password change attempt for user_id: {user_id} from {request.client.host}"
)
# Implementation will go here when completed
logger.warning(f"Password change not implemented for user_id: {user_id}")
raise HTTPException(status_code=401, detail="Not implemented") raise HTTPException(status_code=401, detail="Not implemented")
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Generated
+1 -1
View File
@@ -131,7 +131,7 @@ wheels = [
[[package]] [[package]]
name = "eurovision-25-backend" name = "eurovision-25-backend"
version = "0.1.0" version = "1.0rc2"
source = { virtual = "." } source = { virtual = "." }
dependencies = [ dependencies = [
{ name = "aiofiles" }, { name = "aiofiles" },