Flask remake (#23)

* initial backend setup

* copied db setup

* added login

* added autoincrement to ids

* update db again

* added script to populate with test data

* added functionality to fetch videos

* added vote functionality

* added logic to update video info

* added history

* updated readme

* Update thank you page

* fixed small bugs

* small improvements

* added admin view, UI fix

* fix vote buttons style

* added pagination

* added command to load sample data from csv

* Autoplay videos

* Adding before you vote page

* added yt video id extractor

* improve visibility for the history page votes

* added logic for before you vote disclaimer

* Remind people to set videos to unlisted and allowed for embed

* Update home.html

* Update home.html

* Add progress bar to total

* Small adjustments

* fix home {% if user %}

* Update macros.html

* limit vote views to users with specific roles

* added endpoint to delete votes

* added confirmation to the delete vote action

* Update non-fund members CTA

* allow voting to specific fund members

* added result csv endpoint

* updated auth

* revert adding 'vote_allowed' column, merged it into is_staff

* added `is_fund_member` attribute to table and updated code

* refactored code

* update style for voting page

* updated readme

* Add static files

* remove options from dropdown for users that can't vote

* added keyboard shortcuts to vote buttons

* added azerty support for shortcuts

* update skip to S key

* remove placeholder

---------

Co-authored-by: Emi <2206700+coppolaemilio@users.noreply.github.com>
This commit is contained in:
Iñigo
2025-10-02 12:07:03 +02:00
committed by GitHub
parent 7fb976ff11
commit b031a52d6a
31 changed files with 2519 additions and 153 deletions

7
.gitignore vendored
View File

@@ -5,11 +5,12 @@ __pycache__
.venv
venv/
# Collected static files
static/
# VS project
.vscode
# Ignore the config file as it may contain secrets
.env
# db files
instance/*
!instance/example-config.py

View File

@@ -7,7 +7,86 @@ showcase Godot engine projects.
## Current state
The following functionality is implemented:
* Cast a vote:
Videos participating in the showreel are shown in a random order, a user can cast a positive or negative vote or skip the specific video
> **_Note:_** If a video is skipped it will not be in the pool of possible videos for the immediate next request. The `skipped` state is not persisted
* Edit a vote:
Users are able to browse through their vote history. If they so desire they are able to update their vote.
* Delete a vote:
Users can delete a vote they have cast. When a vote is deleted, it is complete erase from the database. Which will result in the video showing again in the showreel vote system
* Admin view
There is an admin view that displays the current state of the votes, ordered by number of positive votes descending.
In this panel there is a button to download a `.csv` file with the results. The file contains the following information:
* Author
* Follow-me link
* Game
* Video link
* Download link
* Contact email
* Store Link
* Positive votes
* Negative votes
* staff
* fund_member
## Requirements
For this project I'm trying [uv](https://docs.astral.sh/uv/),a python project manager. The idea is that this tool should replace the need for virtualenvironments and package managers with a a single one.
Configuration is fetched from a config file located in `./instance/config.py`. [An example configuration](instance/example-config.py) is provided with everything setup for local development.
### Quickstart
Install `uv`: https://docs.astral.sh/uv/#installation
* To run the project:
uv run flask --app main run --debug
* To add dependencies:
uv add [package-name]
* To add dev dependencies
uv add --dev [package-name]
* To install dependencies into an environment
uv sync
> **_NOTE:_** If `uv sync` did not work try `uv pip install -r pyproject.toml`.
### DB setup
* To create a new migration
uv run flask --app main db migrate
* To apply/initialize database
uv run flask --app main db upgrade
* To load sample data
uv run flask --app main create-sample-data
* To load data from a `.csv` file
uv run flask --app main load-data-from-csv [CSV_FILE_PATH]
> **_NOTE:_** Prior to this last command it's necessary to run the `create-sample-data` one, as it creates the showreel and user that will be used.
## License

156
gdshowreelvote/auth.py Normal file
View File

@@ -0,0 +1,156 @@
from datetime import datetime, timedelta
from functools import wraps
from typing import Dict, List
from flask import Flask, current_app, render_template, request, url_for, session
from flask import redirect
from authlib.integrations.flask_client import OAuth
from gdshowreelvote.database import User, DB
ADMIN_ROLE = 'admin'
STAFF_ROLE = 'staff'
oauth = OAuth()
MOCK_USERS = {
'moderator': {'mod': True, 'staff': True, 'fund_member': True},
'staff': {'mod': False, 'staff': True, 'fund_member': False},
'diamond-member': {'mod': False, 'staff': False, 'fund_member': True},
'user': {'mod': False, 'staff': False, 'fund_member': False},
}
def login_required(f):
@wraps(f)
def decorated_func(*args, **kwargs):
if session.get('user'):
return f(*args, **kwargs)
else:
return redirect(url_for('oidc.login'))
return decorated_func
def admin_required(f):
@wraps(f)
def decorated_func(*args, **kwargs):
if session.get('user') and ADMIN_ROLE in session['user'].get('roles', []):
return f(*args, **kwargs)
else:
return redirect(url_for('oidc.login'))
return decorated_func
def _can_vote(user: Dict) -> bool:
if ADMIN_ROLE in user.get('roles', []) or STAFF_ROLE in user.get('roles', []) or _fund_member_can_vote(user):
return True
return False
def vote_role_required(f):
@wraps(f)
def decorated_func(*args, **kwargs):
if session.get('user') and _can_vote(session['user']):
return f(*args, **kwargs)
else:
return redirect(url_for('oidc.login'))
return decorated_func
def get_issuer():
if current_app.config.get('OIDC_MOCK', False):
return 'https://example.org/keycloak/realms/test'
else:
return oauth.oidc.load_server_metadata()['issuer']
# Mock implementation
def mock_login():
content = render_template('mock-login.html', users=MOCK_USERS)
return render_template('default.html', content=content, title='Login')
def mock_auth():
username = request.form.get('username', '').lower()
if not username in MOCK_USERS:
return redirect(url_for('oidc.login'))
roles = []
roles.append(ADMIN_ROLE) if MOCK_USERS[username]['mod'] else None
roles.append(STAFF_ROLE) if MOCK_USERS[username]['staff'] else None
fund_roles = []
fund_roles.append('member') if MOCK_USERS[username]['fund_member'] else None
oidc_info = {
'sub': f'MOCK_USER:{username}',
'email_verified': True,
'name': username.capitalize(),
'preferred_username': username,
'given_name': username.capitalize(),
'family_name': username.capitalize(),
'email': f'{username}@example.com',
'roles': roles,
'fund': {'roles': fund_roles}
}
update_or_create_user(oidc_info)
session['user'] = oidc_info
return redirect('/')
def mock_logout():
session.pop('user', None)
return redirect('/')
# OIDC implementation
def oidc_login():
redirect_uri = url_for('oidc.auth', _external=True)
return oauth.oidc.authorize_redirect(redirect_uri)
def _fund_member_can_vote(user: Dict):
fund_roles = user.get('fund', {}).get('roles', [])
return any([role in fund_roles for role in current_app.config.get('FUND_ROLES_WITH_VOTE_RIGHTS', [])])
def oidc_auth():
token = oauth.oidc.authorize_access_token()
session['user'] = token['userinfo']
update_or_create_user(token['userinfo'])
return redirect('/')
def update_or_create_user(oidc_info: Dict):
if user := DB.session.get(User, oidc_info['sub']):
user.is_staff = STAFF_ROLE in oidc_info.get('roles', [])
user.is_superuser = ADMIN_ROLE in oidc_info.get('roles', [])
user.is_fund_member = _fund_member_can_vote(oidc_info)
else:
user = User(
id=oidc_info['sub'],
username=oidc_info.get('name', oidc_info.get('preferred_username', '')),
email=oidc_info['email'],
is_staff = STAFF_ROLE in oidc_info.get('roles', []),
is_superuser = ADMIN_ROLE in oidc_info.get('roles', []),
is_fund_member = _fund_member_can_vote(oidc_info)
)
DB.session.add(user)
DB.session.commit()
def oidc_logout():
session.pop('user', None)
return redirect('/')
def init_app(app: Flask):
if app.config.get('OIDC_MOCK', False):
app.add_url_rule('/login', 'oidc.login', mock_login)
app.add_url_rule('/auth', 'oidc.auth', mock_auth, methods=['POST'])
app.add_url_rule('/logout', 'oidc.logout', mock_logout)
else:
oauth.init_app(app)
oauth.register(name='oidc')
app.add_url_rule('/login', 'oidc.login', oidc_login)
app.add_url_rule('/auth', 'oidc.auth', oidc_auth)
app.add_url_rule('/logout', 'oidc.logout', oidc_logout)

View File

@@ -0,0 +1,27 @@
from typing import Optional
from flask_wtf import FlaskForm
from wtforms import IntegerField, StringField, ValidationError
from wtforms.validators import InputRequired
from gdshowreelvote.utils import downvote_video, upvote_video
VOTE_ACTIONS = {
'upvote': upvote_video,
'downvote': downvote_video,
'skip': lambda *args: None
}
def validate_action(form, field):
if field.data:
if VOTE_ACTIONS.get(field.data) is None:
raise ValidationError(f"Action '{field.data}' is not supported.")
class CastVoteForm(FlaskForm):
action = StringField('Action', validators=[validate_action])
video_id = IntegerField('Video ID', validators=[InputRequired()])
class SelectVideoForm(FlaskForm):
video_id = IntegerField('Video ID', validators=[InputRequired()])

View File

@@ -0,0 +1,165 @@
import csv
from io import StringIO
from sqlalchemy import func
from werkzeug.exceptions import NotFound
from flask import Blueprint, Response, current_app, g, redirect, render_template, request, url_for
from gdshowreelvote import auth
from gdshowreelvote.blueprints.forms import VOTE_ACTIONS, CastVoteForm, SelectVideoForm
from gdshowreelvote.database import DB, User, Video, Vote
from gdshowreelvote.utils import choose_random_video, get_total_votes, vote_data
bp = Blueprint('votes', __name__)
@bp.route('/')
def home():
content = render_template('home.html', user=g.user)
return render_template('default.html', content = content, user=g.user)
@bp.route('/about')
def about():
content = render_template('about.html')
return render_template('default.html', content = content, user=g.user)
@bp.route('/before-you-vote')
def before_you_vote():
content = render_template('before-you-vote.html')
return render_template('default.html', content = content, user=g.user)
@bp.route('/vote', methods=['GET'])
@bp.route('/vote/<int:video_id>', methods=['GET'])
@auth.vote_role_required
def vote_get(video_id=None):
if video_id:
video = DB.session.query(Video).filter(Video.id == video_id).first()
if not video:
current_app.logger.warning(f"Video with ID {video_id} not found.")
return "Video not found", 404
else:
video = choose_random_video(g.user)
data, progress = vote_data(g.user, video)
content = render_template('vote.html', data=data, progress=progress, cast_vote_form=CastVoteForm(), select_specific_video_form=SelectVideoForm())
return render_template('default.html', content = content, user=g.user, hide_nav=True)
@bp.route('/vote', methods=['POST'])
@auth.vote_role_required
def vote():
cast_vote_form = CastVoteForm()
select_specific_video_form = SelectVideoForm()
skip_videos = []
if cast_vote_form.validate():
action = cast_vote_form.action.data
video = DB.session.query(Video).filter(Video.id == cast_vote_form.video_id.data).first()
if not video:
current_app.logger.warning(f"Video with ID {cast_vote_form.video_id.data} not found.")
return "Video not found", 404
VOTE_ACTIONS[action](g.user, video)
if action == 'skip':
skip_videos.append(video.id)
else:
current_app.logger.warning(f"Form validation failed: {cast_vote_form.errors} {select_specific_video_form.errors}")
return "Invalid form submission", 400
video = choose_random_video(g.user, skip_videos)
data, progress = vote_data(g.user, video)
return render_template('vote.html', data=data, progress=progress, cast_vote_form=cast_vote_form, select_specific_video_form=select_specific_video_form)
@bp.route('/vote/<int:video_id>/delete', methods=['POST'])
@auth.vote_role_required
def delete_vote(video_id: int):
vote = DB.session.query(Vote).filter(Vote.user_id == g.user.id).filter(Vote.video_id == video_id).first()
if not vote:
current_app.logger.warning(f"Video with ID {video_id} not found.")
return "Video not found", 404
DB.session.delete(vote)
DB.session.commit()
return redirect(url_for('votes.history'))
@bp.route('/history')
@auth.vote_role_required
def history():
page = int(request.args.get('page', 1))
total_video_count = DB.session.query(Video).count()
total_user_votes = DB.session.query(Vote).filter(Vote.user_id == g.user.id).count()
progress = {
'total': total_video_count,
'current': total_user_votes,
}
query = DB.session.query(Vote).filter(Vote.user_id == g.user.id).order_by(Vote.created_at.desc())
try:
submitted_votes = DB.paginate(query, page=page, per_page=30)
except NotFound:
submitted_votes = DB.paginate(query, page=1, per_page=30)
# We probably want to add pagination here
content = render_template('history.html', progress=progress, submitted_votes=submitted_votes)
if request.args.get('page'):
return content
return render_template('default.html', content = content, user=g.user)
@bp.route('/admin')
@auth.admin_required
def admin_view():
page = int(request.args.get('page', 1))
vote_tally = get_total_votes(page)
content = render_template('admin.html', vote_tally=vote_tally)
if request.args.get('page'):
return content
return render_template('default.html', content = content, user=g.user)
@bp.route('/results')
@auth.admin_required
def download_vote_results():
result = (
DB.session.query(
Video,
func.count(Vote.id).filter(Vote.rating == 1).label("plus_votes"),
func.count(Vote.id).filter(Vote.rating == -1).label("minus_votes"),
func.count(Vote.id).filter((User.is_staff == True)).label("staff_votes"),
func.count(Vote.id).filter((User.is_fund_member == True)).label("fund_member_votes"),
)
.outerjoin(Vote, Vote.video_id == Video.id)
.outerjoin(User, User.id == Vote.user_id)
.group_by(Video.id)
.order_by(func.coalesce(func.sum(Vote.rating), 0).desc()).all()
)
csv_file = StringIO()
writer = csv.writer(csv_file)
writer.writerow(['Author', 'Follow-me link', 'Game', 'Video link', 'Download link', 'Contact email', 'Store Link', 'Positive votes', 'Negative votes', 'staff', 'fund_member'])
for video, plus_votes, minus_votes, staff_votes, fund_member_votes in result:
writer.writerow([
video.author_name,
video.follow_me_link,
video.game,
video.video_link,
video.video_download_link,
video.contact_email,
video.store_link,
plus_votes,
minus_votes,
staff_votes,
fund_member_votes
])
response = Response(csv_file.getvalue(), mimetype='text/csv')
response.headers["Content-Disposition"] = "attachment; filename=vote_results.csv"
return response

View File

@@ -0,0 +1,92 @@
from datetime import datetime
import enum
from typing import List
from flask_migrate import Migrate
from flask_sqlalchemy import SQLAlchemy
from sqlalchemy import Boolean, Column, DateTime, Enum, ForeignKey, Integer, MetaData, String, Table
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
KEYCLOAK_ID_SIZE = 128 # This looks more like a config item than a constant
class Base(DeclarativeBase):
metadata = MetaData(naming_convention={
'ix': 'ix_%(column_0_label)s',
'uq': 'uq_%(table_name)s_%(column_0_name)s',
'ck': 'ck_%(table_name)s_`%(constraint_name)s`',
'fk': 'fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s',
'pk': 'pk_%(table_name)s'
})
DB = SQLAlchemy(model_class=Base)
migrate = Migrate()
class ShowreelStatus(enum.Enum):
OPENED_TO_SUBMISSIONS = 'OPEN'
VOTE = 'VOTE'
CLOSED = 'CLOSED'
class User(DB.Model):
__tablename__ = "users"
id: Mapped[str] = mapped_column(String(KEYCLOAK_ID_SIZE), primary_key=True, autoincrement=False)
email: Mapped[str] = mapped_column(String(128), unique=True, nullable=False)
username: Mapped[str] = mapped_column(String(150), unique=True, nullable=True)
is_staff: Mapped[bool] = mapped_column(Boolean, default=False)
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
is_superuser: Mapped[bool] = mapped_column(Boolean, default=False)
is_fund_member: Mapped[bool] = mapped_column(Boolean, default=False)
date_joined: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
videos: Mapped[List['Video']] = relationship(back_populates="author", cascade="all, delete-orphan")
votes: Mapped[List['Vote']] = relationship(back_populates="user", cascade="all, delete-orphan")
def can_vote(self):
return self.is_superuser or self.is_staff or self.is_fund_member
class Showreel(DB.Model):
__tablename__ = "showreels"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
status: Mapped[ShowreelStatus] = mapped_column(Enum(ShowreelStatus), default=ShowreelStatus.CLOSED, nullable=False)
title: Mapped[str] = mapped_column(String(200), nullable=False)
videos: Mapped[List['Video']] = relationship(back_populates="showreel", cascade="all, delete-orphan")
class Video(DB.Model):
__tablename__ = "videos"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
showreel_id: Mapped[int] = mapped_column(Integer, ForeignKey("showreels.id"), nullable=True)
author_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id"), nullable=False)
game: Mapped[str] = mapped_column(String(200), nullable=False, default="")
author_name: Mapped[str] = mapped_column(String(200), nullable=False, default="")
video_link: Mapped[str] = mapped_column(String(200), unique=True, nullable=False)
video_download_link: Mapped[str] = mapped_column(String(200), unique=True, nullable=False)
contact_email: Mapped[str] = mapped_column(String(200), default="", nullable=True)
follow_me_link: Mapped[str] = mapped_column(String(200), default="", nullable=True)
store_link: Mapped[str] = mapped_column(String(200), default="", nullable=True)
showreel = relationship("Showreel", back_populates="videos")
author = relationship("User", back_populates="videos")
votes = relationship("Vote", back_populates="video", cascade="all, delete-orphan")
class Vote(DB.Model):
__tablename__ = "votes"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id"), nullable=False)
video_id: Mapped[int] = mapped_column(Integer, ForeignKey("videos.id"), nullable=False)
rating: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
user = relationship("User", back_populates="votes")
video = relationship("Video", back_populates="votes")

122
gdshowreelvote/utils.py Normal file
View File

@@ -0,0 +1,122 @@
from urllib.parse import parse_qs, urlparse
from werkzeug.exceptions import NotFound
from typing import Dict, List, Optional, Tuple
from sqlalchemy import and_, func
from gdshowreelvote.database import DB, Showreel, ShowreelStatus, User, Video, Vote
def choose_random_video(user: User, skip_videos: List[int]=[]) -> Video:
""" Choose a random video from the showreel that the user has not voted on yet. """
# Is there any chance that there are multiple showreels in VOTE status?
# Should we choose the showreel with a specific ID?
random_video_without_votes = (
DB.session.query(Video)
.join(Showreel, Video.showreel_id == Showreel.id) # join explicitly
.outerjoin(Vote, and_(Video.id == Vote.video_id, Vote.user_id == user.id))
.filter(Showreel.status == ShowreelStatus.VOTE) # filter on Showreel column
.filter(Vote.id == None) # exclude videos already voted on by this user
.filter(Video.id.notin_(skip_videos)) # exclude skipped videos
.order_by(func.random())
.first()
)
return random_video_without_votes
def upvote_video(user: User, video: Video):
""" Cast an upvote for a video by a user. """
vote = DB.session.query(Vote).filter(and_(Vote.user_id == user.id, Vote.video_id == video.id)).first()
if vote:
vote.rating = 1
else:
vote = Vote(user_id=user.id, video_id=video.id, rating=1)
DB.session.add(vote)
DB.session.commit()
return vote
def downvote_video(user: User, video: Video):
""" Cast a downvote for a video by a user. """
vote = DB.session.query(Vote).filter(and_(Vote.user_id == user.id, Vote.video_id == video.id)).first()
if vote:
vote.rating = -1
else:
vote = Vote(user_id=user.id, video_id=video.id, rating=-1)
DB.session.add(vote)
DB.session.commit()
return vote
def _video_data(video: Video) -> Dict:
data = {
'id': video.id,
'game': video.game,
'author': video.author_name,
'follow_me_link': video.follow_me_link,
'video_link': video.video_link,
'store_link': video.store_link,
'category': video.showreel.title,
}
youtube_id = parse_youtuvbe_video_id(video.video_link)
data['youtube_embed'] = f'https://www.youtube.com/embed/{youtube_id}'
return data
def vote_data(user: User, video: Video) -> Tuple[Dict, Dict]:
total_video_count = DB.session.query(Video).count()
total_user_votes = DB.session.query(Vote).filter(Vote.user_id == user.id).count()
data = _video_data(video) if video else None
progress = {
'total': total_video_count,
'current': total_user_votes,
}
return data, progress
def get_total_votes(page: int) -> List[Tuple[Video, int, int]]:
query = (
DB.session.query(
Video,
func.coalesce(func.sum(Vote.rating), 0).label("vote_sum"),
func.count(Vote.id).label("vote_count"),
)
.outerjoin(Vote, Vote.video_id == Video.id)
.group_by(Video.id)
.order_by(func.coalesce(func.sum(Vote.rating), 0).desc())
)
try:
results = query.paginate(page=page, per_page=30)
except NotFound:
results = query.paginate(page=1, per_page=30)
return results
def parse_youtuvbe_video_id(yt_url: str) -> Optional[str]:
"""
Source: https://stackoverflow.com/questions/4356538/how-can-i-extract-video-id-from-youtubes-link-in-python/7936523#7936523
Examples:
- http://youtu.be/SA2iWivDJiE
- http://www.youtube.com/watch?v=_oPAwA_Udwc&feature=feedu
- http://www.youtube.com/embed/SA2iWivDJiE
- http://www.youtube.com/v/SA2iWivDJiE?version=3&amp;hl=en_US
"""
query = urlparse(yt_url)
if query.hostname == 'youtu.be':
return query.path[1:]
if query.hostname in ('www.youtube.com', 'youtube.com', 'm.youtube.com'):
if query.path == '/watch':
p = parse_qs(query.query)
return p['v'][0]
if query.path[:7] == '/embed/':
return query.path.split('/')[2]
if query.path[:3] == '/v/':
return query.path.split('/')[2]
# fail?
return None

View File

@@ -0,0 +1,6 @@
ENV = 'dev'
OIDC_MOCK = True
SECRET_KEY = 'dev'
SQLALCHEMY_DATABASE_URI = 'sqlite:///app.sqlite?charset=utf8mb4'
FUND_ROLES_WITH_VOTE_RIGHTS = ["member"]

188
main.py
View File

@@ -1,51 +1,157 @@
from flask import Flask, render_template
import csv
from datetime import timedelta
import hashlib
from http.client import HTTPException
import os
import click
from flask import Flask, current_app, g, render_template, session
app = Flask(__name__)
from gdshowreelvote import auth
from gdshowreelvote.blueprints.votes import bp as votes_bp
from gdshowreelvote.database import DB, Showreel, ShowreelStatus, User, Video, Vote, migrate
@app.route('/')
def home():
content = render_template('home.html')
return render_template('default.html', content = content)
def create_app(config=None):
# ------------------------------------------------
# App Config
# ------------------------------------------------
app = Flask(__name__, instance_relative_config=True)
@app.route('/about')
def about():
content = render_template('about.html')
return render_template('default.html', content = content)
# Load the default config
app.config.from_pyfile('config.py', silent=True)
if config:
app.config.update(config)
@app.route('/vote')
def vote():
# Every time you visit this page, it should load a new entry
# You should add an option argument allowing to load a specific entry to overwrite the vote
data = { # I hardcoded an example for the entry:
'game': 'Brotato',
'author': 'Blobfish Games',
'follow_me_link': 'https://twitter.com/BlobfishGames',
'category': '2025 Godot Desktop/Console Games',
'video_link': 'https://www.youtube.com/watch?v=nfceZHR7Yq0',
'store_link': 'https://store.steampowered.com/app/1592190/Brotato/',
}
progress = {
'total': 310, # How many entries in total
'current': 42, # How many entries has the user rated so far
}
# TODO: Need to make sure we extract the YouTube ID correctly
youtube_id = data['video_link'].split('v=')[-1]
data['youtube_embed'] = f'https://www.youtube.com/embed/{youtube_id}'
app.register_blueprint(votes_bp, url_prefix='/')
content = render_template('vote.html', data=data, progress=progress)
return render_template('default.html', content = content)
DB.init_app(app)
migrate.init_app(app, DB)
auth.init_app(app)
# ------------------------------------------------
# OIDC User Handling
# ------------------------------------------------
# Set session timeout to 1 day
app.config['PERMANENT_SESSION_LIFETIME'] = timedelta(days=1)
@app.before_request
def setup_globals():
session.permanent = True
oidc_info = session.get('user', None)
if oidc_info:
g.user = DB.session.get(User, oidc_info['sub'])
# Calculate Gravatar hash
g.user.gravatar_hash = hashlib.md5(g.user.email.encode('utf-8')).hexdigest()
else:
g.user = None
# ------------------------------------------------
# Error Handling
# ------------------------------------------------
@app.errorhandler(HTTPException)
def page_not_found(error):
user = g.user if hasattr(g, 'user') else None
content = render_template('error.html', error=error)
return render_template('default.html', content=content, user=user)
# ------------------------------------------------
# Commands
# ------------------------------------------------
@app.cli.command('create-sample-data')
def create_sample_data():
if current_app.config['ENV'] != 'dev':
print('Sample data can only be created in development environment.')
return
print('Creating sample data...')
# Reset state
DB.session.query(Showreel).delete()
DB.session.query(User).filter(User.email == 'author@example.com').delete()
DB.session.query(Vote).delete()
DB.session.query(Video).delete()
DB.session.commit()
# Create showreel
showreel = Showreel(status=ShowreelStatus.VOTE, title='2025 Godot Desktop/Console Games')
DB.session.add(showreel)
DB.session.commit()
# Create author of videos
author = User(id='sample-author-id', email='author@example.com', username='Sample Author')
DB.session.add(author)
DB.session.commit()
# Create sample video entries
video_data = [{
'game': 'Brotato',
'author_name': 'Blobfish Games',
'follow_me_link': 'https://twitter.com/BlobfishGames',
'category': '2025 Godot Desktop/Console Games',
'video_link': 'https://www.youtube.com/watch?v=nfceZHR7Yq0',
'video_download_link': 'https://www.youtube.com/watch?v=nfceZHR7Yq0',
'store_link': 'https://store.steampowered.com/app/1592190/Brotato/',
},
{'game': 'Vampire Survivors',
'author_name': 'Blobfish Games',
'follow_me_link': 'https://twitter.com/BlobfishGames',
'category': '2025 Godot Desktop/Console Games',
'video_link': 'https://www.youtube.com/watch?v=6HXNxWbRgsg',
'video_download_link': 'https://www.youtube.com/watch?v=6HXNxWbRgsg',
'store_link': 'https://store.steampowered.com/app/1592190/Brotato/',
},
]
for video in video_data:
video = Video(game=video['game'],
author_name=video['author_name'],
follow_me_link=video['follow_me_link'],
video_link=video['video_link'],
video_download_link=video['video_download_link'],
store_link=video['store_link'],
author=author,
showreel=showreel)
DB.session.add(video)
DB.session.commit()
print(f'Created sample video entry: {video.game} (ID: {video.id})')
return showreel, author
@app.route('/history')
def history():
progress = {
'total': 310, # How many entries in total
'current': 42, # How many entries has the user rated so far
}
content = render_template('history.html', progress=progress)
return render_template('default.html', content = content)
@app.cli.command('load-data-from-csv')
@click.argument('file')
def load_data_from_csv(file):
showreel = DB.session.query(Showreel).first()
author = DB.session.query(User).filter(User.id == 'sample-author-id').first()
if not showreel or not author:
print('No showreel or user to attach videos to. Please execute "uv run flask --app main create-sample-data" and then try again.')
return
file_path = 'instance/sample.csv'
if file and os.path.isfile(file):
file_path = file
with open(file_path) as csvfile:
spamreader = csv.DictReader(csvfile, delimiter=',')
for row in spamreader:
video = Video(game=row['Game'],
author_name=row['Author'],
follow_me_link=row['Follow-me link'],
video_link=row['Video link'],
video_download_link=row['Download link'],
store_link=row['Store Link'],
contact_email=row['Contact email'],
author=author,
showreel=showreel)
DB.session.add(video)
DB.session.commit()
print(f'added {spamreader.line_num} videos')
return app
app = create_app()
if __name__ == '__main__':
app.run(debug=True)

1
migrations/README Normal file
View File

@@ -0,0 +1 @@
Single-database configuration for Flask.

50
migrations/alembic.ini Normal file
View File

@@ -0,0 +1,50 @@
# A generic, single database configuration.
[alembic]
# template used to generate migration files
# file_template = %%(rev)s_%%(slug)s
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false
# Logging configuration
[loggers]
keys = root,sqlalchemy,alembic,flask_migrate
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[logger_flask_migrate]
level = INFO
handlers =
qualname = flask_migrate
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

113
migrations/env.py Normal file
View File

@@ -0,0 +1,113 @@
import logging
from logging.config import fileConfig
from flask import current_app
from alembic import context
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
# Interpret the config file for Python logging.
# This line sets up loggers basically.
fileConfig(config.config_file_name)
logger = logging.getLogger('alembic.env')
def get_engine():
try:
# this works with Flask-SQLAlchemy<3 and Alchemical
return current_app.extensions['migrate'].db.get_engine()
except (TypeError, AttributeError):
# this works with Flask-SQLAlchemy>=3
return current_app.extensions['migrate'].db.engine
def get_engine_url():
try:
return get_engine().url.render_as_string(hide_password=False).replace(
'%', '%%')
except AttributeError:
return str(get_engine().url).replace('%', '%%')
# add your model's MetaData object here
# for 'autogenerate' support
# from myapp import mymodel
# target_metadata = mymodel.Base.metadata
config.set_main_option('sqlalchemy.url', get_engine_url())
target_db = current_app.extensions['migrate'].db
# other values from the config, defined by the needs of env.py,
# can be acquired:
# my_important_option = config.get_main_option("my_important_option")
# ... etc.
def get_metadata():
if hasattr(target_db, 'metadatas'):
return target_db.metadatas[None]
return target_db.metadata
def run_migrations_offline():
"""Run migrations in 'offline' mode.
This configures the context with just a URL
and not an Engine, though an Engine is acceptable
here as well. By skipping the Engine creation
we don't even need a DBAPI to be available.
Calls to context.execute() here emit the given string to the
script output.
"""
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url, target_metadata=get_metadata(), literal_binds=True
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online():
"""Run migrations in 'online' mode.
In this scenario we need to create an Engine
and associate a connection with the context.
"""
# this callback is used to prevent an auto-migration from being generated
# when there are no changes to the schema
# reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html
def process_revision_directives(context, revision, directives):
if getattr(config.cmd_opts, 'autogenerate', False):
script = directives[0]
if script.upgrade_ops.is_empty():
directives[:] = []
logger.info('No changes in schema detected.')
conf_args = current_app.extensions['migrate'].configure_args
if conf_args.get("process_revision_directives") is None:
conf_args["process_revision_directives"] = process_revision_directives
connectable = get_engine()
with connectable.connect() as connection:
context.configure(
connection=connection,
target_metadata=get_metadata(),
**conf_args
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

24
migrations/script.py.mako Normal file
View File

@@ -0,0 +1,24 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision = ${repr(up_revision)}
down_revision = ${repr(down_revision)}
branch_labels = ${repr(branch_labels)}
depends_on = ${repr(depends_on)}
def upgrade():
${upgrades if upgrades else "pass"}
def downgrade():
${downgrades if downgrades else "pass"}

View File

@@ -0,0 +1,32 @@
"""empty message
Revision ID: 8c12d32e406b
Revises: f2cbdb804d58
Create Date: 2025-09-29 15:39:39.296125
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '8c12d32e406b'
down_revision = 'f2cbdb804d58'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('users', schema=None) as batch_op:
batch_op.add_column(sa.Column('is_fund_member', sa.Boolean(), nullable=False))
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('users', schema=None) as batch_op:
batch_op.drop_column('is_fund_member')
# ### end Alembic commands ###

View File

@@ -0,0 +1,75 @@
"""empty message
Revision ID: f2cbdb804d58
Revises:
Create Date: 2025-09-16 17:57:25.578892
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'f2cbdb804d58'
down_revision = None
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('showreels',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('status', sa.Enum('OPENED_TO_SUBMISSIONS', 'VOTE', 'CLOSED', name='showreelstatus'), nullable=False),
sa.Column('title', sa.String(length=200), nullable=False),
sa.PrimaryKeyConstraint('id', name=op.f('pk_showreels'))
)
op.create_table('users',
sa.Column('id', sa.String(length=128), autoincrement=False, nullable=False),
sa.Column('email', sa.String(length=128), nullable=False),
sa.Column('username', sa.String(length=150), nullable=True),
sa.Column('is_staff', sa.Boolean(), nullable=False),
sa.Column('is_active', sa.Boolean(), nullable=False),
sa.Column('is_superuser', sa.Boolean(), server_default='0', nullable=False),
sa.Column('date_joined', sa.DateTime(), nullable=False),
sa.PrimaryKeyConstraint('id', name=op.f('pk_users')),
sa.UniqueConstraint('email', name=op.f('uq_users_email')),
sa.UniqueConstraint('username', name=op.f('uq_users_username'))
)
op.create_table('videos',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('showreel_id', sa.Integer(), nullable=True),
sa.Column('author_id', sa.Integer(), nullable=False),
sa.Column('game', sa.String(length=200), nullable=False),
sa.Column('author_name', sa.String(length=200), nullable=False),
sa.Column('video_link', sa.String(length=200), nullable=False),
sa.Column('video_download_link', sa.String(length=200), nullable=False),
sa.Column('contact_email', sa.String(length=200), nullable=True),
sa.Column('follow_me_link', sa.String(length=200), nullable=True),
sa.Column('store_link', sa.String(length=200), nullable=True),
sa.ForeignKeyConstraint(['author_id'], ['users.id'], name=op.f('fk_videos_author_id_users')),
sa.ForeignKeyConstraint(['showreel_id'], ['showreels.id'], name=op.f('fk_videos_showreel_id_showreels')),
sa.PrimaryKeyConstraint('id', name=op.f('pk_videos')),
sa.UniqueConstraint('video_download_link', name=op.f('uq_videos_video_download_link')),
sa.UniqueConstraint('video_link', name=op.f('uq_videos_video_link'))
)
op.create_table('votes',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('video_id', sa.Integer(), nullable=False),
sa.Column('rating', sa.Integer(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], name=op.f('fk_votes_user_id_users')),
sa.ForeignKeyConstraint(['video_id'], ['videos.id'], name=op.f('fk_votes_video_id_videos')),
sa.PrimaryKeyConstraint('id', name=op.f('pk_votes'))
)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('votes')
op.drop_table('videos')
op.drop_table('users')
op.drop_table('showreels')
# ### end Alembic commands ###

20
pyproject.toml Normal file
View File

@@ -0,0 +1,20 @@
[project]
name = "godot-showreel"
version = "0.1.0"
description = ""
readme = "README.md"
requires-python = ">=3.12"
dependencies = [
"authlib>=1.5.2",
"flask>=3.1.0",
"flask-migrate>=4.1.0",
"flask-sqlalchemy>=3.1.1",
"flask-wtf>=1.2.2",
"requests>=2.32.5",
"sqlalchemy>=2.0.40",
]
[dependency-groups]
dev = [
"pytest>=8.3.5",
]

239
static/css/1.1/base.css Normal file
View File

@@ -0,0 +1,239 @@
/* This css file contains the base of all elements used in the Godot Foundation Web Apps */
/* V1.1 */
@import url('https://fonts.googleapis.com/css2?family=Montserrat:ital,wght@0,100..900;1,100..900&display=swap');
@import url('https://fonts.googleapis.com/css2?family=Noto+Sans:ital,wght@0,100..900;1,100..900&display=swap');
:root {
color-scheme: light dark;
--background-color: #e7e7e7;
--text-color: #272727;
--header-font-family: "Montserrat", sans-serif;
--main-font-family: "Noto Sans", sans-serif;
--panel-background: white;
--basic-gap: 20px;
--accent-color: #478cbf;
--notebox-color: #d79e54;
--notebox-text-color: #1e1e1e;
@media (prefers-color-scheme: dark) {
--background-color: #131313;
--text-color: white;
--panel-background: #1e1e1e;
}
}
.mobile {
@media (min-width: 768px) {
display: none !important;
}
}
.desktop {
@media (max-width: 768px) {
display: none !important;
}
}
/* Reset */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
/* Global styles */
html {
background: #1e1e1e;
color: var(--text-color);
}
body {
background: var(--background-color);
font-family: var(--main-font-family);
}
h1, h2, h3, h4, h5, h6 {
font-family: var(--header-font-family);
font-weight: 800;
}
button {
border: 0;
background: none;
cursor: pointer;
}
hr {
border: 0;
height: 1px;
background: #9595951f;
margin: 15px 0px;
}
a {
color: #2fa7ff;
}
table {
min-width: 100%;
border-collapse: collapse;
td, th {
text-align: left;
border: none;
padding: calc( var(--basic-gap) / 2 );
}
tr:nth-child(even) {
background-color: var(--panel-background);
}
tr:nth-child(odd) {
background-color: var(--background-color);
}
td.fit-content, th.fit-content {
width: 1%;
white-space: nowrap;
}
}
footer {
width: 100%;
text-align: center;
font-size: 12px;
background: #1e1e1e;
color: white;
margin: 0;
padding: 30px 0;
margin-top: 30px;
}
main, div.main {
max-width: 1300px;
margin: 0 auto;
margin-top: 20px;
padding: 0 20px;
}
blockquote {
border-left: 3px solid #aaa;
padding: 12px;
margin: 12px 0;
background: #00000026;
}
textarea {
width: 100%;
resize: vertical;
}
ol, ul {
padding-left: 20px;
}
input, select, textarea {
padding: 10px;
margin-top: 5px;
font-family: inherit;
border-radius: 5px;
border: 1px solid #606060;
margin-bottom: 9px;
@media (prefers-color-scheme: light) {
border-color: #e1e1e1;
}
}
option {
@media (prefers-color-scheme: dark) {
background-color: #1e1e1e;
}
}
input[type="checkbox"] {
width: auto;
}
textarea {
resize: vertical;
}
.button, button, input[type="button"], input[type="submit"] {
text-decoration: none;
background: linear-gradient(10deg, rgb(97, 129, 255), rgb(1, 170, 187));
max-width: 100%;
display: inline-block;
text-align: center;
padding: 13px;
border-radius: 6px;
box-shadow: 0 7px 27px -17px black;
font-weight: 700;
font-size: 19px;
margin-top: 4px;
transition: all 0.1s ease-in-out;
color: white;
span.muted {
font-weight: 500;
opacity: 0.8;
font-size: 16px;
}
&:hover {
box-shadow: none;
}
&.primary {
background: linear-gradient(10deg, rgb(96, 213, 103), rgb(134, 206, 94));
@media (prefers-color-scheme: dark) {
background: linear-gradient(10deg, rgb(96, 213, 103), rgb(1, 151, 88))
}
color: white;
}
&.secondary {
background: linear-gradient(10deg, rgb(97, 129, 255), rgb(1, 170, 187));
color: white;
}
&.tertiary {
background: linear-gradient(10deg, rgb(255, 97, 97), rgb(245, 71, 71));
color: white;
}
&.dim, &:disabled {
background: linear-gradient(10deg, rgb(68, 68, 68), rgb(98, 98, 98));
@media (prefers-color-scheme: dark) {
background: linear-gradient(10deg, rgba(97, 129, 255, 0.1), rgba(71, 229, 245, 0.06));
}
color: white;
}
&:disabled {
color: #cccccc;
cursor: not-allowed;
}
&.small {
padding: 6px 11px;
font-size: 16px;
margin: 0;
}
}
.panel {
background-color: var(--panel-background);
border-radius: 8px;
box-shadow: light-dark(0 0 10px -9px black, none);
border: 1px solid light-dark(#d3d3d3, #3a3a3a);
&.padded {
padding: 16px;
}
&.note {
background-color: var(--notebox-color);
color: var(--notebox-text-color);
}
&.hoverable {
transition: background-color 0.1s ease-in-out;
&:hover {
background-color: light-dark(#e0fff6, #404040);
}
}
}

190
static/css/1.1/nav.css Normal file
View File

@@ -0,0 +1,190 @@
/* v1.1 */
/* Account for fixed navbar when linking to anchors */
.anchor-fix {
display: block;
position: relative;
top: -125px;
visibility: hidden;
width: 0px;
height: 0px;
margin: 0px;
padding: 0px;
}
.nav-gap {
height: 70px;
}
nav {
position: fixed;
top: 0;
width: 100%;
z-index: 100;
padding: 0px;
background: #1e1e1e;
font-family: var(--header-font-family);
.wrapper {
padding: 0 20px;
display: flex;
align-items: center;
max-width: 1300px;
margin: 0 auto;
gap: 30px;
}
a {
color: white;
text-decoration: none;
position: relative;
}
img.logo {
max-width: 150px;
}
img.beta {
position: absolute;
top: -18px;
rotate: 45deg;
right: 0px;
}
form.search {
margin-left: auto;
margin-right: 0;
.search-wrapper {
position: relative;
width: 100%;
max-width: 320px;
margin: 0 auto;
}
svg.magnifying-glass {
position: absolute;
left: 16px;
top: 12px;
width: 20px;
height: 20px;
fill: #424242;
}
input {
width: 100%;
padding: 9px 16px;
display: block;
border-radius: 50px;
font-size: 17px;
font-weight: 300;
transition: all 0.1s ease-in-out;
padding-left: 43px;
background: white;
color: black;
border: 2px solid transparent;
@media (prefers-color-scheme: dark) {
color: white;
border: 2px solid #424242;
background: #1e1e1e;
}
&:focus {
outline: none;
border-color: var(--accent-color);
}
}
}
div.navigation-links {
text-align: right;
display: flex;
align-items: center;
gap: 20px;
margin-right: 0;
margin-left: auto;
}
ul {
list-style: none;
padding: 0;
li {
display: inline;
margin-right: 10px;
}
}
.dropdown {
position: relative;
display: inline-block;
text-align: left;
.dd-button-view {
display: grid;
cursor: pointer;
&.logged-out {
grid-template-columns: 1fr 32px;
align-items: center;
gap: 12px;
}
img.user-icon {
width: 32px;
height: 32px;
padding: 2px;
border-radius: 50%;
background: #ffffff3d;
&.gravatar {
padding: 0;
}
}
}
hr {
margin: 0;
opacity: 0.4;
}
.dropdown-content {
display: none;
position: absolute;
right: 0;
top: 39px;
min-width: 160px;
box-shadow: 0px 8px 16px 0px rgba(0,0,0,0.2);
z-index: 1;
border-radius: 5px;
background: #303030;
border: 1px solid #ffffff3b;
}
.dropdown-content a {
color: white;
padding: 12px 16px;
text-decoration: none;
display: block;
transition: all 0.1s ease-in-out;
}
.dropdown-content a:hover {
background-color: #f1f1f11a;
}
.show {
display: block;
}
}
.upload-asset {
padding: 6px 12px;
border-radius: 9px;
background-color: #5899ff5e;
color: #46cbff;
}
.dropdown.notifications .dd-button-view {
width: 30px;
padding: 4px;
background-color: #d7547d;
color: white;
border-radius: 100%;
border: 0;
box-shadow: 0 6px 5px -3px black;
font-weight: bold;
text-align: center;
}
}

View File

@@ -0,0 +1,4 @@
<svg width="37" height="34" viewBox="0 0 37 34" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M2 0.46875V4.11426H7V0.46875H30V4.11426H35V0.46875H37V34H35V31.8857H30V34H7V31.8857H2V34H0V0.46875H2ZM2 25.7139V29.8281H7V25.7139H2ZM30 25.7139V29.8281H35V25.7139H30ZM2 19.543V23.6572H7V19.543H2ZM30 19.543V23.6572H35V19.543H30ZM2 12.3428V16.457H7V12.3428H2ZM30 12.3428V16.457H35V12.3428H30ZM2 6.17188V10.2861H7V6.17188H2ZM30 6.17188V10.2861H35V6.17188H30Z" fill="#478CBF"/>
<path d="M7 19.6777C7.5184 19.7536 7.91521 20.1834 7.95215 20.7197L8.13965 23.457L13.4297 23.8408L13.79 21.3604C13.8702 20.8086 14.3441 20.3917 14.8926 20.3916H22.1094C22.658 20.3916 23.1328 20.8086 23.2129 21.3604L23.5723 23.8408L28.8623 23.457L29.0508 20.7197C29.0876 20.1843 29.4829 19.7556 30 19.6787V23.6572H31.0703L31.0186 24.417C30.9801 24.9762 30.5365 25.4286 29.9863 25.4688L22.7002 25.9971C22.674 25.9991 22.6468 26 22.6201 26C22.072 26 21.5977 25.5838 21.5176 25.0322L21.1475 22.4795H15.8545L15.4844 25.0322C15.4041 25.5839 14.9312 26 14.3838 26C14.3566 26 14.3294 25.9991 14.3018 25.9971L7.0166 25.4688C6.46626 25.4286 6.02177 24.9762 5.9834 24.417L5.93164 23.6572H7V19.6777ZM5.64258 19.543H2V21.0322L0 20.7051L0.00878906 19.3799C0.0440103 19.3799 0.0703106 18.9991 0.105469 19L5.64258 19.543ZM36.8936 19C36.9295 18.9992 36.9902 19.3799 36.9902 19.3799L37 20.7012L35 21.0303V19.543H31.3594L36.8936 19ZM11.5 10C12.8807 10 14 11.1193 14 12.5C14 13.8808 12.8807 15 11.5 15C10.1193 15 9 13.8808 9 12.5C9 11.1193 10.1193 10 11.5 10ZM25.5 10C26.8807 10 28 11.1193 28 12.5C28 13.8808 26.8807 15 25.5 15C24.1193 15 23 13.8808 23 12.5C23 11.1193 24.1193 10 25.5 10Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

@@ -0,0 +1,17 @@
<svg width="120" height="43" viewBox="0 0 120 43" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M70.5061 14.3996C69.7735 14.3996 69.1599 14.7363 68.662 15.4076C68.1668 16.0796 67.9179 17.0227 67.9179 18.2364C67.9179 19.4526 68.1545 20.3838 68.6286 21.0326C69.1018 21.6835 69.7236 22.0076 70.4949 22.0076C71.2658 22.0076 71.8921 21.6794 72.373 21.0209C72.854 20.3647 73.095 19.4255 73.095 18.2027C73.095 16.9809 72.8461 16.0404 72.3499 15.3837C71.8543 14.7277 71.2396 14.3996 70.5061 14.3996ZM70.4949 26.4738C68.35 26.4738 66.6009 25.7715 65.249 24.3673C63.899 22.9619 63.2236 20.9111 63.2236 18.215C63.2236 15.5174 63.9067 13.4749 65.2728 12.0841C66.6393 10.6951 68.4032 9.99997 70.5639 9.99997C72.7242 9.99997 74.4692 10.6825 75.7966 12.0516C77.1264 13.4181 77.7901 15.4896 77.7901 18.2601C77.7901 21.0326 77.111 23.0948 75.7514 24.4481C74.3918 25.7985 72.6408 26.4738 70.4949 26.4738Z" fill="white"/>
<path d="M84.2086 14.5582V21.4115C84.2086 21.7316 84.2322 21.9333 84.278 22.0179C84.3234 22.1021 84.4612 22.1444 84.6896 22.1444C85.5303 22.1444 86.1671 21.8302 86.6024 21.2039C87.0388 20.5782 87.2546 19.5364 87.2546 18.0774C87.2546 16.6178 87.0288 15.6668 86.5801 15.2249C86.1291 14.7819 85.4149 14.5582 84.4377 14.5582H84.2086ZM79.8115 25.1913V11.4444C79.8115 11.0622 79.9065 10.7606 80.0982 10.537C80.2887 10.3168 80.5366 10.2049 80.8429 10.2049H84.6677C87.095 10.2049 88.9386 10.8174 90.1972 12.0396C91.458 13.2614 92.0883 15.1852 92.0883 17.813C92.0883 23.4347 89.6902 26.245 84.8955 26.245H80.9798C80.2016 26.245 79.8115 25.8945 79.8115 25.1913Z" fill="white"/>
<path d="M100.882 14.3996C100.149 14.3996 99.5339 14.7363 99.0368 15.4076C98.5418 16.0796 98.2939 17.0227 98.2939 18.2364C98.2939 19.4526 98.5311 20.3838 99.0038 21.0326C99.4764 21.6835 100.099 22.0076 100.87 22.0076C101.641 22.0076 102.268 21.6794 102.748 21.0209C103.229 20.3647 103.47 19.4255 103.47 18.2027C103.47 16.9809 103.222 16.0404 102.726 15.3837C102.229 14.7277 101.615 14.3996 100.882 14.3996ZM100.87 26.4738C98.7244 26.4738 96.9765 25.7715 95.6259 24.3673C94.2742 22.9619 93.5986 20.9111 93.5986 18.215C93.5986 15.5174 94.2817 13.4749 95.6476 12.0841C97.0151 10.6951 98.778 9.99997 100.94 9.99997C103.1 9.99997 104.844 10.6825 106.173 12.0516C107.502 13.4181 108.165 15.4896 108.165 18.2601C108.165 21.0326 107.486 23.0948 106.127 24.4481C104.768 25.7985 103.015 26.4738 100.87 26.4738Z" fill="white"/>
<path d="M116.701 25.8797C116.701 26.1834 115.946 26.3374 114.434 26.3374C112.923 26.3374 112.166 26.1834 112.166 25.8797V14.3763H109.418C109.158 14.3763 108.975 14.0258 108.869 13.3213C108.823 12.986 108.801 12.6428 108.801 12.2909C108.801 11.941 108.823 11.5964 108.869 11.2605C108.975 10.5579 109.158 10.2048 109.418 10.2048H119.381C119.641 10.2048 119.823 10.5579 119.932 11.2605C119.976 11.5964 120 11.941 120 12.2909C120 12.6428 119.976 12.986 119.932 13.3213C119.823 14.0258 119.641 14.3763 119.381 14.3763H116.701V25.8797Z" fill="white"/>
<path d="M59.1731 17.7375C57.9401 17.7184 56.5289 17.9756 56.5289 17.9756V20.3832H57.948L57.9321 21.4564C57.9321 21.854 57.5382 22.0537 56.7525 22.0537C55.9658 22.0537 55.271 21.7204 54.6682 21.0558C54.0641 20.3903 53.7637 19.4175 53.7637 18.1357C53.7637 16.8512 54.0572 15.9042 54.6451 15.2932C55.2318 14.6827 56.0009 14.3764 56.9468 14.3764C57.3442 14.3764 57.7557 14.4406 58.1835 14.5715C58.6115 14.7011 58.8974 14.8227 59.0432 14.9366C59.1879 15.0537 59.3254 15.1091 59.4555 15.1091C59.5849 15.1091 59.7944 14.9577 60.0843 14.6514C60.3746 14.346 60.6347 13.883 60.8642 13.2659C61.0926 12.6454 61.207 12.1699 61.207 11.8326C61.207 11.498 61.1997 11.2672 61.1849 11.1454C60.8642 10.7943 60.2722 10.5159 59.4088 10.3089C58.547 10.1025 57.5808 9.99997 56.5119 9.99997C54.1599 9.99997 52.3207 10.7407 50.9925 12.2223C49.6634 13.7045 49 15.6284 49 17.9967C49 20.7773 49.679 22.8852 51.037 24.321C52.3974 25.7567 54.1834 26.4738 56.3977 26.4738C57.5885 26.4738 58.6454 26.3713 59.5689 26.1649C60.4933 25.9593 61.1078 25.7482 61.4135 25.5347L61.5052 18.3644C61.5052 17.9478 60.4061 17.7586 59.1731 17.7375Z" fill="white"/>
<path d="M2 9.46875V13.1143H7V9.46875H30V13.1143H35V9.46875H37V43H35V40.8857H30V43H7V40.8857H2V43H0V9.46875H2ZM2 34.7139V38.8281H7V34.7139H2ZM30 34.7139V38.8281H35V34.7139H30ZM2 28.543V32.6572H7V28.543H2ZM30 28.543V32.6572H35V28.543H30ZM2 21.3428V25.457H7V21.3428H2ZM30 21.3428V25.457H35V21.3428H30ZM2 15.1719V19.2861H7V15.1719H2ZM30 15.1719V19.2861H35V15.1719H30Z" fill="#478CBF"/>
<path d="M7 28.6777C7.5184 28.7536 7.91521 29.1834 7.95215 29.7197L8.13965 32.457L13.4297 32.8408L13.79 30.3604C13.8702 29.8086 14.3441 29.3917 14.8926 29.3916H22.1094C22.658 29.3916 23.1328 29.8086 23.2129 30.3604L23.5723 32.8408L28.8623 32.457L29.0508 29.7197C29.0876 29.1843 29.4829 28.7556 30 28.6787V32.6572H31.0703L31.0186 33.417C30.9801 33.9762 30.5365 34.4286 29.9863 34.4688L22.7002 34.9971C22.674 34.9991 22.6468 35 22.6201 35C22.072 35 21.5977 34.5838 21.5176 34.0322L21.1475 31.4795H15.8545L15.4844 34.0322C15.4041 34.5839 14.9312 35 14.3838 35C14.3566 35 14.3294 34.9991 14.3018 34.9971L7.0166 34.4688C6.46626 34.4286 6.02177 33.9762 5.9834 33.417L5.93164 32.6572H7V28.6777ZM5.64258 28.543H2V30.0322L0 29.7051L0.00878906 28.3799C0.0440103 28.3799 0.0703106 27.9991 0.105469 28L5.64258 28.543ZM36.8936 28C36.9295 27.9992 36.9902 28.3799 36.9902 28.3799L37 29.7012L35 30.0303V28.543H31.3594L36.8936 28ZM11.5 19C12.8807 19 14 20.1193 14 21.5C14 22.8808 12.8807 24 11.5 24C10.1193 24 9 22.8808 9 21.5C9 20.1193 10.1193 19 11.5 19ZM25.5 19C26.8807 19 28 20.1193 28 21.5C28 22.8808 26.8807 24 25.5 24C24.1193 24 23 22.8808 23 21.5C23 20.1193 24.1193 19 25.5 19Z" fill="white"/>
<path d="M117.204 41.864C117.204 41.966 116.626 42.017 115.47 42.017C114.564 42.0057 114.11 41.9603 114.11 41.881V30.304C114.11 30.134 114.66 30.049 115.759 30.049C116.723 30.083 117.204 30.1397 117.204 30.219V41.864Z" fill="white"/>
<path d="M112.269 41.541C111.555 41.9603 110.637 42.17 109.515 42.17C108.393 42.17 107.446 41.7903 106.676 41.031C105.905 40.2603 105.52 39.2007 105.52 37.852C105.52 36.492 105.888 35.3983 106.625 34.571C107.373 33.7437 108.313 33.33 109.447 33.33C110.58 33.33 111.453 33.619 112.065 34.197C112.688 34.775 113 35.4777 113 36.305C113 38.141 111.946 39.059 109.838 39.059H108.665C108.665 39.399 108.755 39.637 108.937 39.773C109.129 39.8977 109.407 39.96 109.77 39.96C110.563 39.96 111.317 39.7787 112.031 39.416C112.042 39.4047 112.093 39.484 112.184 39.654C112.456 40.1413 112.592 40.555 112.592 40.895C112.592 41.2237 112.484 41.439 112.269 41.541ZM110.144 36.628C110.144 36.152 109.911 35.914 109.447 35.914C109.231 35.914 109.044 35.982 108.886 36.118C108.738 36.2427 108.665 36.4297 108.665 36.679V37.206H109.583C109.957 37.206 110.144 37.0133 110.144 36.628Z" fill="white"/>
<path d="M104.001 41.541C103.287 41.9603 102.369 42.17 101.247 42.17C100.125 42.17 99.1787 41.7903 98.4081 41.031C97.6374 40.2603 97.2521 39.2007 97.2521 37.852C97.2521 36.492 97.6204 35.3983 98.3571 34.571C99.1051 33.7437 100.046 33.33 101.179 33.33C102.312 33.33 103.185 33.619 103.797 34.197C104.42 34.775 104.732 35.4777 104.732 36.305C104.732 38.141 103.678 39.059 101.57 39.059H100.397C100.397 39.399 100.488 39.637 100.669 39.773C100.862 39.8977 101.139 39.96 101.502 39.96C102.295 39.96 103.049 39.7787 103.763 39.416C103.774 39.4047 103.825 39.484 103.916 39.654C104.188 40.1413 104.324 40.555 104.324 40.895C104.324 41.2237 104.216 41.439 104.001 41.541ZM101.876 36.628C101.876 36.152 101.644 35.914 101.179 35.914C100.964 35.914 100.777 35.982 100.618 36.118C100.471 36.2427 100.397 36.4297 100.397 36.679V37.206H101.315C101.689 37.206 101.876 37.0133 101.876 36.628Z" fill="white"/>
<path d="M94.5578 41.983C94.5578 42.0623 94.0422 42.102 93.0108 42.102C91.9795 42.0793 91.4638 42.034 91.4638 41.966V36.322H91.0048C90.8235 36.322 90.6762 36.2143 90.5628 35.999C90.4608 35.7723 90.4098 35.4947 90.4098 35.166C90.4098 33.942 90.7725 33.33 91.4978 33.33C92.0078 33.33 92.4328 33.4887 92.7728 33.806C93.1128 34.112 93.2828 34.4633 93.2828 34.86C93.5208 34.3953 93.8382 34.027 94.2348 33.755C94.6428 33.4717 95.0565 33.33 95.4758 33.33C96.1332 33.33 96.5298 33.5 96.6658 33.84C96.6998 33.9307 96.7168 34.0667 96.7168 34.248C96.7168 34.418 96.6602 34.7297 96.5468 35.183C96.4448 35.6363 96.3372 35.965 96.2238 36.169C96.1105 36.373 96.0425 36.475 96.0198 36.475C95.9972 36.475 95.8895 36.4353 95.6968 36.356C95.5155 36.2653 95.3512 36.22 95.2038 36.22C94.7732 36.22 94.5578 36.56 94.5578 37.24V41.983Z" fill="white"/>
<path d="M86.8746 33.585C86.8746 33.483 87.1863 33.432 87.8096 33.432C89.215 33.432 89.9063 33.4773 89.8836 33.568L88.2176 41.83C88.1836 42.0113 87.5546 42.102 86.3306 42.102C85.1066 42.102 84.4776 42.0113 84.4436 41.83L83.7466 38.022L82.8796 41.83C82.8456 42 82.194 42.085 80.9246 42.085C79.6666 42.085 79.0206 41.983 78.9866 41.779L77.4736 33.5C77.4396 33.432 77.927 33.398 78.9356 33.398C79.933 33.4093 80.4316 33.432 80.4316 33.466L81.2136 39.212L82.6246 33.534C82.6473 33.466 83.0213 33.432 83.7466 33.432C84.472 33.432 84.846 33.466 84.8686 33.534L86.2626 39.144L86.8746 33.585Z" fill="white"/>
<path d="M70.0668 40.997C69.3641 40.215 69.0128 39.1327 69.0128 37.75C69.0128 36.3673 69.3641 35.285 70.0668 34.503C70.7808 33.721 71.7385 33.33 72.9398 33.33C74.1411 33.33 75.0931 33.721 75.7958 34.503C76.4985 35.285 76.8498 36.3673 76.8498 37.75C76.8498 39.1327 76.4985 40.215 75.7958 40.997C75.0931 41.779 74.1411 42.17 72.9398 42.17C71.7385 42.17 70.7808 41.779 70.0668 40.997ZM72.9398 36.169C72.3051 36.169 71.9878 36.6903 71.9878 37.733C71.9878 38.7757 72.3051 39.297 72.9398 39.297C73.5858 39.297 73.9088 38.7757 73.9088 37.733C73.9088 36.6903 73.5858 36.169 72.9398 36.169Z" fill="white"/>
<path d="M63.6582 41.881C63.6582 41.983 63.1822 42.034 62.2302 42.034C61.2895 42.034 60.8192 41.9547 60.8192 41.796V30.151C60.8192 30.0717 60.9949 30.015 61.3462 29.981C61.6975 29.947 61.9922 29.93 62.2302 29.93C63.1822 29.93 63.6582 30.032 63.6582 30.236V33.636C64.2929 33.4433 64.7745 33.347 65.1032 33.347C65.9645 33.347 66.6445 33.5793 67.1432 34.044C67.6419 34.4973 67.8912 35.3473 67.8912 36.594V41.864C67.8912 41.966 67.4492 42.017 66.5652 42.017C65.5679 41.983 65.0692 41.9263 65.0692 41.847V36.849C65.0692 36.2937 64.8879 36.016 64.5252 36.016C64.2759 36.016 63.9869 36.135 63.6582 36.373V41.881Z" fill="white"/>
<path d="M52.3149 37.852C52.5303 38.022 52.8986 38.2543 53.4199 38.549C53.9526 38.8437 54.4569 38.991 54.9329 38.991C55.4203 38.991 55.6639 38.804 55.6639 38.43C55.6639 38.26 55.5959 38.1127 55.4599 37.988C55.3239 37.852 55.0746 37.699 54.7119 37.529C54.3493 37.359 54.0773 37.2287 53.8959 37.138C53.7146 37.036 53.4709 36.8773 53.1649 36.662C52.8703 36.4353 52.6436 36.203 52.4849 35.965C52.0316 35.319 51.8049 34.4973 51.8049 33.5C51.8049 32.5027 52.1733 31.664 52.9099 30.984C53.6579 30.2927 54.6326 29.947 55.8339 29.947C56.6499 29.947 57.4036 30.0377 58.0949 30.219C58.7863 30.389 59.1433 30.6157 59.1659 30.899C59.1659 30.933 59.1659 30.967 59.1659 31.001C59.1659 31.3977 59.0413 31.902 58.7919 32.514C58.5426 33.1147 58.3669 33.449 58.2649 33.517C57.5396 33.143 56.8879 32.956 56.3099 32.956C55.7433 32.956 55.4599 33.16 55.4599 33.568C55.4599 33.8173 55.6696 34.0383 56.0889 34.231C56.1796 34.2763 56.3099 34.3387 56.4799 34.418C56.6499 34.4973 56.8426 34.5937 57.0579 34.707C57.2846 34.809 57.5226 34.945 57.7719 35.115C58.0326 35.2737 58.3103 35.489 58.6049 35.761C59.2056 36.3277 59.5059 37.0757 59.5059 38.005C59.5059 39.2177 59.1716 40.2093 58.5029 40.98C57.8343 41.7507 56.8029 42.1473 55.4089 42.17C54.7289 42.17 54.1113 42.1133 53.5559 42C53.0119 41.8867 52.5359 41.6657 52.1279 41.337C51.7199 41.0083 51.5159 40.6117 51.5159 40.147C51.5159 39.6823 51.6009 39.2233 51.7709 38.77C51.9409 38.3053 52.1223 37.9993 52.3149 37.852Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 12 KiB

View File

@@ -37,6 +37,7 @@
<li>Do not add logos or text overlays to your video. Our final edit will incorporate watermarks to credit you.</li>
<li>Your video must be captured with good quality and stable framerate. If your project uses Godot 4, consider using <a href="https://docs.godotengine.org/en/latest/tutorials/animation/creating_movies.html">Movie Maker mode</a>.</li>
<li>All content has to comply with <a href="https://support.google.com/youtube/answer/9288567">YouTube content guideliens</a> since the final video will posted there. Avoid nudity, gore, hateful or shocking content.</li>
<li>Please ensure your submission video is set to <strong>unlisted</strong> and <strong>embedding is enabled</strong>. Otherwise, it will not be possible to view or vote on your video from our platform.</li>
</ul>
<p>

36
templates/admin.html Normal file
View File

@@ -0,0 +1,36 @@
<style>
.entries {
margin-top: 20px;
display: grid;
gap: 20px;
@media (min-width: 768px) {
grid-template-columns: 1fr 1fr 1fr;
}
}
.entry {
display: grid;
grid-template-columns: 1fr;
}
</style>
<main id="admin">
<h1 style="margin-bottom: 20px;">Vote results</h1>
<form action="{{ url_for('votes.download_vote_results') }}" method="get" style="display:inline;">
<button type="submit" class="button primary small">Download results .csv</button>
</form>
<div>
<div class="entries">
{% for entry in vote_tally.items %}
<div class="entry panel padded">
<h2>{{ entry[0].game }}</h2>
<p>Category: <strong>{{ entry[0].showreel.title }}</strong></p>
<p>Author: <strong>{{ entry[0].author_name }}</strong></p>
<p>Vote sum: <strong>{{ entry[1] }}</strong></p>
<p>Votes received: <strong>{{ entry[2] }}</strong></p>
</div>
{% endfor %}
</div>
{% with elements=vote_tally, request_url="votes.admin_view", hx_target="#admin", strategy="outerHTML" %}
{% include "pagination.html" with context %}
{% endwith %}
</div>
</main>

View File

@@ -0,0 +1,31 @@
<style>
main {
max-width: 600px;
margin: 20px auto;
background-color: #7d7d7d1c;
padding: 20px;
border-radius: 15px;
}
section {
margin: 40px 0px;
}
h1 {
/* margin-top: 1em; */
margin-bottom: 0.5em;
}
p {
margin-bottom: 1em;
}
</style>
<main>
<h1>Before you vote!</h1>
<p>The goal of the showreel is not to choose the best games overall, but to select those that would make the most impressive additions to the showreel.</p>
<p>If a project seems out of place, please take a moment to review its store page or the category it was submitted to before voting. This helps ensure a fair and thoughtful selection process.</p>
<p>Please make sure to read the <a href="/about">about page</a> to know more about the submission requirements and guidelines.</p>
<a class="button" href="/vote">Continue</a>
</main>
<script>
localStorage.setItem("first-vote", true)
</script>

View File

@@ -7,16 +7,9 @@
<meta name="description" content="{% if description %}{{ description }}{% else %}Godot's annual showreel voting platform.{% endif %}">
<title>{% if title %}{{ title }} - {% endif %}Godot Showreel Voting</title>
<link rel="icon" href="/static/images/favicon.svg" type="image/svg+xml">
<link rel="stylesheet" href="https://store-beta.godotengine.org/static/base.css">
<link rel="stylesheet" href="/static/css/1.1/base.css">
<link rel="stylesheet" href="https://store-beta.godotengine.org/static/style/main.css">
<link rel="stylesheet" href="https://store-beta.godotengine.org/static/style/nav.css">
<link rel="stylesheet" href="https://store-beta.godotengine.org/static/style/asset-dashboard.css">
<link rel="stylesheet" href="https://store-beta.godotengine.org/static/style/asset-item.css">
<link rel="stylesheet" href="https://store-beta.godotengine.org/static/style/generic-form.css">
<link rel="stylesheet" href="https://store-beta.godotengine.org/static/style/popup.css">
<link rel="stylesheet" href="https://store-beta.godotengine.org/static/style/tags.css">
<link rel="stylesheet" href="https://store-beta.godotengine.org/static/style/page/library.css">
<link rel="stylesheet" href="https://store-beta.godotengine.org/static/style/code-hilite.css">
<link rel="stylesheet" href="/static/css/1.1/nav.css">
<script src="https://unpkg.com/htmx.org@1.9.12"></script>
<script src="https://store-beta.godotengine.org/static/js/tiny-slider.js"></script>
<meta property="og:site_name" content="Godot Showreel Voting">
@@ -32,7 +25,7 @@
<p>JavaScript is disabled in your browser. Some features of this site may not work properly.</p>
</noscript>
{% import "macros.html" as macros %}
{{ macros.nav(user) }}
{% if hide_nav %}{% else %}{{ macros.nav(user) }}{% endif %}
{{ content | safe }}
{{ macros.footer() }}
<script src="/static/main.js"></script>

View File

@@ -11,46 +11,54 @@
display: grid;
grid-template-columns: 1fr;
}
.vote {
background: #48ec2563;
border-radius: 6px;
padding: 2px 8px;
display: inline-block;
margin-top: 4px;
&.yes {
background: #48ec2563;
}
&.no {
background: #ec484863;
}
}
</style>
<main>
<h1 style="margin-bottom: 20px;">Vote history</h1>
<div class="">
<p>You have voted {{ progress.current }} out of {{ progress.total }} entries. <a href="/vote">Continue voting</a>.</p>
<main id="history">
<h1 style="margin-bottom: 20px;">Vote history</h1>
<div>
<p>You have voted {{ progress.current }} out of {{ progress.total }} entries. {% if progress.current != progress.total %}<a href="/vote">Continue voting</a>.{% endif %}</p>
<div class="entries">
<!-- Items should be sorted by what you Voted last in chronological order -->
<!-- This should be a loop -->
<div class="entry panel padded">
<h2>Brotato</h2>
<p>Category: <strong>2025 Godot Desktop/Console Games</strong></p>
<p>Author: <strong>Blobfish Games</strong></p>
<p>Your vote: <strong>Yes 👍</strong></p>
<div style="margin-top: 10px;">
<a class="button dim small" href="/vote/22">Edit vote</a>
</div>
</div>
<div class="entry panel padded">
<h2>Project Katana</h2>
<p>Category: <strong>2025 Godot Desktop/Console Games</strong></p>
<p>Author: <strong>John Doe</strong></p>
<p>Your vote: <strong>No 👎</strong></p>
<div style="margin-top: 10px;">
<a class="button dim small" href="/vote/18">Edit vote</a>
{% for entry in submitted_votes.items %}
<div class="entry panel padded">
<h2>{{ entry.video.game }}</h2>
<p>Category: <strong>{{ entry.video.showreel.title }}</strong></p>
<p>Author: <strong>{{ entry.video.author_name }}</strong></p>
{% if entry.rating == 1 %}
<p>Your vote: <strong class="vote yes">Yes 👍</strong></p>
{% elif entry.rating == -1 %}
<p>Your vote: <strong class="vote no">No 👎</strong></p>
{% else %}
<!-- This should not happen -->
<p>Your vote: <strong>Skip ⏭️</strong></p>
{% endif %}
<div style="margin-top: 10px;">
<form action="{{ url_for('votes.vote_get', video_id=entry.video.id) }}" method="get" style="display:inline;">
<button type="submit" class="button dim small">Edit vote</button>
</form>
<form action="{{ url_for('votes.delete_vote', video_id=entry.video.id) }}" method="post" style="display:inline;" onsubmit="return confirm('Are you sure you want to delete this vote?\n\nThis video will show again in the showreel vote.');">
<button type="submit" class="button dim small">Delete vote</button>
</form>
</div>
</div>
</div>
<div class="entry panel padded">
<h2>Space Adventure</h2>
<p>Category: <strong>2025 Godot Desktop/Console Games</strong></p>
<p>Author: <strong>Jane Smith</strong></p>
<p>Your vote: <strong>Skip ⏭️</strong></p>
<div style="margin-top: 10px;">
<a class="button dim small" href="/vote/25">Edit vote</a>
</div>
</div>
{% endfor %}
</div>
</div>
{% with elements=submitted_votes, request_url="votes.history", hx_target="#history", strategy="outerHTML" %}
{% include "pagination.html" with context %}
{% endwith %}
</div>
</main>

View File

@@ -1,8 +1,78 @@
<style>
h1 {
margin-bottom: 0.5em;
}
h2 span a {
font-size: 14px;
font-weight: 400;
color: gray;
position: relative;
left: 7px;
top: -3px;
text-decoration: none;
}
.grid-2 {
display: grid;
gap: 10px;
@media (min-width: 768px) {
grid-template-columns: 1fr 1fr;
}
}
</style>
<main>
<h1>Index</h1>
<div class="panel padded" style="">
<h1>Welcome!</h1>
{% if user %}
{% if user.can_vote() %}
<p>Cast your votes until <strong>October 20th</strong> 📅</p>
<ul>
<li>Videos are presented in random order, and scores are hidden until the voting period ends</li>
<li>Your votes are anonymous</li>
<li>You can change your votes until the voting period ends</li>
<li>We encourage you to vote for as many entries as you can</li>
<li>If you found any issue or if you have a suggestion, please create an issue in our <a href="https://github.com/godotengine/godot-showreel-voting/issues">issue tracker</a></li>
</ul>
<p style="margin: 20px 0;">Happy voting!</p>
<a class="button primary" id="vote-link" href="/vote">Start Voting</a> <a class="button dim" href="/history">History</a>
{% else %}
<p>Only Godot Engine maintainers or members of the Development Fund can vote.</p>
<p>If you would like to participate in the process, make sure to sign up to <a href="https://fund.godotengine.org">the Development Fund</a>.</p>
<p style="margin-top: 20px;">
<a href="https://fund.godotengine.org" class="button">Sign Up</a>
</p>
{% endif %}
{% else %}
<hr>
You need to be logged in to vote:
<a href="/login" class="button dim small">Log In</a>
{% endif %}
<p>About page ✅ <a href="/about">/about</a></p>
<p>Vote page example (pending backend implementation): <a href="/vote">/vote</a></p>
<p>Vote history example (pending backend implementation): <a href="/history">/history</a></p>
</div>
</main>
<div class="panel padded" style="margin-top: 20px;">
<h2>Previous editions:<span><a href="https://www.youtube.com/playlist?list=PLeG_dAglpVo6EpaO9A1nkwJZOwrfiLdQ8">(View all)</a></span></h2>
<div class="grid-2" style="margin-top: 10px;">
<iframe width="560" height="315" src="https://www.youtube.com/embed/n1Lon_Q2T18" frameborder="0" allowfullscreen="" style="width:100%;aspect-ratio:16/9;height:auto; border-radius: 11px;"></iframe>
<iframe width="560" height="315" src="https://www.youtube.com/embed/W1_zKxYEP6Q" frameborder="0" allowfullscreen="" style="width:100%;aspect-ratio:16/9;height:auto; border-radius: 11px;"></iframe>
</div>
</div>
</main>
<script>
document.addEventListener("DOMContentLoaded", function() {
const voteLink = document.getElementById("vote-link");
if (voteLink) {
const token = localStorage.getItem("first-vote") || false;
if (!token) {
voteLink.href = "/before-you-vote";
voteLink.textContent = "Start Voting";
} else {
voteLink.href = "/vote";
voteLink.textContent = "Continue Voting";
}
}
});
</script>

View File

@@ -9,39 +9,32 @@
<div style="height: 58px;"></div>
<!-- navigation links -->
<div class="navigation-links">
<a class="desktop" href="/about">About</a>
{% if user %}
<a class="desktop" href="/">My Library</a>
<div class="dropdown">
<a href="#" class="dd-button-view" onclick="document.getElementById('dropdown-menu').classList.toggle('show'); event.preventDefault();">
<img src="https://www.gravatar.com/avatar/{{ user.gravatar_hash }}?d=retro" alt="User" title="{{ user.name }}" class="user-icon gravatar">
</a>
<div id="dropdown-menu" class="dropdown-content">
<a href="/">{{ user.name }}</a>
<hr>
<a class="mobile" href="/about">About</a>
<hr class="mobile">
{% if user.moderator %}
<a href="/">Queue</a>
<a href="/">Administration</a>
<div class="dropdown">
<a href="#" class="dd-button-view" onclick="document.getElementById('dropdown-menu').classList.toggle('show'); event.preventDefault();">
<img src="https://www.gravatar.com/avatar/{{ user.gravatar_hash }}?d=retro" alt="User" title="{{ user.usernamename }}" class="user-icon gravatar">
</a>
<div id="dropdown-menu" class="dropdown-content">
<a href="/about">About</a>
{% if user.can_vote() %}
<a href="/vote">Vote</a>
<a href="/history">History</a>
{% endif %}
{% if user.is_superuser %}
<a href="/admin">Administration</a>
{% endif %}
<hr>
{% endif %}
<a href="/">My Library</a>
<a href="/">My Uploads</a>
<a href="/">Settings</a>
<a href="/" >Support</a>
<hr>
<a href="/">Logout</a>
<a href="{{ url_for('oidc.logout') }}">Logout</a>
</div>
</div>
</div>
{% else %}
<div class="dropdown">
<a href="/" class="dd-button-view logged-out">
<p class="desktop">Log in</p>
<img src="https://store-beta.godotengine.org/static/images/user.svg" alt="User" class="user-icon">
</a>
</div>
<a class="desktop" href="/about">About</a>
<div class="dropdown">
<a href="{{ url_for('oidc.login') }}" class="dd-button-view logged-out">
<p class="desktop">Login</p>
<img src="https://store-beta.godotengine.org/static/images/user.svg" alt="User" class="user-icon">
</a>
</div>
{% endif %}
<script>
// Close the dropdown if the user clicks outside of it
@@ -68,4 +61,4 @@
<br>
<p>&copy; 2025 - Godot Showreel Voting by <a href="https://godot.foundation">Godot Foundation</a></p>
</footer>
{% endmacro %}
{% endmacro %}

10
templates/mock-login.html Normal file
View File

@@ -0,0 +1,10 @@
<main class="catalog" style="max-width: 230px; text-align: center; padding-top: 70px; padding-bottom: 120px;">
<h2 class="section-title">Mock Login</h2>
<form method="POST" action="{{ url_for('oidc.auth') }}">
{% for user in users %}
<button class="button secondary" style="display: block; width: 100%; cursor: pointer;" type="submit" value="{{ user }}" name="username">
{{ user | capitalize }}
</button>
{% endfor %}
</form>
</main>

45
templates/pagination.html Normal file
View File

@@ -0,0 +1,45 @@
<style>
.pagination {
text-align: center;
margin-top: 20px;
}
.pagination .button.current {
cursor: initial;
color: #409bff;
}
@media (prefers-color-scheme: light) {
.pagination .button.dim {
background: white;
color: inherit;
}
.pagination .button:disabled {
background: #f0f0f0;
color: #999;
}
}
</style>
{% if elements.pages > 1 %}
<div class="pagination">
Page:
{% for page in elements.iter_pages() %}
{% if page %}
{% if page != elements.page %}
<button
class="button small dim"
type="button"
hx-get="{{ url_for(request_url, page=page) }}"
hx-target="{{ hx_target }}"
hx-swap="{{ strategy }} show:window:top"
>
{{ page }}
</button>
{% else %}
<button class="button small current" disabled>{{ page }}</button>
{% endif %}
{% else %}
<span class="ellipsis"></span>
{% endif %}
{% endfor %}
</div>
{% endif %}

View File

@@ -3,45 +3,152 @@
width: 100%;
aspect-ratio: 16 / 9;
height: auto;
border-radius: 6px;
margin-bottom: -5px;
}
div.main {
max-width: 134vh;
}
.col-2 {
text-align: left;
display: grid;
grid-template-columns: 1fr 1fr;
margin: 20px 0px;
@media (min-width: 768px) {
grid-template-columns: 1fr 1fr;
}
}
.thank-you {
font-size: 1.3em;
padding: 41px 50px;
text-align: left;
margin: 0 auto;
h1 {
margin-bottom: 10px;
}
}
.progress {
@media (min-width: 768px) {
text-align: right;
}
a {
display: inline-block;
text-align: right;
font-size: 18px;
color: inherit;
opacity: 0.8;
text-decoration: none;
position: relative;
top: 3px;
progress {
max-width: 190px;
margin-right: 0;
margin-left: auto;
position: relative;
top: -8px;
}
}
span {
display: block;
}
}
div.main.title {
margin-top: -20px;
margin-bottom: -15px;
padding-top: 10px;
max-width: 100%;
h1 {
@media (min-width: 768px) {
text-align: center;
position: relative;
top: 4px;
}
}
.col-2 {
@media (min-width: 768px) {
grid-template-columns: 200px 1fr 200px;
}
}
}
</style>
<main>
<div class="col-2">
<h1>Submission: {{ data.game }}</h1>
<div style="display: grid; align-content: end;">
<a href="/history" style="text-align: right; font-size: 21px; color: inherit;">Progress: {{ progress.current }}/{{ progress.total }}</a>
<div id="vote-view">
{% if not data %}
<div class="main">
<div class="thank-you panel">
<h1>Thank you for participating!</h1>
<p>You already voted on all the submitted projects.</p>
<p>After the voting period ends, we will start working on the video.</p>
<p>If you want to revisit your votes, review them <a href="/history">here</a>.</p>
</div>
</div>
</div>
<div >
<iframe width="560" height="315" src="{{ data.youtube_embed }}?rel=0" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>
<div class="panel padded">
<h2 style="margin-bottom: 10px; text-align: center;">Should this submission be included in the next showreel?</h2>
<div style="display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 10px;">
<a href="/vote" class="button tertiary">No 👎</a>
<a href="/vote" class="button dim" >Skip ⏭️</a>
<a href="/vote" class="button primary">Yes 👍</a>
{% else %}
<div style="background: black; color: white; margin-bottom: -20px; padding-bottom: 24px;">
<div class="main title">
<div class="col-2" style="margin-bottom: 0;">
<div>
<a href="/">
<img class="logo" src="/static/images/nav-logo.svg" alt="Godot Showreel Voting">
</a>
</div>
<h1>{{ data.game }}</h1>
<div style="display: grid; align-content: end;">
<div class="progress">
<a href="/history">
<span>Progress: {{ progress.current }}/{{ progress.total }}</span>
<progress value="{{ progress.current }}" max="{{ progress.total }}"></progress>
</a>
</div>
</div>
</div>
</div>
</div>
<div class="panel padded" style="margin-top: 10px;">
<h2>Submission info:</h2>
<div class="col-2">
<p>Project: <strong>{{ data.game }}</strong></p>
<p>Video Link: <a href="{{ data.video_link }}" target="_blank">{{ data.video_link }}</a></p>
<p>Author: <strong>{{ data.author }}</strong></p>
<p>Store Link: <a href="{{ data.store_link }}" target="_blank">{{ data.store_link }}</a></p>
<p>Category: <strong>{{ data.category }}</strong></p>
<p>Follow them: <a href="{{ data.follow_me_link }}" target="_blank">{{ data.follow_me_link }}</a></p>
<div>
<div style="background: black; margin: 20px 0;">
<div class="main">
<iframe width="560" height="315" src="{{ data.youtube_embed }}?rel=0&autoplay=1" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; gyroscope; picture-in-picture; web-share" allowfullscreen></iframe>
</div>
</div>
<div class="main">
<div class="panel padded">
<h2 style="margin-bottom: 10px; text-align: center;">Should this submission be included in the next showreel?</h2>
<form method="POST" enctype="multipart/form-data" hx-post="{{ url_for("votes.vote", _method='POST') }}" hx-target='#vote-view' hx-swap='outerHTML'>
<div style="display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 10px;">
{{ cast_vote_form.csrf_token }}
{{ cast_vote_form.video_id(value=data.id, hidden=True) }}
<button id="downvote" type="submit" name="action" value="downvote" class="button tertiary" title="Shortcut: 'A' or 'Q' keys">No 👎</button>
<button id="skip" type="submit" name="action" value="skip" class="button dim" title="Shortcut: 'S' key">Skip ⏭️</button>
<button id="upvote" type="submit" name="action" value="upvote" class="button primary" title="Shortcut: 'D' key">Yes 👍</button>
</div>
</form>
</div>
<div class="panel padded" style="margin-top: 10px;">
<h2>Submission info:</h2>
<div class="col-2">
<p>Project: <strong>{{ data.game }}</strong></p>
<p>Video Link: <a href="{{ data.video_link }}" target="_blank">{{ data.video_link }}</a></p>
<p>Author: <strong>{{ data.author }}</strong></p>
<p>Store Link: <a href="{{ data.store_link }}" target="_blank">{{ data.store_link }}</a></p>
<p>Category: <strong>{{ data.category }}</strong></p>
<p>Follow them: <a href="{{ data.follow_me_link }}" target="_blank">{{ data.follow_me_link }}</a></p>
</div>
</div>
</div>
</div>
</div>
</main>
{% endif %}
</div>
<script>
document.addEventListener("DOMContentLoaded", function() {
document.onkeypress = function (e) {
var e = e || window.event;
if (e.key === "d") {
document.getElementById("upvote").click();
} else if (e.key === "a" || e.key === "q") {
document.getElementById("downvote").click();
} else if (e.key === "s") {
document.getElementById("skip").click();
}
};
});
</script>

553
uv.lock generated Normal file
View File

@@ -0,0 +1,553 @@
version = 1
revision = 1
requires-python = ">=3.12"
[[package]]
name = "alembic"
version = "1.16.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "mako" },
{ name = "sqlalchemy" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/9a/ca/4dc52902cf3491892d464f5265a81e9dff094692c8a049a3ed6a05fe7ee8/alembic-1.16.5.tar.gz", hash = "sha256:a88bb7f6e513bd4301ecf4c7f2206fe93f9913f9b48dac3b78babde2d6fe765e", size = 1969868 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/39/4a/4c61d4c84cfd9befb6fa08a702535b27b21fff08c946bc2f6139decbf7f7/alembic-1.16.5-py3-none-any.whl", hash = "sha256:e845dfe090c5ffa7b92593ae6687c5cb1a101e91fa53868497dbd79847f9dbe3", size = 247355 },
]
[[package]]
name = "authlib"
version = "1.6.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cryptography" },
]
sdist = { url = "https://files.pythonhosted.org/packages/5d/c6/d9a9db2e71957827e23a34322bde8091b51cb778dcc38885b84c772a1ba9/authlib-1.6.3.tar.gz", hash = "sha256:9f7a982cc395de719e4c2215c5707e7ea690ecf84f1ab126f28c053f4219e610", size = 160836 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/25/2f/efa9d26dbb612b774990741fd8f13c7cf4cfd085b870e4a5af5c82eaf5f1/authlib-1.6.3-py2.py3-none-any.whl", hash = "sha256:7ea0f082edd95a03b7b72edac65ec7f8f68d703017d7e37573aee4fc603f2a48", size = 240105 },
]
[[package]]
name = "blinker"
version = "1.9.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/21/28/9b3f50ce0e048515135495f198351908d99540d69bfdc8c1d15b73dc55ce/blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf", size = 22460 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458 },
]
[[package]]
name = "certifi"
version = "2025.8.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/dc/67/960ebe6bf230a96cda2e0abcf73af550ec4f090005363542f0765df162e0/certifi-2025.8.3.tar.gz", hash = "sha256:e564105f78ded564e3ae7c923924435e1daa7463faeab5bb932bc53ffae63407", size = 162386 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e5/48/1549795ba7742c948d2ad169c1c8cdbae65bc450d6cd753d124b17c8cd32/certifi-2025.8.3-py3-none-any.whl", hash = "sha256:f6c12493cfb1b06ba2ff328595af9350c65d6644968e5d3a2ffd78699af217a5", size = 161216 },
]
[[package]]
name = "cffi"
version = "2.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pycparser", marker = "implementation_name != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271 },
{ url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048 },
{ url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529 },
{ url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097 },
{ url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983 },
{ url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519 },
{ url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572 },
{ url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963 },
{ url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361 },
{ url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932 },
{ url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557 },
{ url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762 },
{ url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230 },
{ url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043 },
{ url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446 },
{ url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101 },
{ url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948 },
{ url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422 },
{ url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499 },
{ url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928 },
{ url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302 },
{ url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909 },
{ url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402 },
{ url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780 },
{ url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320 },
{ url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487 },
{ url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049 },
{ url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793 },
{ url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300 },
{ url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244 },
{ url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828 },
{ url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926 },
{ url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328 },
{ url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650 },
{ url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687 },
{ url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773 },
{ url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013 },
{ url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593 },
{ url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354 },
{ url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480 },
{ url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584 },
{ url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443 },
{ url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437 },
{ url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487 },
{ url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726 },
{ url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195 },
]
[[package]]
name = "charset-normalizer"
version = "3.4.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/83/2d/5fd176ceb9b2fc619e63405525573493ca23441330fcdaee6bef9460e924/charset_normalizer-3.4.3.tar.gz", hash = "sha256:6fce4b8500244f6fcb71465d4a4930d132ba9ab8e71a7859e6a5d59851068d14", size = 122371 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e9/5e/14c94999e418d9b87682734589404a25854d5f5d0408df68bc15b6ff54bb/charset_normalizer-3.4.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e28e334d3ff134e88989d90ba04b47d84382a828c061d0d1027b1b12a62b39b1", size = 205655 },
{ url = "https://files.pythonhosted.org/packages/7d/a8/c6ec5d389672521f644505a257f50544c074cf5fc292d5390331cd6fc9c3/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0cacf8f7297b0c4fcb74227692ca46b4a5852f8f4f24b3c766dd94a1075c4884", size = 146223 },
{ url = "https://files.pythonhosted.org/packages/fc/eb/a2ffb08547f4e1e5415fb69eb7db25932c52a52bed371429648db4d84fb1/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c6fd51128a41297f5409deab284fecbe5305ebd7e5a1f959bee1c054622b7018", size = 159366 },
{ url = "https://files.pythonhosted.org/packages/82/10/0fd19f20c624b278dddaf83b8464dcddc2456cb4b02bb902a6da126b87a1/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3cfb2aad70f2c6debfbcb717f23b7eb55febc0bb23dcffc0f076009da10c6392", size = 157104 },
{ url = "https://files.pythonhosted.org/packages/16/ab/0233c3231af734f5dfcf0844aa9582d5a1466c985bbed6cedab85af9bfe3/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1606f4a55c0fd363d754049cdf400175ee96c992b1f8018b993941f221221c5f", size = 151830 },
{ url = "https://files.pythonhosted.org/packages/ae/02/e29e22b4e02839a0e4a06557b1999d0a47db3567e82989b5bb21f3fbbd9f/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:027b776c26d38b7f15b26a5da1044f376455fb3766df8fc38563b4efbc515154", size = 148854 },
{ url = "https://files.pythonhosted.org/packages/05/6b/e2539a0a4be302b481e8cafb5af8792da8093b486885a1ae4d15d452bcec/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:42e5088973e56e31e4fa58eb6bd709e42fc03799c11c42929592889a2e54c491", size = 160670 },
{ url = "https://files.pythonhosted.org/packages/31/e7/883ee5676a2ef217a40ce0bffcc3d0dfbf9e64cbcfbdf822c52981c3304b/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:cc34f233c9e71701040d772aa7490318673aa7164a0efe3172b2981218c26d93", size = 158501 },
{ url = "https://files.pythonhosted.org/packages/c1/35/6525b21aa0db614cf8b5792d232021dca3df7f90a1944db934efa5d20bb1/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:320e8e66157cc4e247d9ddca8e21f427efc7a04bbd0ac8a9faf56583fa543f9f", size = 153173 },
{ url = "https://files.pythonhosted.org/packages/50/ee/f4704bad8201de513fdc8aac1cabc87e38c5818c93857140e06e772b5892/charset_normalizer-3.4.3-cp312-cp312-win32.whl", hash = "sha256:fb6fecfd65564f208cbf0fba07f107fb661bcd1a7c389edbced3f7a493f70e37", size = 99822 },
{ url = "https://files.pythonhosted.org/packages/39/f5/3b3836ca6064d0992c58c7561c6b6eee1b3892e9665d650c803bd5614522/charset_normalizer-3.4.3-cp312-cp312-win_amd64.whl", hash = "sha256:86df271bf921c2ee3818f0522e9a5b8092ca2ad8b065ece5d7d9d0e9f4849bcc", size = 107543 },
{ url = "https://files.pythonhosted.org/packages/65/ca/2135ac97709b400c7654b4b764daf5c5567c2da45a30cdd20f9eefe2d658/charset_normalizer-3.4.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:14c2a87c65b351109f6abfc424cab3927b3bdece6f706e4d12faaf3d52ee5efe", size = 205326 },
{ url = "https://files.pythonhosted.org/packages/71/11/98a04c3c97dd34e49c7d247083af03645ca3730809a5509443f3c37f7c99/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:41d1fc408ff5fdfb910200ec0e74abc40387bccb3252f3f27c0676731df2b2c8", size = 146008 },
{ url = "https://files.pythonhosted.org/packages/60/f5/4659a4cb3c4ec146bec80c32d8bb16033752574c20b1252ee842a95d1a1e/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1bb60174149316da1c35fa5233681f7c0f9f514509b8e399ab70fea5f17e45c9", size = 159196 },
{ url = "https://files.pythonhosted.org/packages/86/9e/f552f7a00611f168b9a5865a1414179b2c6de8235a4fa40189f6f79a1753/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:30d006f98569de3459c2fc1f2acde170b7b2bd265dc1943e87e1a4efe1b67c31", size = 156819 },
{ url = "https://files.pythonhosted.org/packages/7e/95/42aa2156235cbc8fa61208aded06ef46111c4d3f0de233107b3f38631803/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:416175faf02e4b0810f1f38bcb54682878a4af94059a1cd63b8747244420801f", size = 151350 },
{ url = "https://files.pythonhosted.org/packages/c2/a9/3865b02c56f300a6f94fc631ef54f0a8a29da74fb45a773dfd3dcd380af7/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6aab0f181c486f973bc7262a97f5aca3ee7e1437011ef0c2ec04b5a11d16c927", size = 148644 },
{ url = "https://files.pythonhosted.org/packages/77/d9/cbcf1a2a5c7d7856f11e7ac2d782aec12bdfea60d104e60e0aa1c97849dc/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:fdabf8315679312cfa71302f9bd509ded4f2f263fb5b765cf1433b39106c3cc9", size = 160468 },
{ url = "https://files.pythonhosted.org/packages/f6/42/6f45efee8697b89fda4d50580f292b8f7f9306cb2971d4b53f8914e4d890/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:bd28b817ea8c70215401f657edef3a8aa83c29d447fb0b622c35403780ba11d5", size = 158187 },
{ url = "https://files.pythonhosted.org/packages/70/99/f1c3bdcfaa9c45b3ce96f70b14f070411366fa19549c1d4832c935d8e2c3/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:18343b2d246dc6761a249ba1fb13f9ee9a2bcd95decc767319506056ea4ad4dc", size = 152699 },
{ url = "https://files.pythonhosted.org/packages/a3/ad/b0081f2f99a4b194bcbb1934ef3b12aa4d9702ced80a37026b7607c72e58/charset_normalizer-3.4.3-cp313-cp313-win32.whl", hash = "sha256:6fb70de56f1859a3f71261cbe41005f56a7842cc348d3aeb26237560bfa5e0ce", size = 99580 },
{ url = "https://files.pythonhosted.org/packages/9a/8f/ae790790c7b64f925e5c953b924aaa42a243fb778fed9e41f147b2a5715a/charset_normalizer-3.4.3-cp313-cp313-win_amd64.whl", hash = "sha256:cf1ebb7d78e1ad8ec2a8c4732c7be2e736f6e5123a4146c5b89c9d1f585f8cef", size = 107366 },
{ url = "https://files.pythonhosted.org/packages/8e/91/b5a06ad970ddc7a0e513112d40113e834638f4ca1120eb727a249fb2715e/charset_normalizer-3.4.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3cd35b7e8aedeb9e34c41385fda4f73ba609e561faedfae0a9e75e44ac558a15", size = 204342 },
{ url = "https://files.pythonhosted.org/packages/ce/ec/1edc30a377f0a02689342f214455c3f6c2fbedd896a1d2f856c002fc3062/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b89bc04de1d83006373429975f8ef9e7932534b8cc9ca582e4db7d20d91816db", size = 145995 },
{ url = "https://files.pythonhosted.org/packages/17/e5/5e67ab85e6d22b04641acb5399c8684f4d37caf7558a53859f0283a650e9/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2001a39612b241dae17b4687898843f254f8748b796a2e16f1051a17078d991d", size = 158640 },
{ url = "https://files.pythonhosted.org/packages/f1/e5/38421987f6c697ee3722981289d554957c4be652f963d71c5e46a262e135/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8dcfc373f888e4fb39a7bc57e93e3b845e7f462dacc008d9749568b1c4ece096", size = 156636 },
{ url = "https://files.pythonhosted.org/packages/a0/e4/5a075de8daa3ec0745a9a3b54467e0c2967daaaf2cec04c845f73493e9a1/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18b97b8404387b96cdbd30ad660f6407799126d26a39ca65729162fd810a99aa", size = 150939 },
{ url = "https://files.pythonhosted.org/packages/02/f7/3611b32318b30974131db62b4043f335861d4d9b49adc6d57c1149cc49d4/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ccf600859c183d70eb47e05a44cd80a4ce77394d1ac0f79dbd2dd90a69a3a049", size = 148580 },
{ url = "https://files.pythonhosted.org/packages/7e/61/19b36f4bd67f2793ab6a99b979b4e4f3d8fc754cbdffb805335df4337126/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:53cd68b185d98dde4ad8990e56a58dea83a4162161b1ea9272e5c9182ce415e0", size = 159870 },
{ url = "https://files.pythonhosted.org/packages/06/57/84722eefdd338c04cf3030ada66889298eaedf3e7a30a624201e0cbe424a/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:30a96e1e1f865f78b030d65241c1ee850cdf422d869e9028e2fc1d5e4db73b92", size = 157797 },
{ url = "https://files.pythonhosted.org/packages/72/2a/aff5dd112b2f14bcc3462c312dce5445806bfc8ab3a7328555da95330e4b/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d716a916938e03231e86e43782ca7878fb602a125a91e7acb8b5112e2e96ac16", size = 152224 },
{ url = "https://files.pythonhosted.org/packages/b7/8c/9839225320046ed279c6e839d51f028342eb77c91c89b8ef2549f951f3ec/charset_normalizer-3.4.3-cp314-cp314-win32.whl", hash = "sha256:c6dbd0ccdda3a2ba7c2ecd9d77b37f3b5831687d8dc1b6ca5f56a4880cc7b7ce", size = 100086 },
{ url = "https://files.pythonhosted.org/packages/ee/7a/36fbcf646e41f710ce0a563c1c9a343c6edf9be80786edeb15b6f62e17db/charset_normalizer-3.4.3-cp314-cp314-win_amd64.whl", hash = "sha256:73dc19b562516fc9bcf6e5d6e596df0b4eb98d87e4f79f3ae71840e6ed21361c", size = 107400 },
{ url = "https://files.pythonhosted.org/packages/8a/1f/f041989e93b001bc4e44bb1669ccdcf54d3f00e628229a85b08d330615c5/charset_normalizer-3.4.3-py3-none-any.whl", hash = "sha256:ce571ab16d890d23b5c278547ba694193a45011ff86a9162a71307ed9f86759a", size = 53175 },
]
[[package]]
name = "click"
version = "8.2.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/60/6c/8ca2efa64cf75a977a0d7fac081354553ebe483345c734fb6b6515d96bbc/click-8.2.1.tar.gz", hash = "sha256:27c491cc05d968d271d5a1db13e3b5a184636d9d930f148c50b038f0d0646202", size = 286342 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/85/32/10bb5764d90a8eee674e9dc6f4db6a0ab47c8c4d0d83c27f7c39ac415a4d/click-8.2.1-py3-none-any.whl", hash = "sha256:61a3265b914e850b85317d0b3109c7f8cd35a670f963866005d6ef1d5175a12b", size = 102215 },
]
[[package]]
name = "colorama"
version = "0.4.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 },
]
[[package]]
name = "cryptography"
version = "45.0.7"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a7/35/c495bffc2056f2dadb32434f1feedd79abde2a7f8363e1974afa9c33c7e2/cryptography-45.0.7.tar.gz", hash = "sha256:4b1654dfc64ea479c242508eb8c724044f1e964a47d1d1cacc5132292d851971", size = 744980 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0c/91/925c0ac74362172ae4516000fe877912e33b5983df735ff290c653de4913/cryptography-45.0.7-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:3be4f21c6245930688bd9e162829480de027f8bf962ede33d4f8ba7d67a00cee", size = 7041105 },
{ url = "https://files.pythonhosted.org/packages/fc/63/43641c5acce3a6105cf8bd5baeceeb1846bb63067d26dae3e5db59f1513a/cryptography-45.0.7-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:67285f8a611b0ebc0857ced2081e30302909f571a46bfa7a3cc0ad303fe015c6", size = 4205799 },
{ url = "https://files.pythonhosted.org/packages/bc/29/c238dd9107f10bfde09a4d1c52fd38828b1aa353ced11f358b5dd2507d24/cryptography-45.0.7-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:577470e39e60a6cd7780793202e63536026d9b8641de011ed9d8174da9ca5339", size = 4430504 },
{ url = "https://files.pythonhosted.org/packages/62/62/24203e7cbcc9bd7c94739428cd30680b18ae6b18377ae66075c8e4771b1b/cryptography-45.0.7-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:4bd3e5c4b9682bc112d634f2c6ccc6736ed3635fc3319ac2bb11d768cc5a00d8", size = 4209542 },
{ url = "https://files.pythonhosted.org/packages/cd/e3/e7de4771a08620eef2389b86cd87a2c50326827dea5528feb70595439ce4/cryptography-45.0.7-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:465ccac9d70115cd4de7186e60cfe989de73f7bb23e8a7aa45af18f7412e75bf", size = 3889244 },
{ url = "https://files.pythonhosted.org/packages/96/b8/bca71059e79a0bb2f8e4ec61d9c205fbe97876318566cde3b5092529faa9/cryptography-45.0.7-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:16ede8a4f7929b4b7ff3642eba2bf79aa1d71f24ab6ee443935c0d269b6bc513", size = 4461975 },
{ url = "https://files.pythonhosted.org/packages/58/67/3f5b26937fe1218c40e95ef4ff8d23c8dc05aa950d54200cc7ea5fb58d28/cryptography-45.0.7-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:8978132287a9d3ad6b54fcd1e08548033cc09dc6aacacb6c004c73c3eb5d3ac3", size = 4209082 },
{ url = "https://files.pythonhosted.org/packages/0e/e4/b3e68a4ac363406a56cf7b741eeb80d05284d8c60ee1a55cdc7587e2a553/cryptography-45.0.7-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:b6a0e535baec27b528cb07a119f321ac024592388c5681a5ced167ae98e9fff3", size = 4460397 },
{ url = "https://files.pythonhosted.org/packages/22/49/2c93f3cd4e3efc8cb22b02678c1fad691cff9dd71bb889e030d100acbfe0/cryptography-45.0.7-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:a24ee598d10befaec178efdff6054bc4d7e883f615bfbcd08126a0f4931c83a6", size = 4337244 },
{ url = "https://files.pythonhosted.org/packages/04/19/030f400de0bccccc09aa262706d90f2ec23d56bc4eb4f4e8268d0ddf3fb8/cryptography-45.0.7-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:fa26fa54c0a9384c27fcdc905a2fb7d60ac6e47d14bc2692145f2b3b1e2cfdbd", size = 4568862 },
{ url = "https://files.pythonhosted.org/packages/29/56/3034a3a353efa65116fa20eb3c990a8c9f0d3db4085429040a7eef9ada5f/cryptography-45.0.7-cp311-abi3-win32.whl", hash = "sha256:bef32a5e327bd8e5af915d3416ffefdbe65ed975b646b3805be81b23580b57b8", size = 2936578 },
{ url = "https://files.pythonhosted.org/packages/b3/61/0ab90f421c6194705a99d0fa9f6ee2045d916e4455fdbb095a9c2c9a520f/cryptography-45.0.7-cp311-abi3-win_amd64.whl", hash = "sha256:3808e6b2e5f0b46d981c24d79648e5c25c35e59902ea4391a0dcb3e667bf7443", size = 3405400 },
{ url = "https://files.pythonhosted.org/packages/63/e8/c436233ddf19c5f15b25ace33979a9dd2e7aa1a59209a0ee8554179f1cc0/cryptography-45.0.7-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:bfb4c801f65dd61cedfc61a83732327fafbac55a47282e6f26f073ca7a41c3b2", size = 7021824 },
{ url = "https://files.pythonhosted.org/packages/bc/4c/8f57f2500d0ccd2675c5d0cc462095adf3faa8c52294ba085c036befb901/cryptography-45.0.7-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:81823935e2f8d476707e85a78a405953a03ef7b7b4f55f93f7c2d9680e5e0691", size = 4202233 },
{ url = "https://files.pythonhosted.org/packages/eb/ac/59b7790b4ccaed739fc44775ce4645c9b8ce54cbec53edf16c74fd80cb2b/cryptography-45.0.7-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3994c809c17fc570c2af12c9b840d7cea85a9fd3e5c0e0491f4fa3c029216d59", size = 4423075 },
{ url = "https://files.pythonhosted.org/packages/b8/56/d4f07ea21434bf891faa088a6ac15d6d98093a66e75e30ad08e88aa2b9ba/cryptography-45.0.7-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:dad43797959a74103cb59c5dac71409f9c27d34c8a05921341fb64ea8ccb1dd4", size = 4204517 },
{ url = "https://files.pythonhosted.org/packages/e8/ac/924a723299848b4c741c1059752c7cfe09473b6fd77d2920398fc26bfb53/cryptography-45.0.7-cp37-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:ce7a453385e4c4693985b4a4a3533e041558851eae061a58a5405363b098fcd3", size = 3882893 },
{ url = "https://files.pythonhosted.org/packages/83/dc/4dab2ff0a871cc2d81d3ae6d780991c0192b259c35e4d83fe1de18b20c70/cryptography-45.0.7-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:b04f85ac3a90c227b6e5890acb0edbaf3140938dbecf07bff618bf3638578cf1", size = 4450132 },
{ url = "https://files.pythonhosted.org/packages/12/dd/b2882b65db8fc944585d7fb00d67cf84a9cef4e77d9ba8f69082e911d0de/cryptography-45.0.7-cp37-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:48c41a44ef8b8c2e80ca4527ee81daa4c527df3ecbc9423c41a420a9559d0e27", size = 4204086 },
{ url = "https://files.pythonhosted.org/packages/5d/fa/1d5745d878048699b8eb87c984d4ccc5da4f5008dfd3ad7a94040caca23a/cryptography-45.0.7-cp37-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:f3df7b3d0f91b88b2106031fd995802a2e9ae13e02c36c1fc075b43f420f3a17", size = 4449383 },
{ url = "https://files.pythonhosted.org/packages/36/8b/fc61f87931bc030598e1876c45b936867bb72777eac693e905ab89832670/cryptography-45.0.7-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:dd342f085542f6eb894ca00ef70236ea46070c8a13824c6bde0dfdcd36065b9b", size = 4332186 },
{ url = "https://files.pythonhosted.org/packages/0b/11/09700ddad7443ccb11d674efdbe9a832b4455dc1f16566d9bd3834922ce5/cryptography-45.0.7-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:1993a1bb7e4eccfb922b6cd414f072e08ff5816702a0bdb8941c247a6b1b287c", size = 4561639 },
{ url = "https://files.pythonhosted.org/packages/71/ed/8f4c1337e9d3b94d8e50ae0b08ad0304a5709d483bfcadfcc77a23dbcb52/cryptography-45.0.7-cp37-abi3-win32.whl", hash = "sha256:18fcf70f243fe07252dcb1b268a687f2358025ce32f9f88028ca5c364b123ef5", size = 2926552 },
{ url = "https://files.pythonhosted.org/packages/bc/ff/026513ecad58dacd45d1d24ebe52b852165a26e287177de1d545325c0c25/cryptography-45.0.7-cp37-abi3-win_amd64.whl", hash = "sha256:7285a89df4900ed3bfaad5679b1e668cb4b38a8de1ccbfc84b05f34512da0a90", size = 3392742 },
]
[[package]]
name = "flask"
version = "3.1.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "blinker" },
{ name = "click" },
{ name = "itsdangerous" },
{ name = "jinja2" },
{ name = "markupsafe" },
{ name = "werkzeug" },
]
sdist = { url = "https://files.pythonhosted.org/packages/dc/6d/cfe3c0fcc5e477df242b98bfe186a4c34357b4847e87ecaef04507332dab/flask-3.1.2.tar.gz", hash = "sha256:bf656c15c80190ed628ad08cdfd3aaa35beb087855e2f494910aa3774cc4fd87", size = 720160 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ec/f9/7f9263c5695f4bd0023734af91bedb2ff8209e8de6ead162f35d8dc762fd/flask-3.1.2-py3-none-any.whl", hash = "sha256:ca1d8112ec8a6158cc29ea4858963350011b5c846a414cdb7a954aa9e967d03c", size = 103308 },
]
[[package]]
name = "flask-migrate"
version = "4.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "alembic" },
{ name = "flask" },
{ name = "flask-sqlalchemy" },
]
sdist = { url = "https://files.pythonhosted.org/packages/5a/8e/47c7b3c93855ceffc2eabfa271782332942443321a07de193e4198f920cf/flask_migrate-4.1.0.tar.gz", hash = "sha256:1a336b06eb2c3ace005f5f2ded8641d534c18798d64061f6ff11f79e1434126d", size = 21965 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d2/c4/3f329b23d769fe7628a5fc57ad36956f1fb7132cf8837be6da762b197327/Flask_Migrate-4.1.0-py3-none-any.whl", hash = "sha256:24d8051af161782e0743af1b04a152d007bad9772b2bca67b7ec1e8ceeb3910d", size = 21237 },
]
[[package]]
name = "flask-sqlalchemy"
version = "3.1.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "flask" },
{ name = "sqlalchemy" },
]
sdist = { url = "https://files.pythonhosted.org/packages/91/53/b0a9fcc1b1297f51e68b69ed3b7c3c40d8c45be1391d77ae198712914392/flask_sqlalchemy-3.1.1.tar.gz", hash = "sha256:e4b68bb881802dda1a7d878b2fc84c06d1ee57fb40b874d3dc97dabfa36b8312", size = 81899 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1d/6a/89963a5c6ecf166e8be29e0d1bf6806051ee8fe6c82e232842e3aeac9204/flask_sqlalchemy-3.1.1-py3-none-any.whl", hash = "sha256:4ba4be7f419dc72f4efd8802d69974803c37259dd42f3913b0dcf75c9447e0a0", size = 25125 },
]
[[package]]
name = "flask-wtf"
version = "1.2.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "flask" },
{ name = "itsdangerous" },
{ name = "wtforms" },
]
sdist = { url = "https://files.pythonhosted.org/packages/80/9b/f1cd6e41bbf874f3436368f2c7ee3216c1e82d666ff90d1d800e20eb1317/flask_wtf-1.2.2.tar.gz", hash = "sha256:79d2ee1e436cf570bccb7d916533fa18757a2f18c290accffab1b9a0b684666b", size = 42641 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/dc/19/354449145fbebb65e7c621235b6ad69bebcfaec2142481f044d0ddc5b5c5/flask_wtf-1.2.2-py3-none-any.whl", hash = "sha256:e93160c5c5b6b571cf99300b6e01b72f9a101027cab1579901f8b10c5daf0b70", size = 12779 },
]
[[package]]
name = "godot-showreel"
version = "0.1.0"
source = { virtual = "." }
dependencies = [
{ name = "authlib" },
{ name = "flask" },
{ name = "flask-migrate" },
{ name = "flask-sqlalchemy" },
{ name = "flask-wtf" },
{ name = "requests" },
{ name = "sqlalchemy" },
]
[package.dev-dependencies]
dev = [
{ name = "pytest" },
]
[package.metadata]
requires-dist = [
{ name = "authlib", specifier = ">=1.5.2" },
{ name = "flask", specifier = ">=3.1.0" },
{ name = "flask-migrate", specifier = ">=4.1.0" },
{ name = "flask-sqlalchemy", specifier = ">=3.1.1" },
{ name = "flask-wtf", specifier = ">=1.2.2" },
{ name = "requests", specifier = ">=2.32.5" },
{ name = "sqlalchemy", specifier = ">=2.0.40" },
]
[package.metadata.requires-dev]
dev = [{ name = "pytest", specifier = ">=8.3.5" }]
[[package]]
name = "greenlet"
version = "3.2.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/03/b8/704d753a5a45507a7aab61f18db9509302ed3d0a27ac7e0359ec2905b1a6/greenlet-3.2.4.tar.gz", hash = "sha256:0dca0d95ff849f9a364385f36ab49f50065d76964944638be9691e1832e9f86d", size = 188260 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/44/69/9b804adb5fd0671f367781560eb5eb586c4d495277c93bde4307b9e28068/greenlet-3.2.4-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:3b67ca49f54cede0186854a008109d6ee71f66bd57bb36abd6d0a0267b540cdd", size = 274079 },
{ url = "https://files.pythonhosted.org/packages/46/e9/d2a80c99f19a153eff70bc451ab78615583b8dac0754cfb942223d2c1a0d/greenlet-3.2.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ddf9164e7a5b08e9d22511526865780a576f19ddd00d62f8a665949327fde8bb", size = 640997 },
{ url = "https://files.pythonhosted.org/packages/3b/16/035dcfcc48715ccd345f3a93183267167cdd162ad123cd93067d86f27ce4/greenlet-3.2.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f28588772bb5fb869a8eb331374ec06f24a83a9c25bfa1f38b6993afe9c1e968", size = 655185 },
{ url = "https://files.pythonhosted.org/packages/31/da/0386695eef69ffae1ad726881571dfe28b41970173947e7c558d9998de0f/greenlet-3.2.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:5c9320971821a7cb77cfab8d956fa8e39cd07ca44b6070db358ceb7f8797c8c9", size = 649926 },
{ url = "https://files.pythonhosted.org/packages/68/88/69bf19fd4dc19981928ceacbc5fd4bb6bc2215d53199e367832e98d1d8fe/greenlet-3.2.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c60a6d84229b271d44b70fb6e5fa23781abb5d742af7b808ae3f6efd7c9c60f6", size = 651839 },
{ url = "https://files.pythonhosted.org/packages/19/0d/6660d55f7373b2ff8152401a83e02084956da23ae58cddbfb0b330978fe9/greenlet-3.2.4-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3b3812d8d0c9579967815af437d96623f45c0f2ae5f04e366de62a12d83a8fb0", size = 607586 },
{ url = "https://files.pythonhosted.org/packages/8e/1a/c953fdedd22d81ee4629afbb38d2f9d71e37d23caace44775a3a969147d4/greenlet-3.2.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:abbf57b5a870d30c4675928c37278493044d7c14378350b3aa5d484fa65575f0", size = 1123281 },
{ url = "https://files.pythonhosted.org/packages/3f/c7/12381b18e21aef2c6bd3a636da1088b888b97b7a0362fac2e4de92405f97/greenlet-3.2.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:20fb936b4652b6e307b8f347665e2c615540d4b42b3b4c8a321d8286da7e520f", size = 1151142 },
{ url = "https://files.pythonhosted.org/packages/e9/08/b0814846b79399e585f974bbeebf5580fbe59e258ea7be64d9dfb253c84f/greenlet-3.2.4-cp312-cp312-win_amd64.whl", hash = "sha256:a7d4e128405eea3814a12cc2605e0e6aedb4035bf32697f72deca74de4105e02", size = 299899 },
{ url = "https://files.pythonhosted.org/packages/49/e8/58c7f85958bda41dafea50497cbd59738c5c43dbbea5ee83d651234398f4/greenlet-3.2.4-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:1a921e542453fe531144e91e1feedf12e07351b1cf6c9e8a3325ea600a715a31", size = 272814 },
{ url = "https://files.pythonhosted.org/packages/62/dd/b9f59862e9e257a16e4e610480cfffd29e3fae018a68c2332090b53aac3d/greenlet-3.2.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cd3c8e693bff0fff6ba55f140bf390fa92c994083f838fece0f63be121334945", size = 641073 },
{ url = "https://files.pythonhosted.org/packages/f7/0b/bc13f787394920b23073ca3b6c4a7a21396301ed75a655bcb47196b50e6e/greenlet-3.2.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:710638eb93b1fa52823aa91bf75326f9ecdfd5e0466f00789246a5280f4ba0fc", size = 655191 },
{ url = "https://files.pythonhosted.org/packages/f2/d6/6adde57d1345a8d0f14d31e4ab9c23cfe8e2cd39c3baf7674b4b0338d266/greenlet-3.2.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:c5111ccdc9c88f423426df3fd1811bfc40ed66264d35aa373420a34377efc98a", size = 649516 },
{ url = "https://files.pythonhosted.org/packages/7f/3b/3a3328a788d4a473889a2d403199932be55b1b0060f4ddd96ee7cdfcad10/greenlet-3.2.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d76383238584e9711e20ebe14db6c88ddcedc1829a9ad31a584389463b5aa504", size = 652169 },
{ url = "https://files.pythonhosted.org/packages/ee/43/3cecdc0349359e1a527cbf2e3e28e5f8f06d3343aaf82ca13437a9aa290f/greenlet-3.2.4-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23768528f2911bcd7e475210822ffb5254ed10d71f4028387e5a99b4c6699671", size = 610497 },
{ url = "https://files.pythonhosted.org/packages/b8/19/06b6cf5d604e2c382a6f31cafafd6f33d5dea706f4db7bdab184bad2b21d/greenlet-3.2.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:00fadb3fedccc447f517ee0d3fd8fe49eae949e1cd0f6a611818f4f6fb7dc83b", size = 1121662 },
{ url = "https://files.pythonhosted.org/packages/a2/15/0d5e4e1a66fab130d98168fe984c509249c833c1a3c16806b90f253ce7b9/greenlet-3.2.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:d25c5091190f2dc0eaa3f950252122edbbadbb682aa7b1ef2f8af0f8c0afefae", size = 1149210 },
{ url = "https://files.pythonhosted.org/packages/0b/55/2321e43595e6801e105fcfdee02b34c0f996eb71e6ddffca6b10b7e1d771/greenlet-3.2.4-cp313-cp313-win_amd64.whl", hash = "sha256:554b03b6e73aaabec3745364d6239e9e012d64c68ccd0b8430c64ccc14939a8b", size = 299685 },
{ url = "https://files.pythonhosted.org/packages/22/5c/85273fd7cc388285632b0498dbbab97596e04b154933dfe0f3e68156c68c/greenlet-3.2.4-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:49a30d5fda2507ae77be16479bdb62a660fa51b1eb4928b524975b3bde77b3c0", size = 273586 },
{ url = "https://files.pythonhosted.org/packages/d1/75/10aeeaa3da9332c2e761e4c50d4c3556c21113ee3f0afa2cf5769946f7a3/greenlet-3.2.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:299fd615cd8fc86267b47597123e3f43ad79c9d8a22bebdce535e53550763e2f", size = 686346 },
{ url = "https://files.pythonhosted.org/packages/c0/aa/687d6b12ffb505a4447567d1f3abea23bd20e73a5bed63871178e0831b7a/greenlet-3.2.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:c17b6b34111ea72fc5a4e4beec9711d2226285f0386ea83477cbb97c30a3f3a5", size = 699218 },
{ url = "https://files.pythonhosted.org/packages/dc/8b/29aae55436521f1d6f8ff4e12fb676f3400de7fcf27fccd1d4d17fd8fecd/greenlet-3.2.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b4a1870c51720687af7fa3e7cda6d08d801dae660f75a76f3845b642b4da6ee1", size = 694659 },
{ url = "https://files.pythonhosted.org/packages/92/2e/ea25914b1ebfde93b6fc4ff46d6864564fba59024e928bdc7de475affc25/greenlet-3.2.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:061dc4cf2c34852b052a8620d40f36324554bc192be474b9e9770e8c042fd735", size = 695355 },
{ url = "https://files.pythonhosted.org/packages/72/60/fc56c62046ec17f6b0d3060564562c64c862948c9d4bc8aa807cf5bd74f4/greenlet-3.2.4-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44358b9bf66c8576a9f57a590d5f5d6e72fa4228b763d0e43fee6d3b06d3a337", size = 657512 },
{ url = "https://files.pythonhosted.org/packages/e3/a5/6ddab2b4c112be95601c13428db1d8b6608a8b6039816f2ba09c346c08fc/greenlet-3.2.4-cp314-cp314-win_amd64.whl", hash = "sha256:e37ab26028f12dbb0ff65f29a8d3d44a765c61e729647bf2ddfbbed621726f01", size = 303425 },
]
[[package]]
name = "idna"
version = "3.10"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442 },
]
[[package]]
name = "iniconfig"
version = "2.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050 },
]
[[package]]
name = "itsdangerous"
version = "2.2.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234 },
]
[[package]]
name = "jinja2"
version = "3.1.6"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markupsafe" },
]
sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899 },
]
[[package]]
name = "mako"
version = "1.3.10"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markupsafe" },
]
sdist = { url = "https://files.pythonhosted.org/packages/9e/38/bd5b78a920a64d708fe6bc8e0a2c075e1389d53bef8413725c63ba041535/mako-1.3.10.tar.gz", hash = "sha256:99579a6f39583fa7e5630a28c3c1f440e4e97a414b80372649c0ce338da2ea28", size = 392474 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/87/fb/99f81ac72ae23375f22b7afdb7642aba97c00a713c217124420147681a2f/mako-1.3.10-py3-none-any.whl", hash = "sha256:baef24a52fc4fc514a0887ac600f9f1cff3d82c61d4d700a1fa84d597b88db59", size = 78509 },
]
[[package]]
name = "markupsafe"
version = "3.0.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b2/97/5d42485e71dfc078108a86d6de8fa46db44a1a9295e89c5d6d4a06e23a62/markupsafe-3.0.2.tar.gz", hash = "sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0", size = 20537 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/22/09/d1f21434c97fc42f09d290cbb6350d44eb12f09cc62c9476effdb33a18aa/MarkupSafe-3.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:9778bd8ab0a994ebf6f84c2b949e65736d5575320a17ae8984a77fab08db94cf", size = 14274 },
{ url = "https://files.pythonhosted.org/packages/6b/b0/18f76bba336fa5aecf79d45dcd6c806c280ec44538b3c13671d49099fdd0/MarkupSafe-3.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:846ade7b71e3536c4e56b386c2a47adf5741d2d8b94ec9dc3e92e5e1ee1e2225", size = 12348 },
{ url = "https://files.pythonhosted.org/packages/e0/25/dd5c0f6ac1311e9b40f4af06c78efde0f3b5cbf02502f8ef9501294c425b/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1c99d261bd2d5f6b59325c92c73df481e05e57f19837bdca8413b9eac4bd8028", size = 24149 },
{ url = "https://files.pythonhosted.org/packages/f3/f0/89e7aadfb3749d0f52234a0c8c7867877876e0a20b60e2188e9850794c17/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e17c96c14e19278594aa4841ec148115f9c7615a47382ecb6b82bd8fea3ab0c8", size = 23118 },
{ url = "https://files.pythonhosted.org/packages/d5/da/f2eeb64c723f5e3777bc081da884b414671982008c47dcc1873d81f625b6/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:88416bd1e65dcea10bc7569faacb2c20ce071dd1f87539ca2ab364bf6231393c", size = 22993 },
{ url = "https://files.pythonhosted.org/packages/da/0e/1f32af846df486dce7c227fe0f2398dc7e2e51d4a370508281f3c1c5cddc/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2181e67807fc2fa785d0592dc2d6206c019b9502410671cc905d132a92866557", size = 24178 },
{ url = "https://files.pythonhosted.org/packages/c4/f6/bb3ca0532de8086cbff5f06d137064c8410d10779c4c127e0e47d17c0b71/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:52305740fe773d09cffb16f8ed0427942901f00adedac82ec8b67752f58a1b22", size = 23319 },
{ url = "https://files.pythonhosted.org/packages/a2/82/8be4c96ffee03c5b4a034e60a31294daf481e12c7c43ab8e34a1453ee48b/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ad10d3ded218f1039f11a75f8091880239651b52e9bb592ca27de44eed242a48", size = 23352 },
{ url = "https://files.pythonhosted.org/packages/51/ae/97827349d3fcffee7e184bdf7f41cd6b88d9919c80f0263ba7acd1bbcb18/MarkupSafe-3.0.2-cp312-cp312-win32.whl", hash = "sha256:0f4ca02bea9a23221c0182836703cbf8930c5e9454bacce27e767509fa286a30", size = 15097 },
{ url = "https://files.pythonhosted.org/packages/c1/80/a61f99dc3a936413c3ee4e1eecac96c0da5ed07ad56fd975f1a9da5bc630/MarkupSafe-3.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:8e06879fc22a25ca47312fbe7c8264eb0b662f6db27cb2d3bbbc74b1df4b9b87", size = 15601 },
{ url = "https://files.pythonhosted.org/packages/83/0e/67eb10a7ecc77a0c2bbe2b0235765b98d164d81600746914bebada795e97/MarkupSafe-3.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ba9527cdd4c926ed0760bc301f6728ef34d841f405abf9d4f959c478421e4efd", size = 14274 },
{ url = "https://files.pythonhosted.org/packages/2b/6d/9409f3684d3335375d04e5f05744dfe7e9f120062c9857df4ab490a1031a/MarkupSafe-3.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f8b3d067f2e40fe93e1ccdd6b2e1d16c43140e76f02fb1319a05cf2b79d99430", size = 12352 },
{ url = "https://files.pythonhosted.org/packages/d2/f5/6eadfcd3885ea85fe2a7c128315cc1bb7241e1987443d78c8fe712d03091/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:569511d3b58c8791ab4c2e1285575265991e6d8f8700c7be0e88f86cb0672094", size = 24122 },
{ url = "https://files.pythonhosted.org/packages/0c/91/96cf928db8236f1bfab6ce15ad070dfdd02ed88261c2afafd4b43575e9e9/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15ab75ef81add55874e7ab7055e9c397312385bd9ced94920f2802310c930396", size = 23085 },
{ url = "https://files.pythonhosted.org/packages/c2/cf/c9d56af24d56ea04daae7ac0940232d31d5a8354f2b457c6d856b2057d69/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f3818cb119498c0678015754eba762e0d61e5b52d34c8b13d770f0719f7b1d79", size = 22978 },
{ url = "https://files.pythonhosted.org/packages/2a/9f/8619835cd6a711d6272d62abb78c033bda638fdc54c4e7f4272cf1c0962b/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cdb82a876c47801bb54a690c5ae105a46b392ac6099881cdfb9f6e95e4014c6a", size = 24208 },
{ url = "https://files.pythonhosted.org/packages/f9/bf/176950a1792b2cd2102b8ffeb5133e1ed984547b75db47c25a67d3359f77/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cabc348d87e913db6ab4aa100f01b08f481097838bdddf7c7a84b7575b7309ca", size = 23357 },
{ url = "https://files.pythonhosted.org/packages/ce/4f/9a02c1d335caabe5c4efb90e1b6e8ee944aa245c1aaaab8e8a618987d816/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:444dcda765c8a838eaae23112db52f1efaf750daddb2d9ca300bcae1039adc5c", size = 23344 },
{ url = "https://files.pythonhosted.org/packages/ee/55/c271b57db36f748f0e04a759ace9f8f759ccf22b4960c270c78a394f58be/MarkupSafe-3.0.2-cp313-cp313-win32.whl", hash = "sha256:bcf3e58998965654fdaff38e58584d8937aa3096ab5354d493c77d1fdd66d7a1", size = 15101 },
{ url = "https://files.pythonhosted.org/packages/29/88/07df22d2dd4df40aba9f3e402e6dc1b8ee86297dddbad4872bd5e7b0094f/MarkupSafe-3.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:e6a2a455bd412959b57a172ce6328d2dd1f01cb2135efda2e4576e8a23fa3b0f", size = 15603 },
{ url = "https://files.pythonhosted.org/packages/62/6a/8b89d24db2d32d433dffcd6a8779159da109842434f1dd2f6e71f32f738c/MarkupSafe-3.0.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:b5a6b3ada725cea8a5e634536b1b01c30bcdcd7f9c6fff4151548d5bf6b3a36c", size = 14510 },
{ url = "https://files.pythonhosted.org/packages/7a/06/a10f955f70a2e5a9bf78d11a161029d278eeacbd35ef806c3fd17b13060d/MarkupSafe-3.0.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a904af0a6162c73e3edcb969eeeb53a63ceeb5d8cf642fade7d39e7963a22ddb", size = 12486 },
{ url = "https://files.pythonhosted.org/packages/34/cf/65d4a571869a1a9078198ca28f39fba5fbb910f952f9dbc5220afff9f5e6/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4aa4e5faecf353ed117801a068ebab7b7e09ffb6e1d5e412dc852e0da018126c", size = 25480 },
{ url = "https://files.pythonhosted.org/packages/0c/e3/90e9651924c430b885468b56b3d597cabf6d72be4b24a0acd1fa0e12af67/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0ef13eaeee5b615fb07c9a7dadb38eac06a0608b41570d8ade51c56539e509d", size = 23914 },
{ url = "https://files.pythonhosted.org/packages/66/8c/6c7cf61f95d63bb866db39085150df1f2a5bd3335298f14a66b48e92659c/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d16a81a06776313e817c951135cf7340a3e91e8c1ff2fac444cfd75fffa04afe", size = 23796 },
{ url = "https://files.pythonhosted.org/packages/bb/35/cbe9238ec3f47ac9a7c8b3df7a808e7cb50fe149dc7039f5f454b3fba218/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6381026f158fdb7c72a168278597a5e3a5222e83ea18f543112b2662a9b699c5", size = 25473 },
{ url = "https://files.pythonhosted.org/packages/e6/32/7621a4382488aa283cc05e8984a9c219abad3bca087be9ec77e89939ded9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:3d79d162e7be8f996986c064d1c7c817f6df3a77fe3d6859f6f9e7be4b8c213a", size = 24114 },
{ url = "https://files.pythonhosted.org/packages/0d/80/0985960e4b89922cb5a0bac0ed39c5b96cbc1a536a99f30e8c220a996ed9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:131a3c7689c85f5ad20f9f6fb1b866f402c445b220c19fe4308c0b147ccd2ad9", size = 24098 },
{ url = "https://files.pythonhosted.org/packages/82/78/fedb03c7d5380df2427038ec8d973587e90561b2d90cd472ce9254cf348b/MarkupSafe-3.0.2-cp313-cp313t-win32.whl", hash = "sha256:ba8062ed2cf21c07a9e295d5b8a2a5ce678b913b45fdf68c32d95d6c1291e0b6", size = 15208 },
{ url = "https://files.pythonhosted.org/packages/4f/65/6079a46068dfceaeabb5dcad6d674f5f5c61a6fa5673746f42a9f4c233b3/MarkupSafe-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f", size = 15739 },
]
[[package]]
name = "packaging"
version = "25.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469 },
]
[[package]]
name = "pluggy"
version = "1.6.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538 },
]
[[package]]
name = "pycparser"
version = "2.23"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/fe/cf/d2d3b9f5699fb1e4615c8e32ff220203e43b248e1dfcc6736ad9057731ca/pycparser-2.23.tar.gz", hash = "sha256:78816d4f24add8f10a06d6f05b4d424ad9e96cfebf68a4ddc99c65c0720d00c2", size = 173734 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a0/e3/59cd50310fc9b59512193629e1984c1f95e5c8ae6e5d8c69532ccc65a7fe/pycparser-2.23-py3-none-any.whl", hash = "sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934", size = 118140 },
]
[[package]]
name = "pygments"
version = "2.19.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217 },
]
[[package]]
name = "pytest"
version = "8.4.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "iniconfig" },
{ name = "packaging" },
{ name = "pluggy" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750 },
]
[[package]]
name = "requests"
version = "2.32.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "charset-normalizer" },
{ name = "idna" },
{ name = "urllib3" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738 },
]
[[package]]
name = "sqlalchemy"
version = "2.0.43"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "greenlet", marker = "(python_full_version < '3.14' and platform_machine == 'AMD64') or (python_full_version < '3.14' and platform_machine == 'WIN32') or (python_full_version < '3.14' and platform_machine == 'aarch64') or (python_full_version < '3.14' and platform_machine == 'amd64') or (python_full_version < '3.14' and platform_machine == 'ppc64le') or (python_full_version < '3.14' and platform_machine == 'win32') or (python_full_version < '3.14' and platform_machine == 'x86_64')" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/d7/bc/d59b5d97d27229b0e009bd9098cd81af71c2fa5549c580a0a67b9bed0496/sqlalchemy-2.0.43.tar.gz", hash = "sha256:788bfcef6787a7764169cfe9859fe425bf44559619e1d9f56f5bddf2ebf6f417", size = 9762949 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/61/db/20c78f1081446095450bdc6ee6cc10045fce67a8e003a5876b6eaafc5cc4/sqlalchemy-2.0.43-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:20d81fc2736509d7a2bd33292e489b056cbae543661bb7de7ce9f1c0cd6e7f24", size = 2134891 },
{ url = "https://files.pythonhosted.org/packages/45/0a/3d89034ae62b200b4396f0f95319f7d86e9945ee64d2343dcad857150fa2/sqlalchemy-2.0.43-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:25b9fc27650ff5a2c9d490c13c14906b918b0de1f8fcbb4c992712d8caf40e83", size = 2123061 },
{ url = "https://files.pythonhosted.org/packages/cb/10/2711f7ff1805919221ad5bee205971254845c069ee2e7036847103ca1e4c/sqlalchemy-2.0.43-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6772e3ca8a43a65a37c88e2f3e2adfd511b0b1da37ef11ed78dea16aeae85bd9", size = 3320384 },
{ url = "https://files.pythonhosted.org/packages/6e/0e/3d155e264d2ed2778484006ef04647bc63f55b3e2d12e6a4f787747b5900/sqlalchemy-2.0.43-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1a113da919c25f7f641ffbd07fbc9077abd4b3b75097c888ab818f962707eb48", size = 3329648 },
{ url = "https://files.pythonhosted.org/packages/5b/81/635100fb19725c931622c673900da5efb1595c96ff5b441e07e3dd61f2be/sqlalchemy-2.0.43-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4286a1139f14b7d70141c67a8ae1582fc2b69105f1b09d9573494eb4bb4b2687", size = 3258030 },
{ url = "https://files.pythonhosted.org/packages/0c/ed/a99302716d62b4965fded12520c1cbb189f99b17a6d8cf77611d21442e47/sqlalchemy-2.0.43-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:529064085be2f4d8a6e5fab12d36ad44f1909a18848fcfbdb59cc6d4bbe48efe", size = 3294469 },
{ url = "https://files.pythonhosted.org/packages/5d/a2/3a11b06715149bf3310b55a98b5c1e84a42cfb949a7b800bc75cb4e33abc/sqlalchemy-2.0.43-cp312-cp312-win32.whl", hash = "sha256:b535d35dea8bbb8195e7e2b40059e2253acb2b7579b73c1b432a35363694641d", size = 2098906 },
{ url = "https://files.pythonhosted.org/packages/bc/09/405c915a974814b90aa591280623adc6ad6b322f61fd5cff80aeaef216c9/sqlalchemy-2.0.43-cp312-cp312-win_amd64.whl", hash = "sha256:1c6d85327ca688dbae7e2b06d7d84cfe4f3fffa5b5f9e21bb6ce9d0e1a0e0e0a", size = 2126260 },
{ url = "https://files.pythonhosted.org/packages/41/1c/a7260bd47a6fae7e03768bf66451437b36451143f36b285522b865987ced/sqlalchemy-2.0.43-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e7c08f57f75a2bb62d7ee80a89686a5e5669f199235c6d1dac75cd59374091c3", size = 2130598 },
{ url = "https://files.pythonhosted.org/packages/8e/84/8a337454e82388283830b3586ad7847aa9c76fdd4f1df09cdd1f94591873/sqlalchemy-2.0.43-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:14111d22c29efad445cd5021a70a8b42f7d9152d8ba7f73304c4d82460946aaa", size = 2118415 },
{ url = "https://files.pythonhosted.org/packages/cf/ff/22ab2328148492c4d71899d62a0e65370ea66c877aea017a244a35733685/sqlalchemy-2.0.43-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:21b27b56eb2f82653168cefe6cb8e970cdaf4f3a6cb2c5e3c3c1cf3158968ff9", size = 3248707 },
{ url = "https://files.pythonhosted.org/packages/dc/29/11ae2c2b981de60187f7cbc84277d9d21f101093d1b2e945c63774477aba/sqlalchemy-2.0.43-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9c5a9da957c56e43d72126a3f5845603da00e0293720b03bde0aacffcf2dc04f", size = 3253602 },
{ url = "https://files.pythonhosted.org/packages/b8/61/987b6c23b12c56d2be451bc70900f67dd7d989d52b1ee64f239cf19aec69/sqlalchemy-2.0.43-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5d79f9fdc9584ec83d1b3c75e9f4595c49017f5594fee1a2217117647225d738", size = 3183248 },
{ url = "https://files.pythonhosted.org/packages/86/85/29d216002d4593c2ce1c0ec2cec46dda77bfbcd221e24caa6e85eff53d89/sqlalchemy-2.0.43-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9df7126fd9db49e3a5a3999442cc67e9ee8971f3cb9644250107d7296cb2a164", size = 3219363 },
{ url = "https://files.pythonhosted.org/packages/b6/e4/bd78b01919c524f190b4905d47e7630bf4130b9f48fd971ae1c6225b6f6a/sqlalchemy-2.0.43-cp313-cp313-win32.whl", hash = "sha256:7f1ac7828857fcedb0361b48b9ac4821469f7694089d15550bbcf9ab22564a1d", size = 2096718 },
{ url = "https://files.pythonhosted.org/packages/ac/a5/ca2f07a2a201f9497de1928f787926613db6307992fe5cda97624eb07c2f/sqlalchemy-2.0.43-cp313-cp313-win_amd64.whl", hash = "sha256:971ba928fcde01869361f504fcff3b7143b47d30de188b11c6357c0505824197", size = 2123200 },
{ url = "https://files.pythonhosted.org/packages/b8/d9/13bdde6521f322861fab67473cec4b1cc8999f3871953531cf61945fad92/sqlalchemy-2.0.43-py3-none-any.whl", hash = "sha256:1681c21dd2ccee222c2fe0bef671d1aef7c504087c9c4e800371cfcc8ac966fc", size = 1924759 },
]
[[package]]
name = "typing-extensions"
version = "4.15.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614 },
]
[[package]]
name = "urllib3"
version = "2.5.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/15/22/9ee70a2574a4f4599c47dd506532914ce044817c7752a79b6a51286319bc/urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760", size = 393185 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795 },
]
[[package]]
name = "werkzeug"
version = "3.1.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markupsafe" },
]
sdist = { url = "https://files.pythonhosted.org/packages/9f/69/83029f1f6300c5fb2471d621ab06f6ec6b3324685a2ce0f9777fd4a8b71e/werkzeug-3.1.3.tar.gz", hash = "sha256:60723ce945c19328679790e3282cc758aa4a6040e4bb330f53d30fa546d44746", size = 806925 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/52/24/ab44c871b0f07f491e5d2ad12c9bd7358e527510618cb1b803a88e986db1/werkzeug-3.1.3-py3-none-any.whl", hash = "sha256:54b78bf3716d19a65be4fceccc0d1d7b89e608834989dfae50ea87564639213e", size = 224498 },
]
[[package]]
name = "wtforms"
version = "3.2.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markupsafe" },
]
sdist = { url = "https://files.pythonhosted.org/packages/01/e4/633d080897e769ed5712dcfad626e55dbd6cf45db0ff4d9884315c6a82da/wtforms-3.2.1.tar.gz", hash = "sha256:df3e6b70f3192e92623128123ec8dca3067df9cfadd43d59681e210cfb8d4682", size = 137801 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/08/c9/2088fb5645cd289c99ebe0d4cdcc723922a1d8e1beaefb0f6f76dff9b21c/wtforms-3.2.1-py3-none-any.whl", hash = "sha256:583bad77ba1dd7286463f21e11aa3043ca4869d03575921d1a1698d0715e0fd4", size = 152454 },
]