Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
61af47d651 | ||
|
|
c3b63a61cc | ||
|
|
b6a1d4aeaf | ||
|
|
6c512c78aa | ||
|
|
0aee5ca021 | ||
|
|
e863a8a088 | ||
|
|
58632e29cb | ||
|
|
e560809c25 | ||
|
|
a0344903ec | ||
|
|
9ecdc1b040 | ||
|
|
4a480a312e | ||
|
|
f00bff5f64 | ||
|
|
25f528c86d | ||
|
|
21e30b0fb2 | ||
|
|
07d8e15925 | ||
|
|
7609159f60 | ||
|
|
c2884ac9ce | ||
|
|
40010c9860 | ||
|
|
d97a665a9c | ||
|
|
39da976ef3 | ||
|
|
aecc0731bc | ||
|
|
751af50e8a | ||
|
|
005ac8634f | ||
|
|
055dd3b43b | ||
|
|
4a4eda09d1 | ||
|
|
e647bc7811 | ||
|
|
bf7c969629 |
@@ -2,3 +2,6 @@
|
||||
*.duckdb
|
||||
.env
|
||||
__pycache__
|
||||
|
||||
|
||||
src/data/
|
||||
@@ -13,5 +13,9 @@ wheels/
|
||||
*.sqlite
|
||||
*.duckdb
|
||||
*.db
|
||||
*.log
|
||||
*.gz
|
||||
|
||||
data
|
||||
|
||||
.vscode/*
|
||||
@@ -0,0 +1,17 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to the Eurovision-25 backend will be documented in this file.
|
||||
|
||||
## [1.0rc1] - 2025-05-10
|
||||
|
||||
### First Release Candidate
|
||||
|
||||
This is the first release candidate for Eurovision-25 backend, featuring all core functionality:
|
||||
|
||||
- Authentication and user management
|
||||
- Song management and reviews
|
||||
- Results calculation and display
|
||||
- Admin functionality
|
||||
- Static file serving for avatars and country flags
|
||||
|
||||
The application is now feature complete, but may still contain bugs that will be addressed before the final 1.0.0 release.
|
||||
@@ -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
|
||||
@@ -6,10 +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
|
||||
# Create data directory
|
||||
RUN mkdir -p /app/data && chmod 755 /app/data
|
||||
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
|
||||
@@ -220,6 +220,10 @@ Automated tests are not implemented at this time. Manual testing via the API doc
|
||||
## TODOs
|
||||
|
||||
* [ ] 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
|
||||
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
version: '3.8'
|
||||
|
||||
# This compose file can be used with either Docker or Podman
|
||||
# For Podman: podman-compose up -d
|
||||
|
||||
services:
|
||||
backend:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
image: eurovision-25-backend:1.0rc3
|
||||
ports:
|
||||
- "8000:8000"
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- ADMIN_USERNAME=admin
|
||||
- ADMIN_PASSWORD=password
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "Eurovision-25-backend"
|
||||
version = "0.1.0"
|
||||
version = "1.0rc2"
|
||||
description = "Backend for Eurovision 25"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
@@ -8,6 +8,7 @@ dependencies = [
|
||||
"aiofiles>=24.1.0",
|
||||
"duckdb>=1.2.2",
|
||||
"fastapi>=0.115.12",
|
||||
"loguru>=0.7.3",
|
||||
"pandas>=2.2.3",
|
||||
"passlib[bcrypt]>=1.7.4",
|
||||
"python-dotenv>=1.1.0",
|
||||
|
||||
@@ -3,17 +3,27 @@ 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
|
||||
|
||||
# Initialize FastAPI app
|
||||
app = FastAPI(
|
||||
title="Eurovision 25 Homereview API",
|
||||
description="Backend API for Eurovision 25 Homereview application",
|
||||
version="1.0.0",
|
||||
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
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
@@ -32,12 +42,14 @@ app.mount("/static/flags", StaticFiles(directory="static/flags"), name="flags")
|
||||
# Startup event to create database tables
|
||||
@app.on_event("startup")
|
||||
def on_startup():
|
||||
logger.info("Starting up...")
|
||||
init_db()
|
||||
|
||||
|
||||
# Health check endpoint
|
||||
@app.get("/")
|
||||
async def root():
|
||||
logger.debug("Root endpoint accessed")
|
||||
return {"status": "ok", "message": "Eurovision 25 Homereview API"}
|
||||
|
||||
|
||||
@@ -48,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)
|
||||
|
||||
@@ -9,12 +9,16 @@ 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;
|
||||
|
||||
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
|
||||
@@ -23,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
|
||||
@@ -42,6 +63,8 @@ CREATE TABLE "Team" (
|
||||
CREATE TABLE "User" (
|
||||
id INTEGER PRIMARY KEY DEFAULT nextval('user_id_seq'), -- Use sequence for auto-increment
|
||||
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
|
||||
email VARCHAR UNIQUE, -- User's email address (nullable)
|
||||
team_id INTEGER NOT NULL, -- Foreign Key -> Group.id
|
||||
@@ -91,7 +114,7 @@ CREATE TABLE Review (
|
||||
song_id INTEGER NOT NULL, -- Foreign Key -> Song.id
|
||||
score_song INTEGER NOT NULL, -- Score (1-100) for song quality
|
||||
score_show INTEGER NOT NULL, -- Score (1-100) for the stage show
|
||||
score_wardrobe INTEGER NOT NULL, -- Score (1-100) for wardrobe/costumes
|
||||
score_costume INTEGER NOT NULL, -- Score (1-100) for wardrobe/costumes
|
||||
text_review VARCHAR, -- Optional textual comments (nullable)
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, -- Timestamp when the review was created
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, -- Timestamp when the review was last modified
|
||||
@@ -102,7 +125,7 @@ CREATE TABLE Review (
|
||||
-- Ensure scores are within the valid range (1-100)
|
||||
CHECK (score_song >= 1 AND score_song <= 100),
|
||||
CHECK (score_show >= 1 AND score_show <= 100),
|
||||
CHECK (score_wardrobe >= 1 AND score_wardrobe <= 100),
|
||||
CHECK (score_costume >= 1 AND score_costume <= 100),
|
||||
|
||||
-- Ensure each user can only submit one review per song
|
||||
UNIQUE (user_id, song_id)
|
||||
@@ -111,8 +134,90 @@ CREATE TABLE Review (
|
||||
CREATE TABLE CountryCodes (
|
||||
code VARCHAR PRIMARY KEY,
|
||||
name_en VARCHAR NOT NULL,
|
||||
name_fi VARCHAR NOT NULL
|
||||
name_fi 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 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 ('AUS', 'Australia', 'Australia', 'Australien');
|
||||
INSERT INTO CountryCodes (code, name_en, name_fi, name_sv) VALUES ('AUT', 'Austria', 'Itävalta', 'Österrike');
|
||||
INSERT INTO CountryCodes (code, name_en, name_fi, name_sv) VALUES ('AZE', 'Azerbaijan', 'Azerbaidžan', 'Azerbajdzjan');
|
||||
INSERT INTO CountryCodes (code, name_en, name_fi, name_sv) VALUES ('BEL', 'Belgium', 'Belgia', 'Belgien');
|
||||
INSERT INTO CountryCodes (code, name_en, name_fi, name_sv) VALUES ('HRV', 'Croatia', 'Kroatia', 'Kroatien');
|
||||
INSERT INTO CountryCodes (code, name_en, name_fi, name_sv) VALUES ('CYP', 'Cyprus', 'Kypros', 'Cypern');
|
||||
INSERT INTO CountryCodes (code, name_en, name_fi, name_sv) VALUES ('CZE', 'Czechia', 'Tšekki', 'Tjeckien');
|
||||
INSERT INTO CountryCodes (code, name_en, name_fi, name_sv) VALUES ('DNK', 'Denmark', 'Tanska', 'Danmark');
|
||||
INSERT INTO CountryCodes (code, name_en, name_fi, name_sv) VALUES ('EST', 'Estonia', 'Viro', 'Estland');
|
||||
INSERT INTO CountryCodes (code, name_en, name_fi, name_sv) VALUES ('FIN', 'Finland', 'Suomi', 'Finland');
|
||||
INSERT INTO CountryCodes (code, name_en, name_fi, name_sv) VALUES ('FRA', 'France', 'Ranska', 'Frankrike');
|
||||
INSERT INTO CountryCodes (code, name_en, name_fi, name_sv) VALUES ('GEO', 'Georgia', 'Georgia', 'Georgien');
|
||||
INSERT INTO CountryCodes (code, name_en, name_fi, name_sv) VALUES ('DEU', 'Germany', 'Saksa', 'Tyskland');
|
||||
INSERT INTO CountryCodes (code, name_en, name_fi, name_sv) VALUES ('GRC', 'Greece', 'Kreikka', 'Grekland');
|
||||
INSERT INTO CountryCodes (code, name_en, name_fi, name_sv) VALUES ('ISL', 'Iceland', 'Islanti', 'Island');
|
||||
INSERT INTO CountryCodes (code, name_en, name_fi, name_sv) VALUES ('IRL', 'Ireland', 'Irlanti', 'Irland');
|
||||
INSERT INTO CountryCodes (code, name_en, name_fi, name_sv) VALUES ('ISR', 'Israel', 'Israel', 'Israel');
|
||||
INSERT INTO CountryCodes (code, name_en, name_fi, name_sv) VALUES ('ITA', 'Italy', 'Italia', 'Italien');
|
||||
INSERT INTO CountryCodes (code, name_en, name_fi, name_sv) VALUES ('LVA', 'Latvia', 'Latvia', 'Lettland');
|
||||
INSERT INTO CountryCodes (code, name_en, name_fi, name_sv) VALUES ('LTU', 'Lithuania', 'Liettua', 'Litauen');
|
||||
INSERT INTO CountryCodes (code, name_en, name_fi, name_sv) VALUES ('LUX', 'Luxembourg', 'Luxemburg', 'Luxemburg');
|
||||
INSERT INTO CountryCodes (code, name_en, name_fi, name_sv) VALUES ('MLT', 'Malta', 'Malta', 'Malta');
|
||||
INSERT INTO CountryCodes (code, name_en, name_fi, name_sv) VALUES ('MNE', 'Montenegro', 'Montenegro', 'Montenegro');
|
||||
INSERT INTO CountryCodes (code, name_en, name_fi, name_sv) VALUES ('NLD', 'Netherlands', 'Alankomaat', 'Nederländerna');
|
||||
INSERT INTO CountryCodes (code, name_en, name_fi, name_sv) VALUES ('NOR', 'Norway', 'Norja', 'Norge');
|
||||
INSERT INTO CountryCodes (code, name_en, name_fi, name_sv) VALUES ('POL', 'Poland', 'Puola', 'Polen');
|
||||
INSERT INTO CountryCodes (code, name_en, name_fi, name_sv) VALUES ('PRT', 'Portugal', 'Portugali', 'Portugal');
|
||||
INSERT INTO CountryCodes (code, name_en, name_fi, name_sv) VALUES ('SMR', 'San Marino', 'San Marino', 'San Marino');
|
||||
INSERT INTO CountryCodes (code, name_en, name_fi, name_sv) VALUES ('SRB', 'Serbia', 'Serbia', 'Serbien');
|
||||
INSERT INTO CountryCodes (code, name_en, name_fi, name_sv) VALUES ('SVN', 'Slovenia', 'Slovenia', 'Slovenien');
|
||||
INSERT INTO CountryCodes (code, name_en, name_fi, name_sv) VALUES ('ESP', 'Spain', 'Espanja', 'Spanien');
|
||||
INSERT INTO CountryCodes (code, name_en, name_fi, name_sv) VALUES ('SWE', 'Sweden', 'Ruotsi', 'Sverige');
|
||||
INSERT INTO CountryCodes (code, name_en, name_fi, name_sv) VALUES ('CHE', 'Switzerland', 'Sveitsi', 'Schweiz');
|
||||
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)
|
||||
-- =============================================================================
|
||||
|
||||
@@ -6,13 +6,13 @@ from dotenv import load_dotenv
|
||||
import os
|
||||
|
||||
from lib.helpers import hash_password
|
||||
from lib.logger import logger
|
||||
|
||||
load_dotenv()
|
||||
|
||||
DB_PATH = Path("./data/data.duckdb").absolute()
|
||||
SQL_PATH = Path("./lib/database.sql").absolute()
|
||||
SEED_JSON_PATH = Path("./lib/seed_data.json").absolute()
|
||||
COUNTRYCODES_JSON_PATH = Path("./lib/countrycodes.json").absolute()
|
||||
|
||||
|
||||
@contextmanager
|
||||
@@ -53,23 +53,7 @@ def seed_db() -> None:
|
||||
),
|
||||
)
|
||||
|
||||
# Seed CountryCodes
|
||||
with open(COUNTRYCODES_JSON_PATH, "r") as f:
|
||||
data = json.load(f)
|
||||
|
||||
if not data:
|
||||
return
|
||||
|
||||
for item in data:
|
||||
with get_connection() as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO CountryCodes (code, name_en, name_fi) VALUES (?, ?, ?)",
|
||||
(
|
||||
item["code"],
|
||||
item["name_en"],
|
||||
item["name_fi"],
|
||||
),
|
||||
)
|
||||
# CountryCodes are now directly inserted in the SQL file
|
||||
|
||||
# Seed Teams
|
||||
with get_connection() as conn:
|
||||
@@ -77,7 +61,10 @@ def seed_db() -> None:
|
||||
|
||||
# Seed Admin users
|
||||
admin_username = os.getenv("ADMIN_USERNAME")
|
||||
hashed_admin_password = hash_password(os.getenv("ADMIN_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:
|
||||
conn.execute(
|
||||
'INSERT INTO "User" (username, hashed_password, team_id, is_active, is_admin) VALUES (?, ?, ?, ?, ?)',
|
||||
@@ -92,12 +79,14 @@ def seed_db() -> None:
|
||||
|
||||
|
||||
def init_db():
|
||||
logger.info("Initializing database...")
|
||||
if Path(DB_PATH).exists():
|
||||
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:
|
||||
sql = f.read()
|
||||
with get_connection() as conn:
|
||||
conn.execute(sql)
|
||||
seed_db()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
init_db()
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
from loguru import logger
|
||||
from pathlib import Path
|
||||
import sys
|
||||
from dotenv import load_dotenv
|
||||
import os
|
||||
|
||||
load_dotenv()
|
||||
|
||||
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.add(
|
||||
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=os.getenv("LOG_LEVEL_ERROR", "ERROR"),
|
||||
)
|
||||
logger.add(sys.stderr, level=os.getenv("LOG_LEVEL_ERROR", "ERROR"))
|
||||
|
||||
logger = logger
|
||||
@@ -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,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)
|
||||
@@ -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,41 @@
|
||||
from pydantic import BaseModel, Field
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class ReviewBase(BaseModel):
|
||||
song_id: int = Field(description="ID of the song", examples=[1])
|
||||
user_id: int = Field(description="ID of the user", examples=[1])
|
||||
score_song: int = Field(
|
||||
gt=0, le=100, description="Score given by the user", examples=[1]
|
||||
)
|
||||
score_show: int = Field(
|
||||
gt=0, le=100, description="Score given by the user", examples=[1]
|
||||
)
|
||||
score_costume: int = Field(
|
||||
gt=0, le=100, description="Score given by the user", examples=[1]
|
||||
)
|
||||
text_review: str | None = Field(
|
||||
default=None, description="Comment given by the user", examples=["comment"]
|
||||
)
|
||||
|
||||
|
||||
class ReviewIn(ReviewBase):
|
||||
pass
|
||||
|
||||
|
||||
class ReviewSearch(BaseModel):
|
||||
song_id: int = Field(description="ID of the song", examples=[1])
|
||||
user_id: int = Field(description="ID of the user", examples=[1])
|
||||
|
||||
|
||||
class ReviewOut(ReviewBase):
|
||||
created_at: datetime = Field(
|
||||
default_factory=datetime.now,
|
||||
description="Creation timestamp",
|
||||
examples=["2025-01-01T00:00:00.000Z"],
|
||||
)
|
||||
updated_at: datetime = Field(
|
||||
default_factory=datetime.now,
|
||||
description="Last update timestamp",
|
||||
examples=["2025-01-01T00:00:00.000Z"],
|
||||
)
|
||||
@@ -1,9 +1,10 @@
|
||||
from fastapi import APIRouter, Body, HTTPException
|
||||
from fastapi import APIRouter, Body, HTTPException, Request
|
||||
|
||||
from models.user import CreateUser
|
||||
from models.team import TeamBase, Team
|
||||
from lib.db import get_connection
|
||||
from lib.helpers import hash_password
|
||||
from lib.logger import logger
|
||||
|
||||
router = APIRouter(prefix="/admin", tags=["Admin"])
|
||||
|
||||
@@ -19,36 +20,73 @@ router = APIRouter(prefix="/admin", tags=["Admin"])
|
||||
|
||||
|
||||
@router.post("/teams")
|
||||
async def create_team(team: TeamBase):
|
||||
with get_connection() as conn:
|
||||
conn.execute("INSERT INTO Team (name) VALUES (?)", (team.name,))
|
||||
return {"message": "Team created successfully"}
|
||||
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:
|
||||
conn.execute("INSERT INTO Team (name) VALUES (?)", (team.name,))
|
||||
logger.info(f"Team '{team.name}' 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])
|
||||
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:
|
||||
teams = conn.execute("SELECT * FROM Team").fetchdf()
|
||||
return teams.to_dict(orient="records")
|
||||
|
||||
|
||||
@router.post("/users")
|
||||
async def create_user(user: CreateUser):
|
||||
with get_connection() as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO User (username, hashed_password, team_id, is_admin) VALUES (?, ?, ?, ?)",
|
||||
(
|
||||
user.username,
|
||||
hash_password(user.password),
|
||||
user.team_id,
|
||||
user.is_admin,
|
||||
),
|
||||
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:
|
||||
# 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(
|
||||
"INSERT INTO User (username, hashed_password, team_id, is_admin) VALUES (?, ?, ?, ?)",
|
||||
(
|
||||
user.username,
|
||||
hash_password(user.password),
|
||||
user.team_id,
|
||||
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/")
|
||||
async def disable_user(user_id: int = Body(..., embed=True)):
|
||||
@router.post("/users/disable")
|
||||
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")
|
||||
|
||||
|
||||
|
||||
@@ -1,24 +1,34 @@
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
|
||||
from lib.db import get_connection
|
||||
from models.user import User, UserLogin
|
||||
from lib.helpers import verify_password
|
||||
from lib.logger import logger
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||
|
||||
|
||||
@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:
|
||||
user_df = conn.execute(
|
||||
'SELECT * FROM "User" WHERE username = ?', (user_login.username,)
|
||||
).fetchdf()
|
||||
|
||||
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")
|
||||
|
||||
user_data = user_df.to_dict(orient="records")[0]
|
||||
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")
|
||||
|
||||
# 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
|
||||
updated_user = conn.execute(
|
||||
'SELECT * FROM "User" WHERE id = ?',
|
||||
(user_data["id"],)
|
||||
).fetchdf().to_dict(orient="records")[0]
|
||||
updated_user = (
|
||||
conn.execute('SELECT * FROM "User" WHERE id = ?', (user_data["id"],))
|
||||
.fetchdf()
|
||||
.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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,3 +1,151 @@
|
||||
from fastapi import APIRouter
|
||||
from fastapi import APIRouter, HTTPException
|
||||
|
||||
router = APIRouter(prefix="/songs", tags=["songs"])
|
||||
from models.review import ReviewOut, ReviewIn
|
||||
from models.msg import Message
|
||||
from lib.db import get_connection
|
||||
from lib.logger import logger
|
||||
|
||||
router = APIRouter(prefix="/reviews", tags=["reviews"])
|
||||
|
||||
|
||||
@router.get("/all", response_model=list[ReviewOut])
|
||||
async def list_reviews():
|
||||
with get_connection() as conn:
|
||||
reviews = conn.execute("SELECT * FROM Review").fetchdf()
|
||||
if reviews.empty:
|
||||
return []
|
||||
return reviews.to_dict(orient="records")
|
||||
|
||||
|
||||
@router.get("/", response_model=ReviewOut)
|
||||
async def get_review(song_id: int, user_id: int):
|
||||
with get_connection() as conn:
|
||||
review = conn.execute(
|
||||
"SELECT * FROM Review WHERE song_id = ? AND user_id = ?",
|
||||
(song_id, user_id),
|
||||
).fetchdf()
|
||||
if review.empty:
|
||||
return []
|
||||
return review.to_dict(orient="records")[0]
|
||||
|
||||
|
||||
@router.post("/", response_model=Message)
|
||||
async def create_review(review: ReviewIn):
|
||||
logger.debug(f"Received create review request with data: {review.dict()}")
|
||||
try:
|
||||
with get_connection() as conn:
|
||||
# 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 (?, ?, ?, ?, ?, ?)",
|
||||
(
|
||||
review.user_id,
|
||||
review.song_id,
|
||||
review.score_song,
|
||||
review.score_show,
|
||||
review.score_costume,
|
||||
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()
|
||||
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")
|
||||
|
||||
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=Message)
|
||||
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:
|
||||
# Log the SQL query and parameters
|
||||
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.score_song,
|
||||
review.score_show,
|
||||
review.score_costume,
|
||||
review.text_review,
|
||||
review.song_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()
|
||||
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")
|
||||
|
||||
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
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
from fastapi import APIRouter
|
||||
from fastapi import HTTPException
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
|
||||
from models.user import User, UserChangePassword
|
||||
from lib.db import get_connection
|
||||
from lib.logger import logger
|
||||
|
||||
router = APIRouter(prefix="/users", tags=["users"])
|
||||
|
||||
|
||||
@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:
|
||||
users = conn.execute('SELECT * FROM "User"').fetchdf()
|
||||
|
||||
@@ -18,19 +19,29 @@ async def list_users():
|
||||
|
||||
|
||||
@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:
|
||||
user_df = conn.execute(
|
||||
'SELECT * FROM "User" WHERE id = ?', (user_id,)
|
||||
).fetchdf()
|
||||
|
||||
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")
|
||||
|
||||
user_data = user_df.to_dict(orient="records")[0]
|
||||
logger.debug(f"User details retrieved: {user_data}")
|
||||
return User(**user_data)
|
||||
|
||||
|
||||
@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")
|
||||
|
||||
|
After Width: | Height: | Size: 6.8 KiB |
|
After Width: | Height: | Size: 4.3 KiB |
|
After Width: | Height: | Size: 4.6 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 3.5 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 6.3 KiB |
|
After Width: | Height: | Size: 4.7 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 6.9 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 5.8 KiB |
|
After Width: | Height: | Size: 6.8 KiB |
|
After Width: | Height: | Size: 4.5 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 5.7 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 5.3 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
@@ -131,12 +131,13 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "eurovision-25-backend"
|
||||
version = "0.1.0"
|
||||
version = "1.0rc2"
|
||||
source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "aiofiles" },
|
||||
{ name = "duckdb" },
|
||||
{ name = "fastapi" },
|
||||
{ name = "loguru" },
|
||||
{ name = "pandas" },
|
||||
{ name = "passlib", extra = ["bcrypt"] },
|
||||
{ name = "python-dotenv" },
|
||||
@@ -154,6 +155,7 @@ requires-dist = [
|
||||
{ name = "aiofiles", specifier = ">=24.1.0" },
|
||||
{ name = "duckdb", specifier = ">=1.2.2" },
|
||||
{ name = "fastapi", specifier = ">=0.115.12" },
|
||||
{ name = "loguru", specifier = ">=0.7.3" },
|
||||
{ name = "pandas", specifier = ">=2.2.3" },
|
||||
{ name = "passlib", extras = ["bcrypt"], specifier = ">=1.7.4" },
|
||||
{ name = "python-dotenv", specifier = ">=1.1.0" },
|
||||
@@ -218,6 +220,19 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "loguru"
|
||||
version = "0.7.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
{ name = "win32-setctime", marker = "sys_platform == 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/3a/05/a1dae3dffd1116099471c643b8924f5aa6524411dc6c63fdae648c4f1aca/loguru-0.7.3.tar.gz", hash = "sha256:19480589e77d47b8d85b2c827ad95d49bf31b0dcde16593892eb51dd18706eb6", size = 63559, upload-time = "2024-12-06T11:20:56.608Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/29/0348de65b8cc732daa3e33e67806420b2ae89bdce2b04af740289c5c6c8c/loguru-0.7.3-py3-none-any.whl", hash = "sha256:31a33c10c8e1e10422bfd431aeb5d351c7cf7fa671e3c4df004162264b28220c", size = 61595, upload-time = "2024-12-06T11:20:54.538Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "markdown-it-py"
|
||||
version = "3.0.0"
|
||||
@@ -639,3 +654,12 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/1b/6c/c65773d6cab416a64d191d6ee8a8b1c68a09970ea6909d16965d26bfed1e/websockets-15.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:e09473f095a819042ecb2ab9465aee615bd9c2028e4ef7d933600a8401c79561", size = 176837, upload-time = "2025-03-05T20:02:55.237Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743, upload-time = "2025-03-05T20:03:39.41Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "win32-setctime"
|
||||
version = "1.2.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b3/8f/705086c9d734d3b663af0e9bb3d4de6578d08f46b1b101c2442fd9aecaa2/win32_setctime-1.2.0.tar.gz", hash = "sha256:ae1fdf948f5640aae05c511ade119313fb6a30d7eabe25fef9764dca5873c4c0", size = 4867, upload-time = "2024-12-07T15:28:28.314Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e1/07/c6fe3ad3e685340704d314d765b7912993bcb8dc198f0e7a89382d37974b/win32_setctime-1.2.0-py3-none-any.whl", hash = "sha256:95d644c4e708aba81dc3704a116d8cbc974d70b3bdb8be1d150e36be6e9d1390", size = 4083, upload-time = "2024-12-07T15:28:26.465Z" },
|
||||
]
|
||||
|
||||