Compare commits
29 Commits
b4121eada7
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ff6a921550 | ||
|
|
57f7610963 | ||
|
|
a401c1a1ab | ||
|
|
b0b42c0d77 | ||
|
|
89d0a7fb12 | ||
|
|
37e56559a9 | ||
|
|
d9def5c6b6 | ||
|
|
ccb71c37a4 | ||
|
|
7aebf8284d | ||
|
|
28b542e618 | ||
|
|
fb07c1d8ed | ||
|
|
1c51bb6ced | ||
|
|
c4feaa97dd | ||
|
|
73e02a7b12 | ||
|
|
b31ab97cd4 | ||
|
|
895b813a35 | ||
|
|
67009c9603 | ||
|
|
8c08140ad0 | ||
|
|
31078b181a | ||
|
|
a6eca1b4ac | ||
|
|
ce28f7f749 | ||
|
|
31f738cfb3 | ||
|
|
0cd74f7207 | ||
|
|
ef91dc1fe4 | ||
|
|
a9f3dd4a38 | ||
|
|
3f3725d277 | ||
|
|
09d90b5a1e | ||
|
|
3fabde145d | ||
|
|
71a5ae590e |
@@ -1,2 +1,2 @@
|
|||||||
heroku/nodejs
|
heroku/nodejs
|
||||||
https://github.com/heroku/heroku-buildpack-python#archive/v210
|
https://github.com/heroku/heroku-buildpack-python
|
||||||
|
|||||||
1
.python-version
Normal file
1
.python-version
Normal file
@@ -0,0 +1 @@
|
|||||||
|
3.14.0
|
||||||
167
app.py
167
app.py
@@ -1,5 +1,11 @@
|
|||||||
from datetime import date
|
|
||||||
import os
|
import os
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
# Load environment variables from .env file in non-production environments
|
||||||
|
if os.environ.get('FLASK_ENV') != 'production':
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
from flask import Flask, abort, render_template, redirect, request, url_for
|
from flask import Flask, abort, render_template, redirect, request, url_for
|
||||||
from flask_login import LoginManager, login_required, current_user
|
from flask_login import LoginManager, login_required, current_user
|
||||||
import jinja_partials
|
import jinja_partials
|
||||||
@@ -16,22 +22,23 @@ from routes.endpoints import endpoints_bp # Import the new endpoints blueprint
|
|||||||
from routes.export import export_bp # Import the new export blueprint
|
from routes.export import export_bp # Import the new export blueprint
|
||||||
from routes.tags import tags_bp # Import the new tags blueprint
|
from routes.tags import tags_bp # Import the new tags blueprint
|
||||||
from routes.programs import programs_bp # Import the new programs blueprint
|
from routes.programs import programs_bp # Import the new programs blueprint
|
||||||
|
from routes.exercises import exercises_bp # Import the new exercises blueprint
|
||||||
|
from routes.settings import settings_bp # Import the new settings blueprint
|
||||||
from extensions import db
|
from extensions import db
|
||||||
from utils import convert_str_to_date
|
from utils import convert_str_to_date
|
||||||
from flask_htmx import HTMX
|
from flask_htmx import HTMX
|
||||||
import minify_html
|
import minify_html
|
||||||
import os
|
|
||||||
from dotenv import load_dotenv
|
|
||||||
from flask_compress import Compress
|
from flask_compress import Compress
|
||||||
|
from flask_caching import Cache
|
||||||
# Load environment variables from .env file in non-production environments
|
|
||||||
if os.environ.get('FLASK_ENV') != 'production':
|
|
||||||
load_dotenv()
|
|
||||||
|
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
app.config['COMPRESS_REGISTER'] = True
|
app.config['COMPRESS_REGISTER'] = True
|
||||||
app.config['SEND_FILE_MAX_AGE_DEFAULT'] = 31536000 # 1 year
|
app.config['SEND_FILE_MAX_AGE_DEFAULT'] = 31536000 # 1 year
|
||||||
|
app.config['CACHE_TYPE'] = 'SimpleCache'
|
||||||
|
app.config['CACHE_DEFAULT_TIMEOUT'] = 300 # 5 minutes
|
||||||
|
|
||||||
Compress(app)
|
Compress(app)
|
||||||
|
cache = Cache(app)
|
||||||
app.config.from_pyfile('config.py')
|
app.config.from_pyfile('config.py')
|
||||||
app.secret_key = os.environ.get('SECRET_KEY', '2a661781919643cb8a5a8bc57642d99f')
|
app.secret_key = os.environ.get('SECRET_KEY', '2a661781919643cb8a5a8bc57642d99f')
|
||||||
jinja_partials.register_extensions(app)
|
jinja_partials.register_extensions(app)
|
||||||
@@ -66,6 +73,8 @@ app.register_blueprint(endpoints_bp) # Register the endpoints blueprint (prefix
|
|||||||
app.register_blueprint(export_bp) # Register the export blueprint (prefix defined in blueprint file)
|
app.register_blueprint(export_bp) # Register the export blueprint (prefix defined in blueprint file)
|
||||||
app.register_blueprint(tags_bp) # Register the tags blueprint (prefix defined in blueprint file)
|
app.register_blueprint(tags_bp) # Register the tags blueprint (prefix defined in blueprint file)
|
||||||
app.register_blueprint(programs_bp) # Register the programs blueprint (prefix defined in blueprint file)
|
app.register_blueprint(programs_bp) # Register the programs blueprint (prefix defined in blueprint file)
|
||||||
|
app.register_blueprint(exercises_bp) # Register the exercises blueprint
|
||||||
|
app.register_blueprint(settings_bp) # Register the settings blueprint
|
||||||
|
|
||||||
@app.after_request
|
@app.after_request
|
||||||
def response_minify(response):
|
def response_minify(response):
|
||||||
@@ -140,7 +149,10 @@ def person_overview(person_id):
|
|||||||
if not selected_exercise_ids and htmx.trigger_name != 'exercise_id':
|
if not selected_exercise_ids and htmx.trigger_name != 'exercise_id':
|
||||||
selected_exercise_ids = db.person_overview.list_of_performed_exercise_ids(person_id, min_date, max_date)
|
selected_exercise_ids = db.person_overview.list_of_performed_exercise_ids(person_id, min_date, max_date)
|
||||||
|
|
||||||
person = db.person_overview.get(person_id, min_date, max_date, selected_exercise_ids)
|
limit = request.args.get('limit', type=int, default=20)
|
||||||
|
offset = request.args.get('offset', type=int, default=0)
|
||||||
|
|
||||||
|
person = db.person_overview.get(person_id, min_date, max_date, selected_exercise_ids, limit=limit, offset=offset)
|
||||||
exercises = db.person_overview.get_exercises_with_selection(person_id, min_date, max_date, selected_exercise_ids)
|
exercises = db.person_overview.get_exercises_with_selection(person_id, min_date, max_date, selected_exercise_ids)
|
||||||
tags = db.get_tags_for_person(person_id)
|
tags = db.get_tags_for_person(person_id)
|
||||||
|
|
||||||
@@ -151,10 +163,15 @@ def person_overview(person_id):
|
|||||||
"tags": tags,
|
"tags": tags,
|
||||||
"selected_exercise_ids": selected_exercise_ids,
|
"selected_exercise_ids": selected_exercise_ids,
|
||||||
"max_date": max_date,
|
"max_date": max_date,
|
||||||
"min_date": min_date
|
"min_date": min_date,
|
||||||
|
"limit": limit,
|
||||||
|
"offset": offset,
|
||||||
|
"next_offset": offset + limit
|
||||||
}
|
}
|
||||||
|
|
||||||
if htmx:
|
if htmx:
|
||||||
|
if htmx.target == 'load-more-row':
|
||||||
|
return render_template('partials/workout_rows.html', **render_args)
|
||||||
return render_block(app.jinja_env, 'person_overview.html', 'content', **render_args), 200, {"HX-Push-Url": url_for('person_overview', person_id=person_id, min_date=min_date, max_date=max_date, exercise_id=selected_exercise_ids), "HX-Trigger": "refreshStats"}
|
return render_block(app.jinja_env, 'person_overview.html', 'content', **render_args), 200, {"HX-Push-Url": url_for('person_overview', person_id=person_id, min_date=min_date, max_date=max_date, exercise_id=selected_exercise_ids), "HX-Trigger": "refreshStats"}
|
||||||
|
|
||||||
return render_template('person_overview.html', **render_args), 200, {"HX-Push-Url": url_for('person_overview', person_id=person_id, min_date=min_date, max_date=max_date, exercise_id=selected_exercise_ids), "HX-Trigger": "refreshStats"}
|
return render_template('person_overview.html', **render_args), 200, {"HX-Push-Url": url_for('person_overview', person_id=person_id, min_date=min_date, max_date=max_date, exercise_id=selected_exercise_ids), "HX-Trigger": "refreshStats"}
|
||||||
@@ -206,106 +223,10 @@ def get_person_name(person_id):
|
|||||||
return render_template('partials/person.html', person_id=person_id, name=name)
|
return render_template('partials/person.html', person_id=person_id, name=name)
|
||||||
|
|
||||||
|
|
||||||
@ app.route("/exercise", methods=['POST'])
|
|
||||||
@login_required
|
|
||||||
def create_exercise():
|
|
||||||
name = request.form.get("name")
|
|
||||||
attribute_ids = request.form.getlist('attribute_ids')
|
|
||||||
exercise = db.create_exercise(name, attribute_ids)
|
|
||||||
db.activityRequest.log(current_user.id, 'CREATE_EXERCISE', 'exercise', exercise['exercise_id'], f"Created exercise: {name}")
|
|
||||||
return render_template('partials/exercise.html',
|
|
||||||
exercise_id=exercise['exercise_id'],
|
|
||||||
name=exercise['name'],
|
|
||||||
attributes=exercise['attributes'])
|
|
||||||
|
|
||||||
|
|
||||||
@ app.route("/exercise/<int:exercise_id>", methods=['GET'])
|
|
||||||
def get_exercise(exercise_id):
|
|
||||||
exercise = db.get_exercise(exercise_id)
|
|
||||||
return render_template('partials/exercise.html',
|
|
||||||
exercise_id=exercise_id,
|
|
||||||
name=exercise['name'],
|
|
||||||
attributes=exercise['attributes'])
|
|
||||||
|
|
||||||
|
|
||||||
@ app.route("/exercise/<int:exercise_id>/edit_form", methods=['GET'])
|
|
||||||
@login_required
|
|
||||||
def get_exercise_edit_form(exercise_id):
|
|
||||||
exercise = db.get_exercise(exercise_id)
|
|
||||||
all_attributes = db.exercises.get_attributes_by_category()
|
|
||||||
|
|
||||||
# Format options for custom_select
|
|
||||||
formatted_options = {}
|
|
||||||
ex_attr_ids = [a['attribute_id'] for a in exercise['attributes']]
|
|
||||||
for cat, attrs in all_attributes.items():
|
|
||||||
formatted_options[cat] = [
|
|
||||||
{
|
|
||||||
"id": a['attribute_id'],
|
|
||||||
"name": a['name'],
|
|
||||||
"selected": a['attribute_id'] in ex_attr_ids
|
|
||||||
} for a in attrs
|
|
||||||
]
|
|
||||||
|
|
||||||
return render_template('partials/exercise.html',
|
|
||||||
exercise_id=exercise_id,
|
|
||||||
name=exercise['name'],
|
|
||||||
attributes=exercise['attributes'],
|
|
||||||
all_attributes=formatted_options,
|
|
||||||
is_edit=True)
|
|
||||||
|
|
||||||
|
|
||||||
@ app.route("/exercise/<int:exercise_id>/update", methods=['PUT'])
|
|
||||||
@login_required
|
|
||||||
def update_exercise(exercise_id):
|
|
||||||
new_name = request.form.get('name')
|
|
||||||
attribute_ids = request.form.getlist('attribute_ids')
|
|
||||||
exercise = db.update_exercise(exercise_id, new_name, attribute_ids)
|
|
||||||
db.activityRequest.log(current_user.id, 'UPDATE_EXERCISE', 'exercise', exercise_id, f"Updated exercise: {new_name}")
|
|
||||||
return render_template('partials/exercise.html',
|
|
||||||
exercise_id=exercise_id,
|
|
||||||
name=exercise['name'],
|
|
||||||
attributes=exercise['attributes'])
|
|
||||||
|
|
||||||
|
|
||||||
""" @ app.route("/exercise/<int:exercise_id>/delete", methods=['DELETE'])
|
|
||||||
def delete_exercise(exercise_id):
|
|
||||||
db.delete_exercise(exercise_id)
|
|
||||||
return "" """
|
|
||||||
|
|
||||||
|
|
||||||
@ app.route("/settings")
|
|
||||||
@ login_required
|
|
||||||
def settings():
|
|
||||||
people = db.get_people()
|
|
||||||
exercises = db.get_all_exercises()
|
|
||||||
all_attributes = db.exercises.get_attributes_by_category()
|
|
||||||
|
|
||||||
# Format options for custom_select
|
|
||||||
formatted_options = {}
|
|
||||||
for cat, attrs in all_attributes.items():
|
|
||||||
formatted_options[cat] = [{"id": a['attribute_id'], "name": a['name']} for a in attrs]
|
|
||||||
|
|
||||||
if htmx:
|
|
||||||
return render_block(app.jinja_env, "settings.html", "content",
|
|
||||||
people=people, exercises=exercises, all_attributes=formatted_options), 200, {"HX-Trigger": "updatedPeople"}
|
|
||||||
return render_template('settings.html', people=people, exercises=exercises, all_attributes=formatted_options)
|
|
||||||
|
|
||||||
|
|
||||||
@app.route("/settings/activity_logs")
|
|
||||||
@login_required
|
|
||||||
def settings_activity_logs():
|
|
||||||
limit = 50
|
|
||||||
offset = request.args.get('offset', 0, type=int)
|
|
||||||
logs = db.activityRequest.get_recent_logs(limit=limit, offset=offset)
|
|
||||||
|
|
||||||
# Check if there are more logs to load
|
|
||||||
has_more = len(logs) == limit
|
|
||||||
|
|
||||||
return render_template('partials/activity_logs.html',
|
|
||||||
logs=logs,
|
|
||||||
offset=offset,
|
|
||||||
limit=limit,
|
|
||||||
has_more=has_more)
|
|
||||||
|
|
||||||
|
|
||||||
# Routes moved to routes/tags.py blueprint
|
# Routes moved to routes/tags.py blueprint
|
||||||
@@ -330,6 +251,7 @@ def get_exercise_progress_for_user(person_id, exercise_id):
|
|||||||
return render_template('partials/sparkline.html', **exercise_progress)
|
return render_template('partials/sparkline.html', **exercise_progress)
|
||||||
|
|
||||||
@app.route("/stats", methods=['GET'])
|
@app.route("/stats", methods=['GET'])
|
||||||
|
@cache.cached(timeout=300, query_string=True)
|
||||||
def get_stats():
|
def get_stats():
|
||||||
selected_people_ids = request.args.getlist('person_id', type=int)
|
selected_people_ids = request.args.getlist('person_id', type=int)
|
||||||
min_date = request.args.get('min_date', type=convert_str_to_date)
|
min_date = request.args.get('min_date', type=convert_str_to_date)
|
||||||
@@ -339,6 +261,7 @@ def get_stats():
|
|||||||
return render_template('partials/stats.html', stats=stats, refresh_url=request.full_path)
|
return render_template('partials/stats.html', stats=stats, refresh_url=request.full_path)
|
||||||
|
|
||||||
@app.route("/graphs", methods=['GET'])
|
@app.route("/graphs", methods=['GET'])
|
||||||
|
@cache.cached(timeout=300, query_string=True)
|
||||||
def get_people_graphs():
|
def get_people_graphs():
|
||||||
selected_people_ids = request.args.getlist('person_id', type=int)
|
selected_people_ids = request.args.getlist('person_id', type=int)
|
||||||
min_date = request.args.get('min_date', type=convert_str_to_date)
|
min_date = request.args.get('min_date', type=convert_str_to_date)
|
||||||
@@ -349,41 +272,7 @@ def get_people_graphs():
|
|||||||
|
|
||||||
return render_template('partials/people_graphs.html', graphs=graphs, refresh_url=request.full_path)
|
return render_template('partials/people_graphs.html', graphs=graphs, refresh_url=request.full_path)
|
||||||
|
|
||||||
@app.route("/exercises/get")
|
|
||||||
def get_exercises():
|
|
||||||
query = request.args.get('query')
|
|
||||||
person_id = request.args.get('person_id', type=int)
|
|
||||||
exercises = db.exercises.get(query)
|
|
||||||
return render_template('partials/exercise/exercise_dropdown.html', exercises=exercises, person_id=person_id)
|
|
||||||
|
|
||||||
@app.route("/exercise/<int:exercise_id>/edit_name", methods=['GET', 'POST'])
|
|
||||||
@login_required
|
|
||||||
def edit_exercise_name(exercise_id):
|
|
||||||
exercise = db.exercises.get_exercise(exercise_id)
|
|
||||||
person_id = request.args.get('person_id', type=int)
|
|
||||||
if request.method == 'GET':
|
|
||||||
return render_template('partials/exercise/edit_exercise_name.html', exercise=exercise, person_id=person_id)
|
|
||||||
else:
|
|
||||||
updated_name = request.form['name']
|
|
||||||
updated_exercise = db.exercises.update_exercise_name(exercise_id, updated_name)
|
|
||||||
return render_template('partials/exercise/exercise_list_item.html', exercise=updated_exercise, person_id=person_id)
|
|
||||||
|
|
||||||
@app.route("/exercises/add", methods=['POST'])
|
|
||||||
@login_required
|
|
||||||
def add_exercise():
|
|
||||||
exercise_name = request.form['query']
|
|
||||||
new_exercise = db.exercises.add_exercise(exercise_name)
|
|
||||||
person_id = request.args.get('person_id', type=int)
|
|
||||||
return render_template('partials/exercise/exercise_list_item.html', exercise=new_exercise, person_id=person_id)
|
|
||||||
|
|
||||||
@ app.route("/exercise/<int:exercise_id>/delete", methods=['DELETE'])
|
|
||||||
@login_required
|
|
||||||
@admin_required
|
|
||||||
def delete_exercise(exercise_id):
|
|
||||||
exercise = db.get_exercise(exercise_id)
|
|
||||||
db.exercises.delete_exercise(exercise_id)
|
|
||||||
db.activityRequest.log(current_user.id, 'DELETE_EXERCISE', 'exercise', exercise_id, f"Deleted exercise: {exercise['name']}")
|
|
||||||
return ""
|
|
||||||
|
|
||||||
@app.teardown_appcontext
|
@app.teardown_appcontext
|
||||||
def closeConnection(exception):
|
def closeConnection(exception):
|
||||||
|
|||||||
57
db.py
57
db.py
@@ -1,7 +1,7 @@
|
|||||||
import os
|
import os
|
||||||
import psycopg2
|
import psycopg
|
||||||
from psycopg2 import pool
|
from psycopg_pool import ConnectionPool
|
||||||
from psycopg2.extras import RealDictCursor
|
from psycopg.rows import dict_row
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from dateutil.relativedelta import relativedelta
|
from dateutil.relativedelta import relativedelta
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
@@ -32,14 +32,11 @@ class DataBase():
|
|||||||
raise Exception("No DATABASE_URL environment variable set")
|
raise Exception("No DATABASE_URL environment variable set")
|
||||||
|
|
||||||
if DataBase._pool is None:
|
if DataBase._pool is None:
|
||||||
db_url = urlparse(os.environ['DATABASE_URL'])
|
# Note: psycopg3 ConnectionPool takes a conninfo string directly, not parsed kwargs
|
||||||
DataBase._pool = pool.ThreadedConnectionPool(
|
DataBase._pool = ConnectionPool(
|
||||||
1, 20, # minconn, maxconn
|
conninfo=os.environ['DATABASE_URL'],
|
||||||
database=db_url.path[1:],
|
min_size=1,
|
||||||
user=db_url.username,
|
max_size=20
|
||||||
password=db_url.password,
|
|
||||||
host=db_url.hostname,
|
|
||||||
port=db_url.port
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def getDB(self):
|
def getDB(self):
|
||||||
@@ -50,11 +47,13 @@ class DataBase():
|
|||||||
def close_connection(self, exception=None):
|
def close_connection(self, exception=None):
|
||||||
db = g.pop('database', None)
|
db = g.pop('database', None)
|
||||||
if db is not None:
|
if db is not None:
|
||||||
|
db.rollback()
|
||||||
self._pool.putconn(db)
|
self._pool.putconn(db)
|
||||||
|
|
||||||
def execute(self, query, args=(), one=False, commit=False):
|
def execute(self, query, args=(), one=False, commit=False):
|
||||||
conn = self.getDB()
|
conn = self.getDB()
|
||||||
cur = conn.cursor(cursor_factory=RealDictCursor)
|
cur = conn.cursor(row_factory=dict_row)
|
||||||
|
# Convert any custom placeholders from %s to standard %s format used by psycopg3
|
||||||
cur.execute(query, args)
|
cur.execute(query, args)
|
||||||
rv = None
|
rv = None
|
||||||
if cur.description is not None:
|
if cur.description is not None:
|
||||||
@@ -381,6 +380,28 @@ class DataBase():
|
|||||||
else:
|
else:
|
||||||
return (topset.get('repetitions'), topset.get('weight'), topset['exercise_name'])
|
return (topset.get('repetitions'), topset.get('weight'), topset['exercise_name'])
|
||||||
|
|
||||||
|
def get_recent_topsets_for_exercise(self, person_id, exercise_id, limit=5, offset=0):
|
||||||
|
topsets = self.execute("""
|
||||||
|
SELECT
|
||||||
|
t.topset_id,
|
||||||
|
t.repetitions,
|
||||||
|
t.weight,
|
||||||
|
w.start_date,
|
||||||
|
w.workout_id,
|
||||||
|
e.name AS "exercise_name"
|
||||||
|
FROM
|
||||||
|
exercise e
|
||||||
|
JOIN topset t ON e.exercise_id = t.exercise_id
|
||||||
|
JOIN workout w ON t.workout_id = w.workout_id
|
||||||
|
WHERE
|
||||||
|
e.exercise_id = %s AND w.person_id = %s
|
||||||
|
ORDER BY
|
||||||
|
w.start_date DESC, t.topset_id DESC
|
||||||
|
LIMIT %s OFFSET %s;
|
||||||
|
""", [exercise_id, person_id, limit, offset])
|
||||||
|
|
||||||
|
return topsets
|
||||||
|
|
||||||
def get_all_exercises(self):
|
def get_all_exercises(self):
|
||||||
return self.exercises.get("")
|
return self.exercises.get("")
|
||||||
|
|
||||||
@@ -409,8 +430,8 @@ class DataBase():
|
|||||||
WHERE
|
WHERE
|
||||||
W.person_id = %s
|
W.person_id = %s
|
||||||
AND E.exercise_id = %s AND
|
AND E.exercise_id = %s AND
|
||||||
(%s IS NULL OR W.start_date >= %s) AND
|
(%s::date IS NULL OR W.start_date >= %s::date) AND
|
||||||
(%s IS NULL OR W.start_date <= %s)
|
(%s::date IS NULL OR W.start_date <= %s::date)
|
||||||
ORDER BY
|
ORDER BY
|
||||||
W.start_date;
|
W.start_date;
|
||||||
""", [person_id, exercise_id, min_date, min_date, max_date, max_date])
|
""", [person_id, exercise_id, min_date, min_date, max_date, max_date])
|
||||||
@@ -427,6 +448,11 @@ class DataBase():
|
|||||||
start_dates = [t['start_date'] for t in topsets]
|
start_dates = [t['start_date'] for t in topsets]
|
||||||
messages = [f'{t["repetitions"]} x {t["weight"]}kg ({t["estimated_1rm"]}kg E1RM) on {t["start_date"].strftime("%d %b %y")}' for t in topsets]
|
messages = [f'{t["repetitions"]} x {t["weight"]}kg ({t["estimated_1rm"]}kg E1RM) on {t["start_date"].strftime("%d %b %y")}' for t in topsets]
|
||||||
|
|
||||||
|
# Get the latest topset info for badges
|
||||||
|
latest_topset = topsets[-1]
|
||||||
|
latest_topset_id = latest_topset['topset_id']
|
||||||
|
latest_workout_id = latest_topset['workout_id']
|
||||||
|
|
||||||
exercise_progress = get_exercise_graph_model(
|
exercise_progress = get_exercise_graph_model(
|
||||||
exercise_name,
|
exercise_name,
|
||||||
estimated_1rm,
|
estimated_1rm,
|
||||||
@@ -441,6 +467,9 @@ class DataBase():
|
|||||||
max_date,
|
max_date,
|
||||||
degree)
|
degree)
|
||||||
|
|
||||||
|
exercise_progress['latest_topset_id'] = latest_topset_id
|
||||||
|
exercise_progress['latest_workout_id'] = latest_workout_id
|
||||||
|
|
||||||
return exercise_progress
|
return exercise_progress
|
||||||
|
|
||||||
# Note fetching logic moved to routes/notes.py
|
# Note fetching logic moved to routes/notes.py
|
||||||
|
|||||||
@@ -19,9 +19,27 @@ class Activity:
|
|||||||
# We don't want logging to break the main application flow
|
# We don't want logging to break the main application flow
|
||||||
current_app.logger.error(f"Error logging activity: {e}")
|
current_app.logger.error(f"Error logging activity: {e}")
|
||||||
|
|
||||||
def get_recent_logs(self, limit=50, offset=0):
|
def get_recent_logs(self, limit=50, offset=0, search_query=None):
|
||||||
"""Fetches recent activity logs with person names, supporting pagination."""
|
"""Fetches recent activity logs with person names, supporting pagination and search."""
|
||||||
query = """
|
params = [limit, offset]
|
||||||
|
search_clause = ""
|
||||||
|
|
||||||
|
if search_query:
|
||||||
|
# Add wildcard percentages for partial matching
|
||||||
|
term = f"%{search_query}%"
|
||||||
|
search_clause = """
|
||||||
|
WHERE
|
||||||
|
p.name ILIKE %s OR
|
||||||
|
al.action ILIKE %s OR
|
||||||
|
al.entity_type ILIKE %s OR
|
||||||
|
al.details ILIKE %s
|
||||||
|
"""
|
||||||
|
# Prepend search terms to params list (limit/offset must change position if we were using ? placeholders
|
||||||
|
# but with %s list, order matters. Let's reconstruct consistent order).
|
||||||
|
# Actually, LIMIT/OFFSET are at the end. Search params come before.
|
||||||
|
params = [term, term, term, term, limit, offset]
|
||||||
|
|
||||||
|
query = f"""
|
||||||
SELECT
|
SELECT
|
||||||
al.id,
|
al.id,
|
||||||
al.person_id,
|
al.person_id,
|
||||||
@@ -35,7 +53,8 @@ class Activity:
|
|||||||
al.timestamp
|
al.timestamp
|
||||||
FROM activity_log al
|
FROM activity_log al
|
||||||
LEFT JOIN person p ON al.person_id = p.person_id
|
LEFT JOIN person p ON al.person_id = p.person_id
|
||||||
|
{search_clause}
|
||||||
ORDER BY al.timestamp DESC
|
ORDER BY al.timestamp DESC
|
||||||
LIMIT %s OFFSET %s
|
LIMIT %s OFFSET %s
|
||||||
"""
|
"""
|
||||||
return self.execute(query, [limit, offset])
|
return self.execute(query, params)
|
||||||
|
|||||||
@@ -3,15 +3,41 @@ class Exercises:
|
|||||||
self.execute = db_connection_method
|
self.execute = db_connection_method
|
||||||
|
|
||||||
def get(self, query):
|
def get(self, query):
|
||||||
# Add wildcards to the query
|
if not query:
|
||||||
search_query = f"%{query}%"
|
exercises = self.execute("SELECT exercise_id, name FROM exercise ORDER BY name ASC;")
|
||||||
# We need to fetch exercises with their attributes.
|
for ex in exercises:
|
||||||
# Since an exercise can have many attributes, we'll fetch basic info first or use a join.
|
ex['attributes'] = self.get_exercise_attributes(ex['exercise_id'])
|
||||||
# But wait, the settings page just lists names. We can fetch attributes separately for each row or do a group_concat-like join.
|
return exercises
|
||||||
# However, for the settings list, we want to show the tags.
|
|
||||||
|
|
||||||
# Let's use a simpler approach: fetch exercises and then for each one (or via a single join) get attributes.
|
# Check for category:value syntax
|
||||||
exercises = self.execute("SELECT exercise_id, name FROM exercise WHERE LOWER(name) LIKE LOWER(%s) ORDER BY name ASC;", [search_query])
|
if ':' in query:
|
||||||
|
category_part, value_part = query.split(':', 1)
|
||||||
|
category_part = f"%{category_part.strip().lower()}%"
|
||||||
|
value_part = f"%{value_part.strip().lower()}%"
|
||||||
|
|
||||||
|
query = """
|
||||||
|
SELECT DISTINCT e.exercise_id, e.name
|
||||||
|
FROM exercise e
|
||||||
|
JOIN exercise_to_attribute eta ON e.exercise_id = eta.exercise_id
|
||||||
|
JOIN exercise_attribute attr ON eta.attribute_id = attr.attribute_id
|
||||||
|
JOIN exercise_attribute_category cat ON attr.category_id = cat.category_id
|
||||||
|
WHERE LOWER(cat.name) LIKE LOWER(%s) AND LOWER(attr.name) LIKE LOWER(%s)
|
||||||
|
ORDER BY e.name ASC;
|
||||||
|
"""
|
||||||
|
exercises = self.execute(query, [category_part, value_part])
|
||||||
|
else:
|
||||||
|
# Fallback: search in name OR attribute name
|
||||||
|
search_term = query.strip().lower()
|
||||||
|
search_query = f"%{search_term}%"
|
||||||
|
query = """
|
||||||
|
SELECT DISTINCT e.exercise_id, e.name
|
||||||
|
FROM exercise e
|
||||||
|
LEFT JOIN exercise_to_attribute eta ON e.exercise_id = eta.exercise_id
|
||||||
|
LEFT JOIN exercise_attribute attr ON eta.attribute_id = attr.attribute_id
|
||||||
|
WHERE LOWER(e.name) LIKE LOWER(%s) OR LOWER(attr.name) LIKE LOWER(%s)
|
||||||
|
ORDER BY e.name ASC;
|
||||||
|
"""
|
||||||
|
exercises = self.execute(query, [search_query, search_query])
|
||||||
|
|
||||||
for ex in exercises:
|
for ex in exercises:
|
||||||
ex['attributes'] = self.get_exercise_attributes(ex['exercise_id'])
|
ex['attributes'] = self.get_exercise_attributes(ex['exercise_id'])
|
||||||
@@ -121,3 +147,38 @@ class Exercises:
|
|||||||
|
|
||||||
return distribution
|
return distribution
|
||||||
|
|
||||||
|
# Category Management
|
||||||
|
def add_category(self, name):
|
||||||
|
result = self.execute('INSERT INTO exercise_attribute_category (name) VALUES (%s) RETURNING category_id, name', [name], commit=True, one=True)
|
||||||
|
return result
|
||||||
|
|
||||||
|
def update_category(self, category_id, name):
|
||||||
|
self.execute('UPDATE exercise_attribute_category SET name = %s WHERE category_id = %s', [name, category_id], commit=True)
|
||||||
|
return {"category_id": category_id, "name": name}
|
||||||
|
|
||||||
|
def delete_category(self, category_id):
|
||||||
|
# First delete all attributes in this category
|
||||||
|
attributes = self.execute('SELECT attribute_id FROM exercise_attribute WHERE category_id = %s', [category_id])
|
||||||
|
for attr in attributes:
|
||||||
|
self.delete_attribute(attr['attribute_id'])
|
||||||
|
|
||||||
|
self.execute('DELETE FROM exercise_attribute_category WHERE category_id = %s', [category_id], commit=True)
|
||||||
|
|
||||||
|
# Attribute Management
|
||||||
|
def add_attribute(self, name, category_id):
|
||||||
|
result = self.execute('INSERT INTO exercise_attribute (name, category_id) VALUES (%s, %s) RETURNING attribute_id, name, category_id', [name, category_id], commit=True, one=True)
|
||||||
|
return result
|
||||||
|
|
||||||
|
def update_attribute(self, attribute_id, name, category_id=None):
|
||||||
|
if category_id:
|
||||||
|
self.execute('UPDATE exercise_attribute SET name = %s, category_id = %s WHERE attribute_id = %s', [name, category_id, attribute_id], commit=True)
|
||||||
|
else:
|
||||||
|
self.execute('UPDATE exercise_attribute SET name = %s WHERE attribute_id = %s', [name, attribute_id], commit=True)
|
||||||
|
return self.execute('SELECT attribute_id, name, category_id FROM exercise_attribute WHERE attribute_id = %s', [attribute_id], one=True)
|
||||||
|
|
||||||
|
def delete_attribute(self, attribute_id):
|
||||||
|
# Remove from all exercises first
|
||||||
|
self.execute('DELETE FROM exercise_to_attribute WHERE attribute_id = %s', [attribute_id], commit=True)
|
||||||
|
# Delete the attribute
|
||||||
|
self.execute('DELETE FROM exercise_attribute WHERE attribute_id = %s', [attribute_id], commit=True)
|
||||||
|
|
||||||
|
|||||||
@@ -77,11 +77,33 @@ class PersonOverview:
|
|||||||
return exercises
|
return exercises
|
||||||
|
|
||||||
|
|
||||||
def get(self, person_id, start_date, end_date, selected_exercise_ids):
|
def get(self, person_id, start_date, end_date, selected_exercise_ids, limit=20, offset=0):
|
||||||
# Build placeholders for exercise IDs
|
# Build placeholders for exercise IDs
|
||||||
placeholders = ", ".join(["%s"] * len(selected_exercise_ids))
|
exercise_placeholders = ", ".join(["%s"] * len(selected_exercise_ids))
|
||||||
|
|
||||||
# Dynamically inject placeholders into the query
|
# 1. Fetch workout IDs first for pagination
|
||||||
|
# We need to filter by person, date, and selected exercises
|
||||||
|
workout_ids_query = f"""
|
||||||
|
SELECT DISTINCT w.workout_id, w.start_date
|
||||||
|
FROM workout w
|
||||||
|
JOIN topset t ON w.workout_id = t.workout_id
|
||||||
|
WHERE w.person_id = %s
|
||||||
|
AND w.start_date BETWEEN %s AND %s
|
||||||
|
AND t.exercise_id IN ({exercise_placeholders})
|
||||||
|
ORDER BY w.start_date DESC
|
||||||
|
LIMIT %s OFFSET %s
|
||||||
|
"""
|
||||||
|
params = [person_id, start_date, end_date] + selected_exercise_ids + [limit + 1, offset]
|
||||||
|
workout_id_results = self.execute(workout_ids_query, params)
|
||||||
|
|
||||||
|
if not workout_id_results:
|
||||||
|
return {"person_id": person_id, "person_name": None, "workouts": [], "selected_exercises": [], "exercise_progress_graphs": [], "has_more": False}
|
||||||
|
|
||||||
|
has_more = len(workout_id_results) > limit
|
||||||
|
target_workout_ids = [r["workout_id"] for r in workout_id_results[:limit]]
|
||||||
|
workout_id_placeholders = ", ".join(["%s"] * len(target_workout_ids))
|
||||||
|
|
||||||
|
# 2. Fetch all details for these specific workouts
|
||||||
sql_query = f"""
|
sql_query = f"""
|
||||||
SELECT
|
SELECT
|
||||||
p.person_id,
|
p.person_id,
|
||||||
@@ -103,19 +125,18 @@ class PersonOverview:
|
|||||||
JOIN
|
JOIN
|
||||||
exercise e ON t.exercise_id = e.exercise_id
|
exercise e ON t.exercise_id = e.exercise_id
|
||||||
WHERE
|
WHERE
|
||||||
p.person_id = %s
|
w.workout_id IN ({workout_id_placeholders})
|
||||||
AND w.start_date BETWEEN %s AND %s
|
AND e.exercise_id IN ({exercise_placeholders})
|
||||||
AND e.exercise_id IN ({placeholders})
|
|
||||||
ORDER BY
|
ORDER BY
|
||||||
w.start_date DESC, e.exercise_id ASC, t.topset_id ASC;
|
w.start_date DESC, e.exercise_id ASC, t.topset_id ASC;
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# Add parameters for the query
|
# Parameters for the detailed query
|
||||||
params = [person_id, start_date, end_date] + selected_exercise_ids
|
params = target_workout_ids + selected_exercise_ids
|
||||||
result = self.execute(sql_query, params)
|
result = self.execute(sql_query, params)
|
||||||
|
|
||||||
if not result:
|
if not result:
|
||||||
return {"person_id": person_id, "person_name": None, "workouts": [], "selected_exercises": [], "exercise_progress_graphs": []}
|
return {"person_id": person_id, "person_name": None, "workouts": [], "selected_exercises": [], "exercise_progress_graphs": [], "has_more": False}
|
||||||
|
|
||||||
# Extract person info from the first row
|
# Extract person info from the first row
|
||||||
person_info = {"person_id": result[0]["person_id"], "person_name": result[0]["person_name"]}
|
person_info = {"person_id": result[0]["person_id"], "person_name": result[0]["person_name"]}
|
||||||
@@ -132,7 +153,6 @@ class PersonOverview:
|
|||||||
exercises = sorted(exercises, key=lambda ex: ex["name"])
|
exercises = sorted(exercises, key=lambda ex: ex["name"])
|
||||||
|
|
||||||
# Initialize the table structure
|
# Initialize the table structure
|
||||||
workouts = []
|
|
||||||
workout_map = {} # Map to track workouts
|
workout_map = {} # Map to track workouts
|
||||||
|
|
||||||
# Initialize the exercise sets dictionary
|
# Initialize the exercise sets dictionary
|
||||||
@@ -153,6 +173,7 @@ class PersonOverview:
|
|||||||
# Add topset to the corresponding exercise
|
# Add topset to the corresponding exercise
|
||||||
if row["exercise_id"] and row["topset_id"]:
|
if row["exercise_id"] and row["topset_id"]:
|
||||||
# Add to workout exercises
|
# Add to workout exercises
|
||||||
|
if row["exercise_id"] in workout_map[workout_id]["exercises"]:
|
||||||
workout_map[workout_id]["exercises"][row["exercise_id"]].append({
|
workout_map[workout_id]["exercises"][row["exercise_id"]].append({
|
||||||
"repetitions": row["repetitions"],
|
"repetitions": row["repetitions"],
|
||||||
"weight": row["weight"]
|
"weight": row["weight"]
|
||||||
@@ -167,9 +188,8 @@ class PersonOverview:
|
|||||||
"exercise_name": row["exercise_name"]
|
"exercise_name": row["exercise_name"]
|
||||||
})
|
})
|
||||||
|
|
||||||
# Transform into a list of rows
|
# Transform into a list of rows, maintaining DESC order
|
||||||
for workout_id, workout in workout_map.items():
|
workouts = [workout_map[wid] for wid in target_workout_ids if wid in workout_map]
|
||||||
workouts.append(workout)
|
|
||||||
|
|
||||||
exercise_progress_graphs = self.generate_exercise_progress_graphs(person_info["person_id"], exercise_sets)
|
exercise_progress_graphs = self.generate_exercise_progress_graphs(person_info["person_id"], exercise_sets)
|
||||||
|
|
||||||
@@ -177,7 +197,8 @@ class PersonOverview:
|
|||||||
**person_info,
|
**person_info,
|
||||||
"workouts": workouts,
|
"workouts": workouts,
|
||||||
"selected_exercises": exercises,
|
"selected_exercises": exercises,
|
||||||
"exercise_progress_graphs": exercise_progress_graphs
|
"exercise_progress_graphs": exercise_progress_graphs,
|
||||||
|
"has_more": has_more
|
||||||
}
|
}
|
||||||
|
|
||||||
def generate_exercise_progress_graphs(self, person_id, exercise_sets):
|
def generate_exercise_progress_graphs(self, person_id, exercise_sets):
|
||||||
|
|||||||
6
main.py
Normal file
6
main.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
def main():
|
||||||
|
print("Hello from workout!")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
32
pyproject.toml
Normal file
32
pyproject.toml
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
[project]
|
||||||
|
name = "workout"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "Add your description here"
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = ">=3.14.0"
|
||||||
|
dependencies = [
|
||||||
|
"brotli==1.0.9",
|
||||||
|
"email-validator==2.2.0",
|
||||||
|
"flask>=3.0.0",
|
||||||
|
"flask-bcrypt>=1.0.1",
|
||||||
|
"flask-caching>=2.1.0",
|
||||||
|
"flask-compress>=1.14",
|
||||||
|
"flask-htmx>=0.4.0",
|
||||||
|
"flask-login>=0.6.3",
|
||||||
|
"flask-wtf>=1.2.1",
|
||||||
|
"gunicorn>=21.2.0",
|
||||||
|
"jinja-partials==0.1.1",
|
||||||
|
"jinja2>=3.1.0",
|
||||||
|
"jinja2-fragments==0.3.0",
|
||||||
|
"minify-html>=0.15.0",
|
||||||
|
"numpy>=1.26.0",
|
||||||
|
"polars>=0.20.0",
|
||||||
|
"psycopg-pool>=3.2.0",
|
||||||
|
"psycopg[binary]>=3.0.0",
|
||||||
|
"pyarrow>=14.0.0",
|
||||||
|
"python-dateutil==2.8.2",
|
||||||
|
"python-dotenv==1.0.1",
|
||||||
|
"requests>=2.31.0",
|
||||||
|
"werkzeug>=3.0.0",
|
||||||
|
"wtforms>=3.1.0",
|
||||||
|
]
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
Flask==2.2.2
|
|
||||||
gunicorn==19.7.1
|
|
||||||
Jinja2==3.1.0
|
|
||||||
jinja-partials==0.1.1
|
|
||||||
psycopg2-binary==2.9.3
|
|
||||||
flask-htmx==0.2.0
|
|
||||||
python-dateutil==2.8.2
|
|
||||||
minify-html==0.10.3
|
|
||||||
jinja2-fragments==0.3.0
|
|
||||||
Werkzeug==2.2.2
|
|
||||||
numpy==1.19.5
|
|
||||||
python-dotenv==1.0.1
|
|
||||||
wtforms==3.2.1
|
|
||||||
flask-wtf==1.2.2
|
|
||||||
Flask-Login==0.6.3
|
|
||||||
Flask-Bcrypt==1.0.1
|
|
||||||
email-validator==2.2.0
|
|
||||||
requests==2.26.0
|
|
||||||
polars>=0.20.0
|
|
||||||
pyarrow>=14.0.0
|
|
||||||
Flask-Compress==1.13
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
from flask import Blueprint, render_template, redirect, url_for, flash, request
|
from flask import Blueprint, render_template, redirect, url_for, flash, request
|
||||||
from werkzeug.security import generate_password_hash, check_password_hash
|
from werkzeug.security import generate_password_hash, check_password_hash
|
||||||
from flask_login import login_user, login_required, logout_user
|
from flask_login import login_user, login_required, logout_user, current_user
|
||||||
from forms.login import LoginForm
|
from forms.login import LoginForm
|
||||||
from forms.signup import SignupForm
|
from forms.signup import SignupForm
|
||||||
from extensions import db
|
from extensions import db
|
||||||
@@ -122,7 +122,7 @@ def login():
|
|||||||
if form.validate_on_submit():
|
if form.validate_on_submit():
|
||||||
person = get_person_by_email(form.email.data)
|
person = get_person_by_email(form.email.data)
|
||||||
if person and check_password_hash(person.password_hash, form.password.data):
|
if person and check_password_hash(person.password_hash, form.password.data):
|
||||||
login_user(person)
|
login_user(person, remember=True)
|
||||||
db.activityRequest.log(person.id, 'LOGIN_SUCCESS', 'person', person.id, f"User logged in: {form.email.data}")
|
db.activityRequest.log(person.id, 'LOGIN_SUCCESS', 'person', person.id, f"User logged in: {form.email.data}")
|
||||||
flash("Logged in successfully.", "success")
|
flash("Logged in successfully.", "success")
|
||||||
return redirect(url_for('calendar.get_calendar', person_id=person.id))
|
return redirect(url_for('calendar.get_calendar', person_id=person.id))
|
||||||
|
|||||||
184
routes/exercises.py
Normal file
184
routes/exercises.py
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
from flask import Blueprint, render_template, request, url_for
|
||||||
|
from flask_login import login_required, current_user
|
||||||
|
from extensions import db
|
||||||
|
from decorators import admin_required
|
||||||
|
|
||||||
|
exercises_bp = Blueprint('exercises', __name__)
|
||||||
|
|
||||||
|
@exercises_bp.route("/exercise", methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
def create_exercise():
|
||||||
|
name = request.form.get("name")
|
||||||
|
attribute_ids = request.form.getlist('attribute_ids')
|
||||||
|
exercise = db.create_exercise(name, attribute_ids)
|
||||||
|
db.activityRequest.log(current_user.id, 'CREATE_EXERCISE', 'exercise', exercise['exercise_id'], f"Created exercise: {name}")
|
||||||
|
return render_template('partials/exercise.html',
|
||||||
|
exercise_id=exercise['exercise_id'],
|
||||||
|
name=exercise['name'],
|
||||||
|
attributes=exercise['attributes'])
|
||||||
|
|
||||||
|
|
||||||
|
@exercises_bp.route("/exercise/<int:exercise_id>", methods=['GET'])
|
||||||
|
def get_exercise(exercise_id):
|
||||||
|
exercise = db.get_exercise(exercise_id)
|
||||||
|
return render_template('partials/exercise.html',
|
||||||
|
exercise_id=exercise_id,
|
||||||
|
name=exercise['name'],
|
||||||
|
attributes=exercise['attributes'])
|
||||||
|
|
||||||
|
|
||||||
|
@exercises_bp.route("/exercise/<int:exercise_id>/edit_form", methods=['GET'])
|
||||||
|
@login_required
|
||||||
|
def get_exercise_edit_form(exercise_id):
|
||||||
|
exercise = db.get_exercise(exercise_id)
|
||||||
|
all_attributes = db.exercises.get_attributes_by_category()
|
||||||
|
|
||||||
|
# Format options for custom_select
|
||||||
|
formatted_options = {}
|
||||||
|
ex_attr_ids = [a['attribute_id'] for a in exercise['attributes']]
|
||||||
|
for cat, attrs in all_attributes.items():
|
||||||
|
formatted_options[cat] = [
|
||||||
|
{
|
||||||
|
"id": a['attribute_id'],
|
||||||
|
"name": a['name'],
|
||||||
|
"selected": a['attribute_id'] in ex_attr_ids
|
||||||
|
} for a in attrs
|
||||||
|
]
|
||||||
|
|
||||||
|
return render_template('partials/exercise.html',
|
||||||
|
exercise_id=exercise_id,
|
||||||
|
name=exercise['name'],
|
||||||
|
attributes=exercise['attributes'],
|
||||||
|
all_attributes=formatted_options,
|
||||||
|
is_edit=True)
|
||||||
|
|
||||||
|
|
||||||
|
@exercises_bp.route("/exercise/<int:exercise_id>/update", methods=['PUT'])
|
||||||
|
@login_required
|
||||||
|
def update_exercise(exercise_id):
|
||||||
|
new_name = request.form.get('name')
|
||||||
|
attribute_ids = request.form.getlist('attribute_ids')
|
||||||
|
exercise = db.update_exercise(exercise_id, new_name, attribute_ids)
|
||||||
|
db.activityRequest.log(current_user.id, 'UPDATE_EXERCISE', 'exercise', exercise_id, f"Updated exercise: {new_name}")
|
||||||
|
return render_template('partials/exercise.html',
|
||||||
|
exercise_id=exercise_id,
|
||||||
|
name=exercise['name'],
|
||||||
|
attributes=exercise['attributes'])
|
||||||
|
|
||||||
|
|
||||||
|
@exercises_bp.route("/exercises/get")
|
||||||
|
def get_exercises():
|
||||||
|
query = request.args.get('query')
|
||||||
|
person_id = request.args.get('person_id', type=int)
|
||||||
|
exercises = db.exercises.get(query)
|
||||||
|
return render_template('partials/exercise/exercise_dropdown.html', exercises=exercises, person_id=person_id)
|
||||||
|
|
||||||
|
@exercises_bp.route("/exercise/<int:exercise_id>/edit_name", methods=['GET', 'POST'])
|
||||||
|
@login_required
|
||||||
|
def edit_exercise_name(exercise_id):
|
||||||
|
exercise = db.exercises.get_exercise(exercise_id)
|
||||||
|
person_id = request.args.get('person_id', type=int)
|
||||||
|
if request.method == 'GET':
|
||||||
|
return render_template('partials/exercise/edit_exercise_name.html', exercise=exercise, person_id=person_id)
|
||||||
|
else:
|
||||||
|
updated_name = request.form['name']
|
||||||
|
updated_exercise = db.exercises.update_exercise_name(exercise_id, updated_name)
|
||||||
|
return render_template('partials/exercise/exercise_list_item.html', exercise=updated_exercise, person_id=person_id)
|
||||||
|
|
||||||
|
@exercises_bp.route("/exercises/add", methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
def add_exercise():
|
||||||
|
exercise_name = request.form['query']
|
||||||
|
new_exercise = db.exercises.add_exercise(exercise_name)
|
||||||
|
person_id = request.args.get('person_id', type=int)
|
||||||
|
return render_template('partials/exercise/exercise_list_item.html', exercise=new_exercise, person_id=person_id)
|
||||||
|
|
||||||
|
@exercises_bp.route("/exercises/search")
|
||||||
|
@login_required
|
||||||
|
def search_exercises():
|
||||||
|
query = request.args.get('q', '')
|
||||||
|
exercises = db.exercises.get(query)
|
||||||
|
|
||||||
|
html = ""
|
||||||
|
for exercise in exercises:
|
||||||
|
html += render_template('partials/exercise.html',
|
||||||
|
exercise_id=exercise['exercise_id'],
|
||||||
|
name=exercise['name'],
|
||||||
|
attributes=exercise['attributes'])
|
||||||
|
return html
|
||||||
|
|
||||||
|
@exercises_bp.route("/exercise/<int:exercise_id>/delete", methods=['DELETE'])
|
||||||
|
@login_required
|
||||||
|
@admin_required
|
||||||
|
def delete_exercise(exercise_id):
|
||||||
|
exercise = db.get_exercise(exercise_id)
|
||||||
|
db.exercises.delete_exercise(exercise_id)
|
||||||
|
db.activityRequest.log(current_user.id, 'DELETE_EXERCISE', 'exercise', exercise_id, f"Deleted exercise: {exercise['name']}")
|
||||||
|
return ""
|
||||||
|
|
||||||
|
# Category Management Routes
|
||||||
|
@exercises_bp.route("/category", methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
@admin_required
|
||||||
|
def create_category():
|
||||||
|
name = request.form.get("name")
|
||||||
|
category = db.exercises.add_category(name)
|
||||||
|
db.activityRequest.log(current_user.id, 'CREATE_CATEGORY', 'category', category['category_id'], f"Created attribute category: {name}")
|
||||||
|
return render_template('partials/exercise/category_admin.html', category_id=category['category_id'], name=category['name'], attributes=[])
|
||||||
|
|
||||||
|
@exercises_bp.route("/category/<int:category_id>", methods=['GET', 'PUT'])
|
||||||
|
@login_required
|
||||||
|
@admin_required
|
||||||
|
def update_category(category_id):
|
||||||
|
if request.method == 'GET':
|
||||||
|
category = db.exercises.execute('SELECT category_id, name FROM exercise_attribute_category WHERE category_id = %s', [category_id], one=True)
|
||||||
|
is_edit = request.args.get('is_edit') == 'true'
|
||||||
|
all_attrs = db.exercises.execute('SELECT attribute_id, name FROM exercise_attribute WHERE category_id = %s', [category_id])
|
||||||
|
return render_template('partials/exercise/category_admin.html', category_id=category_id, name=category['name'], attributes=all_attrs, is_edit=is_edit)
|
||||||
|
|
||||||
|
name = request.form.get("name")
|
||||||
|
category = db.exercises.update_category(category_id, name)
|
||||||
|
db.activityRequest.log(current_user.id, 'UPDATE_CATEGORY', 'category', category_id, f"Updated attribute category: {name}")
|
||||||
|
all_attrs = db.exercises.execute('SELECT attribute_id, name FROM exercise_attribute WHERE category_id = %s', [category_id])
|
||||||
|
return render_template('partials/exercise/category_admin.html', category_id=category_id, name=name, attributes=all_attrs)
|
||||||
|
|
||||||
|
@exercises_bp.route("/category/<int:category_id>", methods=['DELETE'])
|
||||||
|
@login_required
|
||||||
|
@admin_required
|
||||||
|
def delete_category(category_id):
|
||||||
|
db.exercises.delete_category(category_id)
|
||||||
|
db.activityRequest.log(current_user.id, 'DELETE_CATEGORY', 'category', category_id, f"Deleted attribute category")
|
||||||
|
return ""
|
||||||
|
|
||||||
|
# Attribute Management Routes
|
||||||
|
@exercises_bp.route("/attribute", methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
@admin_required
|
||||||
|
def create_attribute():
|
||||||
|
name = request.form.get("name")
|
||||||
|
category_id = request.form.get("category_id", type=int)
|
||||||
|
attribute = db.exercises.add_attribute(name, category_id)
|
||||||
|
db.activityRequest.log(current_user.id, 'CREATE_ATTRIBUTE', 'attribute', attribute['attribute_id'], f"Created attribute: {name}")
|
||||||
|
return render_template('partials/exercise/attribute_admin.html', attribute=attribute)
|
||||||
|
|
||||||
|
@exercises_bp.route("/attribute/<int:attribute_id>", methods=['GET', 'PUT'])
|
||||||
|
@login_required
|
||||||
|
@admin_required
|
||||||
|
def update_attribute(attribute_id):
|
||||||
|
if request.method == 'GET':
|
||||||
|
attribute = db.exercises.execute('SELECT attribute_id, name, category_id FROM exercise_attribute WHERE attribute_id = %s', [attribute_id], one=True)
|
||||||
|
is_edit = request.args.get('is_edit') == 'true'
|
||||||
|
return render_template('partials/exercise/attribute_admin.html', attribute=attribute, is_edit=is_edit)
|
||||||
|
|
||||||
|
name = request.form.get("name")
|
||||||
|
attribute = db.exercises.update_attribute(attribute_id, name)
|
||||||
|
db.activityRequest.log(current_user.id, 'UPDATE_ATTRIBUTE', 'attribute', attribute_id, f"Updated attribute: {name}")
|
||||||
|
return render_template('partials/exercise/attribute_admin.html', attribute=attribute)
|
||||||
|
|
||||||
|
@exercises_bp.route("/attribute/<int:attribute_id>", methods=['DELETE'])
|
||||||
|
@login_required
|
||||||
|
@admin_required
|
||||||
|
def delete_attribute(attribute_id):
|
||||||
|
db.exercises.delete_attribute(attribute_id)
|
||||||
|
db.activityRequest.log(current_user.id, 'DELETE_ATTRIBUTE', 'attribute', attribute_id, "Deleted attribute")
|
||||||
|
return ""
|
||||||
68
routes/settings.py
Normal file
68
routes/settings.py
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
from flask import Blueprint, render_template, request
|
||||||
|
from flask_login import login_required
|
||||||
|
from jinja2_fragments import render_block
|
||||||
|
from extensions import db
|
||||||
|
from flask import current_app
|
||||||
|
|
||||||
|
settings_bp = Blueprint('settings', __name__)
|
||||||
|
|
||||||
|
@settings_bp.route("/settings")
|
||||||
|
@login_required
|
||||||
|
def settings():
|
||||||
|
# Detect HTMX via header since we don't have the global htmx object here
|
||||||
|
is_htmx = request.headers.get('HX-Request') == 'true'
|
||||||
|
if is_htmx:
|
||||||
|
return render_block(current_app.jinja_env, 'settings.html', 'content')
|
||||||
|
return render_template('settings.html')
|
||||||
|
|
||||||
|
@settings_bp.route("/settings/tab/people")
|
||||||
|
@login_required
|
||||||
|
def settings_people():
|
||||||
|
people = db.get_people()
|
||||||
|
return render_template('partials/settings/people.html', people=people)
|
||||||
|
|
||||||
|
@settings_bp.route("/settings/tab/exercises")
|
||||||
|
@login_required
|
||||||
|
def settings_exercises():
|
||||||
|
exercises = db.get_all_exercises()
|
||||||
|
all_attributes = db.exercises.get_attributes_by_category()
|
||||||
|
categories_list = db.exercises.get_all_attribute_categories()
|
||||||
|
|
||||||
|
# Format options for custom_select
|
||||||
|
formatted_options = {}
|
||||||
|
for cat, attrs in all_attributes.items():
|
||||||
|
formatted_options[cat] = [{"id": a['attribute_id'], "attribute_id": a['attribute_id'], "name": a['name'], "category_id": a['category_id']} for a in attrs]
|
||||||
|
|
||||||
|
return render_template('partials/settings/exercises.html',
|
||||||
|
exercises=exercises,
|
||||||
|
all_attributes=formatted_options,
|
||||||
|
categories_list=categories_list)
|
||||||
|
|
||||||
|
@settings_bp.route("/settings/tab/export")
|
||||||
|
@login_required
|
||||||
|
def settings_export():
|
||||||
|
return render_template('partials/settings/export.html')
|
||||||
|
|
||||||
|
@settings_bp.route("/settings/tab/activity")
|
||||||
|
@login_required
|
||||||
|
def settings_activity():
|
||||||
|
return render_template('partials/settings/activity.html')
|
||||||
|
|
||||||
|
@settings_bp.route("/settings/activity_logs")
|
||||||
|
@login_required
|
||||||
|
def settings_activity_logs():
|
||||||
|
limit = 50
|
||||||
|
offset = request.args.get('offset', 0, type=int)
|
||||||
|
search_query = request.args.get('search_query', '')
|
||||||
|
|
||||||
|
logs = db.activityRequest.get_recent_logs(limit=limit, offset=offset, search_query=search_query)
|
||||||
|
|
||||||
|
# Check if there are more logs to load
|
||||||
|
has_more = len(logs) == limit
|
||||||
|
|
||||||
|
return render_template('partials/activity_logs.html',
|
||||||
|
logs=logs,
|
||||||
|
offset=offset,
|
||||||
|
has_more=has_more,
|
||||||
|
search_query=search_query,
|
||||||
|
limit=limit)
|
||||||
@@ -80,7 +80,7 @@ def _delete_saved_query(query_id):
|
|||||||
|
|
||||||
def _generate_sql_from_natural_language(natural_query):
|
def _generate_sql_from_natural_language(natural_query):
|
||||||
"""Generates SQL query from natural language using Gemini REST API."""
|
"""Generates SQL query from natural language using Gemini REST API."""
|
||||||
gemni_model = os.environ.get("GEMINI_MODEL","gemini-2.0-flash")
|
gemni_model = os.environ.get("GEMINI_MODEL","gemini-2.5-flash-lite")
|
||||||
api_key = os.environ.get("GEMINI_API_KEY")
|
api_key = os.environ.get("GEMINI_API_KEY")
|
||||||
if not api_key:
|
if not api_key:
|
||||||
return None, "GEMINI_API_KEY environment variable not set."
|
return None, "GEMINI_API_KEY environment variable not set."
|
||||||
|
|||||||
@@ -216,7 +216,26 @@ def get_topset(person_id, workout_id, topset_id):
|
|||||||
def get_topset_edit_form(person_id, workout_id, topset_id):
|
def get_topset_edit_form(person_id, workout_id, topset_id):
|
||||||
exercises = db.get_all_exercises()
|
exercises = db.get_all_exercises()
|
||||||
topset = db.get_topset(topset_id)
|
topset = db.get_topset(topset_id)
|
||||||
return render_template('partials/topset.html', person_id=person_id, workout_id=workout_id, topset_id=topset_id, exercises=exercises, exercise_id=topset.get('exercise_id'), exercise_name=topset.get('exercise_name'), repetitions=topset.get('repetitions'), weight=topset.get('weight'), is_edit=True)
|
|
||||||
|
# Format exercises for custom_select
|
||||||
|
formatted_exercises = [
|
||||||
|
{
|
||||||
|
"exercise_id": ex['exercise_id'],
|
||||||
|
"name": ex['name'],
|
||||||
|
"selected": ex['exercise_id'] == topset.get('exercise_id')
|
||||||
|
} for ex in exercises
|
||||||
|
]
|
||||||
|
|
||||||
|
return render_template('partials/topset.html',
|
||||||
|
person_id=person_id,
|
||||||
|
workout_id=workout_id,
|
||||||
|
topset_id=topset_id,
|
||||||
|
exercises=formatted_exercises,
|
||||||
|
exercise_id=topset.get('exercise_id'),
|
||||||
|
exercise_name=topset.get('exercise_name'),
|
||||||
|
repetitions=topset.get('repetitions'),
|
||||||
|
weight=topset.get('weight'),
|
||||||
|
is_edit=True)
|
||||||
|
|
||||||
@workout_bp.route("/person/<int:person_id>/workout/<int:workout_id>/topset", methods=['POST'])
|
@workout_bp.route("/person/<int:person_id>/workout/<int:workout_id>/topset", methods=['POST'])
|
||||||
@login_required
|
@login_required
|
||||||
@@ -226,6 +245,16 @@ def create_topset(person_id, workout_id):
|
|||||||
exercise_id = request.form.get("exercise_id")
|
exercise_id = request.form.get("exercise_id")
|
||||||
repetitions = request.form.get("repetitions")
|
repetitions = request.form.get("repetitions")
|
||||||
weight = request.form.get("weight")
|
weight = request.form.get("weight")
|
||||||
|
|
||||||
|
# Validation: Ensure exercise_id is present and is a valid integer
|
||||||
|
if not exercise_id or not exercise_id.strip():
|
||||||
|
return "Please select an exercise.", 400
|
||||||
|
|
||||||
|
try:
|
||||||
|
exercise_id = int(exercise_id)
|
||||||
|
except ValueError:
|
||||||
|
return "Invalid exercise selection.", 400
|
||||||
|
|
||||||
new_topset_id = db.create_topset(workout_id, exercise_id, repetitions, weight)
|
new_topset_id = db.create_topset(workout_id, exercise_id, repetitions, weight)
|
||||||
exercise = db.get_exercise(exercise_id)
|
exercise = db.get_exercise(exercise_id)
|
||||||
db.activityRequest.log(current_user.id, 'ADD_SET', 'topset', new_topset_id, f"Added set: {repetitions} x {weight}kg {exercise['name']} in workout {workout_id}")
|
db.activityRequest.log(current_user.id, 'ADD_SET', 'topset', new_topset_id, f"Added set: {repetitions} x {weight}kg {exercise['name']} in workout {workout_id}")
|
||||||
@@ -267,6 +296,42 @@ def get_most_recent_topset_for_exercise(person_id, workout_id):
|
|||||||
(repetitions, weight, exercise_name) = topset
|
(repetitions, weight, exercise_name) = topset
|
||||||
return render_template('partials/new_set_form.html', person_id=person_id, workout_id=workout_id, exercise_id=exercise_id, exercise_name=exercise_name, repetitions=repetitions, weight=weight)
|
return render_template('partials/new_set_form.html', person_id=person_id, workout_id=workout_id, exercise_id=exercise_id, exercise_name=exercise_name, repetitions=repetitions, weight=weight)
|
||||||
|
|
||||||
|
@workout_bp.route("/person/<int:person_id>/exercise/<int:exercise_id>/history", methods=['GET'])
|
||||||
|
def get_exercise_history(person_id, exercise_id):
|
||||||
|
limit = request.args.get('limit', type=int, default=5)
|
||||||
|
offset = request.args.get('offset', type=int, default=0)
|
||||||
|
source_topset_id = request.args.get('source_topset_id', type=int)
|
||||||
|
|
||||||
|
topsets = db.get_recent_topsets_for_exercise(person_id, exercise_id, limit, offset)
|
||||||
|
|
||||||
|
title = "History"
|
||||||
|
best_fit_formula = None
|
||||||
|
latest_workout_id = None
|
||||||
|
latest_topset_id = None
|
||||||
|
|
||||||
|
if topsets:
|
||||||
|
# Fetch progress data to get title and best_fit_formula if there's history
|
||||||
|
exercise_progress = db.get_exercise_progress_for_user(
|
||||||
|
person_id, exercise_id, epoch='all')
|
||||||
|
|
||||||
|
if exercise_progress:
|
||||||
|
title = exercise_progress.get('title', "History")
|
||||||
|
best_fit_formula = exercise_progress.get('best_fit_formula')
|
||||||
|
latest_workout_id = exercise_progress.get('latest_workout_id')
|
||||||
|
latest_topset_id = exercise_progress.get('latest_topset_id')
|
||||||
|
|
||||||
|
return render_template('partials/exercise_history.html',
|
||||||
|
person_id=person_id,
|
||||||
|
exercise_id=exercise_id,
|
||||||
|
topsets=topsets,
|
||||||
|
limit=limit,
|
||||||
|
offset=offset,
|
||||||
|
source_topset_id=source_topset_id,
|
||||||
|
title=title,
|
||||||
|
best_fit_formula=best_fit_formula,
|
||||||
|
latest_workout_id=latest_workout_id,
|
||||||
|
latest_topset_id=latest_topset_id)
|
||||||
|
|
||||||
@workout_bp.route("/person/<int:person_id>/workout/<int:workout_id>", methods=['GET'])
|
@workout_bp.route("/person/<int:person_id>/workout/<int:workout_id>", methods=['GET'])
|
||||||
def show_workout(person_id, workout_id):
|
def show_workout(person_id, workout_id):
|
||||||
# Use the local helper function to get the view model
|
# Use the local helper function to get the view model
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
python-3.9.18
|
|
||||||
@@ -102,17 +102,26 @@ tr.htmx-swapping td {
|
|||||||
.loading-indicator {
|
.loading-indicator {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.htmx-request .loading-indicator {
|
.htmx-request .loading-indicator {
|
||||||
display: flex;
|
display: flex;
|
||||||
}
|
}
|
||||||
|
|
||||||
.htmx-request.loading-indicator {
|
.htmx-request.loading-indicator {
|
||||||
display: flex;
|
display: flex;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@keyframes slideInLeft {
|
@keyframes slideInLeft {
|
||||||
from { transform: translateX(-100%); opacity: 0; }
|
from {
|
||||||
to { transform: translateX(0); opacity: 1; }
|
transform: translateX(-100%);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
transform: translateX(0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.animate-slideInLeft {
|
.animate-slideInLeft {
|
||||||
@@ -122,12 +131,76 @@ tr.htmx-swapping td {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@keyframes fadeIn {
|
@keyframes fadeIn {
|
||||||
from { opacity: 0; }
|
from {
|
||||||
to { opacity: 1; }
|
opacity: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
.animate-fadeIn {
|
.animate-fadeIn {
|
||||||
animation-name: fadeIn;
|
animation-name: fadeIn;
|
||||||
animation-duration: 0.5s;
|
animation-duration: 0.5s;
|
||||||
animation-fill-mode: both;
|
animation-fill-mode: both;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* SQL Explorer Custom Styles */
|
||||||
|
.sql-editor-container {
|
||||||
|
background: #1e1e1e;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
padding: 0.5rem;
|
||||||
|
box-shadow: inset 0 2px 4px 0 rgba(0, 0, 0, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sql-editor-textarea {
|
||||||
|
background: transparent !important;
|
||||||
|
color: #dcdcdc !important;
|
||||||
|
font-family: 'Fira Code', 'Courier New', Courier, monospace;
|
||||||
|
line-height: 1.5;
|
||||||
|
tab-size: 4;
|
||||||
|
border: none !important;
|
||||||
|
outline: none !important;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass-card {
|
||||||
|
background: rgba(255, 255, 255, 0.7);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
-webkit-backdrop-filter: blur(10px);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-premium {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-premium::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: -100%;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(90deg,
|
||||||
|
transparent,
|
||||||
|
rgba(255, 255, 255, 0.2),
|
||||||
|
transparent);
|
||||||
|
transition: 0.5s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-premium:hover::after {
|
||||||
|
left: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-zebra tbody tr:nth-child(even) {
|
||||||
|
background-color: rgba(243, 244, 246, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-zebra tbody tr:hover {
|
||||||
|
background-color: rgba(229, 231, 235, 0.8);
|
||||||
|
}
|
||||||
@@ -8,6 +8,10 @@
|
|||||||
<title>Workout Tracker</title>
|
<title>Workout Tracker</title>
|
||||||
<link rel="icon" type="image/png" href="{{ url_for('static', filename='img/logo.png') }}">
|
<link rel="icon" type="image/png" href="{{ url_for('static', filename='img/logo.png') }}">
|
||||||
|
|
||||||
|
<!-- Resource Preloads -->
|
||||||
|
<link rel="preload" href="/static/css/tailwind.css" as="style">
|
||||||
|
<link rel="preload" href="/static/js/htmx.min.js" as="script">
|
||||||
|
|
||||||
<link href="/static/css/tailwind.css" rel="stylesheet">
|
<link href="/static/css/tailwind.css" rel="stylesheet">
|
||||||
|
|
||||||
<link href="/static/css/style.css" rel="stylesheet">
|
<link href="/static/css/style.css" rel="stylesheet">
|
||||||
@@ -188,8 +192,8 @@
|
|||||||
</svg>
|
</svg>
|
||||||
<span class="ml-3">Endpoints</span>
|
<span class="ml-3">Endpoints</span>
|
||||||
</a>
|
</a>
|
||||||
<a hx-get="{{ url_for('settings') }}" hx-push-url="true" hx-target="#container"
|
<a hx-get="{{ url_for('settings.settings') }}" hx-push-url="true" hx-target="#container"
|
||||||
class="text-base text-gray-900 font-normal rounded-lg hover:bg-gray-100 group transition duration-75 flex items-center p-2 cursor-pointer {{ is_selected_page(url_for('settings')) }} page-link"
|
class="text-base text-gray-900 font-normal rounded-lg hover:bg-gray-100 group transition duration-75 flex items-center p-2 cursor-pointer {{ is_selected_page(url_for('settings.settings')) }} page-link"
|
||||||
_="on click add .hidden to #sidebar then remove .ml-64 from #main
|
_="on click add .hidden to #sidebar then remove .ml-64 from #main
|
||||||
on htmx:afterRequest go to the top of the body">
|
on htmx:afterRequest go to the top of the body">
|
||||||
<svg class="w-6 h-6 text-gray-500 group-hover:text-gray-900 transition duration-75"
|
<svg class="w-6 h-6 text-gray-500 group-hover:text-gray-900 transition duration-75"
|
||||||
|
|||||||
@@ -133,9 +133,7 @@
|
|||||||
<div class="w-full mt-2 pb-2 aspect-video"
|
<div class="w-full mt-2 pb-2 aspect-video"
|
||||||
hx-get="{{ url_for('get_exercise_progress_for_user', person_id=person.id, exercise_id=exercise.id, min_date=min_date, max_date=max_date) }}"
|
hx-get="{{ url_for('get_exercise_progress_for_user', person_id=person.id, exercise_id=exercise.id, min_date=min_date, max_date=max_date) }}"
|
||||||
hx-trigger="intersect once" hx-swap="outerHTML">
|
hx-trigger="intersect once" hx-swap="outerHTML">
|
||||||
<div class="flex items-center justify-center h-full bg-gray-50 rounded-lg">
|
{{ render_partial('partials/skeleton_graph.html') }}
|
||||||
<div class="text-sm text-gray-400 animate-pulse font-medium">Loading graph...</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<table class="min-w-full divide-y divide-gray-200">
|
<table class="min-w-full divide-y divide-gray-200">
|
||||||
|
|||||||
@@ -50,7 +50,8 @@
|
|||||||
{% if has_more %}
|
{% if has_more %}
|
||||||
<tr id="load-more-row">
|
<tr id="load-more-row">
|
||||||
<td colspan="5" class="p-4 text-center">
|
<td colspan="5" class="p-4 text-center">
|
||||||
<button hx-get="/settings/activity_logs?offset={{ offset + limit }}"
|
<button
|
||||||
|
hx-get="{{ url_for('settings.settings_activity_logs', offset=offset + limit, search_query=search_query) }}"
|
||||||
hx-target="#load-more-row" hx-swap="outerHTML"
|
hx-target="#load-more-row" hx-swap="outerHTML"
|
||||||
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-cyan-700 bg-cyan-100 hover:bg-cyan-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-cyan-500 transition-colors">
|
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-cyan-700 bg-cyan-100 hover:bg-cyan-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-cyan-500 transition-colors">
|
||||||
Load More...
|
Load More...
|
||||||
|
|||||||
@@ -1,19 +1,30 @@
|
|||||||
<tr>
|
<tr class="hover:bg-gray-50/50 transition-colors group">
|
||||||
<td class="p-4 whitespace-nowrap text-sm font-semibold text-gray-900 w-1/5">
|
<td class="p-4 text-sm font-semibold text-gray-900">
|
||||||
{% if is_edit|default(false, true) == false %}
|
{% if is_edit|default(false, true) == false %}
|
||||||
{{ name }}
|
<div class="flex flex-col">
|
||||||
|
<span class="text-base font-bold text-gray-900">{{ name }}</span>
|
||||||
|
<!-- Mobile-only attributes -->
|
||||||
|
<div class="flex flex-wrap gap-1 mt-1 sm:hidden">
|
||||||
|
{% for attr in attributes %}
|
||||||
|
<span
|
||||||
|
class="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-gray-100 text-gray-600">
|
||||||
|
{{ attr.attribute_name }}
|
||||||
|
</span>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<input
|
<input
|
||||||
class="appearance-none block w-full bg-gray-200 text-gray-700 border border-gray-200 rounded py-3 px-4 leading-tight focus:outline-none focus:bg-white focus:border-gray-500"
|
class="w-full bg-white text-gray-700 border border-gray-300 rounded-lg py-2 px-3 leading-tight focus:outline-none focus:ring-2 focus:ring-cyan-500/20 focus:border-cyan-500 transition-all font-normal"
|
||||||
type="text" name="name" value="{{ name }}">
|
type="text" name="name" value="{{ name }}">
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td class="p-4 text-sm text-gray-900 w-3/5">
|
<td class="p-4 text-sm text-gray-900 hidden sm:table-cell">
|
||||||
{% if is_edit|default(false, true) == false %}
|
{% if is_edit|default(false, true) == false %}
|
||||||
<div class="flex flex-wrap gap-2">
|
<div class="flex flex-wrap gap-2">
|
||||||
{% for attr in attributes %}
|
{% for attr in attributes %}
|
||||||
<span
|
<span
|
||||||
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800"
|
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-semibold bg-cyan-50 text-cyan-700 border border-cyan-100"
|
||||||
title="{{ attr.category_name }}">
|
title="{{ attr.category_name }}">
|
||||||
{{ attr.attribute_name }}
|
{{ attr.attribute_name }}
|
||||||
</span>
|
</span>
|
||||||
@@ -22,8 +33,8 @@
|
|||||||
{% else %}
|
{% else %}
|
||||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
{% for cat_name, options in all_attributes.items() %}
|
{% for cat_name, options in all_attributes.items() %}
|
||||||
<div>
|
<div class="min-w-[150px]">
|
||||||
<label class="block text-xs font-semibold text-gray-500 uppercase mb-1">{{ cat_name }}</label>
|
<label class="block text-[10px] font-bold text-gray-400 uppercase mb-1">{{ cat_name }}</label>
|
||||||
{{ render_partial('partials/custom_select.html',
|
{{ render_partial('partials/custom_select.html',
|
||||||
name='attribute_ids',
|
name='attribute_ids',
|
||||||
options=options,
|
options=options,
|
||||||
@@ -36,12 +47,14 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td class="p-4 whitespace-nowrap text-sm font-semibold text-gray-900 w-1/5 float-right">
|
<td class="p-4 whitespace-nowrap text-right">
|
||||||
|
<div class="flex items-center justify-end gap-1">
|
||||||
{% if is_edit|default(false, true) == false %}
|
{% if is_edit|default(false, true) == false %}
|
||||||
<button
|
<button
|
||||||
class="inline-flex justify-center p-2 text-blue-600 rounded-full cursor-pointer hover:bg-blue-100 dark:text-blue-500 dark:hover:bg-gray-600"
|
class="inline-flex justify-center p-2 text-gray-400 rounded-lg cursor-pointer hover:bg-cyan-50 hover:text-cyan-600 transition-all"
|
||||||
hx-get="{{ url_for('get_exercise_edit_form', exercise_id=exercise_id) }}">
|
hx-get="{{ url_for('exercises.get_exercise_edit_form', exercise_id=exercise_id) }}"
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5"
|
title="Edit Exercise">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2"
|
||||||
stroke="currentColor" class="w-5 h-5">
|
stroke="currentColor" class="w-5 h-5">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round"
|
<path stroke-linecap="round" stroke-linejoin="round"
|
||||||
d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10" />
|
d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10" />
|
||||||
@@ -49,23 +62,22 @@
|
|||||||
<span class="sr-only">Edit</span>
|
<span class="sr-only">Edit</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
class="inline-flex justify-center p-2 text-blue-600 rounded-full cursor-pointer hover:bg-blue-100 dark:text-blue-500 dark:hover:bg-gray-600"
|
class="inline-flex justify-center p-2 text-gray-400 rounded-lg cursor-pointer hover:bg-red-50 hover:text-red-500 transition-all"
|
||||||
hx-delete="{{ url_for('delete_exercise', exercise_id=exercise_id) }}"
|
hx-delete="{{ url_for('exercises.delete_exercise', exercise_id=exercise_id) }}"
|
||||||
hx-confirm="Are you sure you wish to delete {{ name }} from exercises?">
|
hx-confirm="Are you sure you wish to delete {{ name }} from exercises?" title="Delete Exercise">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5"
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2"
|
||||||
stroke="currentColor" class="w-5 h-5">
|
stroke="currentColor" class="w-5 h-5">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round"
|
<path stroke-linecap="round" stroke-linejoin="round"
|
||||||
d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0" />
|
d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0" />
|
||||||
</svg>
|
</svg>
|
||||||
|
|
||||||
<span class="sr-only">Delete</span>
|
<span class="sr-only">Delete</span>
|
||||||
</button>
|
</button>
|
||||||
{% else %}
|
{% else %}
|
||||||
<button
|
<button
|
||||||
class="inline-flex justify-center p-2 text-blue-600 rounded-full cursor-pointer hover:bg-blue-100 dark:text-blue-500 dark:hover:bg-gray-600"
|
class="inline-flex justify-center p-2 text-white bg-cyan-600 rounded-lg cursor-pointer hover:bg-cyan-700 transition-all shadow-sm"
|
||||||
hx-put="{{ url_for('update_exercise', exercise_id=exercise_id) }}" hx-include="closest tr">
|
hx-put="{{ url_for('exercises.update_exercise', exercise_id=exercise_id) }}" hx-include="closest tr"
|
||||||
|
title="Save Changes">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5"
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2.5"
|
||||||
stroke="currentColor" class="w-5 h-5">
|
stroke="currentColor" class="w-5 h-5">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5" />
|
<path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5" />
|
||||||
</svg>
|
</svg>
|
||||||
@@ -73,15 +85,15 @@
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
class="inline-flex justify-center p-2 text-blue-600 rounded-full cursor-pointer hover:bg-blue-100 dark:text-blue-500 dark:hover:bg-gray-600"
|
class="inline-flex justify-center p-2 text-gray-400 rounded-lg cursor-pointer hover:bg-gray-100 transition-all"
|
||||||
hx-get="{{ url_for('get_exercise', exercise_id=exercise_id) }}">
|
hx-get="{{ url_for('exercises.get_exercise', exercise_id=exercise_id) }}" title="Cancel">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5"
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2"
|
||||||
stroke="currentColor" class="w-5 h-5">
|
stroke="currentColor" class="w-5 h-5">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||||
</svg>
|
</svg>
|
||||||
<span class="sr-only">Cancel</span>
|
<span class="sr-only">Cancel</span>
|
||||||
</button>
|
</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
45
templates/partials/exercise/attribute_admin.html
Normal file
45
templates/partials/exercise/attribute_admin.html
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
<div id="attribute-{{ attribute.attribute_id }}"
|
||||||
|
class="group flex items-center px-3 py-1 bg-white border border-gray-200 rounded-lg shadow-sm hover:border-cyan-300 transition-all">
|
||||||
|
{% if is_edit %}
|
||||||
|
<form hx-put="{{ url_for('exercises.update_attribute', attribute_id=attribute.attribute_id) }}"
|
||||||
|
hx-target="#attribute-{{ attribute.attribute_id }}" hx-swap="outerHTML" class="flex items-center space-x-2">
|
||||||
|
<input type="text" name="name" value="{{ attribute.name }}"
|
||||||
|
class="text-xs font-semibold text-gray-700 bg-gray-50 border-none p-0 focus:ring-0 w-20" autofocus
|
||||||
|
onfocus="this.select()">
|
||||||
|
<button type="submit" class="text-green-600 hover:text-green-700">
|
||||||
|
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M5 13l4 4L19 7"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button type="button" hx-get="{{ url_for('exercises.update_attribute', attribute_id=attribute.attribute_id) }}"
|
||||||
|
hx-target="#attribute-{{ attribute.attribute_id }}" hx-swap="outerHTML"
|
||||||
|
class="text-gray-400 hover:text-gray-600">
|
||||||
|
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M6 18L18 6M6 6l12 12"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-xs font-medium text-gray-700 mr-2">{{ attribute.name }}</span>
|
||||||
|
<div class="hidden group-hover:flex items-center space-x-1 ml-auto">
|
||||||
|
<button hx-get="{{ url_for('exercises.update_attribute', attribute_id=attribute.attribute_id) }}?is_edit=true"
|
||||||
|
hx-target="#attribute-{{ attribute.attribute_id }}" hx-swap="outerHTML"
|
||||||
|
class="p-1 text-gray-400 hover:text-cyan-600 transition-colors">
|
||||||
|
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
|
d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z">
|
||||||
|
</path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button hx-delete="{{ url_for('exercises.update_attribute', attribute_id=attribute.attribute_id) }}"
|
||||||
|
hx-target="#attribute-{{ attribute.attribute_id }}" hx-swap="outerHTML" hx-confirm="Delete this attribute?"
|
||||||
|
class="p-1 text-gray-400 hover:text-red-600 transition-colors">
|
||||||
|
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
|
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16">
|
||||||
|
</path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
76
templates/partials/exercise/category_admin.html
Normal file
76
templates/partials/exercise/category_admin.html
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
<div id="category-{{ category_id }}"
|
||||||
|
class="bg-gray-50 border border-gray-200 rounded-2xl p-6 transition-all hover:shadow-md">
|
||||||
|
<div class="flex items-center justify-between mb-6">
|
||||||
|
<div class="flex items-center space-x-4">
|
||||||
|
<div class="p-2 bg-cyan-100 rounded-lg text-cyan-600">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
|
d="M7 7h.01M7 11h.01M7 15h.01M11 7h.01M11 11h.01M11 15h.01M15 7h.01M15 11h.01M15 15h.01M19 7h.01M19 11h.01M19 15h.01M7 3h10a2 2 0 012 2v14a2 2 0 01-2 2H7a2 2 0 01-2-2V5a2 2 0 012-2z">
|
||||||
|
</path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
{% if is_edit %}
|
||||||
|
<form hx-put="{{ url_for('exercises.update_category', category_id=category_id) }}"
|
||||||
|
hx-target="#category-{{ category_id }}" hx-swap="outerHTML" class="flex items-center space-x-2">
|
||||||
|
<input type="text" name="name" value="{{ name }}"
|
||||||
|
class="text-lg font-bold text-gray-900 bg-white border border-cyan-200 rounded-lg px-2 py-1 focus:ring-2 focus:ring-cyan-500/20 focus:border-cyan-500 outline-none"
|
||||||
|
autofocus onfocus="this.select()">
|
||||||
|
<button type="submit" class="text-green-600 hover:text-green-700 font-bold p-1">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M5 13l4 4L19 7"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button type="button" hx-get="{{ url_for('exercises.update_category', category_id=category_id) }}"
|
||||||
|
hx-target="#category-{{ category_id }}" hx-swap="outerHTML"
|
||||||
|
class="text-gray-400 hover:text-gray-600 p-1">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M6 18L18 6M6 6l12 12">
|
||||||
|
</path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{% else %}
|
||||||
|
<h5 class="text-lg font-bold text-gray-900">{{ name }}</h5>
|
||||||
|
<button hx-get="{{ url_for('exercises.update_category', category_id=category_id) }}?is_edit=true"
|
||||||
|
hx-target="#category-{{ category_id }}" hx-swap="outerHTML"
|
||||||
|
class="text-gray-400 hover:text-cyan-600 p-1 transition-colors">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
|
d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z">
|
||||||
|
</path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button hx-delete="{{ url_for('exercises.update_category', category_id=category_id) }}"
|
||||||
|
hx-target="#category-{{ category_id }}" hx-swap="outerHTML"
|
||||||
|
hx-confirm="Deleteting '{{ name }}' will also delete all its attributes. Are you sure?"
|
||||||
|
class="text-gray-400 hover:text-red-600 p-2 rounded-lg hover:bg-red-50 transition-all">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
|
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16">
|
||||||
|
</path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap gap-2 mb-6" id="attributes-list-{{ category_id }}">
|
||||||
|
{% for attr in attributes %}
|
||||||
|
{{ render_partial('partials/exercise/attribute_admin.html', attribute=attr) }}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form hx-post="{{ url_for('exercises.create_attribute') }}" hx-target="#attributes-list-{{ category_id }}"
|
||||||
|
hx-swap="beforeend" _="on htmx:afterRequest reset() me" class="flex items-center space-x-2">
|
||||||
|
<input type="hidden" name="category_id" value="{{ category_id }}">
|
||||||
|
<input type="text" name="name" placeholder="Add new option..."
|
||||||
|
class="flex-1 text-sm bg-white border border-gray-200 rounded-xl px-4 py-2 focus:ring-2 focus:ring-cyan-500/20 focus:border-cyan-500 outline-none transition-all">
|
||||||
|
<button type="submit"
|
||||||
|
class="bg-white border border-gray-200 text-gray-600 hover:text-cyan-600 hover:border-cyan-200 p-2 rounded-xl transition-all shadow-sm active:scale-95">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
@@ -4,7 +4,8 @@
|
|||||||
class="w-full bg-gray-200 text-gray-700 border border-gray-200 rounded py-2 px-4 leading-tight focus:outline-none focus:bg-white focus:border-gray-500"
|
class="w-full bg-gray-200 text-gray-700 border border-gray-200 rounded py-2 px-4 leading-tight focus:outline-none focus:bg-white focus:border-gray-500"
|
||||||
_="on click from me call event.stopPropagation()">
|
_="on click from me call event.stopPropagation()">
|
||||||
<!-- Save Icon -->
|
<!-- Save Icon -->
|
||||||
<button hx-post="{{ url_for('edit_exercise_name', exercise_id=exercise.exercise_id, person_id=person_id) }}"
|
<button
|
||||||
|
hx-post="{{ url_for('exercises.edit_exercise_name', exercise_id=exercise.exercise_id, person_id=person_id) }}"
|
||||||
hx-target="closest li" hx-swap="outerHTML" hx-include="closest li"
|
hx-target="closest li" hx-swap="outerHTML" hx-include="closest li"
|
||||||
class="text-gray-500 hover:text-gray-700 ml-2" _="on click from me call event.stopPropagation()">
|
class="text-gray-500 hover:text-gray-700 ml-2" _="on click from me call event.stopPropagation()">
|
||||||
<!-- Tick icon SVG -->
|
<!-- Tick icon SVG -->
|
||||||
@@ -14,7 +15,8 @@
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<!-- Delete Icon -->
|
<!-- Delete Icon -->
|
||||||
<button hx-delete="{{ url_for('delete_exercise', exercise_id=exercise.exercise_id, person_id=person_id) }}"
|
<button
|
||||||
|
hx-delete="{{ url_for('exercises.delete_exercise', exercise_id=exercise.exercise_id, person_id=person_id) }}"
|
||||||
hx-target="closest li" hx-swap="outerHTML" class="text-red-500 hover:text-red-700 ml-2"
|
hx-target="closest li" hx-swap="outerHTML" class="text-red-500 hover:text-red-700 ml-2"
|
||||||
hx-confirm="Are you sure you wish to delete {{ exercise.name }} from exercises?"
|
hx-confirm="Are you sure you wish to delete {{ exercise.name }} from exercises?"
|
||||||
_="on click from me call event.stopPropagation()">
|
_="on click from me call event.stopPropagation()">
|
||||||
|
|||||||
@@ -8,8 +8,8 @@
|
|||||||
<div class="py-2 px-4 text-gray-500 flex items-center justify-between border border-gray-200">
|
<div class="py-2 px-4 text-gray-500 flex items-center justify-between border border-gray-200">
|
||||||
<span>No results found</span>
|
<span>No results found</span>
|
||||||
<!-- Add Exercise Button -->
|
<!-- Add Exercise Button -->
|
||||||
<button hx-post="{{ url_for('add_exercise', person_id=person_id) }}" hx-target="closest div" hx-swap="outerHTML"
|
<button hx-post="{{ url_for('exercises.add_exercise', person_id=person_id) }}" hx-target="closest div"
|
||||||
hx-include="[name='query']" class="text-blue-500 hover:text-blue-700 font-semibold"
|
hx-swap="outerHTML" hx-include="[name='query']" class="text-blue-500 hover:text-blue-700 font-semibold"
|
||||||
_="on click from me call event.stopPropagation()">
|
_="on click from me call event.stopPropagation()">
|
||||||
Add Exercise
|
Add Exercise
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
<!-- Exercise Name -->
|
<!-- Exercise Name -->
|
||||||
<span>{{ exercise.name }}</span>
|
<span>{{ exercise.name }}</span>
|
||||||
<!-- Edit Icon -->
|
<!-- Edit Icon -->
|
||||||
<a hx-get="{{ url_for('edit_exercise_name', exercise_id=exercise.exercise_id, person_id=person_id) }}"
|
<a hx-get="{{ url_for('exercises.edit_exercise_name', exercise_id=exercise.exercise_id, person_id=person_id) }}"
|
||||||
hx-target="closest li" hx-swap="outerHTML" class="text-gray-500 hover:text-gray-700"
|
hx-target="closest li" hx-swap="outerHTML" class="text-gray-500 hover:text-gray-700"
|
||||||
_="on click from me call event.stopPropagation()">
|
_="on click from me call event.stopPropagation()">
|
||||||
<!-- Edit icon SVG -->
|
<!-- Edit icon SVG -->
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
<input
|
<input
|
||||||
class="appearance-none block w-full bg-gray-200 text-gray-700 border border-gray-200 rounded py-3 px-4 leading-tight focus:outline-none focus:bg-white focus:border-gray-500"
|
class="appearance-none block w-full bg-gray-200 text-gray-700 border border-gray-200 rounded py-3 px-4 leading-tight focus:outline-none focus:bg-white focus:border-gray-500"
|
||||||
id="exercise-search" type="search" name="query" placeholder="Search exercises..."
|
id="exercise-search" type="search" name="query" placeholder="Search exercises..."
|
||||||
hx-get="{{ url_for('get_exercises', person_id=person_id) }}" hx-target="#exercise-results"
|
hx-get="{{ url_for('exercises.get_exercises', person_id=person_id) }}" hx-target="#exercise-results"
|
||||||
hx-trigger="keyup changed delay:500ms" hx-swap="innerHTML" autocomplete="off" {% if exercise_name %}
|
hx-trigger="keyup changed delay:500ms" hx-swap="innerHTML" autocomplete="off" {% if exercise_name %}
|
||||||
value="{{ exercise_name }}" {% endif %} _="
|
value="{{ exercise_name }}" {% endif %} _="
|
||||||
on input
|
on input
|
||||||
|
|||||||
84
templates/partials/exercise_history.html
Normal file
84
templates/partials/exercise_history.html
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
{% if offset == 0 %}
|
||||||
|
<div id="exercise-history-container"
|
||||||
|
class="w-full bg-gray-50 p-4 border-t border-gray-200 shadow-inner overflow-x-auto">
|
||||||
|
<div class="flex items-center justify-between sm:justify-center relative mb-1">
|
||||||
|
<div class="flex flex-col sm:flex-row items-center justify-center w-full gap-x-2">
|
||||||
|
<h4 class="text-lg font-semibold text-blue-400">{{ title }}</h4>
|
||||||
|
<div hx-get="{{ url_for('workout.get_topset_achievements', person_id=person_id, workout_id=latest_workout_id, topset_id=latest_topset_id) }}"
|
||||||
|
hx-trigger="load" hx-target="this" hx-swap="innerHTML" class="flex items-center">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="absolute left-0 z-10">
|
||||||
|
<button
|
||||||
|
class="inline-flex justify-center p-1 text-gray-500 rounded cursor-pointer hover:text-gray-900 hover:bg-gray-100"
|
||||||
|
title="Show Progress Graph"
|
||||||
|
hx-get="{{ url_for('get_exercise_progress_for_user', person_id=person_id, exercise_id=exercise_id) }}"
|
||||||
|
hx-target="#exercise-history-container" hx-swap="outerHTML">
|
||||||
|
<svg class="w-5 h-5 border border-gray-300 rounded p-0.5" fill="none" stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
|
d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6"></path>
|
||||||
|
</svg>
|
||||||
|
<span class="sr-only">Show Progress Graph</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% if best_fit_formula %}
|
||||||
|
<h2 class="text-xs font-semibold text-blue-200 mb-4 text-center">
|
||||||
|
{{ best_fit_formula.kg_per_week }} kg/week, {{ best_fit_formula.kg_per_month }} kg/month
|
||||||
|
</h2>
|
||||||
|
{% else %}
|
||||||
|
<div class="mb-4"></div>
|
||||||
|
{% endif %}
|
||||||
|
<table class="w-full text-left text-sm text-gray-500">
|
||||||
|
<thead class="text-xs text-gray-700 uppercase bg-gray-100">
|
||||||
|
<tr>
|
||||||
|
<th scope="col" class="px-3 py-2">Date</th>
|
||||||
|
<th scope="col" class="px-3 py-2">Set & Achievements</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% for topset in topsets %}
|
||||||
|
<tr class="border-b bg-white">
|
||||||
|
<td class="px-3 py-2 text-sm text-gray-900 whitespace-nowrap">
|
||||||
|
{{ topset.start_date | strftime }}
|
||||||
|
</td>
|
||||||
|
<td class="px-3 py-2 text-sm text-gray-900">
|
||||||
|
<div class="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||||
|
<span class="whitespace-nowrap">{{ topset.repetitions }} x {{ topset.weight }}kg</span>
|
||||||
|
<div hx-get="{{ url_for('workout.get_topset_achievements', person_id=person_id, workout_id=topset.workout_id, topset_id=topset.topset_id) }}"
|
||||||
|
hx-trigger="load" hx-target="this" hx-swap="innerHTML"
|
||||||
|
class="flex flex-wrap items-center gap-1">
|
||||||
|
<!-- Badges load here -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
{% if topsets|length == limit %}
|
||||||
|
<tr id="history-load-more-{{ source_topset_id }}-{{ offset + limit }}">
|
||||||
|
<td colspan="2" class="px-3 py-3 text-center">
|
||||||
|
<button
|
||||||
|
class="text-sm text-blue-600 hover:underline font-medium px-4 py-2 border border-blue-600 rounded"
|
||||||
|
hx-get="{{ url_for('workout.get_exercise_history', person_id=person_id, exercise_id=exercise_id, limit=limit, offset=offset + limit, source_topset_id=source_topset_id) }}"
|
||||||
|
hx-target="#history-load-more-{{ source_topset_id }}-{{ offset + limit }}" hx-swap="outerHTML">
|
||||||
|
Load More
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% elif topsets|length == 0 and offset == 0 %}
|
||||||
|
<tr>
|
||||||
|
<td colspan="2" class="px-3 py-4 text-center text-gray-500">
|
||||||
|
No history found.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if offset == 0 %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
@@ -1,13 +1,24 @@
|
|||||||
<div id="new-set-form-container-{{ workout_id }}" class="w-full">
|
<div id="new-set-form-container-{{ workout_id }}" class="w-full">
|
||||||
<form class="w-full" id="new-set-workout-{{ workout_id }}"
|
<form class="w-full" id="new-set-workout-{{ workout_id }}"
|
||||||
hx-post="{{ url_for('workout.create_topset', person_id=person_id, workout_id=workout_id) }}" hx-swap="beforeend"
|
hx-post="{{ url_for('workout.create_topset', person_id=person_id, workout_id=workout_id) }}" hx-swap="beforeend"
|
||||||
hx-target="#new-workout" _="on htmx:afterOnLoad if #no-workouts add .hidden to #no-workouts end
|
hx-target="#new-workout" _="on htmx:afterOnLoad
|
||||||
|
if #no-workouts add .hidden to #no-workouts end
|
||||||
|
if detail.xhr.status == 200
|
||||||
|
set #validation-error-{{ workout_id }}.innerText to ''
|
||||||
|
add .hidden to #validation-error-{{ workout_id }}
|
||||||
|
else
|
||||||
|
set #validation-error-{{ workout_id }}.innerText to detail.xhr.responseText
|
||||||
|
remove .hidden from #validation-error-{{ workout_id }}
|
||||||
|
end
|
||||||
on topsetAdded
|
on topsetAdded
|
||||||
render #notification-template with (message: 'Topset added') then append it to #notifications-container
|
render #notification-template with (message: 'Topset added') then append it to #notifications-container
|
||||||
then call _hyperscript.processNode(#notifications-container)
|
then call _hyperscript.processNode(#notifications-container)
|
||||||
then reset() me
|
then reset() me
|
||||||
then trigger clearNewSetInputs">
|
then trigger clearNewSetInputs">
|
||||||
|
|
||||||
|
<div id="validation-error-{{ workout_id }}"
|
||||||
|
class="hidden text-red-500 text-xs italic mb-4 p-2 bg-red-50 border border-red-200 rounded"></div>
|
||||||
|
|
||||||
<div class="flex flex-wrap -mx-3 mb-2">
|
<div class="flex flex-wrap -mx-3 mb-2">
|
||||||
<div class="w-full md:w-[30%] px-2 md:px-3 mb-6 md:mb-0">
|
<div class="w-full md:w-[30%] px-2 md:px-3 mb-6 md:mb-0">
|
||||||
<label class="block uppercase tracking-wide text-gray-700 text-xs font-bold mb-2" for="grid-state">
|
<label class="block uppercase tracking-wide text-gray-700 text-xs font-bold mb-2" for="grid-state">
|
||||||
@@ -60,7 +71,7 @@
|
|||||||
|
|
||||||
{% if exercise_id %}
|
{% if exercise_id %}
|
||||||
<div class="flex items-center justify-center">
|
<div class="flex items-center justify-center">
|
||||||
<div class="md:w-full max-w-screen-sm">
|
<div class="w-full">
|
||||||
<div class="hidden"
|
<div class="hidden"
|
||||||
hx-get="{{ url_for('get_exercise_progress_for_user', person_id=person_id, exercise_id=exercise_id) }}"
|
hx-get="{{ url_for('get_exercise_progress_for_user', person_id=person_id, exercise_id=exercise_id) }}"
|
||||||
hx-trigger="load" hx-target="this" hx-swap="outerHTML">
|
hx-trigger="load" hx-target="this" hx-swap="outerHTML">
|
||||||
|
|||||||
@@ -8,12 +8,13 @@
|
|||||||
type="text" name="name" value="{{ name }}">
|
type="text" name="name" value="{{ name }}">
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td class="p-4 whitespace-nowrap text-sm font-semibold text-gray-900 float-right">
|
<td class="p-4 whitespace-nowrap text-right">
|
||||||
|
<div class="flex items-center justify-end gap-1">
|
||||||
{% if is_edit|default(false, true) == false %}
|
{% if is_edit|default(false, true) == false %}
|
||||||
<button
|
<button
|
||||||
class="inline-flex justify-center p-2 text-blue-600 rounded-full cursor-pointer hover:bg-blue-100 dark:text-blue-500 dark:hover:bg-gray-600"
|
class="inline-flex justify-center p-2 text-gray-400 rounded-lg cursor-pointer hover:bg-cyan-50 hover:text-cyan-600 transition-all"
|
||||||
hx-get="{{ url_for('get_person_edit_form', person_id=person_id) }}">
|
hx-get="{{ url_for('get_person_edit_form', person_id=person_id) }}" title="Edit User">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5"
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2"
|
||||||
stroke="currentColor" class="w-5 h-5">
|
stroke="currentColor" class="w-5 h-5">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round"
|
<path stroke-linecap="round" stroke-linejoin="round"
|
||||||
d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10" />
|
d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10" />
|
||||||
@@ -21,39 +22,38 @@
|
|||||||
<span class="sr-only">Edit</span>
|
<span class="sr-only">Edit</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
class="inline-flex justify-center p-2 text-blue-600 rounded-full cursor-pointer hover:bg-blue-100 dark:text-blue-500 dark:hover:bg-gray-600"
|
class="inline-flex justify-center p-2 text-gray-400 rounded-lg cursor-pointer hover:bg-red-50 hover:text-red-500 transition-all"
|
||||||
hx-delete="{{ url_for('delete_person', person_id=person_id) }}"
|
hx-delete="{{ url_for('delete_person', person_id=person_id) }}"
|
||||||
hx-confirm="Are you sure you wish to delete {{ name }} from users?">
|
hx-confirm="Are you sure you wish to delete {{ name }} from users?" title="Delete User">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5"
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2"
|
||||||
stroke="currentColor" class="w-5 h-5">
|
stroke="currentColor" class="w-5 h-5">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round"
|
<path stroke-linecap="round" stroke-linejoin="round"
|
||||||
d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0" />
|
d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0" />
|
||||||
</svg>
|
</svg>
|
||||||
|
|
||||||
<span class="sr-only">Delete</span>
|
<span class="sr-only">Delete</span>
|
||||||
</button>
|
</button>
|
||||||
{% else %}
|
{% else %}
|
||||||
<button
|
<button
|
||||||
class="inline-flex justify-center p-2 text-blue-600 rounded-full cursor-pointer hover:bg-blue-100 dark:text-blue-500 dark:hover:bg-gray-600"
|
class="inline-flex justify-center p-2 text-white bg-cyan-600 rounded-lg cursor-pointer hover:bg-cyan-700 transition-all shadow-sm"
|
||||||
hx-put="{{ url_for('update_person_name', person_id=person_id) }}" hx-include="closest tr">
|
hx-put="{{ url_for('update_person_name', person_id=person_id) }}" hx-include="closest tr"
|
||||||
|
title="Save Changes">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5"
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2.5"
|
||||||
stroke="currentColor" class="w-5 h-5">
|
stroke="currentColor" class="w-5 h-5">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5" />
|
<path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5" />
|
||||||
</svg>
|
</svg>
|
||||||
<span class="sr-only">Cancel</span>
|
<span class="sr-only">Save</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
class="inline-flex justify-center p-2 text-blue-600 rounded-full cursor-pointer hover:bg-blue-100 dark:text-blue-500 dark:hover:bg-gray-600"
|
class="inline-flex justify-center p-2 text-gray-400 rounded-lg cursor-pointer hover:bg-gray-100 transition-all"
|
||||||
hx-get="{{ url_for('get_person_name', person_id=person_id) }}">
|
hx-get="{{ url_for('get_person_name', person_id=person_id) }}" title="Cancel">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5"
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2"
|
||||||
stroke="currentColor" class="w-5 h-5">
|
stroke="currentColor" class="w-5 h-5">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||||
</svg>
|
</svg>
|
||||||
<span class="sr-only">Cancel</span>
|
<span class="sr-only">Cancel</span>
|
||||||
</button>
|
</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
31
templates/partials/settings/activity.html
Normal file
31
templates/partials/settings/activity.html
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
<div class="bg-white shadow rounded-lg p-4 sm:p-6 lg:p-8 mb-8">
|
||||||
|
<div class="mb-6 border-b border-gray-100 pb-4 flex justify-between items-center">
|
||||||
|
<div>
|
||||||
|
<h3 class="text-xl font-bold text-gray-900">Activity Logs</h3>
|
||||||
|
<p class="text-sm text-gray-500">Review recent actions and administrative changes.</p>
|
||||||
|
</div>
|
||||||
|
<div class="relative max-w-sm w-full">
|
||||||
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
|
<svg class="h-5 w-5 text-gray-400" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"
|
||||||
|
fill="currentColor" aria-hidden="true">
|
||||||
|
<path fill-rule="evenodd"
|
||||||
|
d="M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z"
|
||||||
|
clip-rule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<input type="text" name="search_query"
|
||||||
|
class="focus:ring-cyan-500 focus:border-cyan-500 block w-full pl-10 p-2 sm:text-sm border-gray-300 rounded-lg bg-gray-50"
|
||||||
|
placeholder="Search logs by action, user, or details..."
|
||||||
|
hx-get="{{ url_for('settings.settings_activity_logs') }}" hx-trigger="keyup changed delay:500ms, search"
|
||||||
|
hx-target="#activity-logs-container">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="activity-logs-container" hx-get="{{ url_for('settings.settings_activity_logs') }}" hx-trigger="load">
|
||||||
|
<div class="flex justify-center p-12">
|
||||||
|
<div class="flex flex-col items-center">
|
||||||
|
<div class="animate-spin rounded-full h-10 w-10 border-b-2 border-cyan-600 mb-4"></div>
|
||||||
|
<p class="text-sm text-gray-500">Loading activity history...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
163
templates/partials/settings/exercises.html
Normal file
163
templates/partials/settings/exercises.html
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
<div class="bg-white shadow rounded-lg p-4 sm:p-6 lg:p-8 mb-8">
|
||||||
|
<div class="mb-6">
|
||||||
|
<h3 class="text-xl font-bold text-gray-900">Exercise Configuration</h3>
|
||||||
|
<p class="text-sm text-gray-500">Manage available exercises and their categories.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col">
|
||||||
|
<div class="overflow-x-auto rounded-lg">
|
||||||
|
<div class="align-middle inline-block min-w-full">
|
||||||
|
<div class="shadow overflow-hidden border-b border-gray-200 sm:rounded-lg">
|
||||||
|
<table class="min-w-full divide-y divide-gray-200">
|
||||||
|
<thead class="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th scope="col"
|
||||||
|
class="p-4 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">
|
||||||
|
Exercise Name
|
||||||
|
</th>
|
||||||
|
<th scope="col"
|
||||||
|
class="p-4 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider hidden sm:table-cell">
|
||||||
|
Attributes
|
||||||
|
</th>
|
||||||
|
<th scope="col"
|
||||||
|
class="p-4 text-right text-xs font-semibold text-gray-500 uppercase tracking-wider">
|
||||||
|
<div class="relative max-w-xs ml-auto">
|
||||||
|
<div
|
||||||
|
class="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none">
|
||||||
|
<svg class="w-4 h-4 text-gray-400" aria-hidden="true"
|
||||||
|
xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 20 20">
|
||||||
|
<path stroke="currentColor" stroke-linecap="round"
|
||||||
|
stroke-linejoin="round" stroke-width="2"
|
||||||
|
d="m19 19-4-4m0-7A7 7 0 1 1 1 8a7 7 0 0 1 14 0Z"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<input type="search" id="exercise-search" name="q"
|
||||||
|
class="block w-full p-2 pl-10 text-sm text-gray-900 border border-gray-300 rounded-lg bg-gray-50 focus:ring-cyan-500 focus:border-cyan-500 transition-all shadow-sm"
|
||||||
|
placeholder="Search e.g. 'muscle:chest'..." hx-get="/exercises/search"
|
||||||
|
hx-trigger="input changed delay:250ms, search" hx-target="#new-exercise"
|
||||||
|
hx-indicator="#search-spinner">
|
||||||
|
<div id="search-spinner"
|
||||||
|
class="htmx-indicator absolute inset-y-0 right-3 flex items-center">
|
||||||
|
<svg class="animate-spin h-4 w-4 text-cyan-600"
|
||||||
|
xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor"
|
||||||
|
stroke-width="4"></circle>
|
||||||
|
<path class="opacity-75" fill="currentColor"
|
||||||
|
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z">
|
||||||
|
</path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="bg-white divide-y divide-gray-100" id="new-exercise" hx-target="closest tr"
|
||||||
|
hx-swap="innerHTML swap:0.5s">
|
||||||
|
{% for exercise in exercises %}
|
||||||
|
{{ render_partial('partials/exercise.html', exercise_id=exercise.exercise_id,
|
||||||
|
name=exercise.name, attributes=exercise.attributes)}}
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-12 bg-gray-50/50 p-6 rounded-2xl border border-gray-100 shadow-sm">
|
||||||
|
<div class="mb-6">
|
||||||
|
<h4 class="text-lg font-bold text-gray-900">Add New Exercise</h4>
|
||||||
|
<p class="text-sm text-gray-500">Create a new exercise with specific muscle groups and equipment.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form hx-post="{{ url_for('exercises.create_exercise') }}" hx-swap="beforeend" hx-target="#new-exercise" _="on htmx:afterRequest
|
||||||
|
render #notification-template with (message: 'Exercise added') then append it to #notifications-container
|
||||||
|
then call _hyperscript.processNode(#notifications-container)
|
||||||
|
then reset() me">
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||||
|
<div class="lg:col-span-1">
|
||||||
|
<label class="block text-xs font-bold text-gray-700 uppercase mb-2">
|
||||||
|
Exercise Name
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
class="w-full bg-white text-gray-700 border border-gray-300 rounded-xl py-2.5 px-4 leading-tight focus:outline-none focus:ring-2 focus:ring-cyan-500/20 focus:border-cyan-500 transition-all"
|
||||||
|
type="text" name="name" placeholder="e.g. Bench Press" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="md:col-span-2 grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||||
|
{% for cat_name, options in all_attributes.items() %}
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-bold text-gray-400 uppercase mb-2">{{ cat_name
|
||||||
|
}}</label>
|
||||||
|
{{ render_partial('partials/custom_select.html',
|
||||||
|
name='attribute_ids',
|
||||||
|
options=options,
|
||||||
|
multiple=true,
|
||||||
|
search=true,
|
||||||
|
placeholder='Select ' ~ cat_name
|
||||||
|
)}}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="lg:col-span-1 flex items-end">
|
||||||
|
<button
|
||||||
|
class="w-full flex items-center justify-center text-white bg-cyan-600 hover:bg-cyan-700 focus:ring-4 focus:ring-cyan-200 font-bold rounded-xl text-sm px-5 py-3 transition-all shadow-md active:scale-95 cursor-pointer"
|
||||||
|
type="submit">
|
||||||
|
<svg class="w-5 h-5 mr-2" fill="currentColor" viewBox="0 0 20 20"
|
||||||
|
xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path fill-rule="evenodd"
|
||||||
|
d="M10 5a1 1 0 011 1v3h3a1 1 0 110 2h-3v3a1 1 0 11-2 0v-3H6a1 1 0 110-2h3V6a1 1 0 011-1z"
|
||||||
|
clip-rule="evenodd"></path>
|
||||||
|
</svg>
|
||||||
|
Add Exercise
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Category & Attribute Management Section -->
|
||||||
|
<div class="mt-12 pt-12 border-t border-gray-100">
|
||||||
|
<div class="mb-8">
|
||||||
|
<h4 class="text-lg font-bold text-gray-900">Manage Categories & Options</h4>
|
||||||
|
<p class="text-sm text-gray-500">Add or edit muscle groups, equipment types, and other exercise
|
||||||
|
attributes.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-8" id="categories-admin-list">
|
||||||
|
{% for cat in categories_list %}
|
||||||
|
{% set options = all_attributes.get(cat.name, []) %}
|
||||||
|
{{ render_partial('partials/exercise/category_admin.html', category_id=cat.category_id,
|
||||||
|
name=cat.name, attributes=options) }}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Add New Category Form -->
|
||||||
|
<div class="mt-8 p-6 bg-gray-50/50 rounded-2xl border border-dashed border-gray-300">
|
||||||
|
<form hx-post="{{ url_for('exercises.create_category') }}" hx-target="#categories-admin-list"
|
||||||
|
hx-swap="beforeend" _="on htmx:afterRequest reset() me"
|
||||||
|
class="flex flex-col sm:flex-row items-center gap-4">
|
||||||
|
<div class="w-full sm:flex-1">
|
||||||
|
<label class="block text-xs font-bold text-gray-400 uppercase mb-2">New Category
|
||||||
|
Name</label>
|
||||||
|
<input type="text" name="name" placeholder="e.g. Difficulty, Intensity..."
|
||||||
|
class="w-full text-sm bg-white border border-gray-200 rounded-xl px-4 py-2.5 focus:ring-2 focus:ring-cyan-500/20 focus:border-cyan-500 outline-none transition-all shadow-sm"
|
||||||
|
required>
|
||||||
|
</div>
|
||||||
|
<div class="w-full sm:w-auto self-end">
|
||||||
|
<button type="submit"
|
||||||
|
class="w-full flex items-center justify-center text-white bg-gray-800 hover:bg-gray-900 font-bold rounded-xl text-sm px-6 py-2.5 transition-all shadow-md active:scale-95">
|
||||||
|
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4">
|
||||||
|
</path>
|
||||||
|
</svg>
|
||||||
|
Create Category
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
48
templates/partials/settings/export.html
Normal file
48
templates/partials/settings/export.html
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
<div class="bg-white shadow rounded-lg p-4 sm:p-6 lg:p-8 mb-8">
|
||||||
|
<div class="mb-6">
|
||||||
|
<h3 class="text-xl font-bold text-gray-900">Data & Portability</h3>
|
||||||
|
<p class="text-sm text-gray-500">Export your data for backup or external analysis.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||||
|
<!-- CSV Export -->
|
||||||
|
<div class="border border-gray-200 rounded-xl p-6 hover:border-cyan-200 transition-colors bg-gray-50/50">
|
||||||
|
<div class="flex items-center mb-4">
|
||||||
|
<div class="p-3 bg-green-100 rounded-lg text-green-600 mr-4 shadow-sm">
|
||||||
|
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path fill-rule="evenodd"
|
||||||
|
d="M6 2a2 2 0 00-2 2v12a2 2 0 002 2h8a2 2 0 002-2V7.414A2 2 0 0015.414 6L12 2.586A2 2 0 0010.586 2H6zm5 6a1 1 0 10-2 0v3.586l-1.293-1.293a1 1 0 10-1.414 1.414l3 3a1 1 0 001.414 0l3-3a1 1 0 00-1.414-1.414L11 11.586V8z"
|
||||||
|
clip-rule="evenodd"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h4 class="text-lg font-bold text-gray-900">Workout History</h4>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-gray-600 mb-6 font-medium">Download all workout records, sets, and
|
||||||
|
performance data in CSV format.</p>
|
||||||
|
<a href="{{ url_for('export.export_workouts_csv') }}"
|
||||||
|
class="text-white bg-green-600 hover:bg-green-700 focus:ring-4 focus:ring-green-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center inline-flex items-center justify-center w-full shadow-sm">
|
||||||
|
Download CSV
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- SQL Export -->
|
||||||
|
<div class="border border-gray-200 rounded-xl p-6 hover:border-cyan-200 transition-colors bg-gray-50/50">
|
||||||
|
<div class="flex items-center mb-4">
|
||||||
|
<div class="p-3 bg-blue-100 rounded-lg text-blue-600 mr-4 shadow-sm">
|
||||||
|
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path
|
||||||
|
d="M2 5a2 2 0 012-2h12a2 2 0 012 2v10a2 2 0 01-2 2H4a2 2 0 01-2-2V5zm3.293 1.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z"
|
||||||
|
clip-rule="evenodd"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h4 class="text-lg font-bold text-gray-900">Database Snapshot</h4>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-gray-600 mb-6 font-medium">Create a full SQL dump of your database including
|
||||||
|
schema and all records.</p>
|
||||||
|
<a href="{{ url_for('export.export_database_sql') }}"
|
||||||
|
class="text-white bg-blue-600 hover:bg-blue-700 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center inline-flex items-center justify-center w-full shadow-sm">
|
||||||
|
Download SQL Script
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
87
templates/partials/settings/people.html
Normal file
87
templates/partials/settings/people.html
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
<div class="bg-white shadow rounded-lg p-4 sm:p-6 lg:p-8 mb-8">
|
||||||
|
<div class="mb-4 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 class="text-xl font-bold text-gray-900">User Management</h3>
|
||||||
|
<p class="text-sm text-gray-500">Add, edit or remove people from the tracker.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col">
|
||||||
|
<div class="overflow-x-auto rounded-lg">
|
||||||
|
<div class="align-middle inline-block min-w-full">
|
||||||
|
<div class="shadow overflow-hidden border-b border-gray-200 sm:rounded-lg">
|
||||||
|
<table class="min-w-full divide-y divide-gray-200">
|
||||||
|
<thead class="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th scope="col"
|
||||||
|
class="p-4 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
Name
|
||||||
|
</th>
|
||||||
|
<th scope="col"
|
||||||
|
class="p-4 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
<div class="relative max-w-xs ml-auto">
|
||||||
|
<div
|
||||||
|
class="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none">
|
||||||
|
<svg class="w-4 h-4 text-gray-500" aria-hidden="true"
|
||||||
|
xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 20 20">
|
||||||
|
<path stroke="currentColor" stroke-linecap="round"
|
||||||
|
stroke-linejoin="round" stroke-width="2"
|
||||||
|
d="m19 19-4-4m0-7A7 7 0 1 1 1 8a7 7 0 0 1 14 0Z"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<input type="search" id="people-search"
|
||||||
|
class="block w-full p-2 pl-10 text-sm text-gray-900 border border-gray-300 rounded-lg bg-gray-50 focus:ring-cyan-500 focus:border-cyan-500 shadow-sm"
|
||||||
|
placeholder="Search users..."
|
||||||
|
_="on input show <tbody>tr/> in closest <table/> when its textContent.toLowerCase() contains my value.toLowerCase()">
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="bg-white divide-y divide-gray-200" id="new-person" hx-target="closest tr"
|
||||||
|
hx-swap="outerHTML swap:0.5s">
|
||||||
|
{% for p in people %}
|
||||||
|
{{ render_partial('partials/person.html', person_id=p['PersonId'], name=p['Name'])}}
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form class="w-full mt-6 bg-gray-50 p-4 rounded-lg border border-gray-100" hx-post="{{ url_for('create_person') }}"
|
||||||
|
hx-swap="beforeend" hx-target="#new-person" _="on htmx:afterRequest
|
||||||
|
render #notification-template with (message: 'User added') then append it to #notifications-container
|
||||||
|
then call _hyperscript.processNode(#notifications-container)
|
||||||
|
then reset() me">
|
||||||
|
<div class="flex flex-col sm:flex-row gap-4 items-end justify-end">
|
||||||
|
<div class="grow w-full sm:w-auto max-w-sm">
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1" for="person-name">
|
||||||
|
New user
|
||||||
|
</label>
|
||||||
|
<div class="relative rounded-md shadow-sm">
|
||||||
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
|
<svg class="h-5 w-5 text-gray-400" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"
|
||||||
|
fill="currentColor" aria-hidden="true">
|
||||||
|
<path fill-rule="evenodd" d="M10 9a3 3 0 100-6 3 3 0 000 6zm-7 9a7 7 0 1114 0H3z"
|
||||||
|
clip-rule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<input id="person-name"
|
||||||
|
class="focus:ring-cyan-500 focus:border-cyan-500 block w-full pl-10 p-2 sm:text-sm border-gray-300 rounded-lg bg-gray-50"
|
||||||
|
type="text" name="name" placeholder="Full Name">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="w-full sm:w-auto flex items-center justify-center text-white bg-cyan-600 hover:bg-cyan-700 focus:ring-4 focus:ring-cyan-200 font-medium rounded-lg text-sm px-5 py-2.5 transition-colors shadow-sm"
|
||||||
|
type="submit">
|
||||||
|
<svg class="w-5 h-5 mr-2" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path fill-rule="evenodd"
|
||||||
|
d="M10 5a1 1 0 011 1v3h3a1 1 0 110 2h-3v3a1 1 0 11-2 0v-3H6a1 1 0 110-2h3V6a1 1 0 011-1z"
|
||||||
|
clip-rule="evenodd"></path>
|
||||||
|
</svg>
|
||||||
|
Add User
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
17
templates/partials/skeleton_graph.html
Normal file
17
templates/partials/skeleton_graph.html
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<div class="w-full h-full bg-gray-100 rounded-lg animate-pulse relative overflow-hidden">
|
||||||
|
<!-- Subtle shimmer effect -->
|
||||||
|
<div
|
||||||
|
class="absolute inset-0 bg-gradient-to-r from-transparent via-white/20 to-transparent -translate-x-full animate-[shimmer_2s_infinite]">
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-center h-full">
|
||||||
|
<div class="text-xs text-gray-400 font-medium">Loading...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
@keyframes shimmer {
|
||||||
|
100% {
|
||||||
|
transform: translateX(100%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -46,7 +46,28 @@
|
|||||||
<div id="popover-{{ unique_id }}" class="absolute t-0 r-0 hidden bg-white border border-gray-300 p-2 z-10">
|
<div id="popover-{{ unique_id }}" class="absolute t-0 r-0 hidden bg-white border border-gray-300 p-2 z-10">
|
||||||
<!-- Popover content will be dynamically inserted here -->
|
<!-- Popover content will be dynamically inserted here -->
|
||||||
</div>
|
</div>
|
||||||
<h4 class="text-l font-semibold text-blue-400 text-center">{{ title }}</h4>
|
<div class="flex items-center justify-between sm:justify-center relative mb-1">
|
||||||
|
<div class="flex flex-col sm:flex-row items-center justify-center w-full gap-x-2">
|
||||||
|
<h4 class="text-lg font-semibold text-blue-400">{{ title }}</h4>
|
||||||
|
<div hx-get="{{ url_for('workout.get_topset_achievements', person_id=person_id, workout_id=latest_workout_id, topset_id=latest_topset_id) }}"
|
||||||
|
hx-trigger="load" hx-target="this" hx-swap="innerHTML" class="flex items-center">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="absolute left-0 z-10">
|
||||||
|
<button
|
||||||
|
class="inline-flex justify-center p-1 text-gray-500 rounded cursor-pointer hover:text-gray-900 hover:bg-gray-100 dark:text-gray-400 dark:hover:text-white dark:hover:bg-gray-600"
|
||||||
|
title="Show History"
|
||||||
|
hx-get="{{ url_for('workout.get_exercise_history', person_id=person_id, exercise_id=exercise_id, source_topset_id=latest_topset_id) }}"
|
||||||
|
hx-target="#svg-plot-{{ unique_id }}" hx-swap="outerHTML">
|
||||||
|
<svg class="w-5 h-5 border border-gray-300 rounded p-0.5" fill="none" stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
|
d="M4 6h16M4 10h16M4 14h16M4 18h16"></path>
|
||||||
|
</svg>
|
||||||
|
<span class="sr-only">Show History</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<h2 class="text-xs font-semibold text-blue-200 mb-1 text-center">
|
<h2 class="text-xs font-semibold text-blue-200 mb-1 text-center">
|
||||||
{% if best_fit_formula %}
|
{% if best_fit_formula %}
|
||||||
{{ best_fit_formula.kg_per_week }} kg/week, {{ best_fit_formula.kg_per_month }} kg/month
|
{{ best_fit_formula.kg_per_week }} kg/week, {{ best_fit_formula.kg_per_month }} kg/month
|
||||||
|
|||||||
@@ -1,43 +1,56 @@
|
|||||||
{% if error or results %}
|
{% if error or results %}
|
||||||
<div class="relative">
|
<div class="mt-12 bg-white border border-gray-200 rounded-2xl overflow-hidden shadow-lg animate-fadeIn relative">
|
||||||
<!-- Floating Clear Button -->
|
<!-- Floating Clear Button -->
|
||||||
<button _="on click set the innerHTML of my.parentElement to ''"
|
<button _="on click transition opacity to 0 then set my.parentElement.innerHTML to ''"
|
||||||
class="absolute top-0 right-0 m-2 px-3 py-2 flex items-center gap-2 rounded-full bg-gray-800 text-white shadow-md opacity-50 hover:opacity-100 hover:bg-gray-700 transition-all">
|
class="absolute top-4 right-4 p-2 bg-gray-900/10 hover:bg-red-50 text-gray-500 hover:text-red-600 rounded-full transition-all duration-200 group z-10"
|
||||||
<!-- Trash Icon -->
|
title="Clear results">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
stroke-linecap="round" stroke-linejoin="round" class="h-5 w-5">
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
<path
|
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5-4h4a2 2 0 012 2v1H7V5a2 2 0 012-2z" />
|
||||||
d="M3 6h18M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6m5 4v6m4-6v6" />
|
|
||||||
</svg>
|
</svg>
|
||||||
<span>Clear</span>
|
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<div class="px-6 py-4 border-b border-gray-100 bg-gray-50/50">
|
||||||
|
<h3 class="text-sm font-bold text-gray-700 uppercase tracking-wider">Query Results</h3>
|
||||||
|
{% if results %}
|
||||||
|
<p class="text-xs text-gray-500 mt-0.5">{{ results|length }} rows returned</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
{% if error %}
|
{% if error %}
|
||||||
<div class="bg-red-200 text-red-800 p-4 rounded mb-4">
|
<div class="p-6">
|
||||||
<strong>Error:</strong> {{ error }}
|
<div class="bg-red-50 border-l-4 border-red-400 p-4 rounded text-red-700 text-sm">
|
||||||
|
<strong class="font-bold">Execution Error:</strong> {{ error }}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if results %}
|
{% if results %}
|
||||||
<table class="min-w-full bg-white">
|
<div class="overflow-x-auto">
|
||||||
<thead>
|
<table class="min-w-full divide-y divide-gray-200 table-zebra">
|
||||||
|
<thead class="bg-gray-50/30">
|
||||||
<tr>
|
<tr>
|
||||||
{% for col in columns %}
|
{% for col in columns %}
|
||||||
<th class="py-2 px-4 border-b">{{ col }}</th>
|
<th scope="col"
|
||||||
|
class="px-6 py-3 text-left text-xs font-bold text-gray-500 uppercase tracking-widest border-b border-gray-100">
|
||||||
|
{{ col }}
|
||||||
|
</th>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody class="bg-white divide-y divide-gray-100">
|
||||||
{% for row in results %}
|
{% for row in results %}
|
||||||
<tr class="text-center">
|
<tr class="hover:bg-blue-50/30 transition-colors">
|
||||||
{% for col in columns %}
|
{% for col in columns %}
|
||||||
<td class="py-2 px-4 border-b">{{ row[col] }}</td>
|
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-600 font-medium">
|
||||||
|
{{ row[col] if row[col] is not none else 'NULL' }}
|
||||||
|
</td>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -1,23 +1,51 @@
|
|||||||
<div class="relative">
|
<div class="relative space-y-4">
|
||||||
<!-- Hidden textarea containing the actual SQL (so we preserve line breaks) -->
|
<!-- Hidden textarea containing the actual SQL (so we preserve line breaks) -->
|
||||||
<textarea id="create_sql_text" style="display: none;">{{ create_sql }}</textarea>
|
<textarea id="create_sql_text" class="hidden">{{ create_sql }}</textarea>
|
||||||
|
|
||||||
<!-- Floating Clear Button -->
|
<!-- Floating Actions Container -->
|
||||||
<button onclick="copySqlToClipboard()"
|
<div class="absolute top-4 right-4 flex items-center gap-2 z-10">
|
||||||
class="absolute top-0 right-0 m-2 px-3 py-2 flex items-center gap-2 rounded-full bg-gray-800 text-white shadow-md opacity-50 hover:opacity-100 hover:bg-gray-700 transition-all">
|
<button id="copy-ddl-btn" onclick="copySqlToClipboard()"
|
||||||
|
_="on click set my.innerText to 'Copied!' then wait 2s then set my.innerText to 'Copy DDL SQL'"
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"
|
class="px-4 py-2 flex items-center gap-2 rounded-xl bg-gray-900 text-white shadow-lg hover:bg-gray-800 transition-all text-sm font-medium border border-gray-700">
|
||||||
class="h-5 w-5">
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24"
|
||||||
<path stroke-linecap="round" stroke-linejoin="round"
|
stroke="currentColor">
|
||||||
d="M9 12h3.75M9 15h3.75M9 18h3.75m3 .75H18a2.25 2.25 0 0 0 2.25-2.25V6.108c0-1.135-.845-2.098-1.976-2.192a48.424 48.424 0 0 0-1.123-.08m-5.801 0c-.065.21-.1.433-.1.664 0 .414.336.75.75.75h4.5a.75.75 0 0 0 .75-.75 2.25 2.25 0 0 0-.1-.664m-5.8 0A2.251 2.251 0 0 1 13.5 2.25H15c1.012 0 1.867.668 2.15 1.586m-5.8 0c-.376.023-.75.05-1.124.08C9.095 4.01 8.25 4.973 8.25 6.108V8.25m0 0H4.875c-.621 0-1.125.504-1.125 1.125v11.25c0 .621.504 1.125 1.125 1.125h9.75c.621 0 1.125-.504 1.125-1.125V9.375c0-.621-.504-1.125-1.125-1.125H8.25ZM6.75 12h.008v.008H6.75V12Zm0 3h.008v.008H6.75V15Zm0 3h.008v.008H6.75V18Z" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
|
d="M8 5H6a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2v-1M8 5a2 2 0 002 2h2a2 2 0 002-2M8 5a2 2 0 012-2h2a2 2 0 012-2m0 0h2a2 2 0 012 2v3m2 4H10m0 0l3-3m-3 3l3 3" />
|
||||||
</svg>
|
</svg>
|
||||||
|
<span>Copy DDL SQL</span>
|
||||||
<span>Copy SQL</span>
|
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="overflow-auto border rounded-xl bg-slate-50 p-4" style="max-height: 80vh;">
|
<!-- Schema Diagram Frame -->
|
||||||
<div class="flex justify-center">
|
<div class="overflow-auto border-2 border-dashed border-gray-200 rounded-2xl bg-slate-50 p-8 shadow-inner"
|
||||||
<img src="/static/img/schema.svg" alt="Database Schema Diagram" class="max-w-full h-auto">
|
style="max-height: 80vh;">
|
||||||
|
<div class="flex justify-center min-w-max">
|
||||||
|
<div class="bg-white p-4 rounded-2xl shadow-xl border border-gray-100">
|
||||||
|
<object data="/static/img/schema.svg" type="image/svg+xml" id="schema-svg-object"
|
||||||
|
class="block transition-all duration-300"
|
||||||
|
style="min-width: 1000px; height: auto; min-height: 600px;">
|
||||||
|
<p class="text-gray-500">Your browser does not support SVG objects.
|
||||||
|
<a href="/static/img/schema.svg" target="_blank" class="text-blue-500 hover:underline">Click
|
||||||
|
here to view the schema directly.</a>
|
||||||
|
</p>
|
||||||
|
</object>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Schema Footer Info -->
|
||||||
|
<div class="flex items-center justify-center gap-4 text-xs font-medium text-gray-400">
|
||||||
|
<div class="flex items-center gap-1.5">
|
||||||
|
<span class="w-2 h-2 rounded-full bg-green-500"></span>
|
||||||
|
Primary Keys
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-1.5">
|
||||||
|
<span class="w-2 h-2 rounded-full bg-blue-500"></span>
|
||||||
|
Foreign Keys
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-1.5">
|
||||||
|
<span class="w-2 h-2 rounded-full bg-gray-300"></span>
|
||||||
|
Columns
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -27,27 +55,23 @@
|
|||||||
const text = textArea.value;
|
const text = textArea.value;
|
||||||
|
|
||||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||||
// Modern approach: Use Clipboard API
|
|
||||||
navigator.clipboard.writeText(text)
|
navigator.clipboard.writeText(text)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
alert("SQL copied to clipboard!");
|
// We could use a toast here if available
|
||||||
|
console.log("SQL copied to clipboard!");
|
||||||
})
|
})
|
||||||
.catch(err => {
|
.catch(err => {
|
||||||
alert("Failed to copy: " + err);
|
console.error("Failed to copy: " + err);
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// Fallback (older browsers):
|
textArea.classList.remove('hidden');
|
||||||
// - Temporarily show the textarea, select, and use document.execCommand('copy')
|
|
||||||
// - This approach is less reliable but widely supported before navigator.clipboard.
|
|
||||||
textArea.style.display = "block"; // show temporarily
|
|
||||||
textArea.select();
|
textArea.select();
|
||||||
try {
|
try {
|
||||||
document.execCommand("copy");
|
document.execCommand("copy");
|
||||||
alert("SQL copied to clipboard!");
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
alert("Failed to copy: " + err);
|
console.error("Failed to copy: " + err);
|
||||||
}
|
}
|
||||||
textArea.style.display = "none"; // hide again
|
textArea.classList.add('hidden');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,46 +1,66 @@
|
|||||||
<div id="sql-query">
|
<div id="sql-query" class="space-y-8">
|
||||||
{% if error %}
|
{% if error %}
|
||||||
<div class="bg-red-200 text-red-800 p-3 rounded mb-4">
|
<div class="bg-red-50 border-l-4 border-red-400 p-4 rounded shadow-sm animate-fadeIn">
|
||||||
<strong>Error:</strong> {{ error }}
|
<div class="flex">
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<svg class="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
<path fill-rule="evenodd"
|
||||||
|
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"
|
||||||
|
clip-rule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="ml-3">
|
||||||
|
<p class="text-sm text-red-700">
|
||||||
|
<strong class="font-bold">Error:</strong> {{ error }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<form method="POST" hx-post="{{ url_for('sql_explorer.sql_query') }}" hx-target="#sql-query">
|
<form method="POST" hx-post="{{ url_for('sql_explorer.sql_query') }}" hx-target="#sql-query" class="space-y-6">
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
<!-- Title Input -->
|
<!-- Title Input -->
|
||||||
<div>
|
<div class="space-y-1">
|
||||||
<label for="query-title" class="block text-sm font-medium text-gray-700">Title</label>
|
<label for="query-title" class="block text-sm font-semibold text-gray-700">Query Title</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
|
<svg class="h-5 w-5 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
|
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
<input type="text" id="query-title" name="title"
|
<input type="text" id="query-title" name="title"
|
||||||
class="mt-1 block w-full p-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
class="block w-full pl-10 pr-3 py-2.5 border border-gray-300 rounded-xl shadow-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-sm"
|
||||||
placeholder="Enter a title for your query" {% if title %} value="{{ title }}" {% endif %}>
|
placeholder="Untitled Query" {% if title %} value="{{ title }}" {% endif %}>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class=" pt-2">
|
<!-- Magic SQL Generator -->
|
||||||
<label for="query" class="block text-sm font-medium text-gray-700 pb-1">Query</label>
|
<div class="space-y-1">
|
||||||
<textarea name="query" spellcheck="false" id="query"
|
<label for="natural-query" class="block text-sm font-semibold text-gray-700">AI SQL Generator</label>
|
||||||
class="w-full h-48 p-4 font-mono text-sm text-gray-800 bg-gray-100 border border-gray-300 rounded-md resize-none focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
<div class="flex items-center gap-2">
|
||||||
placeholder="Enter your SQL query here..." required
|
<div class="relative flex-grow">
|
||||||
_="on load set my.style.height to my.scrollHeight + 'px'
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
on input set my.style.height to 0 then set my.style.height to my.scrollHeight + 'px'">{{ query }}</textarea>
|
<svg class="h-4 w-4 text-purple-400" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fill-rule="evenodd"
|
||||||
|
d="M5 2a1 1 0 011 1v1h1a1 1 0 010 2H6v1a1 1 0 01-2 0V6H3a1 1 0 010-2h1V3a1 1 0 011-1zm0 10a1 1 0 011 1v1h1a1 1 0 110 2H6v1a1 1 0 11-2 0v-1H3a1 1 0 110-2h1v-1a1 1 0 011-1zM12 2a1 1 0 01.967.744L14.146 7.2 17.5 9.134a1 1 0 010 1.732l-3.354 1.935-1.18 4.455a1 1 0 01-1.933 0L9.854 12.8 6.5 10.866a1 1 0 010-1.732l3.354-1.935 1.18-4.455A1 1 0 0112 2z"
|
||||||
|
clip-rule="evenodd" />
|
||||||
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Natural Language Query Input -->
|
|
||||||
<div class="pt-2">
|
|
||||||
<label for="natural-query" class="block text-sm font-medium text-gray-700 pb-1">Generate SQL from Natural
|
|
||||||
Language</label>
|
|
||||||
<div class="flex items-center">
|
|
||||||
<input type="text" id="natural-query" name="natural_query"
|
<input type="text" id="natural-query" name="natural_query"
|
||||||
class="flex-grow p-2 border border-gray-300 rounded-l-md shadow-sm focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
class="block w-full pl-9 pr-3 py-2.5 border border-purple-200 rounded-xl shadow-sm focus:ring-2 focus:ring-purple-500 focus:border-purple-500 text-sm bg-purple-50/30 placeholder-purple-300"
|
||||||
placeholder="e.g., 'Show me the number of workouts per person'">
|
placeholder="e.g. 'Workouts per person last month'">
|
||||||
|
</div>
|
||||||
<button type="button" hx-post="{{ url_for('sql_explorer.generate_sql') }}"
|
<button type="button" hx-post="{{ url_for('sql_explorer.generate_sql') }}"
|
||||||
hx-include="[name='natural_query']" hx-indicator="#sql-spinner" hx-swap="none"
|
hx-include="[name='natural_query']" hx-indicator="#sql-spinner" hx-swap="none"
|
||||||
_="on htmx:afterRequest set #query.value to detail.xhr.responseText then send input to #query"
|
_="on htmx:afterRequest set #query.value to detail.xhr.responseText then send input to #query"
|
||||||
class="bg-purple-600 text-white p-2.5 rounded-r-md hover:bg-purple-700 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:ring-opacity-50 inline-flex items-center">
|
class="btn-premium whitespace-nowrap inline-flex items-center justify-center px-4 py-2.5 border border-transparent text-sm font-medium rounded-xl text-white bg-purple-600 hover:bg-purple-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-purple-500 shadow-sm transition-all">
|
||||||
Generate SQL
|
Generate
|
||||||
<span id="sql-spinner" class="htmx-indicator ml-2">
|
<span id="sql-spinner" class="htmx-indicator ml-2">
|
||||||
<svg class="animate-spin h-4 w-4 text-white" xmlns="http://www.w3.org/2000/svg" fill="none"
|
<svg class="animate-spin h-4 w-4 text-white" fill="none" viewBox="0 0 24 24">
|
||||||
viewBox="0 0 24 24">
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor"
|
||||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4">
|
stroke-width="4"></circle>
|
||||||
</circle>
|
|
||||||
<path class="opacity-75" fill="currentColor"
|
<path class="opacity-75" fill="currentColor"
|
||||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z">
|
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z">
|
||||||
</path>
|
</path>
|
||||||
@@ -49,151 +69,140 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- SQL Editor -->
|
||||||
|
<div class="space-y-1">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<label for="query" class="block text-sm font-semibold text-gray-700">SQL Statement</label>
|
||||||
|
<span class="text-xs text-gray-400 font-mono">PostgreSQL Dialect</span>
|
||||||
|
</div>
|
||||||
|
<div class="sql-editor-container border border-gray-800 shadow-lg">
|
||||||
|
<textarea name="query" spellcheck="false" id="query"
|
||||||
|
class="sql-editor-textarea h-64 p-4 text-sm resize-none"
|
||||||
|
placeholder="SELECT * FROM workouts LIMIT 10;" required
|
||||||
|
_="on load set my.style.height to Math.max(256, my.scrollHeight) + 'px'
|
||||||
|
on input set my.style.height to 0 then set my.style.height to Math.max(256, my.scrollHeight) + 'px'">{{ query }}</textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Action Buttons -->
|
<!-- Action Buttons -->
|
||||||
<div class="flex space-x-2 pt-1">
|
<div class="flex flex-wrap items-center gap-3 pt-2">
|
||||||
<!-- Execute Button -->
|
<!-- Execute Button -->
|
||||||
<button hx-post="{{ url_for('sql_explorer.execute_sql_query') }}" hx-target="#execute-query-results"
|
<button hx-post="{{ url_for('sql_explorer.execute_sql_query') }}" hx-target="#execute-query-results"
|
||||||
hx-include="[name='query']" hx-trigger="click" hx-swap="innerHTML"
|
hx-include="[name='query']" hx-trigger="click" hx-swap="innerHTML"
|
||||||
class="flex items-center bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500">
|
class="btn-premium inline-flex items-center px-6 py-2.5 border border-transparent text-sm font-semibold rounded-xl text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 shadow-md">
|
||||||
<!-- Execute Icon (Heroicon: Play) -->
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mr-2" viewBox="0 0 20 20" fill="currentColor">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2" fill="none" viewBox="0 0 24 24"
|
<path fill-rule="evenodd"
|
||||||
stroke="currentColor">
|
d="M10 18a8 8 0 100-16 8 8 0 000 16zM9.555 7.168A1 1 0 008 8v4a1 1 0 001.555.832l3-2a1 1 0 000-1.664l-3-2z"
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
clip-rule="evenodd" />
|
||||||
d="M14.752 11.168l-5.197-2.132A1 1 0 008 9.868v4.264a1 1 0 001.555.832l5.197-2.132a1 1 0 000-1.664z" />
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
|
||||||
d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
||||||
</svg>
|
</svg>
|
||||||
Execute
|
Execute Query
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<!-- Plot Button -->
|
<!-- Plot Button -->
|
||||||
<button hx-post="{{ url_for('sql_explorer.plot_unsaved_query') }}" hx-target="#sql-plot-results"
|
<button hx-post="{{ url_for('sql_explorer.plot_unsaved_query') }}" hx-target="#sql-plot-results"
|
||||||
hx-trigger="click" hx-include="[name='query'],[name='title']" hx-indicator="#sql-plot-results-loader"
|
hx-trigger="click" hx-include="[name='query'],[name='title']" hx-indicator="#sql-plot-results-loader"
|
||||||
class="flex items-center bg-blue-100 text-white px-4 py-2 rounded hover:bg-blue-300 focus:outline-none focus:ring-2 focus:ring-blue-300">
|
class="btn-premium inline-flex items-center px-6 py-2.5 border border-gray-300 text-sm font-semibold rounded-xl text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 shadow-sm">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mr-2 text-blue-500" fill="none"
|
||||||
<!-- Plot Icon (Heroicon: Chart Bar) -->
|
viewBox="0 0 24 24" stroke="currentColor">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5"
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
stroke="currentColor" class="h-5 w-5 mr-1">
|
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
||||||
<path stroke-linecap=" round" stroke-linejoin="round"
|
|
||||||
d="M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 0 1 3 19.875v-6.75ZM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 0 1-1.125-1.125V8.625ZM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 0 1-1.125-1.125V4.125Z" />
|
|
||||||
</svg>
|
</svg>
|
||||||
|
Visualize Plot
|
||||||
Plot
|
<span id="sql-plot-results-loader" class="htmx-indicator ml-2">
|
||||||
|
<svg class="animate-spin h-3 w-3 text-blue-500" fill="none" viewBox="0 0 24 24">
|
||||||
<!-- Overlay with Animated Spinner -->
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4">
|
||||||
<div id="sql-plot-results-loader" class="loading-indicator inset-0 opacity-35 pl-2">
|
</circle>
|
||||||
<svg class="animate-spin h-5 w-5 text-white opacity-100" xmlns="http://www.w3.org/2000/svg"
|
<path class="opacity-75" fill="currentColor"
|
||||||
viewBox="0 0 100 100" fill="none">
|
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z">
|
||||||
<circle cx="50" cy="50" r="45" stroke="currentColor" stroke-width="10" stroke-linecap="round"
|
</path>
|
||||||
class="opacity-20"></circle>
|
|
||||||
<path d="M50,5 A45,45 0 0,1 95,50" stroke="currentColor" stroke-width="10"
|
|
||||||
stroke-linecap="round" class="opacity-75"></path>
|
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<!-- Save Button -->
|
<!-- Save Button -->
|
||||||
<button type="submit" name="action" value="save"
|
<button type="submit" name="action" value="save"
|
||||||
class="flex items-center bg-green-500 text-white px-4 py-2 rounded hover:bg-green-600 focus:outline-none focus:ring-2 focus:ring-green-500">
|
class="btn-premium inline-flex items-center px-6 py-2.5 border border-transparent text-sm font-semibold rounded-xl text-white bg-green-600 hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500 shadow-md">
|
||||||
<!-- Save Icon (Heroicon: Save) -->
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mr-2" fill="none" viewBox="0 0 24 24"
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2" fill="none" viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor">
|
stroke="currentColor">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a2 2 0 01-2 2H5a2 2 0 01-2-2V7a2 2 0 012-2h7a2 2 0 012 2v1" />
|
d="M8 7H5a2 2 0 00-2 2v9a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-3m-1 4l-3 3m0 0l-3-3m3 3V4" />
|
||||||
</svg>
|
</svg>
|
||||||
Save
|
Save Query
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<!-- Sql query Results Section -->
|
<!-- Query Results -->
|
||||||
<div id="execute-query-results" class="mt-6">
|
<div id="execute-query-results" class="transition-all duration-300"></div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Plot Results Section -->
|
|
||||||
<div id="sql-plot-results" class="mt-8">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
<!-- Plot Results -->
|
||||||
|
<div id="sql-plot-results" class="transition-all duration-300"></div>
|
||||||
|
|
||||||
<!-- Saved Queries Section -->
|
<!-- Saved Queries Section -->
|
||||||
<div class="mt-8">
|
<div class="pt-10 border-t border-gray-100">
|
||||||
<h2 class="text-xl font-semibold mb-4">Saved Queries</h2>
|
<div class="flex items-center justify-between mb-6">
|
||||||
|
<h2 class="text-xl font-bold text-gray-800">Saved Queries Library</h2>
|
||||||
|
<span class="text-xs font-medium text-gray-400 uppercase tracking-widest">{{ saved_queries|length }} Queries
|
||||||
|
Total</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
{% if saved_queries %}
|
{% if saved_queries %}
|
||||||
<div class="overflow-x-auto">
|
<div class="bg-white border border-gray-200 rounded-2xl overflow-hidden shadow-sm">
|
||||||
<table class="min-w-full bg-white shadow-md rounded-lg overflow-hidden">
|
<table class="min-w-full table-zebra">
|
||||||
<thead>
|
<thead class="bg-gray-50/50">
|
||||||
<tr>
|
<tr>
|
||||||
<th
|
<th
|
||||||
class="py-3 px-6 bg-gray-200 text-left text-xs font-medium text-gray-700 uppercase tracking-wider">
|
class="px-6 py-4 text-left text-xs font-bold text-gray-500 uppercase tracking-wider border-b">
|
||||||
Title</th>
|
Query Title</th>
|
||||||
<th
|
<th
|
||||||
class="py-3 px-6 bg-gray-200 text-left text-xs font-medium text-gray-700 uppercase tracking-wider">
|
class="px-6 py-4 text-right text-xs font-bold text-gray-500 uppercase tracking-wider border-b">
|
||||||
Actions</th>
|
Quick Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody class="divide-y divide-gray-100">
|
||||||
{% for saved in saved_queries %}
|
{% for saved in saved_queries %}
|
||||||
<tr class="hover:bg-gray-100 transition-colors duration-200">
|
<tr class="group transition-colors">
|
||||||
<!-- Query Title as Load Action -->
|
<td class="px-6 py-4 whitespace-nowrap">
|
||||||
<td class="py-4 px-6 border-b">
|
<button hx-get="{{ url_for('sql_explorer.load_sql_query', query_id=saved.id) }}"
|
||||||
<a href="#" hx-get="{{ url_for('sql_explorer.load_sql_query', query_id=saved.id) }}"
|
|
||||||
hx-target="#sql-query"
|
hx-target="#sql-query"
|
||||||
class="flex items-center text-blue-500 hover:text-blue-700 cursor-pointer">
|
class="flex items-center text-sm font-medium text-gray-900 hover:text-blue-600 group-hover:translate-x-1 transition-all">
|
||||||
<!-- Load Icon (Heroicon: Eye) -->
|
<svg xmlns="http://www.w3.org/2000/svg"
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2" fill="none"
|
class="h-4 w-4 mr-2.5 text-gray-400 group-hover:text-blue-500" fill="none"
|
||||||
viewBox="0 0 24 24" stroke="currentColor">
|
viewBox="0 0 24 24" stroke="currentColor">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.477 0 8.268 2.943 9.542 7-1.274 4.057-5.065 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.477 0 8.268 2.943 9.542 7-1.274 4.057-5.065 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
||||||
</svg>
|
</svg>
|
||||||
{{ saved.title }}
|
{{ saved.title or 'Untitled Query' }}
|
||||||
</a>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
<td class="py-4 px-6 border-b">
|
<td class="px-6 py-4 whitespace-nowrap text-right">
|
||||||
<div class="flex space-x-4">
|
<div class="flex items-center justify-end gap-4">
|
||||||
<!-- Plot Action -->
|
<!-- Plot Action -->
|
||||||
<a href="#" hx-get="{{ url_for('sql_explorer.plot_query', query_id=saved.id) }}"
|
<button hx-get="{{ url_for('sql_explorer.plot_query', query_id=saved.id) }}"
|
||||||
hx-target="#sql-plot-results"
|
hx-target="#sql-plot-results" hx-indicator="#sql-plot-results-loader-{{ saved.id }}"
|
||||||
class="flex items-center text-green-500 hover:text-green-700 cursor-pointer"
|
class="text-green-600 hover:text-green-800 p-1 rounded-lg hover:bg-green-50 transition-colors tooltip"
|
||||||
hx-trigger="click" hx-indicator="#sql-plot-results-loader-{{ saved.id }}">
|
title="Visualize Plot">
|
||||||
<!-- Plot Icon (Heroicon: Chart Bar) -->
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none"
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
|
viewBox="0 0 24 24" stroke="currentColor">
|
||||||
stroke-width="1.5" stroke="currentColor" class="h-5 w-5 mr-1">
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
<path stroke-linecap=" round" stroke-linejoin="round"
|
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
||||||
d="M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 0 1 3 19.875v-6.75ZM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 0 1-1.125-1.125V8.625ZM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 0 1-1.125-1.125V4.125Z" />
|
|
||||||
</svg>
|
</svg>
|
||||||
Plot
|
</button>
|
||||||
|
|
||||||
<!-- Overlay with Animated Spinner -->
|
|
||||||
<div id="sql-plot-results-loader-{{ saved.id }}"
|
|
||||||
class="loading-indicator inset-0 opacity-35 pl-2">
|
|
||||||
<svg class="animate-spin h-5 w-5 text-white opacity-100"
|
|
||||||
xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" fill="none">
|
|
||||||
<circle cx="50" cy="50" r="45" stroke="currentColor" stroke-width="10"
|
|
||||||
stroke-linecap="round" class="opacity-20"></circle>
|
|
||||||
<path d="M50,5 A45,45 0 0,1 95,50" stroke="currentColor" stroke-width="10"
|
|
||||||
stroke-linecap="round" class="opacity-75"></path>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<!-- Delete Action -->
|
<!-- Delete Action -->
|
||||||
<a href="#"
|
<button hx-delete="{{ url_for('sql_explorer.delete_sql_query', query_id=saved.id) }}"
|
||||||
hx-delete="{{ url_for('sql_explorer.delete_sql_query', query_id=saved.id) }}"
|
hx-target="#sql-query" hx-confirm="Delete query '{{ saved.title }}'?"
|
||||||
hx-target="#sql-query"
|
class="text-red-400 hover:text-red-600 p-1 rounded-lg hover:bg-red-50 transition-colors">
|
||||||
class="flex items-center text-red-500 hover:text-red-700 cursor-pointer"
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none"
|
||||||
hx-confirm="Are you sure you want to delete the query titled '{{ saved.title }}'?">
|
|
||||||
<!-- Delete Icon (Heroicon: Trash) -->
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-1" fill="none"
|
|
||||||
viewBox="0 0 24 24" stroke="currentColor">
|
viewBox="0 0 24 24" stroke="currentColor">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5-4h4a2 2 0 012 2v1H7V5a2 2 0 012-2z" />
|
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5-4h4a2 2 0 012 2v1H7V5a2 2 0 012-2z" />
|
||||||
</svg>
|
</svg>
|
||||||
Delete
|
</button>
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -202,8 +211,14 @@
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<p class="text-gray-600">No saved queries found.</p>
|
<div class="text-center py-12 bg-gray-50 rounded-2xl border-2 border-dashed border-gray-200">
|
||||||
|
<svg class="mx-auto h-12 w-12 text-gray-300" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
|
d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4" />
|
||||||
|
</svg>
|
||||||
|
<h3 class="mt-2 text-sm font-medium text-gray-900">No saved queries</h3>
|
||||||
|
<p class="mt-1 text-sm text-gray-500">Get started by creating and saving your first SQL query.</p>
|
||||||
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
@@ -6,11 +6,12 @@
|
|||||||
hx-vals='{"filter": "?exercise_id={{ exercise_id }}", "person_id" : "{{ person_id }}" }'
|
hx-vals='{"filter": "?exercise_id={{ exercise_id }}", "person_id" : "{{ person_id }}" }'
|
||||||
hx-target="#container" hx-swap="innerHTML" hx-push-url="true"
|
hx-target="#container" hx-swap="innerHTML" hx-push-url="true"
|
||||||
_='on click trigger closeModalWithoutRefresh'>{{ exercise_name }}</span>
|
_='on click trigger closeModalWithoutRefresh'>{{ exercise_name }}</span>
|
||||||
|
<div class="flex flex-col sm:flex-row space-y-1 sm:space-y-0 sm:space-x-1">
|
||||||
<button
|
<button
|
||||||
class="inline-flex justify-center p-1 text-gray-500 rounded cursor-pointer hover:text-gray-900 hover:bg-gray-100 dark:text-gray-400 dark:hover:text-white dark:hover:bg-gray-600"
|
class="inline-flex justify-center p-1 text-gray-500 rounded cursor-pointer hover:text-gray-900 hover:bg-gray-100 dark:text-gray-400 dark:hover:text-white dark:hover:bg-gray-600"
|
||||||
title="Show Progress Graph"
|
title="Show Progress Graph"
|
||||||
hx-get="{{ url_for('get_exercise_progress_for_user', person_id=person_id, exercise_id=exercise_id) }}"
|
hx-get="{{ url_for('get_exercise_progress_for_user', person_id=person_id, exercise_id=exercise_id) }}"
|
||||||
hx-target="#graph-content-{{ topset_id }}" hx-swap="innerHTML">
|
hx-target="#extra-content-{{ topset_id }}" hx-swap="innerHTML">
|
||||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"
|
||||||
xmlns="http://www.w3.org/2000/svg">
|
xmlns="http://www.w3.org/2000/svg">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
@@ -18,6 +19,19 @@
|
|||||||
</svg>
|
</svg>
|
||||||
<span class="sr-only">Show Progress Graph</span>
|
<span class="sr-only">Show Progress Graph</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
class="inline-flex justify-center p-1 text-gray-500 rounded cursor-pointer hover:text-gray-900 hover:bg-gray-100 dark:text-gray-400 dark:hover:text-white dark:hover:bg-gray-600"
|
||||||
|
title="Show History"
|
||||||
|
hx-get="{{ url_for('workout.get_exercise_history', person_id=person_id, exercise_id=exercise_id, source_topset_id=topset_id) }}"
|
||||||
|
hx-target="#extra-content-{{ topset_id }}" hx-swap="innerHTML">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"
|
||||||
|
xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
|
d="M4 6h16M4 10h16M4 14h16M4 18h16"></path>
|
||||||
|
</svg>
|
||||||
|
<span class="sr-only">Show History</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="w-full">
|
<div class="w-full">
|
||||||
@@ -103,10 +117,10 @@
|
|||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{# Target row modified for dismissible graph #}
|
{# Target row modified for dismissible extra content (graph or history) #}
|
||||||
<tr id="graph-target-{{ topset_id }}">
|
<tr id="extra-target-{{ topset_id }}">
|
||||||
<td colspan="3" class="p-0 relative">
|
<td colspan="3" class="p-0 relative">
|
||||||
<div id="graph-content-{{ topset_id }}" class="graph-content-container" _="
|
<div id="extra-content-{{ topset_id }}" class="extra-content-container" _="
|
||||||
on htmx:afterSwap
|
on htmx:afterSwap
|
||||||
get the next <button.dismiss-button/>
|
get the next <button.dismiss-button/>
|
||||||
if my.innerHTML is not empty and my.innerHTML is not ' '
|
if my.innerHTML is not empty and my.innerHTML is not ' '
|
||||||
@@ -115,12 +129,12 @@
|
|||||||
add .hidden to it
|
add .hidden to it
|
||||||
end
|
end
|
||||||
end">
|
end">
|
||||||
<!-- Progress graph will be loaded here -->
|
<!-- Progress graph or history will be loaded here -->
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
class="absolute top-1 right-1 p-1 bg-white rounded-full text-gray-500 hover:text-gray-700 hover:bg-gray-100 z-10 dismiss-button hidden"
|
class="absolute top-1 right-1 p-1 bg-white rounded-full text-gray-500 hover:text-gray-700 hover:bg-gray-100 z-10 dismiss-button hidden"
|
||||||
title="Dismiss Graph" _="on click
|
title="Dismiss Content" _="on click
|
||||||
get #graph-content-{{ topset_id }}
|
get #extra-content-{{ topset_id }}
|
||||||
set its innerHTML to ''
|
set its innerHTML to ''
|
||||||
add .hidden to me
|
add .hidden to me
|
||||||
end">
|
end">
|
||||||
@@ -129,7 +143,7 @@
|
|||||||
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
|
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
|
||||||
clip-rule="evenodd"></path>
|
clip-rule="evenodd"></path>
|
||||||
</svg>
|
</svg>
|
||||||
<span class="sr-only">Dismiss Graph</span>
|
<span class="sr-only">Dismiss Content</span>
|
||||||
</button>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
29
templates/partials/workout_rows.html
Normal file
29
templates/partials/workout_rows.html
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
{% for workout in workouts %}
|
||||||
|
<tr hx-get="{{ url_for('workout.show_workout', person_id=person_id, workout_id=workout.id) }}" hx-push-url="true"
|
||||||
|
hx-target="#container" class="cursor-pointer">
|
||||||
|
<td class="p-4 whitespace-nowrap text-sm font-normal text-gray-500">
|
||||||
|
{{ workout.start_date | strftime("%b %d %Y") }}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{% for exercise in selected_exercises %}
|
||||||
|
<td class="p-4 whitespace-nowrap text-sm font-semibold text-gray-900">
|
||||||
|
{% for set in workout.exercises[exercise.id] %}
|
||||||
|
{{ set.repetitions }} x {{ set.weight }}kg
|
||||||
|
{% endfor %}
|
||||||
|
</td>
|
||||||
|
{% endfor %}
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
{% if loop.last and has_more %}
|
||||||
|
<tr id="load-more-row">
|
||||||
|
<td colspan="{{ selected_exercises|length + 1 }}" class="p-4 text-center">
|
||||||
|
<button class="text-blue-600 font-medium hover:underline px-4 py-2"
|
||||||
|
hx-get="{{ url_for('person_overview', person_id=person_id, offset=next_offset, limit=limit) }}"
|
||||||
|
hx-include="[name='exercise_id'],[name='min_date'],[name='max_date']" hx-target="#load-more-row"
|
||||||
|
hx-swap="outerHTML">
|
||||||
|
Load More Workouts
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
@@ -107,9 +107,8 @@
|
|||||||
{% for graph in exercise_progress_graphs %}
|
{% for graph in exercise_progress_graphs %}
|
||||||
<div hx-get="{{ url_for('get_exercise_progress_for_user', person_id=person_id, exercise_id=graph.exercise_id, min_date=min_date, max_date=max_date) }}"
|
<div hx-get="{{ url_for('get_exercise_progress_for_user', person_id=person_id, exercise_id=graph.exercise_id, min_date=min_date, max_date=max_date) }}"
|
||||||
hx-trigger="intersect once" hx-swap="outerHTML">
|
hx-trigger="intersect once" hx-swap="outerHTML">
|
||||||
<div class="flex items-center justify-center h-48 bg-gray-50 rounded-lg">
|
<div class="h-48">
|
||||||
<div class="text-sm text-gray-400 animate-pulse font-medium">Loading {{ graph.exercise_name }}...
|
{{ render_partial('partials/skeleton_graph.html') }}
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
@@ -138,22 +137,7 @@
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody class="bg-white">
|
<tbody class="bg-white">
|
||||||
|
|
||||||
{% for workout in workouts %}
|
{% include 'partials/workout_rows.html' %}
|
||||||
<tr hx-get="{{ url_for('workout.show_workout', person_id=person_id, workout_id=workout.id) }}"
|
|
||||||
hx-push-url="true" hx-target="#container" class="cursor-pointer">
|
|
||||||
<td class="p-4 whitespace-nowrap text-sm font-normal text-gray-500">
|
|
||||||
{{ workout.start_date | strftime("%b %d %Y") }}
|
|
||||||
</td>
|
|
||||||
|
|
||||||
{% for exercise in selected_exercises %}
|
|
||||||
<td class="p-4 whitespace-nowrap text-sm font-semibold text-gray-900">
|
|
||||||
{% for set in workout.exercises[exercise.id] %}
|
|
||||||
{{ set.repetitions }} x {{ set.weight }}kg
|
|
||||||
{% endfor %}
|
|
||||||
</td>
|
|
||||||
{% endfor %}
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
|
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
@@ -13,7 +13,8 @@
|
|||||||
<div class="border-b border-gray-200 mb-6 bg-gray-50 z-10">
|
<div class="border-b border-gray-200 mb-6 bg-gray-50 z-10">
|
||||||
<ul class="flex flex-wrap -mb-px text-sm font-medium text-center text-gray-500">
|
<ul class="flex flex-wrap -mb-px text-sm font-medium text-center text-gray-500">
|
||||||
<li class="mr-2">
|
<li class="mr-2">
|
||||||
<label for="radio-users"
|
<label for="radio-users" hx-get="{{ url_for('settings.settings_people') }}"
|
||||||
|
hx-target="#people-tab-content" hx-trigger="click"
|
||||||
class="inline-flex items-center justify-center p-4 border-b-2 rounded-t-lg group cursor-pointer transition-colors
|
class="inline-flex items-center justify-center p-4 border-b-2 rounded-t-lg group cursor-pointer transition-colors
|
||||||
peer-checked/users:border-cyan-600 peer-checked/users:text-cyan-600 border-transparent hover:text-gray-700 hover:border-gray-300">
|
peer-checked/users:border-cyan-600 peer-checked/users:text-cyan-600 border-transparent hover:text-gray-700 hover:border-gray-300">
|
||||||
<svg class="w-5 h-5 mr-2" fill="currentColor" viewBox="0 0 20 20"
|
<svg class="w-5 h-5 mr-2" fill="currentColor" viewBox="0 0 20 20"
|
||||||
@@ -25,7 +26,8 @@
|
|||||||
</label>
|
</label>
|
||||||
</li>
|
</li>
|
||||||
<li class="mr-2">
|
<li class="mr-2">
|
||||||
<label for="radio-exercises"
|
<label for="radio-exercises" hx-get="{{ url_for('settings.settings_exercises') }}"
|
||||||
|
hx-target="#exercises-tab-content" hx-trigger="click"
|
||||||
class="inline-flex items-center justify-center p-4 border-b-2 rounded-t-lg group cursor-pointer transition-colors
|
class="inline-flex items-center justify-center p-4 border-b-2 rounded-t-lg group cursor-pointer transition-colors
|
||||||
peer-checked/exercises:border-cyan-600 peer-checked/exercises:text-cyan-600 border-transparent hover:text-gray-700 hover:border-gray-300">
|
peer-checked/exercises:border-cyan-600 peer-checked/exercises:text-cyan-600 border-transparent hover:text-gray-700 hover:border-gray-300">
|
||||||
<svg class="w-5 h-5 mr-2" fill="currentColor" viewBox="0 0 20 20"
|
<svg class="w-5 h-5 mr-2" fill="currentColor" viewBox="0 0 20 20"
|
||||||
@@ -39,7 +41,8 @@
|
|||||||
</label>
|
</label>
|
||||||
</li>
|
</li>
|
||||||
<li class="mr-2">
|
<li class="mr-2">
|
||||||
<label for="radio-export"
|
<label for="radio-export" hx-get="{{ url_for('settings.settings_export') }}"
|
||||||
|
hx-target="#export-tab-content" hx-trigger="click"
|
||||||
class="inline-flex items-center justify-center p-4 border-b-2 rounded-t-lg group cursor-pointer transition-colors
|
class="inline-flex items-center justify-center p-4 border-b-2 rounded-t-lg group cursor-pointer transition-colors
|
||||||
peer-checked/export:border-cyan-600 peer-checked/export:text-cyan-600 border-transparent hover:text-gray-700 hover:border-gray-300">
|
peer-checked/export:border-cyan-600 peer-checked/export:text-cyan-600 border-transparent hover:text-gray-700 hover:border-gray-300">
|
||||||
<svg class="w-5 h-5 mr-2" fill="currentColor" viewBox="0 0 20 20"
|
<svg class="w-5 h-5 mr-2" fill="currentColor" viewBox="0 0 20 20"
|
||||||
@@ -52,8 +55,8 @@
|
|||||||
</label>
|
</label>
|
||||||
</li>
|
</li>
|
||||||
<li class="mr-2">
|
<li class="mr-2">
|
||||||
<label for="radio-activity" hx-get="/settings/activity_logs" hx-target="#activity-logs-container"
|
<label for="radio-activity" hx-get="{{ url_for('settings.settings_activity') }}"
|
||||||
hx-trigger="click"
|
hx-target="#activity-tab-content" hx-trigger="click"
|
||||||
class="inline-flex items-center justify-center p-4 border-b-2 rounded-t-lg group cursor-pointer transition-colors
|
class="inline-flex items-center justify-center p-4 border-b-2 rounded-t-lg group cursor-pointer transition-colors
|
||||||
peer-checked/activity:border-cyan-600 peer-checked/activity:text-cyan-600 border-transparent hover:text-gray-700 hover:border-gray-300">
|
peer-checked/activity:border-cyan-600 peer-checked/activity:text-cyan-600 border-transparent hover:text-gray-700 hover:border-gray-300">
|
||||||
<svg class="w-5 h-5 mr-2" fill="currentColor" viewBox="0 0 20 20"
|
<svg class="w-5 h-5 mr-2" fill="currentColor" viewBox="0 0 20 20"
|
||||||
@@ -69,264 +72,38 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Users Tab Content -->
|
<!-- Users Tab Content -->
|
||||||
<div class="hidden peer-checked/users:block">
|
<div class="hidden peer-checked/users:block" id="people-tab-content"
|
||||||
<div class="bg-white shadow rounded-lg p-4 sm:p-6 lg:p-8 mb-8">
|
hx-get="{{ url_for('settings.settings_people') }}" hx-trigger="load">
|
||||||
<div class="mb-4 flex items-center justify-between">
|
<div class="flex justify-center p-12">
|
||||||
<div>
|
<div class="flex flex-col items-center">
|
||||||
<h3 class="text-xl font-bold text-gray-900">User Management</h3>
|
<div class="animate-spin rounded-full h-10 w-10 border-b-2 border-cyan-600 mb-4"></div>
|
||||||
<p class="text-sm text-gray-500">Add, edit or remove people from the tracker.</p>
|
<p class="text-sm text-gray-500">Loading users...</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-col">
|
|
||||||
<div class="overflow-x-auto rounded-lg">
|
|
||||||
<div class="align-middle inline-block min-w-full">
|
|
||||||
<div class="shadow overflow-hidden border-b border-gray-200 sm:rounded-lg">
|
|
||||||
<table class="min-w-full divide-y divide-gray-200">
|
|
||||||
<thead class="bg-gray-50">
|
|
||||||
<tr>
|
|
||||||
<th scope="col"
|
|
||||||
class="p-4 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
||||||
Name
|
|
||||||
</th>
|
|
||||||
<th scope="col"
|
|
||||||
class="p-4 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
||||||
<div class="relative max-w-xs ml-auto">
|
|
||||||
<div
|
|
||||||
class="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none">
|
|
||||||
<svg class="w-4 h-4 text-gray-500" aria-hidden="true"
|
|
||||||
xmlns="http://www.w3.org/2000/svg" fill="none"
|
|
||||||
viewBox="0 0 20 20">
|
|
||||||
<path stroke="currentColor" stroke-linecap="round"
|
|
||||||
stroke-linejoin="round" stroke-width="2"
|
|
||||||
d="m19 19-4-4m0-7A7 7 0 1 1 1 8a7 7 0 0 1 14 0Z"></path>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<input type="search" id="people-search"
|
|
||||||
class="block w-full p-2 pl-10 text-sm text-gray-900 border border-gray-300 rounded-lg bg-gray-50 focus:ring-cyan-500 focus:border-cyan-500 shadow-sm"
|
|
||||||
placeholder="Search users..."
|
|
||||||
_="on input show <tbody>tr/> in closest <table/> when its textContent.toLowerCase() contains my value.toLowerCase()">
|
|
||||||
</div>
|
|
||||||
</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody class="bg-white divide-y divide-gray-200" id="new-person" hx-target="closest tr"
|
|
||||||
hx-swap="outerHTML swap:0.5s">
|
|
||||||
{% for p in people %}
|
|
||||||
{{ render_partial('partials/person.html', person_id=p['PersonId'], name=p['Name'])}}
|
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form class="w-full mt-6 bg-gray-50 p-4 rounded-lg border border-gray-100"
|
|
||||||
hx-post="{{ url_for('create_person') }}" hx-swap="beforeend" hx-target="#new-person" _="on htmx:afterRequest
|
|
||||||
render #notification-template with (message: 'User added') then append it to #notifications-container
|
|
||||||
then call _hyperscript.processNode(#notifications-container)
|
|
||||||
then reset() me">
|
|
||||||
<div class="flex flex-col sm:flex-row gap-4 items-end">
|
|
||||||
<div class="grow w-full sm:w-auto">
|
|
||||||
<label class="block uppercase tracking-wide text-gray-700 text-xs font-bold mb-2"
|
|
||||||
for="person-name">
|
|
||||||
New user
|
|
||||||
</label>
|
|
||||||
<input id="person-name"
|
|
||||||
class="appearance-none block w-full bg-white text-gray-700 border border-gray-300 rounded-lg py-3 px-4 leading-tight focus:outline-none focus:ring-2 focus:ring-cyan-500 focus:border-transparent"
|
|
||||||
type="text" name="name" placeholder="Full Name">
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
class="w-full sm:w-auto flex items-center justify-center text-white bg-cyan-600 hover:bg-cyan-700 focus:ring-4 focus:ring-cyan-200 font-medium rounded-lg text-sm px-5 py-3 transition-colors shadow-sm"
|
|
||||||
type="submit">
|
|
||||||
<svg class="w-5 h-5 mr-2" fill="currentColor" viewBox="0 0 20 20"
|
|
||||||
xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<path fill-rule="evenodd"
|
|
||||||
d="M10 5a1 1 0 011 1v3h3a1 1 0 110 2h-3v3a1 1 0 11-2 0v-3H6a1 1 0 110-2h3V6a1 1 0 011-1z"
|
|
||||||
clip-rule="evenodd"></path>
|
|
||||||
</svg>
|
|
||||||
Add User
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Exercises Tab Content -->
|
<!-- Exercises Tab Content -->
|
||||||
<div class="hidden peer-checked/exercises:block">
|
<div class="hidden peer-checked/exercises:block" id="exercises-tab-content">
|
||||||
<div class="bg-white shadow rounded-lg p-4 sm:p-6 lg:p-8 mb-8">
|
<div class="flex justify-center p-12">
|
||||||
<div class="mb-6">
|
<div class="flex flex-col items-center">
|
||||||
<h3 class="text-xl font-bold text-gray-900">Exercise Configuration</h3>
|
<div class="animate-spin rounded-full h-10 w-10 border-b-2 border-cyan-600 mb-4"></div>
|
||||||
<p class="text-sm text-gray-500">Manage available exercises and their categories.</p>
|
<p class="text-sm text-gray-500">Loading exercises...</p>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex flex-col">
|
|
||||||
<div class="overflow-x-auto rounded-lg">
|
|
||||||
<div class="align-middle inline-block min-w-full">
|
|
||||||
<div class="shadow overflow-hidden border-b border-gray-200 sm:rounded-lg">
|
|
||||||
<table class="min-w-full divide-y divide-gray-200">
|
|
||||||
<thead class="bg-gray-50">
|
|
||||||
<tr>
|
|
||||||
<th scope="col"
|
|
||||||
class="p-4 text-left text-xs font-medium text-gray-500 uppercase tracking-wider w-1/4">
|
|
||||||
Name
|
|
||||||
</th>
|
|
||||||
<th scope="col"
|
|
||||||
class="p-4 text-left text-xs font-medium text-gray-500 uppercase tracking-wider w-1/2">
|
|
||||||
Attributes
|
|
||||||
</th>
|
|
||||||
<th scope="col"
|
|
||||||
class="p-4 text-right text-xs font-medium text-gray-500 uppercase tracking-wider w-1/4">
|
|
||||||
<div class="relative max-w-xs ml-auto">
|
|
||||||
<div
|
|
||||||
class="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none">
|
|
||||||
<svg class="w-4 h-4 text-gray-500" aria-hidden="true"
|
|
||||||
xmlns="http://www.w3.org/2000/svg" fill="none"
|
|
||||||
viewBox="0 0 20 20">
|
|
||||||
<path stroke="currentColor" stroke-linecap="round"
|
|
||||||
stroke-linejoin="round" stroke-width="2"
|
|
||||||
d="m19 19-4-4m0-7A7 7 0 1 1 1 8a7 7 0 0 1 14 0Z"></path>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<input type="search" id="exercise-search"
|
|
||||||
class="block w-full p-2 pl-10 text-sm text-gray-900 border border-gray-300 rounded-lg bg-gray-50 focus:ring-cyan-500 focus:border-cyan-500 shadow-sm"
|
|
||||||
placeholder="Search exercises..."
|
|
||||||
_="on input show <tbody>tr/> in closest <table/> when its textContent.toLowerCase() contains my value.toLowerCase()">
|
|
||||||
</div>
|
|
||||||
</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody class="bg-white divide-y divide-gray-200" id="new-exercise"
|
|
||||||
hx-target="closest tr" hx-swap="outerHTML swap:0.5s">
|
|
||||||
{% for exercise in exercises %}
|
|
||||||
{{ render_partial('partials/exercise.html', exercise_id=exercise.exercise_id,
|
|
||||||
name=exercise.name, attributes=exercise.attributes)}}
|
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-10">
|
|
||||||
<h4 class="text-lg font-semibold text-gray-900 mb-4">Add New Exercise</h4>
|
|
||||||
<form class="bg-gray-50 p-6 rounded-lg border border-gray-100"
|
|
||||||
hx-post="{{ url_for('create_exercise') }}" hx-swap="beforeend" hx-target="#new-exercise" _="on htmx:afterRequest
|
|
||||||
render #notification-template with (message: 'Exercise added') then append it to #notifications-container
|
|
||||||
then call _hyperscript.processNode(#notifications-container)
|
|
||||||
then reset() me">
|
|
||||||
|
|
||||||
<div class="grid grid-cols-1 lg:grid-cols-4 gap-6 items-start">
|
|
||||||
<div class="lg:col-span-1">
|
|
||||||
<label class="block uppercase tracking-wide text-gray-700 text-xs font-bold mb-2">
|
|
||||||
Exercise Name
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
class="appearance-none block w-full bg-white text-gray-700 border border-gray-300 rounded-lg py-3 px-4 leading-tight focus:outline-none focus:ring-2 focus:ring-cyan-500 focus:border-transparent"
|
|
||||||
type="text" name="name" placeholder="e.g. Bench Press">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="lg:col-span-2 grid grid-cols-1 sm:grid-cols-3 gap-4">
|
|
||||||
{% for cat_name, options in all_attributes.items() %}
|
|
||||||
<div>
|
|
||||||
<label class="block text-xs font-bold text-gray-500 uppercase mb-1">{{ cat_name
|
|
||||||
}}</label>
|
|
||||||
{{ render_partial('partials/custom_select.html',
|
|
||||||
name='attribute_ids',
|
|
||||||
options=options,
|
|
||||||
multiple=true,
|
|
||||||
search=true,
|
|
||||||
placeholder='Select ' ~ cat_name
|
|
||||||
)}}
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="lg:col-span-1 pt-6">
|
|
||||||
<button
|
|
||||||
class="w-full flex items-center justify-center text-white bg-cyan-600 hover:bg-cyan-700 focus:ring-4 focus:ring-cyan-200 font-medium rounded-lg text-sm px-5 py-3 transition-colors h-12 cursor-pointer shadow-sm"
|
|
||||||
type="submit">
|
|
||||||
<svg class="w-5 h-5 mr-2" fill="currentColor" viewBox="0 0 20 20"
|
|
||||||
xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<path fill-rule="evenodd"
|
|
||||||
d="M10 5a1 1 0 011 1v3h3a1 1 0 110 2h-3v3a1 1 0 11-2 0v-3H6a1 1 0 110-2h3V6a1 1 0 011-1z"
|
|
||||||
clip-rule="evenodd"></path>
|
|
||||||
</svg>
|
|
||||||
Add Exercise
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Export Tab Content -->
|
<!-- Export Tab Content -->
|
||||||
<div class="hidden peer-checked/export:block">
|
<div class="hidden peer-checked/export:block" id="export-tab-content">
|
||||||
<div class="bg-white shadow rounded-lg p-4 sm:p-6 lg:p-8 mb-8">
|
<div class="flex justify-center p-12">
|
||||||
<div class="mb-6">
|
<div class="flex flex-col items-center">
|
||||||
<h3 class="text-xl font-bold text-gray-900">Data & Portability</h3>
|
<div class="animate-spin rounded-full h-10 w-10 border-b-2 border-cyan-600 mb-4"></div>
|
||||||
<p class="text-sm text-gray-500">Export your data for backup or external analysis.</p>
|
<p class="text-sm text-gray-500">Loading data settings...</p>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-8">
|
|
||||||
<!-- CSV Export -->
|
|
||||||
<div
|
|
||||||
class="border border-gray-200 rounded-xl p-6 hover:border-cyan-200 transition-colors bg-gray-50/50">
|
|
||||||
<div class="flex items-center mb-4">
|
|
||||||
<div class="p-3 bg-green-100 rounded-lg text-green-600 mr-4 shadow-sm">
|
|
||||||
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 20 20"
|
|
||||||
xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<path fill-rule="evenodd"
|
|
||||||
d="M6 2a2 2 0 00-2 2v12a2 2 0 002 2h8a2 2 0 002-2V7.414A2 2 0 0015.414 6L12 2.586A2 2 0 0010.586 2H6zm5 6a1 1 0 10-2 0v3.586l-1.293-1.293a1 1 0 10-1.414 1.414l3 3a1 1 0 001.414 0l3-3a1 1 0 00-1.414-1.414L11 11.586V8z"
|
|
||||||
clip-rule="evenodd"></path>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<h4 class="text-lg font-bold text-gray-900">Workout History</h4>
|
|
||||||
</div>
|
|
||||||
<p class="text-sm text-gray-600 mb-6 font-medium">Download all workout records, sets, and
|
|
||||||
performance data in CSV format.</p>
|
|
||||||
<a href="{{ url_for('export.export_workouts_csv') }}"
|
|
||||||
class="text-white bg-green-600 hover:bg-green-700 focus:ring-4 focus:ring-green-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center inline-flex items-center justify-center w-full shadow-sm">
|
|
||||||
Download CSV
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- SQL Export -->
|
|
||||||
<div
|
|
||||||
class="border border-gray-200 rounded-xl p-6 hover:border-cyan-200 transition-colors bg-gray-50/50">
|
|
||||||
<div class="flex items-center mb-4">
|
|
||||||
<div class="p-3 bg-blue-100 rounded-lg text-blue-600 mr-4 shadow-sm">
|
|
||||||
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 20 20"
|
|
||||||
xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<path
|
|
||||||
d="M2 5a2 2 0 012-2h12a2 2 0 012 2v10a2 2 0 01-2 2H4a2 2 0 01-2-2V5zm3.293 1.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z"
|
|
||||||
clip-rule="evenodd"></path>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<h4 class="text-lg font-bold text-gray-900">Database Snapshot</h4>
|
|
||||||
</div>
|
|
||||||
<p class="text-sm text-gray-600 mb-6 font-medium">Create a full SQL dump of your database including
|
|
||||||
schema and all records.</p>
|
|
||||||
<a href="{{ url_for('export.export_database_sql') }}"
|
|
||||||
class="text-white bg-blue-600 hover:bg-blue-700 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center inline-flex items-center justify-center w-full shadow-sm">
|
|
||||||
Download SQL Script
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Activity Tab Content -->
|
<!-- Activity Tab Content -->
|
||||||
<div class="hidden peer-checked/activity:block">
|
<div class="hidden peer-checked/activity:block" id="activity-tab-content">
|
||||||
<div class="bg-white shadow rounded-lg p-4 sm:p-6 lg:p-8 mb-8">
|
|
||||||
<div class="mb-6 border-b border-gray-100 pb-4">
|
|
||||||
<h3 class="text-xl font-bold text-gray-900">Activity Logs</h3>
|
|
||||||
<p class="text-sm text-gray-500">Review recent actions and administrative changes.</p>
|
|
||||||
</div>
|
|
||||||
<div id="activity-logs-container">
|
|
||||||
<div class="flex justify-center p-12">
|
<div class="flex justify-center p-12">
|
||||||
<div class="flex flex-col items-center">
|
<div class="flex flex-col items-center">
|
||||||
<div class="animate-spin rounded-full h-10 w-10 border-b-2 border-cyan-600 mb-4"></div>
|
<div class="animate-spin rounded-full h-10 w-10 border-b-2 border-cyan-600 mb-4"></div>
|
||||||
@@ -335,7 +112,5 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -2,17 +2,74 @@
|
|||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
|
||||||
<div class="bg-white shadow rounded-lg p-4 sm:p-6 xl:p-8 mb-8">
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
<div class="mb-4 flex items-center justify-between">
|
<div class="mb-8 flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<h3 class="text-xl font-bold text-gray-900 mb-2">SQL Explorer</h3>
|
<h1 class="text-3xl font-extrabold text-gray-900 tracking-tight sm:text-4xl">
|
||||||
|
SQL <span class="text-blue-600">Explorer</span>
|
||||||
|
</h1>
|
||||||
|
<p class="mt-2 text-sm text-gray-500 max-w-2xl">
|
||||||
|
Query your workout data directly using SQL or natural language. Explore the database schema below to
|
||||||
|
understand the available tables and relationships.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
class="inline-flex items-center px-3 py-1 rounded-full text-xs font-medium bg-blue-100 text-blue-800 border border-blue-200">
|
||||||
|
<span class="flex h-2 w-2 mr-1.5 space-x-1">
|
||||||
|
<span class="animate-ping absolute inline-flex h-2 w-2 rounded-full bg-blue-400 opacity-75"></span>
|
||||||
|
<span class="relative inline-flex rounded-full h-2 w-2 bg-blue-500"></span>
|
||||||
|
</span>
|
||||||
|
PostgreSQL Connected
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div hx-get="{{ url_for('sql_explorer.sql_schema') }}" hx-trigger="load"></div>
|
<div class="grid grid-cols-1 gap-8">
|
||||||
|
<!-- Schema Section -->
|
||||||
|
<section
|
||||||
|
class="bg-white shadow-sm border border-gray-200 rounded-2xl overflow-hidden transition-all hover:shadow-md">
|
||||||
|
<div class="px-6 py-4 border-b border-gray-100 flex items-center justify-between bg-gray-50/50">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-gray-400" fill="none"
|
||||||
|
viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
|
d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
|
||||||
|
</svg>
|
||||||
|
<h3 class="text-lg font-semibold text-gray-800">Database Schema</h3>
|
||||||
|
</div>
|
||||||
|
<button class="text-sm text-blue-600 hover:text-blue-700 font-medium transition-colors" _="on click toggle .hidden on #schema-content then
|
||||||
|
if #schema-content.classList.contains('hidden') set my.innerText to 'Show Schema'
|
||||||
|
else set my.innerText to 'Hide Schema'">
|
||||||
|
Hide Schema
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div id="schema-content" class="p-6 transition-all duration-300">
|
||||||
|
<div hx-get="{{ url_for('sql_explorer.sql_schema') }}" hx-trigger="load">
|
||||||
|
<!-- Loader placeholder -->
|
||||||
|
<div class="flex justify-center items-center py-12">
|
||||||
|
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Query Section -->
|
||||||
|
<section
|
||||||
|
class="bg-white shadow-sm border border-gray-200 rounded-2xl overflow-hidden transition-all hover:shadow-md">
|
||||||
|
<div class="px-6 py-4 border-b border-gray-100 flex items-center gap-2 bg-gray-50/50">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-gray-400" fill="none" viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
|
d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4" />
|
||||||
|
</svg>
|
||||||
|
<h3 class="text-lg font-semibold text-gray-800">SQL Query Editor</h3>
|
||||||
|
</div>
|
||||||
|
<div class="p-6">
|
||||||
{{ render_partial('partials/sql_explorer/sql_query.html', saved_queries=saved_queries) }}
|
{{ render_partial('partials/sql_explorer/sql_query.html', saved_queries=saved_queries) }}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
38
utils.py
38
utils.py
@@ -34,10 +34,10 @@ def get_exercise_graph_model(title, estimated_1rm, repetitions, weight, start_da
|
|||||||
vb_height *= 75 / vb_height # Scale to 75px height
|
vb_height *= 75 / vb_height # Scale to 75px height
|
||||||
|
|
||||||
# Use NumPy arrays for efficient scaling
|
# Use NumPy arrays for efficient scaling
|
||||||
relative_positions = np.array([(date - min_date).days / total_span for date in start_dates])
|
relative_positions = np.round(np.array([(date - min_date).days / total_span for date in start_dates]), 1)
|
||||||
estimated_1rm_scaled = ((np.array(estimated_1rm) - min_e1rm) / e1rm_range) * vb_height
|
estimated_1rm_scaled = np.round(((np.array(estimated_1rm) - min_e1rm) / e1rm_range) * vb_height, 1)
|
||||||
repetitions_scaled = ((np.array(repetitions) - min_reps) / reps_range) * vb_height
|
repetitions_scaled = np.round(((np.array(repetitions) - min_reps) / reps_range) * vb_height, 1)
|
||||||
weight_scaled = ((np.array(weight) - min_weight) / weight_range) * vb_height
|
weight_scaled = np.round(((np.array(weight) - min_weight) / weight_range) * vb_height, 1)
|
||||||
|
|
||||||
# Calculate slope and line of best fit
|
# Calculate slope and line of best fit
|
||||||
slope_kg_per_day = e1rm_range / total_span
|
slope_kg_per_day = e1rm_range / total_span
|
||||||
@@ -57,7 +57,7 @@ def get_exercise_graph_model(title, estimated_1rm, repetitions, weight, start_da
|
|||||||
if len(np.unique(x_fit)) > degree:
|
if len(np.unique(x_fit)) > degree:
|
||||||
coeffs = np.polyfit(x_fit, y_fit, degree)
|
coeffs = np.polyfit(x_fit, y_fit, degree)
|
||||||
poly_fit = np.poly1d(coeffs)
|
poly_fit = np.poly1d(coeffs)
|
||||||
y_best_fit = poly_fit(relative_positions)
|
y_best_fit = np.round(poly_fit(relative_positions), 1)
|
||||||
best_fit_points = list(zip(y_best_fit.tolist(), relative_positions.tolist()))
|
best_fit_points = list(zip(y_best_fit.tolist(), relative_positions.tolist()))
|
||||||
else:
|
else:
|
||||||
best_fit_points = []
|
best_fit_points = []
|
||||||
@@ -258,20 +258,6 @@ def prepare_svg_plot_data(results, columns, title):
|
|||||||
plot_data['plot_type'] = 'table' # Fallback if essential data is missing
|
plot_data['plot_type'] = 'table' # Fallback if essential data is missing
|
||||||
return plot_data
|
return plot_data
|
||||||
|
|
||||||
def get_client_ip():
|
|
||||||
"""Get real client IP address, checking proxy headers first"""
|
|
||||||
# Check common proxy headers in order of preference
|
|
||||||
if request.headers.get('X-Forwarded-For'):
|
|
||||||
# X-Forwarded-For can contain multiple IPs, get the first (original client)
|
|
||||||
return request.headers.get('X-Forwarded-For').split(',')[0].strip()
|
|
||||||
elif request.headers.get('X-Real-IP'):
|
|
||||||
return request.headers.get('X-Real-IP')
|
|
||||||
elif request.headers.get('CF-Connecting-IP'): # Cloudflare
|
|
||||||
return request.headers.get('CF-Connecting-IP')
|
|
||||||
else:
|
|
||||||
# Fallback to direct connection IP
|
|
||||||
return request.remote_addr
|
|
||||||
|
|
||||||
# Calculate ranges (handle datetime separately)
|
# Calculate ranges (handle datetime separately)
|
||||||
if x_type == 'datetime':
|
if x_type == 'datetime':
|
||||||
valid_dates = [d for d in x_values_raw if d is not None]
|
valid_dates = [d for d in x_values_raw if d is not None]
|
||||||
@@ -361,3 +347,17 @@ def get_client_ip():
|
|||||||
|
|
||||||
|
|
||||||
return plot_data
|
return plot_data
|
||||||
|
|
||||||
|
def get_client_ip():
|
||||||
|
"""Get real client IP address, checking proxy headers first"""
|
||||||
|
# Check common proxy headers in order of preference
|
||||||
|
if request.headers.get('X-Forwarded-For'):
|
||||||
|
# X-Forwarded-For can contain multiple IPs, get the first (original client)
|
||||||
|
return request.headers.get('X-Forwarded-For').split(',')[0].strip()
|
||||||
|
elif request.headers.get('X-Real-IP'):
|
||||||
|
return request.headers.get('X-Real-IP')
|
||||||
|
elif request.headers.get('CF-Connecting-IP'): # Cloudflare
|
||||||
|
return request.headers.get('CF-Connecting-IP')
|
||||||
|
else:
|
||||||
|
# Fallback to direct connection IP
|
||||||
|
return request.remote_addr
|
||||||
|
|||||||
710
uv.lock
generated
Normal file
710
uv.lock
generated
Normal file
@@ -0,0 +1,710 @@
|
|||||||
|
version = 1
|
||||||
|
revision = 3
|
||||||
|
requires-python = ">=3.14.0"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bcrypt"
|
||||||
|
version = "5.0.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/d4/36/3329e2518d70ad8e2e5817d5a4cac6bba05a47767ec416c7d020a965f408/bcrypt-5.0.0.tar.gz", hash = "sha256:f748f7c2d6fd375cc93d3fba7ef4a9e3a092421b8dbf34d8d4dc06be9492dfdd", size = 25386, upload-time = "2025-09-25T19:50:47.829Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f8/14/c18006f91816606a4abe294ccc5d1e6f0e42304df5a33710e9e8e95416e1/bcrypt-5.0.0-cp314-cp314t-macosx_10_12_universal2.whl", hash = "sha256:4870a52610537037adb382444fefd3706d96d663ac44cbb2f37e3919dca3d7ef", size = 481862, upload-time = "2025-09-25T19:49:28.365Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/67/49/dd074d831f00e589537e07a0725cf0e220d1f0d5d8e85ad5bbff251c45aa/bcrypt-5.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48f753100931605686f74e27a7b49238122aa761a9aefe9373265b8b7aa43ea4", size = 268544, upload-time = "2025-09-25T19:49:30.39Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f5/91/50ccba088b8c474545b034a1424d05195d9fcbaaf802ab8bfe2be5a4e0d7/bcrypt-5.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f70aadb7a809305226daedf75d90379c397b094755a710d7014b8b117df1ebbf", size = 271787, upload-time = "2025-09-25T19:49:32.144Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/aa/e7/d7dba133e02abcda3b52087a7eea8c0d4f64d3e593b4fffc10c31b7061f3/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:744d3c6b164caa658adcb72cb8cc9ad9b4b75c7db507ab4bc2480474a51989da", size = 269753, upload-time = "2025-09-25T19:49:33.885Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/33/fc/5b145673c4b8d01018307b5c2c1fc87a6f5a436f0ad56607aee389de8ee3/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a28bc05039bdf3289d757f49d616ab3efe8cf40d8e8001ccdd621cd4f98f4fc9", size = 289587, upload-time = "2025-09-25T19:49:35.144Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/27/d7/1ff22703ec6d4f90e62f1a5654b8867ef96bafb8e8102c2288333e1a6ca6/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:7f277a4b3390ab4bebe597800a90da0edae882c6196d3038a73adf446c4f969f", size = 272178, upload-time = "2025-09-25T19:49:36.793Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c8/88/815b6d558a1e4d40ece04a2f84865b0fef233513bd85fd0e40c294272d62/bcrypt-5.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:79cfa161eda8d2ddf29acad370356b47f02387153b11d46042e93a0a95127493", size = 269295, upload-time = "2025-09-25T19:49:38.164Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/51/8c/e0db387c79ab4931fc89827d37608c31cc57b6edc08ccd2386139028dc0d/bcrypt-5.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:a5393eae5722bcef046a990b84dff02b954904c36a194f6cfc817d7dca6c6f0b", size = 271700, upload-time = "2025-09-25T19:49:39.917Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/06/83/1570edddd150f572dbe9fc00f6203a89fc7d4226821f67328a85c330f239/bcrypt-5.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7f4c94dec1b5ab5d522750cb059bb9409ea8872d4494fd152b53cca99f1ddd8c", size = 334034, upload-time = "2025-09-25T19:49:41.227Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c9/f2/ea64e51a65e56ae7a8a4ec236c2bfbdd4b23008abd50ac33fbb2d1d15424/bcrypt-5.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0cae4cb350934dfd74c020525eeae0a5f79257e8a201c0c176f4b84fdbf2a4b4", size = 352766, upload-time = "2025-09-25T19:49:43.08Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d7/d4/1a388d21ee66876f27d1a1f41287897d0c0f1712ef97d395d708ba93004c/bcrypt-5.0.0-cp314-cp314t-win32.whl", hash = "sha256:b17366316c654e1ad0306a6858e189fc835eca39f7eb2cafd6aaca8ce0c40a2e", size = 152449, upload-time = "2025-09-25T19:49:44.971Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3f/61/3291c2243ae0229e5bca5d19f4032cecad5dfb05a2557169d3a69dc0ba91/bcrypt-5.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:92864f54fb48b4c718fc92a32825d0e42265a627f956bc0361fe869f1adc3e7d", size = 149310, upload-time = "2025-09-25T19:49:46.162Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3e/89/4b01c52ae0c1a681d4021e5dd3e45b111a8fb47254a274fa9a378d8d834b/bcrypt-5.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dd19cf5184a90c873009244586396a6a884d591a5323f0e8a5922560718d4993", size = 143761, upload-time = "2025-09-25T19:49:47.345Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/84/29/6237f151fbfe295fe3e074ecc6d44228faa1e842a81f6d34a02937ee1736/bcrypt-5.0.0-cp38-abi3-macosx_10_12_universal2.whl", hash = "sha256:fc746432b951e92b58317af8e0ca746efe93e66555f1b40888865ef5bf56446b", size = 494553, upload-time = "2025-09-25T19:49:49.006Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/45/b6/4c1205dde5e464ea3bd88e8742e19f899c16fa8916fb8510a851fae985b5/bcrypt-5.0.0-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c2388ca94ffee269b6038d48747f4ce8df0ffbea43f31abfa18ac72f0218effb", size = 275009, upload-time = "2025-09-25T19:49:50.581Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3b/71/427945e6ead72ccffe77894b2655b695ccf14ae1866cd977e185d606dd2f/bcrypt-5.0.0-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:560ddb6ec730386e7b3b26b8b4c88197aaed924430e7b74666a586ac997249ef", size = 278029, upload-time = "2025-09-25T19:49:52.533Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/17/72/c344825e3b83c5389a369c8a8e58ffe1480b8a699f46c127c34580c4666b/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:d79e5c65dcc9af213594d6f7f1fa2c98ad3fc10431e7aa53c176b441943efbdd", size = 275907, upload-time = "2025-09-25T19:49:54.709Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0b/7e/d4e47d2df1641a36d1212e5c0514f5291e1a956a7749f1e595c07a972038/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2b732e7d388fa22d48920baa267ba5d97cca38070b69c0e2d37087b381c681fd", size = 296500, upload-time = "2025-09-25T19:49:56.013Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0f/c3/0ae57a68be2039287ec28bc463b82e4b8dc23f9d12c0be331f4782e19108/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0c8e093ea2532601a6f686edbc2c6b2ec24131ff5c52f7610dd64fa4553b5464", size = 278412, upload-time = "2025-09-25T19:49:57.356Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/45/2b/77424511adb11e6a99e3a00dcc7745034bee89036ad7d7e255a7e47be7d8/bcrypt-5.0.0-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:5b1589f4839a0899c146e8892efe320c0fa096568abd9b95593efac50a87cb75", size = 275486, upload-time = "2025-09-25T19:49:59.116Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/43/0a/405c753f6158e0f3f14b00b462d8bca31296f7ecfc8fc8bc7919c0c7d73a/bcrypt-5.0.0-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:89042e61b5e808b67daf24a434d89bab164d4de1746b37a8d173b6b14f3db9ff", size = 277940, upload-time = "2025-09-25T19:50:00.869Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/62/83/b3efc285d4aadc1fa83db385ec64dcfa1707e890eb42f03b127d66ac1b7b/bcrypt-5.0.0-cp38-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:e3cf5b2560c7b5a142286f69bde914494b6d8f901aaa71e453078388a50881c4", size = 310776, upload-time = "2025-09-25T19:50:02.393Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/95/7d/47ee337dacecde6d234890fe929936cb03ebc4c3a7460854bbd9c97780b8/bcrypt-5.0.0-cp38-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:f632fd56fc4e61564f78b46a2269153122db34988e78b6be8b32d28507b7eaeb", size = 312922, upload-time = "2025-09-25T19:50:04.232Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d6/3a/43d494dfb728f55f4e1cf8fd435d50c16a2d75493225b54c8d06122523c6/bcrypt-5.0.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:801cad5ccb6b87d1b430f183269b94c24f248dddbbc5c1f78b6ed231743e001c", size = 341367, upload-time = "2025-09-25T19:50:05.559Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/55/ab/a0727a4547e383e2e22a630e0f908113db37904f58719dc48d4622139b5c/bcrypt-5.0.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3cf67a804fc66fc217e6914a5635000259fbbbb12e78a99488e4d5ba445a71eb", size = 359187, upload-time = "2025-09-25T19:50:06.916Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1b/bb/461f352fdca663524b4643d8b09e8435b4990f17fbf4fea6bc2a90aa0cc7/bcrypt-5.0.0-cp38-abi3-win32.whl", hash = "sha256:3abeb543874b2c0524ff40c57a4e14e5d3a66ff33fb423529c88f180fd756538", size = 153752, upload-time = "2025-09-25T19:50:08.515Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/41/aa/4190e60921927b7056820291f56fc57d00d04757c8b316b2d3c0d1d6da2c/bcrypt-5.0.0-cp38-abi3-win_amd64.whl", hash = "sha256:35a77ec55b541e5e583eb3436ffbbf53b0ffa1fa16ca6782279daf95d146dcd9", size = 150881, upload-time = "2025-09-25T19:50:09.742Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/54/12/cd77221719d0b39ac0b55dbd39358db1cd1246e0282e104366ebbfb8266a/bcrypt-5.0.0-cp38-abi3-win_arm64.whl", hash = "sha256:cde08734f12c6a4e28dc6755cd11d3bdfea608d93d958fffbe95a7026ebe4980", size = 144931, upload-time = "2025-09-25T19:50:11.016Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5d/ba/2af136406e1c3839aea9ecadc2f6be2bcd1eff255bd451dd39bcf302c47a/bcrypt-5.0.0-cp39-abi3-macosx_10_12_universal2.whl", hash = "sha256:0c418ca99fd47e9c59a301744d63328f17798b5947b0f791e9af3c1c499c2d0a", size = 495313, upload-time = "2025-09-25T19:50:12.309Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ac/ee/2f4985dbad090ace5ad1f7dd8ff94477fe089b5fab2040bd784a3d5f187b/bcrypt-5.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ddb4e1500f6efdd402218ffe34d040a1196c072e07929b9820f363a1fd1f4191", size = 275290, upload-time = "2025-09-25T19:50:13.673Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e4/6e/b77ade812672d15cf50842e167eead80ac3514f3beacac8902915417f8b7/bcrypt-5.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7aeef54b60ceddb6f30ee3db090351ecf0d40ec6e2abf41430997407a46d2254", size = 278253, upload-time = "2025-09-25T19:50:15.089Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/36/c4/ed00ed32f1040f7990dac7115f82273e3c03da1e1a1587a778d8cea496d8/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f0ce778135f60799d89c9693b9b398819d15f1921ba15fe719acb3178215a7db", size = 276084, upload-time = "2025-09-25T19:50:16.699Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e7/c4/fa6e16145e145e87f1fa351bbd54b429354fd72145cd3d4e0c5157cf4c70/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a71f70ee269671460b37a449f5ff26982a6f2ba493b3eabdd687b4bf35f875ac", size = 297185, upload-time = "2025-09-25T19:50:18.525Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/24/b4/11f8a31d8b67cca3371e046db49baa7c0594d71eb40ac8121e2fc0888db0/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f8429e1c410b4073944f03bd778a9e066e7fad723564a52ff91841d278dfc822", size = 278656, upload-time = "2025-09-25T19:50:19.809Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ac/31/79f11865f8078e192847d2cb526e3fa27c200933c982c5b2869720fa5fce/bcrypt-5.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:edfcdcedd0d0f05850c52ba3127b1fce70b9f89e0fe5ff16517df7e81fa3cbb8", size = 275662, upload-time = "2025-09-25T19:50:21.567Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d4/8d/5e43d9584b3b3591a6f9b68f755a4da879a59712981ef5ad2a0ac1379f7a/bcrypt-5.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:611f0a17aa4a25a69362dcc299fda5c8a3d4f160e2abb3831041feb77393a14a", size = 278240, upload-time = "2025-09-25T19:50:23.305Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/89/48/44590e3fc158620f680a978aafe8f87a4c4320da81ed11552f0323aa9a57/bcrypt-5.0.0-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:db99dca3b1fdc3db87d7c57eac0c82281242d1eabf19dcb8a6b10eb29a2e72d1", size = 311152, upload-time = "2025-09-25T19:50:24.597Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5f/85/e4fbfc46f14f47b0d20493669a625da5827d07e8a88ee460af6cd9768b44/bcrypt-5.0.0-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:5feebf85a9cefda32966d8171f5db7e3ba964b77fdfe31919622256f80f9cf42", size = 313284, upload-time = "2025-09-25T19:50:26.268Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/25/ae/479f81d3f4594456a01ea2f05b132a519eff9ab5768a70430fa1132384b1/bcrypt-5.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:3ca8a166b1140436e058298a34d88032ab62f15aae1c598580333dc21d27ef10", size = 341643, upload-time = "2025-09-25T19:50:28.02Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/df/d2/36a086dee1473b14276cd6ea7f61aef3b2648710b5d7f1c9e032c29b859f/bcrypt-5.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:61afc381250c3182d9078551e3ac3a41da14154fbff647ddf52a769f588c4172", size = 359698, upload-time = "2025-09-25T19:50:31.347Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c0/f6/688d2cd64bfd0b14d805ddb8a565e11ca1fb0fd6817175d58b10052b6d88/bcrypt-5.0.0-cp39-abi3-win32.whl", hash = "sha256:64d7ce196203e468c457c37ec22390f1a61c85c6f0b8160fd752940ccfb3a683", size = 153725, upload-time = "2025-09-25T19:50:34.384Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9f/b9/9d9a641194a730bda138b3dfe53f584d61c58cd5230e37566e83ec2ffa0d/bcrypt-5.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:64ee8434b0da054d830fa8e89e1c8bf30061d539044a39524ff7dec90481e5c2", size = 150912, upload-time = "2025-09-25T19:50:35.69Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/27/44/d2ef5e87509158ad2187f4dd0852df80695bb1ee0cfe0a684727b01a69e0/bcrypt-5.0.0-cp39-abi3-win_arm64.whl", hash = "sha256:f2347d3534e76bf50bca5500989d6c1d05ed64b440408057a37673282c654927", size = 144953, upload-time = "2025-09-25T19:50:37.32Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[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, upload-time = "2024-11-08T17:25:47.436Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458, upload-time = "2024-11-08T17:25:46.184Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "brotli"
|
||||||
|
version = "1.0.9"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/2a/18/70c32fe9357f3eea18598b23aa9ed29b1711c3001835f7cf99a9818985d0/Brotli-1.0.9.zip", hash = "sha256:4d1b810aa0ed773f81dceda2cc7b403d01057458730e309856356d4ef4188438", size = 510202, upload-time = "2020-08-27T14:27:08.104Z" }
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "brotlicffi"
|
||||||
|
version = "1.2.0.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "cffi" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/84/85/57c314a6b35336efbbdc13e5fc9ae13f6b60a0647cfa7c1221178ac6d8ae/brotlicffi-1.2.0.0.tar.gz", hash = "sha256:34345d8d1f9d534fcac2249e57a4c3c8801a33c9942ff9f8574f67a175e17adb", size = 476682, upload-time = "2025-11-21T18:17:57.334Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e4/df/a72b284d8c7bef0ed5756b41c2eb7d0219a1dd6ac6762f1c7bdbc31ef3af/brotlicffi-1.2.0.0-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:9458d08a7ccde8e3c0afedbf2c70a8263227a68dea5ab13590593f4c0a4fd5f4", size = 432340, upload-time = "2025-11-21T18:17:42.277Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/74/2b/cc55a2d1d6fb4f5d458fba44a3d3f91fb4320aa14145799fd3a996af0686/brotlicffi-1.2.0.0-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:84e3d0020cf1bd8b8131f4a07819edee9f283721566fe044a20ec792ca8fd8b7", size = 1534002, upload-time = "2025-11-21T18:17:43.746Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e4/9c/d51486bf366fc7d6735f0e46b5b96ca58dc005b250263525a1eea3cd5d21/brotlicffi-1.2.0.0-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:33cfb408d0cff64cd50bef268c0fed397c46fbb53944aa37264148614a62e990", size = 1536547, upload-time = "2025-11-21T18:17:45.729Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1b/37/293a9a0a7caf17e6e657668bebb92dfe730305999fe8c0e2703b8888789c/brotlicffi-1.2.0.0-cp38-abi3-win32.whl", hash = "sha256:23e5c912fdc6fd37143203820230374d24babd078fc054e18070a647118158f6", size = 343085, upload-time = "2025-11-21T18:17:48.887Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/07/6b/6e92009df3b8b7272f85a0992b306b61c34b7ea1c4776643746e61c380ac/brotlicffi-1.2.0.0-cp38-abi3-win_amd64.whl", hash = "sha256:f139a7cdfe4ae7859513067b736eb44d19fae1186f9e99370092f6915216451b", size = 378586, upload-time = "2025-11-21T18:17:50.531Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cachelib"
|
||||||
|
version = "0.13.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/1d/69/0b5c1259e12fbcf5c2abe5934b5c0c1294ec0f845e2b4b2a51a91d79a4fb/cachelib-0.13.0.tar.gz", hash = "sha256:209d8996e3c57595bee274ff97116d1d73c4980b2fd9a34c7846cd07fd2e1a48", size = 34418, upload-time = "2024-04-13T14:18:27.782Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9b/42/960fc9896ddeb301716fdd554bab7941c35fb90a1dc7260b77df3366f87f/cachelib-0.13.0-py3-none-any.whl", hash = "sha256:8c8019e53b6302967d4e8329a504acf75e7bc46130291d30188a6e4e58162516", size = 20914, upload-time = "2024-04-13T14:18:26.361Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "certifi"
|
||||||
|
version = "2026.2.25"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[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, upload-time = "2025-09-08T23:24:04.541Z" }
|
||||||
|
wheels = [
|
||||||
|
{ 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, upload-time = "2025-09-08T23:23:18.087Z" },
|
||||||
|
{ 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, upload-time = "2025-09-08T23:23:19.622Z" },
|
||||||
|
{ 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, upload-time = "2025-09-08T23:23:20.853Z" },
|
||||||
|
{ 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, upload-time = "2025-09-08T23:23:22.08Z" },
|
||||||
|
{ 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, upload-time = "2025-09-08T23:23:23.314Z" },
|
||||||
|
{ 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, upload-time = "2025-09-08T23:23:24.541Z" },
|
||||||
|
{ 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, upload-time = "2025-09-08T23:23:26.143Z" },
|
||||||
|
{ 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, upload-time = "2025-09-08T23:23:27.873Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" },
|
||||||
|
{ 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, upload-time = "2025-09-08T23:23:29.347Z" },
|
||||||
|
{ 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, upload-time = "2025-09-08T23:23:30.63Z" },
|
||||||
|
{ 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, upload-time = "2025-09-08T23:23:31.91Z" },
|
||||||
|
{ 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, upload-time = "2025-09-08T23:23:33.214Z" },
|
||||||
|
{ 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, upload-time = "2025-09-08T23:23:34.495Z" },
|
||||||
|
{ 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, upload-time = "2025-09-08T23:23:36.096Z" },
|
||||||
|
{ 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, upload-time = "2025-09-08T23:23:37.328Z" },
|
||||||
|
{ 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, upload-time = "2025-09-08T23:23:38.945Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "charset-normalizer"
|
||||||
|
version = "3.4.4"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2a/35/7051599bd493e62411d6ede36fd5af83a38f37c4767b92884df7301db25d/charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd", size = 207746, upload-time = "2025-10-14T04:41:33.773Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/10/9a/97c8d48ef10d6cd4fcead2415523221624bf58bcf68a802721a6bc807c8f/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb", size = 147889, upload-time = "2025-10-14T04:41:34.897Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/10/bf/979224a919a1b606c82bd2c5fa49b5c6d5727aa47b4312bb27b1734f53cd/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e", size = 143641, upload-time = "2025-10-14T04:41:36.116Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ba/33/0ad65587441fc730dc7bd90e9716b30b4702dc7b617e6ba4997dc8651495/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14", size = 160779, upload-time = "2025-10-14T04:41:37.229Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/67/ed/331d6b249259ee71ddea93f6f2f0a56cfebd46938bde6fcc6f7b9a3d0e09/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191", size = 159035, upload-time = "2025-10-14T04:41:38.368Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/67/ff/f6b948ca32e4f2a4576aa129d8bed61f2e0543bf9f5f2b7fc3758ed005c9/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838", size = 152542, upload-time = "2025-10-14T04:41:39.862Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/16/85/276033dcbcc369eb176594de22728541a925b2632f9716428c851b149e83/charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6", size = 149524, upload-time = "2025-10-14T04:41:41.319Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9e/f2/6a2a1f722b6aba37050e626530a46a68f74e63683947a8acff92569f979a/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e", size = 150395, upload-time = "2025-10-14T04:41:42.539Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/60/bb/2186cb2f2bbaea6338cad15ce23a67f9b0672929744381e28b0592676824/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c", size = 143680, upload-time = "2025-10-14T04:41:43.661Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7d/a5/bf6f13b772fbb2a90360eb620d52ed8f796f3c5caee8398c3b2eb7b1c60d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090", size = 162045, upload-time = "2025-10-14T04:41:44.821Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/df/c5/d1be898bf0dc3ef9030c3825e5d3b83f2c528d207d246cbabe245966808d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152", size = 149687, upload-time = "2025-10-14T04:41:46.442Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a5/42/90c1f7b9341eef50c8a1cb3f098ac43b0508413f33affd762855f67a410e/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828", size = 160014, upload-time = "2025-10-14T04:41:47.631Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/76/be/4d3ee471e8145d12795ab655ece37baed0929462a86e72372fd25859047c/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec", size = 154044, upload-time = "2025-10-14T04:41:48.81Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b0/6f/8f7af07237c34a1defe7defc565a9bc1807762f672c0fde711a4b22bf9c0/charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9", size = 99940, upload-time = "2025-10-14T04:41:49.946Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4b/51/8ade005e5ca5b0d80fb4aff72a3775b325bdc3d27408c8113811a7cbe640/charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c", size = 107104, upload-time = "2025-10-14T04:41:51.051Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/da/5f/6b8f83a55bb8278772c5ae54a577f3099025f9ade59d0136ac24a0df4bde/charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2", size = 100743, upload-time = "2025-10-14T04:41:52.122Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "click"
|
||||||
|
version = "8.3.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[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, upload-time = "2022-10-25T02:36:22.414Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "dnspython"
|
||||||
|
version = "2.8.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/8c/8b/57666417c0f90f08bcafa776861060426765fdb422eb10212086fb811d26/dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f", size = 368251, upload-time = "2025-09-07T18:58:00.022Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "email-validator"
|
||||||
|
version = "2.2.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "dnspython" },
|
||||||
|
{ name = "idna" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/48/ce/13508a1ec3f8bb981ae4ca79ea40384becc868bfae97fd1c942bb3a001b1/email_validator-2.2.0.tar.gz", hash = "sha256:cb690f344c617a714f22e66ae771445a1ceb46821152df8e165c5f9a364582b7", size = 48967, upload-time = "2024-06-20T11:30:30.034Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d7/ee/bf0adb559ad3c786f12bcbc9296b3f5675f529199bef03e2df281fa1fadb/email_validator-2.2.0-py3-none-any.whl", hash = "sha256:561977c2d73ce3611850a06fa56b414621e0c8faa9d66f2611407d87465da631", size = 33521, upload-time = "2024-06-20T11:30:28.248Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "flask"
|
||||||
|
version = "3.1.3"
|
||||||
|
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/26/00/35d85dcce6c57fdc871f3867d465d780f302a175ea360f62533f12b27e2b/flask-3.1.3.tar.gz", hash = "sha256:0ef0e52b8a9cd932855379197dd8f94047b359ca0a78695144304cb45f87c9eb", size = 759004, upload-time = "2026-02-19T05:00:57.678Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7f/9c/34f6962f9b9e9c71f6e5ed806e0d0ff03c9d1b0b2340088a0cf4bce09b18/flask-3.1.3-py3-none-any.whl", hash = "sha256:f4bcbefc124291925f1a26446da31a5178f9483862233b23c0c96a20701f670c", size = 103424, upload-time = "2026-02-19T05:00:56.027Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "flask-bcrypt"
|
||||||
|
version = "1.0.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "bcrypt" },
|
||||||
|
{ name = "flask" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/0c/f4/25dccfafad391d305b63eb6031e7c1dbb757169d54d3a73292939201698e/Flask-Bcrypt-1.0.1.tar.gz", hash = "sha256:f07b66b811417ea64eb188ae6455b0b708a793d966e1a80ceec4a23bc42a4369", size = 5996, upload-time = "2022-04-05T03:59:52.682Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8b/72/af9a3a3dbcf7463223c089984b8dd4f1547593819e24d57d9dc5873e04fe/Flask_Bcrypt-1.0.1-py3-none-any.whl", hash = "sha256:062fd991dc9118d05ac0583675507b9fe4670e44416c97e0e6819d03d01f808a", size = 6050, upload-time = "2022-04-05T03:59:51.589Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "flask-caching"
|
||||||
|
version = "2.3.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "cachelib" },
|
||||||
|
{ name = "flask" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/e2/80/74846c8af58ed60972d64f23a6cd0c3ac0175677d7555dff9f51bf82c294/flask_caching-2.3.1.tar.gz", hash = "sha256:65d7fd1b4eebf810f844de7de6258254b3248296ee429bdcb3f741bcbf7b98c9", size = 67560, upload-time = "2025-02-23T01:34:40.207Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/00/bb/82daa5e2fcecafadcc8659ce5779679d0641666f9252a4d5a2ae987b0506/Flask_Caching-2.3.1-py3-none-any.whl", hash = "sha256:d3efcf600e5925ea5a2fcb810f13b341ae984f5b52c00e9d9070392f3ca10761", size = 28916, upload-time = "2025-02-23T01:34:37.749Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "flask-compress"
|
||||||
|
version = "1.23"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "brotli", marker = "platform_python_implementation != 'PyPy'" },
|
||||||
|
{ name = "brotlicffi", marker = "platform_python_implementation == 'PyPy'" },
|
||||||
|
{ name = "flask" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/5d/e4/2b54da5cf8ae5d38a495ca20154aa40d6d2ee6dc1756429a82856181aa2c/flask_compress-1.23.tar.gz", hash = "sha256:5580935b422e3f136b9a90909e4b1015ac2b29c9aebe0f8733b790fde461c545", size = 20135, upload-time = "2025-11-06T09:06:29.56Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7d/9a/bebdcdba82d2786b33cd9f5fd65b8d309797c27176a9c4f357c1150c4ac0/flask_compress-1.23-py3-none-any.whl", hash = "sha256:52108afb4d133a5aab9809e6ac3c085ed7b9c788c75c6846c129faa28468f08c", size = 10515, upload-time = "2025-11-06T09:06:28.691Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "flask-htmx"
|
||||||
|
version = "0.4.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "flask" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/4a/b7/1ba8b722ccc12b72b44af949f438a85111ba8db9e39f973dff4a47da068e/flask_htmx-0.4.0.tar.gz", hash = "sha256:2d367fb27c8da99d031a0c566b7e562637139722e2d4e8ec67c7f941addb22fd", size = 5815, upload-time = "2024-09-22T04:14:20.006Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3d/8e/7e75c2210567ba11df9ea7d031eb5b8f45e82f6112cc8be885cb0ce86c7d/flask_htmx-0.4.0-py3-none-any.whl", hash = "sha256:ac0ef976638bc635537a47c4ae622c91aef1e69d8bf52880aa9ae0db089ce7d2", size = 6773, upload-time = "2024-09-22T04:14:18.41Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "flask-login"
|
||||||
|
version = "0.6.3"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "flask" },
|
||||||
|
{ name = "werkzeug" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/c3/6e/2f4e13e373bb49e68c02c51ceadd22d172715a06716f9299d9df01b6ddb2/Flask-Login-0.6.3.tar.gz", hash = "sha256:5e23d14a607ef12806c699590b89d0f0e0d67baeec599d75947bf9c147330333", size = 48834, upload-time = "2023-10-30T14:53:21.151Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/59/f5/67e9cc5c2036f58115f9fe0f00d203cf6780c3ff8ae0e705e7a9d9e8ff9e/Flask_Login-0.6.3-py3-none-any.whl", hash = "sha256:849b25b82a436bf830a054e74214074af59097171562ab10bfa999e6b78aae5d", size = 17303, upload-time = "2023-10-30T14:53:19.636Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[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, upload-time = "2024-10-24T07:18:58.555Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/dc/19/354449145fbebb65e7c621235b6ad69bebcfaec2142481f044d0ddc5b5c5/flask_wtf-1.2.2-py3-none-any.whl", hash = "sha256:e93160c5c5b6b571cf99300b6e01b72f9a101027cab1579901f8b10c5daf0b70", size = 12779, upload-time = "2024-10-24T07:18:56.976Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "gunicorn"
|
||||||
|
version = "25.1.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "packaging" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/66/13/ef67f59f6a7896fdc2c1d62b5665c5219d6b0a9a1784938eb9a28e55e128/gunicorn-25.1.0.tar.gz", hash = "sha256:1426611d959fa77e7de89f8c0f32eed6aa03ee735f98c01efba3e281b1c47616", size = 594377, upload-time = "2026-02-13T11:09:58.989Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/da/73/4ad5b1f6a2e21cf1e85afdaad2b7b1a933985e2f5d679147a1953aaa192c/gunicorn-25.1.0-py3-none-any.whl", hash = "sha256:d0b1236ccf27f72cfe14bce7caadf467186f19e865094ca84221424e839b8b8b", size = 197067, upload-time = "2026-02-13T11:09:57.146Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "idna"
|
||||||
|
version = "3.11"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[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, upload-time = "2024-04-16T21:28:15.614Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "jinja-partials"
|
||||||
|
version = "0.1.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "jinja2" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/f0/30/21850ec17f6be1c2549b98a33ba1e90cff3697e1c111e7ed5c2b375cee33/jinja_partials-0.1.1.tar.gz", hash = "sha256:7afea85362f48c49b32c2134612edf52e5f3d5af6f2038fcdb758c53a50ee7af", size = 4681, upload-time = "2021-12-15T05:48:59.321Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8c/f8/2be19a6ee1d3895576c22fb25c42673072ea1a5eda779e4be1af914d45c7/jinja_partials-0.1.1-py3-none-any.whl", hash = "sha256:c927e0dd51cb299c41c96bd200e01be299f860466fc2d6bb6a67861acb08b498", size = 4635, upload-time = "2021-12-15T05:48:57.854Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[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, upload-time = "2025-03-05T20:05:02.478Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "jinja2-fragments"
|
||||||
|
version = "0.3.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "jinja2" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/f1/54/00b5d04130ff2bd6e4dca20a8fa9f8328aa3b3ce0b506bb87cd221ffe19f/jinja2_fragments-0.3.0.tar.gz", hash = "sha256:4068b47239f0bc2ca71ee2a4d9521a225ae8e3d4bb84a9ca89bd26a608812163", size = 7101, upload-time = "2022-09-18T23:30:04.306Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/12/0c/66c45e428d5929c3e101c75492bdd9f7d4dcf3a0524dbca37e5ee1862435/jinja2_fragments-0.3.0-py3-none-any.whl", hash = "sha256:0e93af60da2af334bfaa0fcc549e9d493bf466774c5f30b49ac085a1ffeaae3f", size = 6979, upload-time = "2022-09-18T23:30:00.979Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "markupsafe"
|
||||||
|
version = "3.0.3"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "minify-html"
|
||||||
|
version = "0.18.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/77/b7/83dc18bef0cd6f4268d1a63dd682730d3c1150d77a973a34c8de63610bdc/minify_html-0.18.1.tar.gz", hash = "sha256:43998530ef537701f003a8e908b756d78eff303c86b041a95855e290518ba79c", size = 96577, upload-time = "2025-10-25T22:27:18.801Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/91/a4/61b966701e1d5fb06a7564d17ac53cc5990b083649748a249833e73d3d6a/minify_html-0.18.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:e34af8574ed701555561fcc29d14ff6e8969df5281d51b62cdf556ca0ca7a56e", size = 3061250, upload-time = "2025-10-25T23:04:30.374Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/40/14/ee02ac4f89afa8b888d5fe36c2f6261831b0bb191d3579b68286a9ef6364/minify_html-0.18.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e93301610f6c78ff83cf9d556d779ed4dee1c8aadf45a12dc4b40cebbe477a2e", size = 2828096, upload-time = "2025-10-25T22:55:15.804Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5a/d9/5e34d74abadf89e40caf5f06e9b52b49d96da1bff437b1f2f05aa454c665/minify_html-0.18.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0f3f167339638f26af34a56027b24e7e2daa03670b84a1ba661975d6d4536481", size = 2900061, upload-time = "2025-10-25T22:27:53.055Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4d/b9/45023457cd150be87fa6893e4e524929f36a46a1de92b7ce95d40e685e0d/minify_html-0.18.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e862f89f1493c17fe74d8c7a75bbd480aa7784bbf47ec396d9db4871101f94e4", size = 3082816, upload-time = "2025-10-25T22:27:26.023Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a7/42/c5015b02b5ee8b8194870f3beace2b14ac0e197d754e43f0973a36a3c6df/minify_html-0.18.1-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:045dd5640e988cc385d350e224e13f609a606a6cf9fa5f5011a1d860d4ebe607", size = 3082224, upload-time = "2025-10-25T22:30:42.538Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6a/04/cf74fd1f980c42068d229e9657415b008b3a65504fb2fa22b09cdf579e88/minify_html-0.18.1-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:3a11a926b2c236f527d8295b7f6e20c41728bdf870732273e2471e8c693f6109", size = 3327448, upload-time = "2025-10-25T22:30:55.968Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/67/22/35ed1e1f733573de2988924bebc7a6e7b37027e37e43e8a3ac35e00fd960/minify_html-0.18.1-cp314-cp314-win_amd64.whl", hash = "sha256:41f46915ce2634dd70138488a96d6b36e8b8cc2c2ee2953d89c525658394500a", size = 3116545, upload-time = "2025-10-25T22:36:25.166Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "numpy"
|
||||||
|
version = "2.4.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/57/fd/0005efbd0af48e55eb3c7208af93f2862d4b1a56cd78e84309a2d959208d/numpy-2.4.2.tar.gz", hash = "sha256:659a6107e31a83c4e33f763942275fd278b21d095094044eb35569e86a21ddae", size = 20723651, upload-time = "2026-01-31T23:13:10.135Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/18/88/b7df6050bf18fdcfb7046286c6535cabbdd2064a3440fca3f069d319c16e/numpy-2.4.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:444be170853f1f9d528428eceb55f12918e4fda5d8805480f36a002f1415e09b", size = 16663092, upload-time = "2026-01-31T23:12:04.521Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/25/7a/1fee4329abc705a469a4afe6e69b1ef7e915117747886327104a8493a955/numpy-2.4.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d1240d50adff70c2a88217698ca844723068533f3f5c5fa6ee2e3220e3bdb000", size = 14698770, upload-time = "2026-01-31T23:12:06.96Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fb/0b/f9e49ba6c923678ad5bc38181c08ac5e53b7a5754dbca8e581aa1a56b1ff/numpy-2.4.2-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:7cdde6de52fb6664b00b056341265441192d1291c130e99183ec0d4b110ff8b1", size = 5208562, upload-time = "2026-01-31T23:12:09.632Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7d/12/d7de8f6f53f9bb76997e5e4c069eda2051e3fe134e9181671c4391677bb2/numpy-2.4.2-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:cda077c2e5b780200b6b3e09d0b42205a3d1c68f30c6dceb90401c13bff8fe74", size = 6543710, upload-time = "2026-01-31T23:12:11.969Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/09/63/c66418c2e0268a31a4cf8a8b512685748200f8e8e8ec6c507ce14e773529/numpy-2.4.2-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d30291931c915b2ab5717c2974bb95ee891a1cf22ebc16a8006bd59cd210d40a", size = 15677205, upload-time = "2026-01-31T23:12:14.33Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5d/6c/7f237821c9642fb2a04d2f1e88b4295677144ca93285fd76eff3bcba858d/numpy-2.4.2-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bba37bc29d4d85761deed3954a1bc62be7cf462b9510b51d367b769a8c8df325", size = 16611738, upload-time = "2026-01-31T23:12:16.525Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c2/a7/39c4cdda9f019b609b5c473899d87abff092fc908cfe4d1ecb2fcff453b0/numpy-2.4.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b2f0073ed0868db1dcd86e052d37279eef185b9c8db5bf61f30f46adac63c909", size = 17028888, upload-time = "2026-01-31T23:12:19.306Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/da/b3/e84bb64bdfea967cc10950d71090ec2d84b49bc691df0025dddb7c26e8e3/numpy-2.4.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7f54844851cdb630ceb623dcec4db3240d1ac13d4990532446761baede94996a", size = 18339556, upload-time = "2026-01-31T23:12:21.816Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/88/f5/954a291bc1192a27081706862ac62bb5920fbecfbaa302f64682aa90beed/numpy-2.4.2-cp314-cp314-win32.whl", hash = "sha256:12e26134a0331d8dbd9351620f037ec470b7c75929cb8a1537f6bfe411152a1a", size = 6006899, upload-time = "2026-01-31T23:12:24.14Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/05/cb/eff72a91b2efdd1bc98b3b8759f6a1654aa87612fc86e3d87d6fe4f948c4/numpy-2.4.2-cp314-cp314-win_amd64.whl", hash = "sha256:068cdb2d0d644cdb45670810894f6a0600797a69c05f1ac478e8d31670b8ee75", size = 12443072, upload-time = "2026-01-31T23:12:26.33Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/37/75/62726948db36a56428fce4ba80a115716dc4fad6a3a4352487f8bb950966/numpy-2.4.2-cp314-cp314-win_arm64.whl", hash = "sha256:6ed0be1ee58eef41231a5c943d7d1375f093142702d5723ca2eb07db9b934b05", size = 10494886, upload-time = "2026-01-31T23:12:28.488Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/36/2f/ee93744f1e0661dc267e4b21940870cabfae187c092e1433b77b09b50ac4/numpy-2.4.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:98f16a80e917003a12c0580f97b5f875853ebc33e2eaa4bccfc8201ac6869308", size = 14818567, upload-time = "2026-01-31T23:12:30.709Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a7/24/6535212add7d76ff938d8bdc654f53f88d35cddedf807a599e180dcb8e66/numpy-2.4.2-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:20abd069b9cda45874498b245c8015b18ace6de8546bf50dfa8cea1696ed06ef", size = 5328372, upload-time = "2026-01-31T23:12:32.962Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5e/9d/c48f0a035725f925634bf6b8994253b43f2047f6778a54147d7e213bc5a7/numpy-2.4.2-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:e98c97502435b53741540a5717a6749ac2ada901056c7db951d33e11c885cc7d", size = 6649306, upload-time = "2026-01-31T23:12:34.797Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/81/05/7c73a9574cd4a53a25907bad38b59ac83919c0ddc8234ec157f344d57d9a/numpy-2.4.2-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:da6cad4e82cb893db4b69105c604d805e0c3ce11501a55b5e9f9083b47d2ffe8", size = 15722394, upload-time = "2026-01-31T23:12:36.565Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/35/fa/4de10089f21fc7d18442c4a767ab156b25c2a6eaf187c0db6d9ecdaeb43f/numpy-2.4.2-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9e4424677ce4b47fe73c8b5556d876571f7c6945d264201180db2dc34f676ab5", size = 16653343, upload-time = "2026-01-31T23:12:39.188Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b8/f9/d33e4ffc857f3763a57aa85650f2e82486832d7492280ac21ba9efda80da/numpy-2.4.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:2b8f157c8a6f20eb657e240f8985cc135598b2b46985c5bccbde7616dc9c6b1e", size = 17078045, upload-time = "2026-01-31T23:12:42.041Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c8/b8/54bdb43b6225badbea6389fa038c4ef868c44f5890f95dd530a218706da3/numpy-2.4.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5daf6f3914a733336dab21a05cdec343144600e964d2fcdabaac0c0269874b2a", size = 18380024, upload-time = "2026-01-31T23:12:44.331Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a5/55/6e1a61ded7af8df04016d81b5b02daa59f2ea9252ee0397cb9f631efe9e5/numpy-2.4.2-cp314-cp314t-win32.whl", hash = "sha256:8c50dd1fc8826f5b26a5ee4d77ca55d88a895f4e4819c7ecc2a9f5905047a443", size = 6153937, upload-time = "2026-01-31T23:12:47.229Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/45/aa/fa6118d1ed6d776b0983f3ceac9b1a5558e80df9365b1c3aa6d42bf9eee4/numpy-2.4.2-cp314-cp314t-win_amd64.whl", hash = "sha256:fcf92bee92742edd401ba41135185866f7026c502617f422eb432cfeca4fe236", size = 12631844, upload-time = "2026-01-31T23:12:48.997Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/32/0a/2ec5deea6dcd158f254a7b372fb09cfba5719419c8d66343bab35237b3fb/numpy-2.4.2-cp314-cp314t-win_arm64.whl", hash = "sha256:1f92f53998a17265194018d1cc321b2e96e900ca52d54c7c77837b71b9465181", size = 10565379, upload-time = "2026-01-31T23:12:51.345Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "packaging"
|
||||||
|
version = "26.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "polars"
|
||||||
|
version = "1.38.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "polars-runtime-32" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/c6/5e/208a24471a433bcd0e9a6889ac49025fd4daad2815c8220c5bd2576e5f1b/polars-1.38.1.tar.gz", hash = "sha256:803a2be5344ef880ad625addfb8f641995cfd777413b08a10de0897345778239", size = 717667, upload-time = "2026-02-06T18:13:23.013Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0a/49/737c1a6273c585719858261753da0b688454d1b634438ccba8a9c4eb5aab/polars-1.38.1-py3-none-any.whl", hash = "sha256:a29479c48fed4984d88b656486d221f638cba45d3e961631a50ee5fdde38cb2c", size = 810368, upload-time = "2026-02-06T18:11:55.819Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "polars-runtime-32"
|
||||||
|
version = "1.38.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/07/4b/04d6b3fb7cf336fbe12fbc4b43f36d1783e11bb0f2b1e3980ec44878df06/polars_runtime_32-1.38.1.tar.gz", hash = "sha256:04f20ed1f5c58771f34296a27029dc755a9e4b1390caeaef8f317e06fdfce2ec", size = 2812631, upload-time = "2026-02-06T18:13:25.206Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ae/a2/a00defbddadd8cf1042f52380dcba6b6592b03bac8e3b34c436b62d12d3b/polars_runtime_32-1.38.1-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:18154e96044724a0ac38ce155cf63aa03c02dd70500efbbf1a61b08cadd269ef", size = 44108001, upload-time = "2026-02-06T18:11:58.127Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a7/fb/599ff3709e6a303024efd7edfd08cf8de55c6ac39527d8f41cbc4399385f/polars_runtime_32-1.38.1-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:c49acac34cc4049ed188f1eb67d6ff3971a39b4af7f7b734b367119970f313ac", size = 40230140, upload-time = "2026-02-06T18:12:01.181Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/dc/8c/3ac18d6f89dc05fe2c7c0ee1dc5b81f77a5c85ad59898232c2500fe2ebbf/polars_runtime_32-1.38.1-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fef2ef2626a954e010e006cc8e4de467ecf32d08008f130cea1c78911f545323", size = 41994039, upload-time = "2026-02-06T18:12:04.332Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f2/5a/61d60ec5cc0ab37cbd5a699edb2f9af2875b7fdfdfb2a4608ca3cc5f0448/polars_runtime_32-1.38.1-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e8a5f7a8125e2d50e2e060296551c929aec09be23a9edcb2b12ca923f555a5ba", size = 45755804, upload-time = "2026-02-06T18:12:07.846Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/91/54/02cd4074c98c361ccd3fec3bcb0bd68dbc639c0550c42a4436b0ff0f3ccf/polars_runtime_32-1.38.1-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:10d19cd9863e129273b18b7fcaab625b5c8143c2d22b3e549067b78efa32e4fa", size = 42159605, upload-time = "2026-02-06T18:12:10.919Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8e/f3/b2a5e720cc56eaa38b4518e63aa577b4bbd60e8b05a00fe43ca051be5879/polars_runtime_32-1.38.1-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:61e8d73c614b46a00d2f853625a7569a2e4a0999333e876354ac81d1bf1bb5e2", size = 45336615, upload-time = "2026-02-06T18:12:14.074Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f1/8d/ee2e4b7de948090cfb3df37d401c521233daf97bfc54ddec5d61d1d31618/polars_runtime_32-1.38.1-cp310-abi3-win_amd64.whl", hash = "sha256:08c2b3b93509c1141ac97891294ff5c5b0c548a373f583eaaea873a4bf506437", size = 45680732, upload-time = "2026-02-06T18:12:19.097Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bf/18/72c216f4ab0c82b907009668f79183ae029116ff0dd245d56ef58aac48e7/polars_runtime_32-1.38.1-cp310-abi3-win_arm64.whl", hash = "sha256:6d07d0cc832bfe4fb54b6e04218c2c27afcfa6b9498f9f6bbf262a00d58cc7c4", size = 41639413, upload-time = "2026-02-06T18:12:22.044Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "psycopg"
|
||||||
|
version = "3.3.3"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "tzdata", marker = "sys_platform == 'win32'" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/d3/b6/379d0a960f8f435ec78720462fd94c4863e7a31237cf81bf76d0af5883bf/psycopg-3.3.3.tar.gz", hash = "sha256:5e9a47458b3c1583326513b2556a2a9473a1001a56c9efe9e587245b43148dd9", size = 165624, upload-time = "2026-02-18T16:52:16.546Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c8/5b/181e2e3becb7672b502f0ed7f16ed7352aca7c109cfb94cf3878a9186db9/psycopg-3.3.3-py3-none-any.whl", hash = "sha256:f96525a72bcfade6584ab17e89de415ff360748c766f0106959144dcbb38c698", size = 212768, upload-time = "2026-02-18T16:46:27.365Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.optional-dependencies]
|
||||||
|
binary = [
|
||||||
|
{ name = "psycopg-binary", marker = "implementation_name != 'pypy'" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "psycopg-binary"
|
||||||
|
version = "3.3.3"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a2/71/7a57e5b12275fe7e7d84d54113f0226080423a869118419c9106c083a21c/psycopg_binary-3.3.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:497852c5eaf1f0c2d88ab74a64a8097c099deac0c71de1cbcf18659a8a04a4b2", size = 4607368, upload-time = "2026-02-18T16:51:19.295Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c7/04/cb834f120f2b2c10d4003515ef9ca9d688115b9431735e3936ae48549af8/psycopg_binary-3.3.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:258d1ea53464d29768bf25930f43291949f4c7becc706f6e220c515a63a24edd", size = 4687047, upload-time = "2026-02-18T16:51:23.84Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/40/e9/47a69692d3da9704468041aa5ed3ad6fc7f6bb1a5ae788d261a26bbca6c7/psycopg_binary-3.3.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:111c59897a452196116db12e7f608da472fbff000693a21040e35fc978b23430", size = 5487096, upload-time = "2026-02-18T16:51:29.645Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0b/b6/0e0dd6a2f802864a4ae3dbadf4ec620f05e3904c7842b326aafc43e5f464/psycopg_binary-3.3.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:17bb6600e2455993946385249a3c3d0af52cd70c1c1cdbf712e9d696d0b0bf1b", size = 5168720, upload-time = "2026-02-18T16:51:36.499Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6f/0d/977af38ac19a6b55d22dff508bd743fd7c1901e1b73657e7937c7cccb0a3/psycopg_binary-3.3.3-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:642050398583d61c9856210568eb09a8e4f2fe8224bf3be21b67a370e677eead", size = 6762076, upload-time = "2026-02-18T16:51:43.167Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/34/40/912a39d48322cf86895c0eaf2d5b95cb899402443faefd4b09abbba6b6e1/psycopg_binary-3.3.3-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:533efe6dc3a7cba5e2a84e38970786bb966306863e45f3db152007e9f48638a6", size = 4997623, upload-time = "2026-02-18T16:51:47.707Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/98/0c/c14d0e259c65dc7be854d926993f151077887391d5a081118907a9d89603/psycopg_binary-3.3.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:5958dbf28b77ce2033482f6cb9ef04d43f5d8f4b7636e6963d5626f000efb23e", size = 4532096, upload-time = "2026-02-18T16:51:51.421Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/39/21/8b7c50a194cfca6ea0fd4d1f276158307785775426e90700ab2eba5cd623/psycopg_binary-3.3.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:a6af77b6626ce92b5817bf294b4d45ec1a6161dba80fc2d82cdffdd6814fd023", size = 4208884, upload-time = "2026-02-18T16:51:57.336Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c7/2c/a4981bf42cf30ebba0424971d7ce70a222ae9b82594c42fc3f2105d7b525/psycopg_binary-3.3.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:47f06fcbe8542b4d96d7392c476a74ada521c5aebdb41c3c0155f6595fc14c8d", size = 3944542, upload-time = "2026-02-18T16:52:04.266Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/60/e9/b7c29b56aa0b85a4e0c4d89db691c1ceef08f46a356369144430c155a2f5/psycopg_binary-3.3.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e7800e6c6b5dc4b0ca7cc7370f770f53ac83886b76afda0848065a674231e856", size = 4254339, upload-time = "2026-02-18T16:52:10.444Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/98/5a/291d89f44d3820fffb7a04ebc8f3ef5dda4f542f44a5daea0c55a84abf45/psycopg_binary-3.3.3-cp314-cp314-win_amd64.whl", hash = "sha256:165f22ab5a9513a3d7425ffb7fcc7955ed8ccaeef6d37e369d6cc1dff1582383", size = 3652796, upload-time = "2026-02-18T16:52:14.02Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "psycopg-pool"
|
||||||
|
version = "3.3.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "typing-extensions" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/56/9a/9470d013d0d50af0da9c4251614aeb3c1823635cab3edc211e3839db0bcf/psycopg_pool-3.3.0.tar.gz", hash = "sha256:fa115eb2860bd88fce1717d75611f41490dec6135efb619611142b24da3f6db5", size = 31606, upload-time = "2025-12-01T11:34:33.11Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e7/c3/26b8a0908a9db249de3b4169692e1c7c19048a9bc41a4d3209cee7dbb758/psycopg_pool-3.3.0-py3-none-any.whl", hash = "sha256:2e44329155c410b5e8666372db44276a8b1ebd8c90f1c3026ebba40d4bc81063", size = 39995, upload-time = "2025-12-01T11:34:29.761Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pyarrow"
|
||||||
|
version = "23.0.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/88/22/134986a4cc224d593c1afde5494d18ff629393d74cc2eddb176669f234a4/pyarrow-23.0.1.tar.gz", hash = "sha256:b8c5873e33440b2bc2f4a79d2b47017a89c5a24116c055625e6f2ee50523f019", size = 1167336, upload-time = "2026-02-16T10:14:12.39Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8d/1b/6da9a89583ce7b23ac611f183ae4843cd3a6cf54f079549b0e8c14031e73/pyarrow-23.0.1-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:5df1161da23636a70838099d4aaa65142777185cc0cdba4037a18cee7d8db9ca", size = 34238755, upload-time = "2026-02-16T10:12:32.819Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ae/b5/d58a241fbe324dbaeb8df07be6af8752c846192d78d2272e551098f74e88/pyarrow-23.0.1-cp314-cp314-macosx_12_0_x86_64.whl", hash = "sha256:fa8e51cb04b9f8c9c5ace6bab63af9a1f88d35c0d6cbf53e8c17c098552285e1", size = 35847826, upload-time = "2026-02-16T10:12:38.949Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/54/a5/8cbc83f04aba433ca7b331b38f39e000efd9f0c7ce47128670e737542996/pyarrow-23.0.1-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:0b95a3994f015be13c63148fef8832e8a23938128c185ee951c98908a696e0eb", size = 44536859, upload-time = "2026-02-16T10:12:45.467Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/36/2e/c0f017c405fcdc252dbccafbe05e36b0d0eb1ea9a958f081e01c6972927f/pyarrow-23.0.1-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:4982d71350b1a6e5cfe1af742c53dfb759b11ce14141870d05d9e540d13bc5d1", size = 47614443, upload-time = "2026-02-16T10:12:55.525Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/af/6b/2314a78057912f5627afa13ba43809d9d653e6630859618b0fd81a4e0759/pyarrow-23.0.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c250248f1fe266db627921c89b47b7c06fee0489ad95b04d50353537d74d6886", size = 48232991, upload-time = "2026-02-16T10:13:04.729Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/40/f2/1bcb1d3be3460832ef3370d621142216e15a2c7c62602a4ea19ec240dd64/pyarrow-23.0.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5f4763b83c11c16e5f4c15601ba6dfa849e20723b46aa2617cb4bffe8768479f", size = 50645077, upload-time = "2026-02-16T10:13:14.147Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/eb/3f/b1da7b61cd66566a4d4c8383d376c606d1c34a906c3f1cb35c479f59d1aa/pyarrow-23.0.1-cp314-cp314-win_amd64.whl", hash = "sha256:3a4c85ef66c134161987c17b147d6bffdca4566f9a4c1d81a0a01cdf08414ea5", size = 28234271, upload-time = "2026-02-16T10:14:09.397Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b5/78/07f67434e910a0f7323269be7bfbf58699bd0c1d080b18a1ab49ba943fe8/pyarrow-23.0.1-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:17cd28e906c18af486a499422740298c52d7c6795344ea5002a7720b4eadf16d", size = 34488692, upload-time = "2026-02-16T10:13:21.541Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/50/76/34cf7ae93ece1f740a04910d9f7e80ba166b9b4ab9596a953e9e62b90fe1/pyarrow-23.0.1-cp314-cp314t-macosx_12_0_x86_64.whl", hash = "sha256:76e823d0e86b4fb5e1cf4a58d293036e678b5a4b03539be933d3b31f9406859f", size = 35964383, upload-time = "2026-02-16T10:13:28.63Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/46/90/459b827238936d4244214be7c684e1b366a63f8c78c380807ae25ed92199/pyarrow-23.0.1-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:a62e1899e3078bf65943078b3ad2a6ddcacf2373bc06379aac61b1e548a75814", size = 44538119, upload-time = "2026-02-16T10:13:35.506Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/28/a1/93a71ae5881e99d1f9de1d4554a87be37da11cd6b152239fb5bd924fdc64/pyarrow-23.0.1-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:df088e8f640c9fae3b1f495b3c64755c4e719091caf250f3a74d095ddf3c836d", size = 47571199, upload-time = "2026-02-16T10:13:42.504Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/88/a3/d2c462d4ef313521eaf2eff04d204ac60775263f1fb08c374b543f79f610/pyarrow-23.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:46718a220d64677c93bc243af1d44b55998255427588e400677d7192671845c7", size = 48259435, upload-time = "2026-02-16T10:13:49.226Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cc/f1/11a544b8c3d38a759eb3fbb022039117fd633e9a7b19e4841cc3da091915/pyarrow-23.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a09f3876e87f48bc2f13583ab551f0379e5dfb83210391e68ace404181a20690", size = 50629149, upload-time = "2026-02-16T10:13:57.238Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/50/f2/c0e76a0b451ffdf0cf788932e182758eb7558953f4f27f1aff8e2518b653/pyarrow-23.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:527e8d899f14bd15b740cd5a54ad56b7f98044955373a17179d5956ddb93d9ce", size = 28365807, upload-time = "2026-02-16T10:14:03.892Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pycparser"
|
||||||
|
version = "3.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "python-dateutil"
|
||||||
|
version = "2.8.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "six" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/4c/c4/13b4776ea2d76c115c1d1b84579f3764ee6d57204f6be27119f13a61d0a9/python-dateutil-2.8.2.tar.gz", hash = "sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86", size = 357324, upload-time = "2021-07-14T08:19:19.783Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/36/7a/87837f39d0296e723bb9b62bbb257d0355c7f6128853c78955f57342a56d/python_dateutil-2.8.2-py2.py3-none-any.whl", hash = "sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9", size = 247702, upload-time = "2021-07-14T08:19:18.161Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "python-dotenv"
|
||||||
|
version = "1.0.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/bc/57/e84d88dfe0aec03b7a2d4327012c1627ab5f03652216c63d49846d7a6c58/python-dotenv-1.0.1.tar.gz", hash = "sha256:e324ee90a023d808f1959c46bcbc04446a10ced277783dc6ee09987c37ec10ca", size = 39115, upload-time = "2024-01-23T06:33:00.505Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6a/3e/b68c118422ec867fa7ab88444e1274aa40681c606d59ac27de5a5588f082/python_dotenv-1.0.1-py3-none-any.whl", hash = "sha256:f7b63ef50f1b690dddf550d03497b66d609393b40b564ed0d674909a68ebf16a", size = 19863, upload-time = "2024-01-23T06:32:58.246Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[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, upload-time = "2025-08-18T20:46:02.573Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "six"
|
||||||
|
version = "1.17.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[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, upload-time = "2025-08-25T13:49:26.313Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tzdata"
|
||||||
|
version = "2025.3"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/5e/a7/c202b344c5ca7daf398f3b8a477eeb205cf3b6f32e7ec3a6bac0629ca975/tzdata-2025.3.tar.gz", hash = "sha256:de39c2ca5dc7b0344f2eba86f49d614019d29f060fc4ebc8a417896a620b56a7", size = 196772, upload-time = "2025-12-13T17:45:35.667Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c7/b0/003792df09decd6849a5e39c28b513c06e84436a54440380862b5aeff25d/tzdata-2025.3-py2.py3-none-any.whl", hash = "sha256:06a47e5700f3081aab02b2e513160914ff0694bce9947d6b76ebd6bf57cfc5d1", size = 348521, upload-time = "2025-12-13T17:45:33.889Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "urllib3"
|
||||||
|
version = "2.6.3"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "werkzeug"
|
||||||
|
version = "3.1.6"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "markupsafe" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/61/f1/ee81806690a87dab5f5653c1f146c92bc066d7f4cebc603ef88eb9e13957/werkzeug-3.1.6.tar.gz", hash = "sha256:210c6bede5a420a913956b4791a7f4d6843a43b6fcee4dfa08a65e93007d0d25", size = 864736, upload-time = "2026-02-19T15:17:18.884Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4d/ec/d58832f89ede95652fd01f4f24236af7d32b70cab2196dfcc2d2fd13c5c2/werkzeug-3.1.6-py3-none-any.whl", hash = "sha256:7ddf3357bb9564e407607f988f683d72038551200c704012bb9a4c523d42f131", size = 225166, upload-time = "2026-02-19T15:17:17.475Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "workout"
|
||||||
|
version = "0.1.0"
|
||||||
|
source = { virtual = "." }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "brotli" },
|
||||||
|
{ name = "email-validator" },
|
||||||
|
{ name = "flask" },
|
||||||
|
{ name = "flask-bcrypt" },
|
||||||
|
{ name = "flask-caching" },
|
||||||
|
{ name = "flask-compress" },
|
||||||
|
{ name = "flask-htmx" },
|
||||||
|
{ name = "flask-login" },
|
||||||
|
{ name = "flask-wtf" },
|
||||||
|
{ name = "gunicorn" },
|
||||||
|
{ name = "jinja-partials" },
|
||||||
|
{ name = "jinja2" },
|
||||||
|
{ name = "jinja2-fragments" },
|
||||||
|
{ name = "minify-html" },
|
||||||
|
{ name = "numpy" },
|
||||||
|
{ name = "polars" },
|
||||||
|
{ name = "psycopg", extra = ["binary"] },
|
||||||
|
{ name = "psycopg-pool" },
|
||||||
|
{ name = "pyarrow" },
|
||||||
|
{ name = "python-dateutil" },
|
||||||
|
{ name = "python-dotenv" },
|
||||||
|
{ name = "requests" },
|
||||||
|
{ name = "werkzeug" },
|
||||||
|
{ name = "wtforms" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.metadata]
|
||||||
|
requires-dist = [
|
||||||
|
{ name = "brotli", specifier = "==1.0.9" },
|
||||||
|
{ name = "email-validator", specifier = "==2.2.0" },
|
||||||
|
{ name = "flask", specifier = ">=3.0.0" },
|
||||||
|
{ name = "flask-bcrypt", specifier = ">=1.0.1" },
|
||||||
|
{ name = "flask-caching", specifier = ">=2.1.0" },
|
||||||
|
{ name = "flask-compress", specifier = ">=1.14" },
|
||||||
|
{ name = "flask-htmx", specifier = ">=0.4.0" },
|
||||||
|
{ name = "flask-login", specifier = ">=0.6.3" },
|
||||||
|
{ name = "flask-wtf", specifier = ">=1.2.1" },
|
||||||
|
{ name = "gunicorn", specifier = ">=21.2.0" },
|
||||||
|
{ name = "jinja-partials", specifier = "==0.1.1" },
|
||||||
|
{ name = "jinja2", specifier = ">=3.1.0" },
|
||||||
|
{ name = "jinja2-fragments", specifier = "==0.3.0" },
|
||||||
|
{ name = "minify-html", specifier = ">=0.15.0" },
|
||||||
|
{ name = "numpy", specifier = ">=1.26.0" },
|
||||||
|
{ name = "polars", specifier = ">=0.20.0" },
|
||||||
|
{ name = "psycopg", extras = ["binary"], specifier = ">=3.0.0" },
|
||||||
|
{ name = "psycopg-pool", specifier = ">=3.2.0" },
|
||||||
|
{ name = "pyarrow", specifier = ">=14.0.0" },
|
||||||
|
{ name = "python-dateutil", specifier = "==2.8.2" },
|
||||||
|
{ name = "python-dotenv", specifier = "==1.0.1" },
|
||||||
|
{ name = "requests", specifier = ">=2.31.0" },
|
||||||
|
{ name = "werkzeug", specifier = ">=3.0.0" },
|
||||||
|
{ name = "wtforms", specifier = ">=3.1.0" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[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, upload-time = "2024-10-21T11:34:00.108Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/08/c9/2088fb5645cd289c99ebe0d4cdcc723922a1d8e1beaefb0f6f76dff9b21c/wtforms-3.2.1-py3-none-any.whl", hash = "sha256:583bad77ba1dd7286463f21e11aa3043ca4869d03575921d1a1698d0715e0fd4", size = 152454, upload-time = "2024-10-21T11:33:58.44Z" },
|
||||||
|
]
|
||||||
Reference in New Issue
Block a user