4 Commits
Author SHA1 Message Date
Esa Kataja 189c6ae1c7 chore: bump version to 1.0rc8 2025-05-17 21:15:20 +03:00
Esa Kataja b4fa3aa04d Add more result types 2025-05-17 21:12:14 +03:00
Esa Kataja 08624052aa Fix regression in db seeding 2025-05-17 21:11:36 +03:00
Esa Kataja 1a3a2aef86 Add docker exports to git ignore 2025-05-16 18:59:51 +03:00
10 changed files with 230 additions and 12 deletions
+1
View File
@@ -19,3 +19,4 @@ wheels/
data
.vscode/*
*.tar
+12
View File
@@ -1,5 +1,17 @@
# Eurovision 25 Backend Changelog
## 1.0rc8 (2025-05-17)
### Features
- Add more result types (b4fa3aa)
### Fixes
- Fix regression in db seeding (0862405)
### Chores
- Add docker exports to git ignore (1a3a2ae)
## 1.0rc7 (2025-05-16)
### Features
+1 -1
View File
@@ -1,6 +1,6 @@
# Eurovision 25 Homereview Backend
**Version: 1.0rc7**
**Version: 1.0rc8**
## Description
+1 -1
View File
@@ -8,7 +8,7 @@ services:
build:
context: .
dockerfile: Dockerfile
image: eurovision-25-backend:1.0rc7
image: eurovision-25-backend:1.0rc8
ports:
- "8000:8000"
volumes:
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "Eurovision-25-backend"
version = "1.0rc7"
version = "1.0rc8"
description = "Backend for Eurovision 25"
readme = "README.md"
requires-python = ">=3.12"
+1 -1
View File
@@ -12,7 +12,7 @@ from lib.db import init_db
app = FastAPI(
title="Eurovision 25 Homereview API",
description="Backend API for Eurovision 25 Homereview application",
version="1.0rc7",
version="1.0rc8",
openapi_url="/sec_schema.json",
docs_url="/docut",
redoc_url=None,
+97 -2
View File
@@ -11,8 +11,10 @@ DROP TABLE IF EXISTS "User"; -- Quoted because USER is a reserved keyword
DROP TABLE IF EXISTS Team;
DROP TABLE IF EXISTS Contest;
DROP TABLE IF EXISTS CountryCodes;
DROP VIEW IF EXISTS ReviewSummary;
DROP VIEW IF EXISTS ReviewSummaryGlobal;
DROP VIEW IF EXISTS ReviewSummaryByTeam;
DROP VIEW IF EXISTS ReviewSummaryByUser;
DROP VIEW IF EXISTS ReviewAllSongs;
DROP SEQUENCE IF EXISTS group_id_seq;
DROP SEQUENCE IF EXISTS user_id_seq;
@@ -177,6 +179,44 @@ 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;
CREATE VIEW ReviewSummaryByUser AS
SELECT
r.song_id,
cc.name_fi AS country_fi,
cc.name_sv AS country_sv,
s.artist,
s.title,
u.id AS user_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.id, cc.name_fi, cc.name_sv, s.artist, s.title
ORDER BY avg_total_score DESC;
CREATE VIEW ReviewAllSongs AS
SELECT
r.song_id,
cc.name_fi AS country_fi,
cc.name_sv AS country_sv,
s.artist,
s.title,
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 Song s ON r.song_id = s.id
JOIN CountryCodes cc ON s.country = cc.code
GROUP BY r.song_id, cc.name_fi, cc.name_sv, s.artist, s.title
ORDER BY avg_total_score DESC;
-- 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 ('ARM', 'Armenia', 'Armenia', 'Armenien');
@@ -218,7 +258,61 @@ INSERT INTO CountryCodes (code, name_en, name_fi, name_sv) VALUES ('GBR', 'Unite
-- 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');
-- INSERT INTO Contest (year, finals_date) VALUES (2025, '2025-05-16');
-- =============================================================================
-- DEBUG DATA
-- =============================================================================
-- Insert Sample Teams
-- INSERT INTO "Team" (name) VALUES ('The Eurovisionaries');
-- INSERT INTO "Team" (name) VALUES ('douze_points_posse');
-- -- Insert Sample Users (Passwords are 'password' hashed with bcrypt - for debug only!)
-- -- For Team 1 (The Eurovisionaries)
-- INSERT INTO "User" (username, first_name, last_name, hashed_password, email, team_id, avatar_id, is_admin)
-- VALUES ('debuguser1', 'Debug', 'UserOne', '$2b$12$7.xP5Y6.K7kZ5yL0HjEPIOjBqzsRcg8yU/gQcNcmauHAFsdn7Y2tu', '[email protected]', (SELECT id FROM "Team" WHERE name = 'The Eurovisionaries'), 1, FALSE);
-- INSERT INTO "User" (username, first_name, last_name, hashed_password, email, team_id, avatar_id, is_admin)
-- VALUES ('debugadmin', 'Debug', 'Admin', '$2b$12$7.xP5Y6.K7kZ5yL0HjEPIOjBqzsRcg8yU/gQcNcmauHAFsdn7Y2tu', '[email protected]', (SELECT id FROM "Team" WHERE name = 'The Eurovisionaries'), 2, TRUE);
-- -- For Team 2 (douze_points_posse)
-- INSERT INTO "User" (username, first_name, last_name, hashed_password, email, team_id, avatar_id, is_admin)
-- VALUES ('debuguser2', 'Another', 'Debugger', '$2b$12$7.xP5Y6.K7kZ5yL0HjEPIOjBqzsRcg8yU/gQcNcmauHAFsdn7Y2tu', '[email protected]', (SELECT id FROM "Team" WHERE name = 'douze_points_posse'), 3, FALSE);
-- -- Insert Sample Songs for Contest 2025
-- -- Assuming a contest with id=1 (or use (SELECT id FROM Contest WHERE year = 2025) if only one entry)
-- INSERT INTO Song (year, country, artist, title, running_order, img_url, language, confidence)
-- VALUES
-- (2025, 'SWE', 'Swedish Singers', 'Dancing in Stockholm', 1, 'https://example.com/swe.jpg', 'English', 0.95),
-- (2025, 'FIN', 'Finnish Rockers', 'Sauna Anthem', 2, 'https://example.com/fin.jpg', 'Finnish', 0.90),
-- (2025, 'NOR', 'Nordic Balladeers', 'Fjord Dreams', 3, 'https://example.com/nor.jpg', 'Norwegian', 0.85),
-- (2025, 'ITA', 'Italian Pop Sensations', 'Roman Holiday', 4, 'https://example.com/ita.jpg', 'Italian', 0.92),
-- (2025, 'GER', 'German Electro Duo', 'Berlin Beats', 5, 'https://example.com/ger.jpg', 'German', 0.88);
-- -- Insert Sample Reviews
-- -- User 1 reviews Song 1 and Song 2
-- INSERT INTO Review (user_id, song_id, score_song, score_show, score_costume, text_review)
-- VALUES
-- ((SELECT id FROM "User" WHERE username = 'debuguser1'), (SELECT id FROM Song WHERE title = 'Dancing in Stockholm'), 80, 75, 70, 'Great song!'),
-- ((SELECT id FROM "User" WHERE username = 'debuguser1'), (SELECT id FROM Song WHERE title = 'Sauna Anthem'), 90, 85, 80, 'Loved the energy!');
-- -- User 2 reviews Song 2 and Song 3
-- INSERT INTO Review (user_id, song_id, score_song, score_show, score_costume, text_review)
-- VALUES
-- ((SELECT id FROM "User" WHERE username = 'debuguser2'), (SELECT id FROM Song WHERE title = 'Sauna Anthem'), 88, 82, 78, 'Rock on Finland!'),
-- ((SELECT id FROM "User" WHERE username = 'debuguser2'), (SELECT id FROM Song WHERE title = 'Fjord Dreams'), 70, 65, 60, 'A bit slow for me.');
-- -- Admin reviews Song 1
-- INSERT INTO Review (user_id, song_id, score_song, score_show, score_costume, text_review)
-- VALUES
-- ((SELECT id FROM "User" WHERE username = 'debugadmin'), (SELECT id FROM Song WHERE title = 'Dancing in Stockholm'), 99, 92, 1, 'Absolutely stellar! A clear frontrunner!'),
-- -- More reviews with varied scores
-- ((SELECT id FROM "User" WHERE username = 'debuguser1'), (SELECT id FROM Song WHERE title = 'Roman Holiday'), 42, 23, 23, 'The song was okay, staging average, costumes needed work.'),
-- ((SELECT id FROM "User" WHERE username = 'debuguser1'), (SELECT id FROM Song WHERE title = 'Berlin Beats'), 96, 91, 87, 'Incredible energy and visuals! Loved it!'),
-- ((SELECT id FROM "User" WHERE username = 'debuguser2'), (SELECT id FROM Song WHERE title = 'Dancing in Stockholm'), 68, 77, 53, 'Decent entry, but not outstanding. Costumes were a bit plain.'),
-- ((SELECT id FROM "User" WHERE username = 'debuguser2'), (SELECT id FROM Song WHERE title = 'Roman Holiday'), 12, 33, 7, 'Really disliked this one. Song was boring, show forgettable, and costumes awful.'),
-- ((SELECT id FROM "User" WHERE username = 'debugadmin'), (SELECT id FROM Song WHERE title = 'Sauna Anthem'), 100, 11, 99, 'Perfection! Finland has outdone themselves!'),
-- ((SELECT id FROM "User" WHERE username = 'debugadmin'), (SELECT id FROM Song WHERE title = 'Berlin Beats'), 2, 94, 78, 'Strong contender. Great production, though costumes could be more imaginative.');
-- =============================================================================
-- Views (Optional - Not Created Here Based on Documentation Decision)
-- =============================================================================
@@ -227,3 +321,4 @@ INSERT INTO Contest (year, finals_date) VALUES (2025, '2025-05-16');
-- =============================================================================
-- End of Schema Definition
+2 -1
View File
@@ -36,12 +36,13 @@ def seed_db() -> None:
with get_connection() as conn:
logger.debug(f"Inserting song {idx + 1} of {len(data)}", item)
conn.execute(
"INSERT INTO Song (year, artist, country, title, lyrics_url, artist_url, flag_url, running_order, lyrics_original, lyrics_translation_fi, tags, confidence, language) VALUES (2025, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
"INSERT INTO Song (year, artist, country, title, lyrics_url, img_url, artist_url, flag_url, running_order, lyrics_original, lyrics_translation_fi, tags, confidence, language) VALUES (2025, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
(
item["artist"],
item["country"],
item["title"],
item["lyrics_url"],
item["img_url"],
item["artist_url"],
item["flag_url"],
item["running_order"],
+111 -2
View File
@@ -1,13 +1,110 @@
from fastapi import APIRouter
from enum import Enum
import duckdb # Added for specific exception handling
from models.result import Result, TeamResult
from models.msg import Message
from lib.db import get_connection
from lib.logger import logger
router = APIRouter(prefix="/results", tags=["results"])
@router.get("/", response_model=list[Result])
async def list_results():
# It's good practice for FastAPI to have Enum derive from str as well
class ResultType(str, Enum):
DEVIATION = "deviation"
COSTUME = "costume"
SHOW = "show"
SONG = "song"
@router.get("/{result_type}", response_model=list[Result] | Message)
async def list_results(result_type: ResultType):
query = ""
try:
with get_connection() as conn:
if result_type == ResultType.DEVIATION:
# Selects the song with the highest standard deviation in its total review scores
query = """
WITH SongReviewDeviations AS (
SELECT
song_id,
COALESCE(STDDEV_SAMP((score_song + score_show + score_costume) / 3.0), 0) AS deviation_value
FROM Review
GROUP BY song_id
)
SELECT
ras.song_id,
ras.country_fi,
ras.country_sv,
ras.artist,
ras.title,
ras.avg_score_song,
ras.avg_score_show,
ras.avg_score_costume,
ras.avg_total_score
FROM ReviewAllSongs ras
JOIN SongReviewDeviations srd ON ras.song_id = srd.song_id
ORDER BY srd.deviation_value DESC
LIMIT 1;
"""
df = conn.execute(query).fetchdf()
logger.debug(f"Query for DEVIATION: {query}")
logger.debug(f"Result DataFrame for DEVIATION: {df}")
elif result_type == ResultType.COSTUME:
# Selects the song with the highest average costume score
query = """SELECT * FROM ReviewAllSongs ORDER BY avg_score_costume DESC LIMIT 1 """
df = conn.execute(query).fetchdf()
elif result_type == ResultType.SHOW:
# Selects the song with the highest average show score
query = """SELECT * FROM ReviewAllSongs ORDER BY avg_score_show DESC LIMIT 1 """
df = conn.execute(query).fetchdf()
elif result_type == ResultType.SONG:
# Selects the song with the highest average song score
query = """SELECT * FROM ReviewAllSongs ORDER BY avg_score_song DESC LIMIT 1 """
df = conn.execute(query).fetchdf()
else:
# This case should ideally not be reached if using Enums properly with FastAPI
return Message(type="error", message="Invalid result type")
if df.empty:
# If the dataframe is empty, return an empty list.
# This applies if a view exists but has no data, or LIMIT 1 returns no row.
return []
# The existing code implies Result model can handle various structures.
# For DEVIATION, row will be {'deviation_value': X}
# For COSTUME/SHOW/SONG (LIMIT 1), it will be a single row from ReviewAllSongs.
# For others, it will be multiple rows from their respective views.
# All these are converted to a list of Result objects.
results = [Result(**row) for row in df.to_dict(orient="records")]
return results
except duckdb.CatalogException as e:
# Handles errors like "View not found" (e.g., ReviewAllSongs doesn't exist)
# You might want to log the error e
print(f"Database Catalog Error: {e}")
return Message(
type="error",
message=f"Data source for '{result_type.value}' not found or query error. Details: {str(e)}",
)
except duckdb.Error as e: # Catch other DuckDB specific errors
print(f"DuckDB Error: {e}")
return Message(
type="error",
message=f"Database query error for '{result_type.value}'. Details: {str(e)}",
)
except Exception as e:
# Catch any other unexpected errors
print(f"Unexpected Error: {e}")
return Message(
type="error",
message=f"An unexpected error occurred while fetching results for '{result_type.value}'.",
)
@router.get("/global", response_model=list[Result])
async def list_global_results():
with get_connection() as conn:
df = conn.execute("SELECT * FROM ReviewSummaryGlobal").fetchdf()
if df.empty:
@@ -16,6 +113,18 @@ async def list_results():
return results
@router.get("/user/{user_id}", response_model=list[Result])
async def list_user_results(user_id: int):
with get_connection() as conn:
df = conn.execute(
"SELECT * FROM ReviewSummaryByUser WHERE user_id = ?", (user_id,)
).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:
Generated
+1 -1
View File
@@ -131,7 +131,7 @@ wheels = [
[[package]]
name = "eurovision-25-backend"
version = "1.0rc7"
version = "1.0rc8"
source = { virtual = "." }
dependencies = [
{ name = "aiofiles" },