9 Commits
39 changed files with 206 additions and 15 deletions
+4 -1
View File
@@ -1,4 +1,7 @@
.git .git
*.duckdb *.duckdb
.env .env
__pycache__ __pycache__
src/data/
+33 -4
View File
@@ -1,19 +1,48 @@
# Eurovision 25 Backend Changelog # 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) ## 1.0rc2 (2025-05-10)
### Changes ### Changes
- Updated application version to 1.0rc2 - Updated application version to 1.0rc2
- Added more comprehensive logging throughout the application - Added more comprehensive logging throughout the application
## 1.0rc1 (2025-05-01)
## 1.0rc1 (Previous Release)
### Features ### Features
- Initial release candidate - Initial release candidate
- Eurovision 25 Homereview API implementation - Eurovision 25 Homereview API implementation
- Authentication and authorization system - Authentication and authorization system
- User management features - User management features
- Song management - Song management and reviews
- Review system - Review system
- Results calculation - 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 -1
View File
@@ -6,11 +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
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"] CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8000"]
+1 -1
View File
@@ -223,7 +223,7 @@ Automated tests are not implemented at this time. Manual testing via the API doc
* [ ] Switch to PostgreSQL from DuckDB * [ ] Switch to PostgreSQL from DuckDB
* [x] Implement more comprehensive logging * [x] Implement more comprehensive logging
* [ ] Implement user disabling functionality * [ ] Implement user disabling functionality
* [ ] Implement user password change functionality * [ ] Implement user profile update functionality (name, email, password, avatar, etc.)
## Contributing ## Contributing
+4
View File
@@ -1,10 +1,14 @@
version: '3.8' version: '3.8'
# This compose file can be used with either Docker or Podman
# For Podman: podman-compose up -d
services: services:
backend: backend:
build: build:
context: . context: .
dockerfile: Dockerfile dockerfile: Dockerfile
image: eurovision-25-backend:1.0rc3
ports: ports:
- "8000:8000" - "8000:8000"
volumes: volumes:
+3 -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,7 +12,7 @@ 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.0rc2", version="1.0rc3",
) )
@@ -60,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)
+39 -4
View File
@@ -9,6 +9,7 @@ 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 ReviewSummary;
DROP VIEW IF EXISTS ReviewSummaryByTeam; 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 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
@@ -25,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
@@ -122,18 +141,30 @@ CREATE TABLE CountryCodes (
CREATE VIEW ReviewSummaryGlobal AS CREATE VIEW ReviewSummaryGlobal AS
SELECT SELECT
song_id, song_id,
cc.name_fi AS country_fi,
cc.name_sv AS country_sv,
s.artist,
s.title,
COUNT(*) AS total_reviews, COUNT(*) AS total_reviews,
AVG(score_song) AS avg_score_song, AVG(score_song) AS avg_score_song,
AVG(score_show) AS avg_score_show, AVG(score_show) AS avg_score_show,
AVG(score_costume) AS avg_score_costume, AVG(score_costume) AS avg_score_costume,
AVG((score_song + score_show + score_costume)/3) AS avg_total_score AVG((score_song + score_show + score_costume)/3) AS avg_total_score
FROM Review FROM Review r
GROUP BY song_id; 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 CREATE VIEW ReviewSummaryByTeam AS
SELECT SELECT
r.song_id, r.song_id,
cc.name_fi AS country_fi,
cc.name_sv AS country_sv,
s.artist,
s.title,
u.team_id, u.team_id,
COUNT(*) AS total_reviews,
AVG(r.score_song) AS avg_score_song, AVG(r.score_song) AS avg_score_song,
AVG(r.score_show) AS avg_score_show, AVG(r.score_show) AS avg_score_show,
AVG(r.score_costume) AS avg_score_costume, AVG(r.score_costume) AS avg_score_costume,
@@ -141,8 +172,9 @@ SELECT
FROM Review r FROM Review r
JOIN "User" u ON r.user_id = u.id JOIN "User" u ON r.user_id = u.id
JOIN Song s ON r.song_id = s.id JOIN Song s ON r.song_id = s.id
GROUP BY r.song_id, u.team_id JOIN CountryCodes cc ON s.country = cc.code
ORDER BY r.song_id, u.team_id; 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');
@@ -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 ('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)
-- ============================================================================= -- =============================================================================
+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"],
)
+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])
+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
+2 -2
View File
@@ -13,7 +13,7 @@ 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")
@@ -25,7 +25,7 @@ async def get_review(song_id: int, user_id: int):
(song_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]
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