16 Commits
Author SHA1 Message Date
Esa Kataja aecc0731bc Prepare 1.0rc1 release 2025-05-10 14:14:35 +03:00
Esa Kataja 751af50e8a Add logger 2025-05-10 14:13:07 +03:00
Esa Kataja 005ac8634f Fix db file being overwritten at startup 2025-05-10 14:10:12 +03:00
Esa Kataja 055dd3b43b Fix admin seed had no password 2025-05-10 11:01:20 +03:00
Esa Kataja 4a4eda09d1 Move country code seeds to sql from db.py 2025-05-10 11:00:37 +03:00
Esa Kataja e647bc7811 Add review model and CRUD endpoints for managing song reviews 2025-05-05 18:32:39 +03:00
Esa Kataja bf7c969629 CHANGE sql schema 2025-05-05 18:30:22 +03:00
Esa Kataja 6706abfc97 Add user and team models with auth routes and admin endpoints 2025-05-05 17:54:39 +03:00
Esa Kataja ed10e7bf80 CHANGE git ignores 2025-05-05 17:53:53 +03:00
Esa Kataja 73935be1ba FIX Admin seed value 2025-05-05 17:53:11 +03:00
Esa Kataja c3494e6169 ADD Verify_password 2025-05-05 17:52:42 +03:00
Esa Kataja df9c5ba4c0 ADD Last login to user 2025-05-05 17:52:22 +03:00
Esa Kataja d3e22eefcb ADD seed admin user 2025-05-05 16:18:09 +03:00
Esa Kataja 29b3ba9192 ADD README todos 2025-05-05 16:17:47 +03:00
Esa Kataja 32eda86f4f Add helper password hash 2025-05-05 16:17:21 +03:00
Esa Kataja b05c8570a1 FIX seed data 2025-05-05 16:16:48 +03:00
20 changed files with 453 additions and 78 deletions
+4
View File
@@ -13,3 +13,7 @@ wheels/
*.sqlite *.sqlite
*.duckdb *.duckdb
*.db *.db
*.log
*.gz
.vscode/*
+17
View File
@@ -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.
+5
View File
@@ -217,6 +217,10 @@ 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. * **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. * **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
## Contributing ## 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. 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 +228,4 @@ This is primarily a personal project. Contributions are generally not expected.
## License ## License
This project is licensed under the **MIT License**. See the `LICENSE` file for details. This project is licensed under the **MIT License**. See the `LICENSE` file for details.
+3
View File
@@ -10,3 +10,6 @@ services:
volumes: volumes:
- ./data:/app/data - ./data:/app/data
restart: unless-stopped restart: unless-stopped
environment:
- ADMIN_USERNAME=admin
- ADMIN_PASSWORD=password
+2 -1
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "Eurovision-25-backend" name = "Eurovision-25-backend"
version = "0.1.0" version = "1.0rc1"
description = "Backend for Eurovision 25" description = "Backend for Eurovision 25"
readme = "README.md" readme = "README.md"
requires-python = ">=3.12" requires-python = ">=3.12"
@@ -8,6 +8,7 @@ dependencies = [
"aiofiles>=24.1.0", "aiofiles>=24.1.0",
"duckdb>=1.2.2", "duckdb>=1.2.2",
"fastapi>=0.115.12", "fastapi>=0.115.12",
"loguru>=0.7.3",
"pandas>=2.2.3", "pandas>=2.2.3",
"passlib[bcrypt]>=1.7.4", "passlib[bcrypt]>=1.7.4",
"python-dotenv>=1.1.0", "python-dotenv>=1.1.0",
+4 -1
View File
@@ -5,13 +5,14 @@ from fastapi.staticfiles import StaticFiles
# Import routers # Import routers
from routes import auth, admin, users, songs, reviews, results from routes import auth, admin, users, songs, reviews, results
from lib.logger import logger
from lib.db import init_db from lib.db import init_db
# Initialize FastAPI app # Initialize FastAPI app
app = FastAPI( app = FastAPI(
title="Eurovision 25 Homereview API", title="Eurovision 25 Homereview API",
description="Backend API for Eurovision 25 Homereview application", description="Backend API for Eurovision 25 Homereview application",
version="1.0.0", version="1.0rc1",
) )
# Add CORS middleware # Add CORS middleware
@@ -32,12 +33,14 @@ app.mount("/static/flags", StaticFiles(directory="static/flags"), name="flags")
# Startup event to create database tables # Startup event to create database tables
@app.on_event("startup") @app.on_event("startup")
def on_startup(): def on_startup():
logger.info("Starting up...")
init_db() init_db()
# Health check endpoint # Health check endpoint
@app.get("/") @app.get("/")
async def root(): async def root():
logger.debug("Root endpoint accessed")
return {"status": "ok", "message": "Eurovision 25 Homereview API"} return {"status": "ok", "message": "Eurovision 25 Homereview API"}
@@ -181,7 +181,7 @@
}, },
{ {
"name_en": "United Kingdom", "name_en": "United Kingdom",
"name_fi": "Yhdistynyt kuningaskunta", "name_fi": "Englanti",
"code": "GBR" "code": "GBR"
} }
] ]
+49 -7
View File
@@ -8,7 +8,7 @@
DROP TABLE IF EXISTS Review; DROP TABLE IF EXISTS Review;
DROP TABLE IF EXISTS Song; DROP TABLE IF EXISTS Song;
DROP TABLE IF EXISTS "User"; -- Quoted because USER is a reserved keyword DROP TABLE IF EXISTS "User"; -- Quoted because USER is a reserved keyword
DROP TABLE IF EXISTS "Group"; -- Quoted because GROUP is a reserved keyword DROP TABLE IF EXISTS Team;
DROP TABLE IF EXISTS CountryCodes; DROP TABLE IF EXISTS CountryCodes;
DROP SEQUENCE IF EXISTS group_id_seq; DROP SEQUENCE IF EXISTS group_id_seq;
@@ -28,7 +28,7 @@ CREATE SEQUENCE review_id_seq START 1;
-- Table: Group -- Table: Group
-- Stores information about the different households or groups participating. -- 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 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 name VARCHAR UNIQUE NOT NULL, -- The name of the household/group
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, -- Timestamp when the group was created created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, -- Timestamp when the group was created
@@ -44,14 +44,15 @@ CREATE TABLE "User" (
username VARCHAR UNIQUE NOT NULL, -- The user's login name username VARCHAR UNIQUE NOT NULL, -- The user's login name
hashed_password VARCHAR NOT NULL, -- The securely hashed password hashed_password VARCHAR NOT NULL, -- The securely hashed password
email VARCHAR UNIQUE, -- User's email address (nullable) 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 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_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 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 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 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 +91,7 @@ CREATE TABLE Review (
song_id INTEGER NOT NULL, -- Foreign Key -> Song.id song_id INTEGER NOT NULL, -- Foreign Key -> Song.id
score_song INTEGER NOT NULL, -- Score (1-100) for song quality score_song INTEGER NOT NULL, -- Score (1-100) for song quality
score_show INTEGER NOT NULL, -- Score (1-100) for the stage show 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) text_review VARCHAR, -- Optional textual comments (nullable)
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, -- Timestamp when the review was created 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 updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, -- Timestamp when the review was last modified
@@ -101,7 +102,7 @@ CREATE TABLE Review (
-- Ensure scores are within the valid range (1-100) -- Ensure scores are within the valid range (1-100)
CHECK (score_song >= 1 AND score_song <= 100), CHECK (score_song >= 1 AND score_song <= 100),
CHECK (score_show >= 1 AND score_show <= 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 -- Ensure each user can only submit one review per song
UNIQUE (user_id, song_id) UNIQUE (user_id, song_id)
@@ -110,8 +111,49 @@ CREATE TABLE Review (
CREATE TABLE CountryCodes ( CREATE TABLE CountryCodes (
code VARCHAR PRIMARY KEY, code VARCHAR PRIMARY KEY,
name_en VARCHAR NOT NULL, name_en VARCHAR NOT NULL,
name_fi VARCHAR NOT NULL name_fi VARCHAR NOT NULL,
name_sv VARCHAR NOT NULL
); );
-- 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');
-- ============================================================================= -- =============================================================================
-- Views (Optional - Not Created Here Based on Documentation Decision) -- Views (Optional - Not Created Here Based on Documentation Decision)
-- ============================================================================= -- =============================================================================
+22 -14
View File
@@ -2,11 +2,16 @@ from duckdb import connect
from contextlib import contextmanager from contextlib import contextmanager
from pathlib import Path from pathlib import Path
import json import json
from dotenv import load_dotenv
import os
from lib.helpers import hash_password
load_dotenv()
DB_PATH = Path("./data/data.duckdb").absolute() DB_PATH = Path("./data/data.duckdb").absolute()
SQL_PATH = Path("./lib/database.sql").absolute() SQL_PATH = Path("./lib/database.sql").absolute()
SEED_JSON_PATH = Path("./lib/seed_data.json").absolute() SEED_JSON_PATH = Path("./lib/seed_data.json").absolute()
COUNTRYCODES_JSON_PATH = Path("./lib/countrycodes.json").absolute()
@contextmanager @contextmanager
@@ -19,6 +24,7 @@ def get_connection():
def seed_db() -> None: def seed_db() -> None:
# Seed Songs
with open(SEED_JSON_PATH, "r") as f: with open(SEED_JSON_PATH, "r") as f:
data = json.load(f) data = json.load(f)
@@ -46,31 +52,33 @@ def seed_db() -> None:
), ),
) )
with open(COUNTRYCODES_JSON_PATH, "r") as f: # CountryCodes are now directly inserted in the SQL file
data = json.load(f)
if not data: # Seed Teams
return with get_connection() as conn:
conn.execute("INSERT INTO Team (name) VALUES (?)", ("Pontus",))
for item in data: # Seed Admin users
admin_username = os.getenv("ADMIN_USERNAME")
hashed_admin_password = hash_password(os.getenv("ADMIN_PASSWORD", "password"))
with get_connection() as conn: with get_connection() as conn:
conn.execute( conn.execute(
"INSERT INTO CountryCodes (code, name_en, name_fi) VALUES (?, ?, ?)", 'INSERT INTO "User" (username, hashed_password, team_id, is_active, is_admin) VALUES (?, ?, ?, ?, ?)',
( (
item["code"], admin_username,
item["name_en"], hashed_admin_password,
item["name_fi"], 1,
True,
True,
), ),
) )
def init_db(): def init_db():
if Path(DB_PATH).exists():
return
with open(SQL_PATH, "r") as f: with open(SQL_PATH, "r") as f:
sql = f.read() sql = f.read()
with get_connection() as conn: with get_connection() as conn:
conn.execute(sql) conn.execute(sql)
seed_db() seed_db()
if __name__ == "__main__":
init_db()
+9
View File
@@ -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
+16
View File
@@ -0,0 +1,16 @@
from loguru import logger
from pathlib import Path
import sys
LOG_PATH = Path("./data/logs/logs.log").absolute()
ERROR_PATH = Path("./data/logs/errors.log").absolute()
logger.remove()
logger.add(LOG_PATH, rotation="10 MB", compression="gz", level="INFO")
logger.add(sys.stdout, level="INFO")
logger.add(ERROR_PATH, rotation="10 MB", compression="gz", level="ERROR")
logger.add(sys.stderr, level="ERROR")
logger.add(sys.stdout, level="DEBUG")
logger = logger
+8 -4
View File
@@ -1,21 +1,25 @@
[ [
{ {
"artist": "Shkodra Elektronike", "id": 1,
"year": 2025,
"country": "ALB", "country": "ALB",
"artist": "Shkodra Elektronike",
"title": "Zjerm", "title": "Zjerm",
"running_order": 1,
"lyrics_url": "https://genius.com/Shkodra-elektronike-zjerm-lyrics", "lyrics_url": "https://genius.com/Shkodra-elektronike-zjerm-lyrics",
"artist_url": "https://eurovision.tv/participant/shkodra_elektronike-2025", "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", "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_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)", "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": [ "tags": [
"toivo", "toivo",
"rauha", "rauha",
"sisu" "sisu"
], ],
"confidence": 0.95, "confidence": 0.949999988079071,
"language": "Albania", "language": "Albania",
"running_order": 1 "created_at": "2025-05-04T18:49:03.381000",
"updated_at": "2025-05-04T18:49:03.381000"
}, },
{ {
"artist": "PARG", "artist": "PARG",
+41
View File
@@ -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"],
)
+9
View File
@@ -0,0 +1,9 @@
from pydantic import BaseModel
class TeamBase(BaseModel):
name: str
class Team(TeamBase):
id: int
+54
View File
@@ -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"])
+40 -8
View File
@@ -1,10 +1,9 @@
from fastapi import APIRouter from fastapi import APIRouter, Body, HTTPException
# from sqlmodel import Session, select
# from models.user import User from models.user import CreateUser
# from models.group import Group from models.team import TeamBase, Team
from lib.db import get_connection
# from ..app import get_session from lib.helpers import hash_password
router = APIRouter(prefix="/admin", tags=["Admin"]) router = APIRouter(prefix="/admin", tags=["Admin"])
@@ -18,8 +17,41 @@ router = APIRouter(prefix="/admin", tags=["Admin"])
# # Implementation will be added with authentication logic # # Implementation will be added with authentication logic
# return {"is_active": is_active} # 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):
with get_connection() as conn:
conn.execute("INSERT INTO Team (name) VALUES (?)", (team.name,))
return {"message": "Team created successfully"}
@router.get("/teams", response_model=list[Team])
async def list_teams():
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,
),
)
return {"message": "User created successfully"}
@router.post("/users/")
async def disable_user(user_id: int = Body(..., embed=True)):
raise HTTPException(status_code=405, detail="Method not yet implemented")
# """ # """
# Add a new user # Add a new user
# Only accessible to admin users # Only accessible to admin users
+34 -4
View File
@@ -1,7 +1,37 @@
from fastapi import APIRouter from fastapi import APIRouter, HTTPException
from lib.db import get_connection
from models.user import User, UserLogin
from lib.helpers import verify_password
router = APIRouter(prefix="/auth", tags=["auth"]) router = APIRouter(prefix="/auth", tags=["auth"])
@router.post("/token")
async def login(): @router.post("/token", response_model=User)
return {"message": "Login successful"} async def login(user_login: UserLogin):
with get_connection() as conn:
user_df = conn.execute(
'SELECT * FROM "User" WHERE username = ?', (user_login.username,)
).fetchdf()
if user_df.empty:
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"]):
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]
return User(**updated_user)
+66 -2
View File
@@ -1,3 +1,67 @@
from fastapi import APIRouter from fastapi import APIRouter, HTTPException
router = APIRouter(prefix="/songs", tags=["songs"]) from models.review import ReviewOut, ReviewSearch, ReviewIn
from lib.db import get_connection
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:
raise HTTPException(status_code=404, detail="Reviews not found")
return reviews.to_dict(orient="records")
@router.get("/", response_model=ReviewOut)
async def get_review(review_id: ReviewSearch):
with get_connection() as conn:
review = conn.execute(
"SELECT * FROM Review WHERE song_id = ? AND user_id = ?",
(review_id.song_id, review_id.user_id),
).fetchdf()
if review.empty:
raise HTTPException(status_code=404, detail="Review not found")
return review.to_dict(orient="records")[0]
@router.post("/", response_model=ReviewOut)
async def create_review(review: ReviewIn):
with get_connection() as conn:
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,
),
).fetchdf()
if review.empty:
raise HTTPException(status_code=404, detail="Review not found")
return review.to_dict(orient="records")[0]
@router.put("/", response_model=ReviewOut)
async def update_review(review: ReviewIn):
with get_connection() as conn:
review = conn.execute(
"UPDATE Review SET user_id = ?, song_id = ?, score_song = ?, score_show = ?, score_costume = ?, text_review = ? WHERE song_id = ? AND user_id = ?",
(
review.user_id,
review.song_id,
review.score_song,
review.score_show,
review.score_costume,
review.text_review,
review.song_id,
review.user_id,
),
).fetchdf()
if review.empty:
raise HTTPException(status_code=404, detail="Review not found")
return review.to_dict(orient="records")[0]
+25 -16
View File
@@ -1,27 +1,36 @@
from fastapi import APIRouter from fastapi import APIRouter
from fastapi import HTTPException
from models.user import User, UserChangePassword
from lib.db import get_connection
router = APIRouter(prefix="/users", tags=["users"]) router = APIRouter(prefix="/users", tags=["users"])
@router.get("/") @router.get("/", response_model=list[User])
async def list_users(): async def list_users():
return {"users": []} 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}") @router.get("/{user_id}", response_model=User)
async def get_user(user_id: int): async def get_user(user_id: int):
return {"user_id": user_id} with get_connection() as conn:
user_df = conn.execute(
'SELECT * FROM "User" WHERE id = ?', (user_id,)
).fetchdf()
if user_df.empty:
raise HTTPException(status_code=404, detail="User not found")
user_data = user_df.to_dict(orient="records")[0]
return User(**user_data)
# @router.post("/") @router.patch("/{user_id}")
# async def create_user(user: User): async def change_password(user: UserChangePassword):
# return {"user": user} raise HTTPException(status_code=401, detail="Not implemented")
# @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}
Generated
+24
View File
@@ -137,6 +137,7 @@ dependencies = [
{ name = "aiofiles" }, { name = "aiofiles" },
{ name = "duckdb" }, { name = "duckdb" },
{ name = "fastapi" }, { name = "fastapi" },
{ name = "loguru" },
{ name = "pandas" }, { name = "pandas" },
{ name = "passlib", extra = ["bcrypt"] }, { name = "passlib", extra = ["bcrypt"] },
{ name = "python-dotenv" }, { name = "python-dotenv" },
@@ -154,6 +155,7 @@ requires-dist = [
{ name = "aiofiles", specifier = ">=24.1.0" }, { name = "aiofiles", specifier = ">=24.1.0" },
{ name = "duckdb", specifier = ">=1.2.2" }, { name = "duckdb", specifier = ">=1.2.2" },
{ name = "fastapi", specifier = ">=0.115.12" }, { name = "fastapi", specifier = ">=0.115.12" },
{ name = "loguru", specifier = ">=0.7.3" },
{ name = "pandas", specifier = ">=2.2.3" }, { name = "pandas", specifier = ">=2.2.3" },
{ name = "passlib", extras = ["bcrypt"], specifier = ">=1.7.4" }, { name = "passlib", extras = ["bcrypt"], specifier = ">=1.7.4" },
{ name = "python-dotenv", specifier = ">=1.1.0" }, { 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" }, { 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]] [[package]]
name = "markdown-it-py" name = "markdown-it-py"
version = "3.0.0" 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/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" }, { 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" },
]