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 | ||
|
|
6706abfc97 | ||
|
|
ed10e7bf80 | ||
|
|
73935be1ba | ||
|
|
c3494e6169 | ||
|
|
df9c5ba4c0 | ||
|
|
d3e22eefcb | ||
|
|
29b3ba9192 | ||
|
|
32eda86f4f | ||
|
|
b05c8570a1 |
@@ -1,4 +1,7 @@
|
||||
.git
|
||||
*.duckdb
|
||||
.env
|
||||
__pycache__
|
||||
__pycache__
|
||||
|
||||
|
||||
src/data/
|
||||
@@ -12,4 +12,10 @@ wheels/
|
||||
.env
|
||||
*.sqlite
|
||||
*.duckdb
|
||||
*.db
|
||||
*.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"]
|
||||
|
||||
@@ -217,6 +217,14 @@ Automated tests are not implemented at this time. Manual testing via the API doc
|
||||
* **Static Files:** The production deployment **must** include configuring the web server (like Nginx running as a reverse proxy or alongside) to efficiently serve the static files (avatars) from their directory (e.g., `/var/www/html/static/avatars` or similar) under the expected URL path (e.g., `/static/avatars/`). This is more performant than serving via Python in production.
|
||||
* **Source Code:** The container build process needs to ensure the `src` directory contents are correctly copied into the container image.
|
||||
|
||||
## 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
|
||||
|
||||
This is primarily a personal project. Contributions are generally not expected. If you find a bug, feel free to open an issue in the repository.
|
||||
@@ -224,3 +232,4 @@ This is primarily a personal project. Contributions are generally not expected.
|
||||
## License
|
||||
|
||||
This project is licensed under the **MIT License**. See the `LICENSE` file for details.
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -181,7 +181,7 @@
|
||||
},
|
||||
{
|
||||
"name_en": "United Kingdom",
|
||||
"name_fi": "Yhdistynyt kuningaskunta",
|
||||
"name_fi": "Englanti",
|
||||
"code": "GBR"
|
||||
}
|
||||
]
|
||||
@@ -8,13 +8,17 @@
|
||||
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 "Group"; -- Quoted because GROUP 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,12 +27,29 @@ 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
|
||||
-- Stores information about the different households or groups participating.
|
||||
-- =============================================================================
|
||||
CREATE TABLE "Group" (
|
||||
CREATE TABLE "Team" (
|
||||
id INTEGER PRIMARY KEY DEFAULT nextval('group_id_seq'), -- Use sequence for auto-increment
|
||||
name VARCHAR UNIQUE NOT NULL, -- The name of the household/group
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, -- Timestamp when the group was created
|
||||
@@ -42,16 +63,19 @@ CREATE TABLE "Group" (
|
||||
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)
|
||||
group_id INTEGER NOT NULL, -- Foreign Key -> Group.id
|
||||
team_id INTEGER NOT NULL, -- Foreign Key -> Group.id
|
||||
avatar_id INTEGER DEFAULT 0 NOT NULL, -- Identifier for the user's avatar
|
||||
is_active BOOLEAN DEFAULT TRUE NOT NULL, -- Flag indicating if the user account is active
|
||||
is_admin BOOLEAN DEFAULT FALSE NOT NULL, -- Flag indicating if the user has admin privileges
|
||||
last_login TIMESTAMP DEFAULT '1970-01-01 00:00:00', -- Timestamp when the user was last logged in. Epoch (1970-01-01) means "never logged in"
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, -- Timestamp when the user was created
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, -- Timestamp when the user was last updated
|
||||
|
||||
FOREIGN KEY (group_id) REFERENCES "Group"(id) -- Link to the Group table
|
||||
FOREIGN KEY (team_id) REFERENCES "Team"(id) -- Link to the Team table
|
||||
);
|
||||
|
||||
-- =============================================================================
|
||||
@@ -90,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
|
||||
@@ -101,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)
|
||||
@@ -110,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)
|
||||
-- =============================================================================
|
||||
|
||||
@@ -2,11 +2,17 @@ from duckdb import connect
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
import json
|
||||
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
|
||||
@@ -19,6 +25,7 @@ def get_connection():
|
||||
|
||||
|
||||
def seed_db() -> None:
|
||||
# Seed Songs
|
||||
with open(SEED_JSON_PATH, "r") as f:
|
||||
data = json.load(f)
|
||||
|
||||
@@ -46,31 +53,40 @@ def seed_db() -> None:
|
||||
),
|
||||
)
|
||||
|
||||
with open(COUNTRYCODES_JSON_PATH, "r") as f:
|
||||
data = json.load(f)
|
||||
# CountryCodes are now directly inserted in the SQL file
|
||||
|
||||
if not data:
|
||||
return
|
||||
# Seed Teams
|
||||
with get_connection() as conn:
|
||||
conn.execute("INSERT INTO Team (name) VALUES (?)", ("Pontus",))
|
||||
|
||||
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"],
|
||||
),
|
||||
)
|
||||
# Seed Admin users
|
||||
admin_username = os.getenv("ADMIN_USERNAME")
|
||||
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 (?, ?, ?, ?, ?)',
|
||||
(
|
||||
admin_username,
|
||||
hashed_admin_password,
|
||||
1,
|
||||
True,
|
||||
True,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
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,9 @@
|
||||
from hashlib import md5
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
return md5(password.encode()).hexdigest()
|
||||
|
||||
|
||||
def verify_password(password: str, hashed_password: str) -> bool:
|
||||
return hash_password(password) == hashed_password
|
||||
@@ -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
|
||||
@@ -1,22 +1,26 @@
|
||||
[
|
||||
{
|
||||
"artist": "Shkodra Elektronike",
|
||||
"country": "ALB",
|
||||
"title": "Zjerm",
|
||||
"lyrics_url": "https://genius.com/Shkodra-elektronike-zjerm-lyrics",
|
||||
"artist_url": "https://eurovision.tv/participant/shkodra_elektronike-2025",
|
||||
"flag_url": "https://eurovision.tv/sites/default/files/media/image/2023-08/ESC-HEART-ALBANIA-WHITE%402000px.png",
|
||||
"lyrics_original": "[Teksti i \"Zjerm\"]\n\n[Strofa 1: Beatriçe Gjergji]\nN'këtë minutë, n'këtë çast, no paranoja (Ah)\nPas shiut, ylber të duket bota (Ua)\nNëpër rrugë, asnjë ambulancë, kurrkush s'flet me arrogancë\nEdhe sot na thanë që s'prishet koha\nPërfundova nën ujë, kurrë s'm'u tha goja (No)\nImagjino një minutë pa ushtarë, pa jetima\nAsnjë shishe n'oqean, naftës i vjen era jargavan\nLirinë e fjalës ta mëson shkolla (Hopa)\n\n[Pararefreni: Beatriçe Gjergji]\nKrijo në mua një zemër t'pastër\nNë natën time, të dërgoj dritën\nAman, miserere\nAman, miserere\n\n[Refreni: Beatriçe Gjergji]\nNë zemrën time, n'zemrën teme\nKy minutë do të vazhdojë\nNjerëz't e mirë e njerëz't pa emër\nKërcejnë valle n'shpirt\nJarnane ti toka ime\nKu kam lindë, s'do të harroj\nJarnane ti bota ime\nVazhdo me shndritë, shndritë, shndritë, shndritë\n\n[Pasrefreni: Beatriçe Gjergji]\n(Shndritë, shndritë, shndritë)\n(Shndritë, shndritë, shndritë, shndritë)\n(Shndritë, shndritë, shndritë)\n(Shndritë, shndritë)\n(Shndritë, shndritë, shndritë, shndritë)\n(Shndritë, shndritë, shndritë, shndritë)\n\n[Strofa 2: Kolë Laca, Kolë Laca & Beatriçe Gjergji]\nZjerm bjer mbi vallet tona tribale\nQë ushtrojnë sa orteku kur bjen n'male\nZjerm, njerz't pa emën e njerz't e dëlirë\nNjësoj këputen e bijnë si shtatë thika që t'ngulen n'shpirt\nZjerm, këtu flen deti, rana e hana\nE yjet s'i shofim se yjet na i shkel kamba kur ecim n'jerm\nZjerm, jena t'untë për flakë e dritë\nE t'kërkojmë n'kyt terr që s'pran' tuej shndritë\n\n[Pararefreni: Beatriçe Gjergji]\nKrijo në mua një zemër t'pastër\nNë natën time, të dërgoj dritën\nAman, miserere\nAman, miserere\n\n[Refreni: Beatriçe Gjergji]\nNë zemrën time, n'zemrën teme\nKy minutë do të vazhdojë\nNjerëz't e mirë e njerëz't pa emër\nKërcejnë valle n'shpirt\nJarnane ti toka ime\nKu kam lindë, s'do të harroj\nJarnane ti bota ime\nVazhdo me shndritë, shndritë, shndritë, shndritë\n\n[Mbyllja: Beatriçe Gjergji]\n(Shndritë, shndritë, shndritë, shndritë)\n(Shndritë, shndritë, shndritë, shndritë)\n(Shndritë, shndritë, shndritë, shndritë)\n(Shndritë, shndritë, shndritë, shndritë)\n(Shndritë, shndritë, shndritë, shndritë)\n(Shndritë, shndritë, shndritë, shndritë)\n(Shndritë, shndritë, shndritë, shndritë)\n(Shndritë, shndritë, shndritë, shndritë)",
|
||||
"lyrics_translation_fi": "[Säkeistö 1: Beatriçe Gjergji] Tässä hetkessä, tässä hetkessä, ei vainoharjoja (Ah) Sateen jälkeen maailma näyttää sateenkaarelta (Ua) Kadulla ei ole ambulansseja, kukaan ei puhu ylimielisesti Jopa tänään he sanoivat meille, että sää ei ole pilalla Päädyin veden alle, suuni ei koskaan kuivunut (Ei) Kuvittele hetki ilman sotilaita, ilman orpoja Ei pulloa valtameressä, öljy tuoksuu syreeniltä Sananvapauden opettaa koulu (Hopa) [Esikertosäe: Beatriçe Gjergji] Luo minussa puhdas sydän Lähetän valon yössäni Ole hyvä, miserere Ole hyvä, miserere [Kertosäe: Beatriçe Gjergji] Sydämessäni, sydämessäni Tämä hetki jatkuu Hyvät ihmiset ja nimettömät Ihmiset tanssivat tansseja sielussa Jarnane sinä minun maani Missä synnyin, en unohda Jarnane sinä minun maailmani Jatka loistamista, loistamista, loistamista, loistamista [Jälkikertosäe: Beatriçe Gjergji] (Loista, loista, loista) (Loista, loista, loista, loista) (Loista, loista, loista) (Loista, loista) (Loista, loista, loista, loista) (Loista, loista, loista, loista) [Säkeistö 2: Kolë Laca, Kolë Laca & Beatriçe Gjergji] Zjerm putoaa heimotanssiemme päälle Jotka harjoittelevat kuin lumivyöry putoaa vuorille Zjerm, nimettömät ihmiset ja puhtaat Ihmiset katkeavat ja putoavat kuin seitsemän veistä, jotka on juurtunut sieluun Zjerm, tässä meri, hiekka ja kuu nukkuvat Emme näe tähtiä, koska jalat polkevat niitä, kun kävelemme aavikolla Zjerm, me olemme nälkäisiä liekeille ja valolle Ja etsimme siitä pimeydestä, joka ei salli sinun loistaa [Esikertosäe: Beatriçe Gjergji] Luo minussa puhdas sydän Lähetän valon yössäni Ole hyvä, miserere Ole hyvä, miserere [Kertosäe: Beatriçe Gjergji] Sydämessäni, sydämessäni Tämä hetki jatkuu Hyvät ihmiset ja nimettömät Ihmiset tanssivat tansseja sielussa Jarnane sinä minun maani Missä synnyin, en unohda Jarnane sinä minun maailmani Jatka loistamista, loistamista, loistamista, loistamista [Loppu: Beatriçe Gjergji] (Loista, loista, loista, loista) (Loista, loista, loista, loista) (Loista, loista, loista, loista) (Loista, loista, loista, loista) (Loista, loista, loista, loista) (Loista, loista, loista, loista) (Loista, loista, loista, loista) (Loista, loista, loista, loista)",
|
||||
"tags": [
|
||||
"toivo",
|
||||
"rauha",
|
||||
"sisu"
|
||||
],
|
||||
"confidence": 0.95,
|
||||
"language": "Albania",
|
||||
"running_order": 1
|
||||
},
|
||||
"id": 1,
|
||||
"year": 2025,
|
||||
"country": "ALB",
|
||||
"artist": "Shkodra Elektronike",
|
||||
"title": "Zjerm",
|
||||
"running_order": 1,
|
||||
"lyrics_url": "https://genius.com/Shkodra-elektronike-zjerm-lyrics",
|
||||
"artist_url": "https://eurovision.tv/participant/shkodra_elektronike-2025",
|
||||
"flag_url": "https://eurovision.tv/sites/default/files/media/image/2023-08/ESC-HEART-ALBANIA-WHITE%402000px.png",
|
||||
"lyrics_original": "[Teksti i \"Zjerm\"]\n\n[Strofa 1: Beatriçe Gjergji]\nN'këtë minutë, n'këtë çast, no paranoja (Ah)\nPas shiut, ylber të duket bota (Ua)\nNëpër rrugë, asnjë ambulancë, kurrkush s'flet me arrogancë\nEdhe sot na thanë që s'prishet koha\nPërfundova nën ujë, kurrë s'm'u tha goja (No)\nImagjino një minutë pa ushtarë, pa jetima\nAsnjë shishe n'oqean, naftës i vjen era jargavan\nLirinë e fjalës ta mëson shkolla (Hopa)\n\n[Pararefreni: Beatriçe Gjergji]\nKrijo në mua një zemër t'pastër\nNë natën time, të dërgoj dritën\nAman, miserere\nAman, miserere\n\n[Refreni: Beatriçe Gjergji]\nNë zemrën time, n'zemrën teme\nKy minutë do të vazhdojë\nNjerëz't e mirë e njerëz't pa emër\nKërcejnë valle n'shpirt\nJarnane ti toka ime\nKu kam lindë, s'do të harroj\nJarnane ti bota ime\nVazhdo me shndritë, shndritë, shndritë, shndritë\n\n[Pasrefreni: Beatriçe Gjergji]\n(Shndritë, shndritë, shndritë)\n(Shndritë, shndritë, shndritë, shndritë)\n(Shndritë, shndritë, shndritë)\n(Shndritë, shndritë)\n(Shndritë, shndritë, shndritë, shndritë)\n(Shndritë, shndritë, shndritë, shndritë)\n\n[Strofa 2: Kolë Laca, Kolë Laca & Beatriçe Gjergji]\nZjerm bjer mbi vallet tona tribale\nQë ushtrojnë sa orteku kur bjen n'male\nZjerm, njerz't pa emën e njerz't e dëlirë\nNjësoj këputen e bijnë si shtatë thika që t'ngulen n'shpirt\nZjerm, këtu flen deti, rana e hana\nE yjet s'i shofim se yjet na i shkel kamba kur ecim n'jerm\nZjerm, jena t'untë për flakë e dritë\nE t'kërkojmë n'kyt terr që s'pran' tuej shndritë\n\n[Pararefreni: Beatriçe Gjergji]\nKrijo në mua një zemër t'pastër\nNë natën time, të dërgoj dritën\nAman, miserere\nAman, miserere\n\n[Refreni: Beatriçe Gjergji]\nNë zemrën time, n'zemrën teme\nKy minutë do të vazhdojë\nNjerëz't e mirë e njerëz't pa emër\nKërcejnë valle n'shpirt\nJarnane ti toka ime\nKu kam lindë, s'do të harroj\nJarnane ti bota ime\nVazhdo me shndritë, shndritë, shndritë, shndritë\n\n[Mbyllja: Beatriçe Gjergji]\n(Shndritë, shndritë, shndritë, shndritë)\n(Shndritë, shndritë, shndritë, shndritë)\n(Shndritë, shndritë, shndritë, shndritë)\n(Shndritë, shndritë, shndritë, shndritë)\n(Shndritë, shndritë, shndritë, shndritë)\n(Shndritë, shndritë, shndritë, shndritë)\n(Shndritë, shndritë, shndritë, shndritë)\n(Shndritë, shndritë, shndritë, shndritë)",
|
||||
"lyrics_translation_fi": "[Säkeistö 1: Beatriçe Gjergji]\nTässä hetkessä, tässä hetkessä, ei vainoharjoja (Ah)\nSateen jälkeen maailma näyttää sateenkaarelta (Ua)\nKadulla ei ole ambulansseja, kukaan ei puhu ylimielisesti\nJopa tänään he sanoivat meille, että sää ei ole pilalla\nPäädyin veden alle, suuni ei koskaan kuivunut (Ei)\nKuvittele hetki ilman sotilaita, ilman orpoja\nEi pulloa valtameressä, öljy tuoksuu syreeniltä\nSananvapauden opettaa koulu (Hopa)\n\n[Esikertosäe: Beatriçe Gjergji]\nLuo minussa puhdas sydän\nLähetän valon yössäni\nOle hyvä, miserere\nOle hyvä, miserere\n\n[Kertosäe: Beatriçe Gjergji]\nSydämessäni, sydämessäni\nTämä hetki jatkuu\nHyvät ihmiset ja nimettömät\nIhmiset tanssivat tansseja sielussa\nJarnane sinä minun maani\nMissä synnyin, en unohda\nJarnane sinä minun maailmani\nJatka loistamista, loistamista, loistamista, loistamista\n\n[Jälkikertosäe: Beatriçe Gjergji]\n(Loista, loista, loista)\n(Loista, loista, loista, loista)\n(Loista, loista, loista)\n(Loista, loista)\n(Loista, loista, loista, loista)\n(Loista, loista, loista, loista)\n\n[Säkeistö 2: Kolë Laca, Kolë Laca & Beatriçe Gjergji]\nZjerm putoaa heimotanssiemme päälle\nJotka harjoittelevat kuin lumivyöry putoaa vuorille\nZjerm, nimettömät ihmiset ja puhtaat\nIhmiset katkeavat ja putoavat kuin seitsemän veistä, jotka on juurtunut sieluun\nZjerm, tässä meri, hiekka ja kuu nukkuvat\nEmme näe tähtiä, koska jalat polkevat niitä, kun kävelemme aavikolla\nZjerm, me olemme nälkäisiä liekeille ja valolle\nJa etsimme siitä pimeydestä, joka ei salli sinun loistaa\n\n[Esikertosäe: Beatriçe Gjergji]\nLuo minussa puhdas sydän\nLähetän valon yössäni\nOle hyvä, miserere\nOle hyvä, miserere\n\n[Kertosäe: Beatriçe Gjergji]\nSydämessäni, sydämessäni\nTämä hetki jatkuu\nHyvät ihmiset ja nimettömät\nIhmiset tanssivat tansseja sielussa\nJarnane sinä minun maani\nMissä synnyin, en unohda\nJarnane sinä minun maailmani\nJatka loistamista, loistamista, loistamista, loistamista\n\n[Loppu: Beatriçe Gjergji]\n(Loista, loista, loista, loista)\n(Loista, loista, loista, loista)\n(Loista, loista, loista, loista)\n(Loista, loista, loista, loista)\n(Loista, loista, loista, loista)\n(Loista, loista, loista, loista)\n(Loista, loista, loista, loista)\n(Loista, loista, loista, loista)",
|
||||
"tags": [
|
||||
"toivo",
|
||||
"rauha",
|
||||
"sisu"
|
||||
],
|
||||
"confidence": 0.949999988079071,
|
||||
"language": "Albania",
|
||||
"created_at": "2025-05-04T18:49:03.381000",
|
||||
"updated_at": "2025-05-04T18:49:03.381000"
|
||||
},
|
||||
{
|
||||
"artist": "PARG",
|
||||
"country": "ARM",
|
||||
@@ -718,4 +722,4 @@
|
||||
"language": "Englanti",
|
||||
"running_order": 37
|
||||
}
|
||||
]
|
||||
]
|
||||
|
||||
@@ -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"],
|
||||
)
|
||||
@@ -0,0 +1,9 @@
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class TeamBase(BaseModel):
|
||||
name: str
|
||||
|
||||
|
||||
class Team(TeamBase):
|
||||
id: int
|
||||
@@ -0,0 +1,54 @@
|
||||
from pydantic import BaseModel, Field
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class UserBase(BaseModel):
|
||||
username: str = Field(description="Username of the user", examples=["Kalle"])
|
||||
|
||||
|
||||
class User(UserBase):
|
||||
id: int = Field(description="ID of the user", examples=[1])
|
||||
team_id: int = Field(description="ID of the team", examples=[1])
|
||||
is_active: bool = Field(
|
||||
default=True, description="Is the user active?", examples=[True]
|
||||
)
|
||||
avatar_id: int = Field(default=0, description="ID of the avatar", examples=[0])
|
||||
is_admin: bool = Field(
|
||||
default=False, description="Is the user an admin?", examples=[False]
|
||||
)
|
||||
last_login: datetime = Field(
|
||||
description="Last login timestamp",
|
||||
examples=["2025-01-01T00:00:00.000Z"],
|
||||
)
|
||||
created_at: datetime = Field(
|
||||
description="Creation timestamp", examples=["2025-01-01T00:00:00.000Z"]
|
||||
)
|
||||
updated_at: datetime = Field(
|
||||
description="Last update timestamp", examples=["2025-01-01T00:00:00.000Z"]
|
||||
)
|
||||
|
||||
@property
|
||||
def has_logged_in(self) -> bool:
|
||||
"""Returns True if the user has ever logged in, False if last_login is the epoch date."""
|
||||
epoch = datetime(1970, 1, 1, 0, 0, 0)
|
||||
return self.last_login and self.last_login > epoch
|
||||
|
||||
|
||||
class CreateUser(UserBase):
|
||||
password: str = Field(description="Password of the user", examples=["sn1rbul4"])
|
||||
team_id: int = Field(description="ID of the team", examples=[1])
|
||||
is_admin: bool = Field(
|
||||
default=False, description="Is the user an admin?", examples=[False]
|
||||
)
|
||||
|
||||
|
||||
class UserLogin(UserBase):
|
||||
password: str = Field(description="Password of the user", examples=["sn1rbul4"])
|
||||
|
||||
|
||||
class UserChangePassword(BaseModel):
|
||||
id: int = Field(description="ID of the user", examples=[1])
|
||||
old_password: str = Field(
|
||||
description="Old password of the user", examples=["sn1rbul4"]
|
||||
)
|
||||
password: str = Field(description="Password of the user", examples=["p1p4l1"])
|
||||
@@ -1,10 +1,10 @@
|
||||
from fastapi import APIRouter
|
||||
# from sqlmodel import Session, select
|
||||
from fastapi import APIRouter, Body, HTTPException, Request
|
||||
|
||||
# from models.user import User
|
||||
# from models.group import Group
|
||||
|
||||
# from ..app import get_session
|
||||
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"])
|
||||
|
||||
@@ -18,8 +18,78 @@ router = APIRouter(prefix="/admin", tags=["Admin"])
|
||||
# # Implementation will be added with authentication logic
|
||||
# return {"is_active": is_active}
|
||||
|
||||
# @router.post("/users")
|
||||
# async def create_user(username: str, password: str, group_id: int, session: Session = Depends(get_session)):
|
||||
|
||||
@router.post("/teams")
|
||||
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(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, 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"}
|
||||
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/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")
|
||||
|
||||
|
||||
# """
|
||||
# Add a new user
|
||||
# Only accessible to admin users
|
||||
|
||||
@@ -1,7 +1,52 @@
|
||||
from fastapi import APIRouter
|
||||
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")
|
||||
async def login():
|
||||
return {"message": "Login successful"}
|
||||
|
||||
@router.post("/token", response_model=User)
|
||||
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
|
||||
with get_connection() as conn:
|
||||
conn.execute(
|
||||
'UPDATE "User" SET last_login = CURRENT_TIMESTAMP WHERE id = ?',
|
||||
(user_data["id"],),
|
||||
)
|
||||
|
||||
# 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]
|
||||
)
|
||||
|
||||
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,27 +1,47 @@
|
||||
from fastapi import APIRouter
|
||||
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("/")
|
||||
async def list_users():
|
||||
return {"users": []}
|
||||
@router.get("/", response_model=list[User])
|
||||
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()
|
||||
|
||||
# Convert DataFrame to dict
|
||||
users_dict = users.to_dict(orient="records")
|
||||
return users_dict
|
||||
|
||||
|
||||
@router.get("/{user_id}")
|
||||
async def get_user(user_id: int):
|
||||
return {"user_id": user_id}
|
||||
@router.get("/{user_id}", response_model=User)
|
||||
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.post("/")
|
||||
# async def create_user(user: User):
|
||||
# return {"user": user}
|
||||
|
||||
# @router.patch("/{user_id}")
|
||||
# async def update_user(user_id: int, user: User):
|
||||
# return {"user_id": user_id, "user": user}
|
||||
|
||||
|
||||
@router.delete("/{user_id}")
|
||||
async def delete_user(user_id: int):
|
||||
return {"user_id": user_id}
|
||||
@router.patch("/{user_id}")
|
||||
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" },
|
||||
]
|
||||
|
||||