Compare commits
5
Commits
9ecdc1b040
...
0aee5ca021
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0aee5ca021 | ||
|
|
e863a8a088 | ||
|
|
58632e29cb | ||
|
|
e560809c25 | ||
|
|
a0344903ec |
@@ -2,3 +2,6 @@
|
||||
*.duckdb
|
||||
.env
|
||||
__pycache__
|
||||
|
||||
|
||||
src/data/
|
||||
+3
-1
@@ -6,11 +6,13 @@ WORKDIR /app
|
||||
COPY pyproject.toml uv.lock /app/
|
||||
RUN uv sync --frozen --no-install-project --no-dev
|
||||
COPY src /app
|
||||
# Ensure data directory exists but don't copy from host
|
||||
RUN uv sync --frozen --no-dev
|
||||
|
||||
FROM base
|
||||
COPY --from=builder /app /app
|
||||
ENV PATH="/app/.venv/bin:$PATH"
|
||||
WORKDIR /app
|
||||
RUN mkdir -p /app/data
|
||||
# Create data directory
|
||||
RUN mkdir -p /app/data && chmod 755 /app/data
|
||||
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
|
||||
+2
-1
@@ -3,7 +3,7 @@ from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
# 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.db import init_db
|
||||
@@ -60,3 +60,4 @@ app.include_router(users.router)
|
||||
app.include_router(songs.router)
|
||||
app.include_router(reviews.router)
|
||||
app.include_router(results.router)
|
||||
app.include_router(contest.router)
|
||||
|
||||
+39
-4
@@ -9,6 +9,7 @@ DROP TABLE IF EXISTS Review;
|
||||
DROP TABLE IF EXISTS Song;
|
||||
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 ReviewSummaryByTeam;
|
||||
@@ -17,6 +18,7 @@ DROP SEQUENCE IF EXISTS group_id_seq;
|
||||
DROP SEQUENCE IF EXISTS user_id_seq;
|
||||
DROP SEQUENCE IF EXISTS song_id_seq;
|
||||
DROP SEQUENCE IF EXISTS review_id_seq;
|
||||
DROP SEQUENCE IF EXISTS contest_id_seq;
|
||||
|
||||
-- =============================================================================
|
||||
-- Sequences for Primary Keys
|
||||
@@ -25,6 +27,23 @@ CREATE SEQUENCE group_id_seq START 1;
|
||||
CREATE SEQUENCE user_id_seq START 1;
|
||||
CREATE SEQUENCE song_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
|
||||
@@ -122,18 +141,30 @@ CREATE TABLE CountryCodes (
|
||||
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
|
||||
GROUP BY song_id;
|
||||
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,
|
||||
@@ -141,8 +172,9 @@ SELECT
|
||||
FROM Review r
|
||||
JOIN "User" u ON r.user_id = u.id
|
||||
JOIN Song s ON r.song_id = s.id
|
||||
GROUP BY r.song_id, u.team_id
|
||||
ORDER BY r.song_id, u.team_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 INTO CountryCodes (code, name_en, name_fi, name_sv) VALUES ('ALB', 'Albania', 'Albania', 'Albanien');
|
||||
@@ -183,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 ('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)
|
||||
-- =============================================================================
|
||||
|
||||
@@ -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"],
|
||||
)
|
||||
@@ -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])
|
||||
@@ -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")
|
||||
@@ -1,3 +1,28 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from models.result import Result, TeamResult
|
||||
from lib.db import get_connection
|
||||
|
||||
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
|
||||
|
||||
@@ -13,7 +13,7 @@ async def list_reviews():
|
||||
with get_connection() as conn:
|
||||
reviews = conn.execute("SELECT * FROM Review").fetchdf()
|
||||
if reviews.empty:
|
||||
raise HTTPException(status_code=404, detail="Reviews not found")
|
||||
return []
|
||||
return reviews.to_dict(orient="records")
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ async def get_review(song_id: int, user_id: int):
|
||||
(song_id, user_id),
|
||||
).fetchdf()
|
||||
if review.empty:
|
||||
raise HTTPException(status_code=404, detail="Review not found")
|
||||
return []
|
||||
return review.to_dict(orient="records")[0]
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user